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:
2026-08-28 18:29:24 -06:00
co-authored by Claude Sonnet 5
parent aeaaef96ec
commit 66e9c80422
6 changed files with 168 additions and 15 deletions
+45 -15
View File
@@ -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))