Private
Public Access
Add the ability to resend unaccepted invites (#60)
Previously the only way to resend was to re-invite the same email from scratch, creating a whole new invite row. Adds a "Resend" action next to Revoke on each pending invite -- rotates the token and refreshes the 7-day expiry on the same row (the old link stops working the moment it's used, same instinct as a password-reset resend), then re-sends the invite email. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import InviteStatus, SiteInvite, User
|
||||
from app.models.site_invite import DEFAULT_SITE_INVITE_LIFETIME
|
||||
from app.schemas.user import UserCreate
|
||||
from app.security import hash_token
|
||||
from app.services.audit import record_audit_log
|
||||
@@ -26,6 +27,24 @@ class SiteInviteInvalidError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _send_invite_email(db: AsyncSession, inviter_username: str, base_url: str, email: str, raw_token: str) -> None:
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
# No theme_user -- the invitee doesn't have an account yet, so there's
|
||||
# no theme of theirs to use (#68). Default palette, same as any
|
||||
# logged-out page.
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join DS Chat",
|
||||
[
|
||||
f"You've been invited to join DS Chat by {inviter_username}.",
|
||||
"This link expires in 7 days.",
|
||||
],
|
||||
cta_label="Set up your account",
|
||||
cta_url=signup_link,
|
||||
)
|
||||
|
||||
|
||||
async def create_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, email: str
|
||||
) -> SiteInvite:
|
||||
@@ -39,21 +58,7 @@ async def create_site_invite(
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
# No theme_user -- the invitee doesn't have an account yet, so there's
|
||||
# no theme of theirs to use (#68). Default palette, same as any
|
||||
# logged-out page.
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join DS Chat",
|
||||
[
|
||||
f"You've been invited to join DS Chat by {actor.username}.",
|
||||
"This link expires in 7 days.",
|
||||
],
|
||||
cta_label="Set up your account",
|
||||
cta_url=signup_link,
|
||||
)
|
||||
await _send_invite_email(db, actor.username, base_url, email, raw_token)
|
||||
return invite
|
||||
|
||||
|
||||
@@ -86,6 +91,31 @@ async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID
|
||||
return invite
|
||||
|
||||
|
||||
async def resend_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, invite_id: uuid.UUID
|
||||
) -> SiteInvite:
|
||||
invite = await db.get(SiteInvite, invite_id)
|
||||
if invite is None:
|
||||
raise SiteInviteNotFoundError()
|
||||
if invite.status != InviteStatus.pending:
|
||||
raise SiteInviteNotPendingError()
|
||||
|
||||
# A fresh token and a reset 7-day expiry, not just re-sending the same
|
||||
# link -- the old link stops working the moment this runs (same
|
||||
# "rotate, don't just repeat" instinct as a password-reset resend), and
|
||||
# it means resending something close to expiring actually buys the
|
||||
# full week again instead of whatever was left.
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
invite.token_hash = hash_token(raw_token)
|
||||
invite.expires_at = datetime.now(timezone.utc) + DEFAULT_SITE_INVITE_LIFETIME
|
||||
record_audit_log(db, actor, "invite.resend", "invite", invite.id, {"email": invite.email})
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
await _send_invite_email(db, actor.username, base_url, invite.email, raw_token)
|
||||
return invite
|
||||
|
||||
|
||||
async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
|
||||
result = await db.execute(
|
||||
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
|
||||
|
||||
Reference in New Issue
Block a user