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
+17
View File
@@ -38,6 +38,7 @@ from app.services.site_invite_service import (
SiteInviteNotPendingError, SiteInviteNotPendingError,
create_site_invite, create_site_invite,
list_site_invites, list_site_invites,
resend_site_invite,
revoke_site_invite, revoke_site_invite,
) )
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings
@@ -322,6 +323,22 @@ async def revoke_site_invite_endpoint(
raise HTTPException(status_code=400, detail="Invite is no longer pending") raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.post("/invites/{invite_id}/resend", response_model=SiteInviteRead)
async def resend_site_invite_endpoint(
invite_id: uuid.UUID,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await resend_site_invite(db, current_user, str(request.base_url), invite_id)
except SiteInviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except SiteInviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.get("/settings/smtp", response_model=SmtpSettingsRead | None) @router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
async def get_smtp_settings_endpoint( async def get_smtp_settings_endpoint(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
+45 -15
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models import InviteStatus, SiteInvite, User 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.schemas.user import UserCreate
from app.security import hash_token from app.security import hash_token
from app.services.audit import record_audit_log from app.services.audit import record_audit_log
@@ -26,6 +27,24 @@ class SiteInviteInvalidError(Exception):
pass 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( async def create_site_invite(
db: AsyncSession, actor: User, base_url: str, email: str db: AsyncSession, actor: User, base_url: str, email: str
) -> SiteInvite: ) -> SiteInvite:
@@ -39,21 +58,7 @@ async def create_site_invite(
await db.commit() await db.commit()
await db.refresh(invite) await db.refresh(invite)
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}" await _send_invite_email(db, actor.username, base_url, email, 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,
)
return invite return invite
@@ -86,6 +91,31 @@ async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID
return invite 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: async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
result = await db.execute( result = await db.execute(
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token)) select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
+73
View File
@@ -191,6 +191,79 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
assert complete.status_code == 400 assert complete.status_code == 400
async def test_resend_site_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
assert resp.status_code == 403
async def test_resend_site_invite_unknown_id_404s(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
assert resp.status_code == 404
async def test_resend_site_invite_new_link_works_and_old_one_doesnt(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "resend-me@example.com"})
invite_id = resp.json()["id"]
original_expires_at = datetime.fromisoformat(resp.json()["expires_at"])
old_token = _extract_token(_plain_text(calls[0]["message"]))
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
assert resend.status_code == 200, resend.text
assert resend.json()["id"] == invite_id
assert resend.json()["status"] == "pending"
# A fresh 7-day window, not whatever was left on the original.
new_expires_at = datetime.fromisoformat(resend.json()["expires_at"])
assert new_expires_at > original_expires_at
assert len(calls) == 2
new_token = _extract_token(_plain_text(calls[1]["message"]))
assert new_token != old_token
# The old link is dead -- resending rotates the token, it doesn't just
# repeat it.
old_validate = await client.get(f"/api/signup/validate?token={old_token}")
assert old_validate.status_code == 400
new_validate = await client.get(f"/api/signup/validate?token={new_token}")
assert new_validate.status_code == 200
assert new_validate.json()["email"] == "resend-me@example.com"
complete = await client.post(
"/api/signup",
json={
"token": new_token,
"username": "resentuser",
"password": "password123",
"password_confirm": "password123",
},
)
assert complete.status_code == 200, complete.text
async def test_resend_site_invite_rejects_non_pending(client, db_session, monkeypatch):
_fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "already-revoked@example.com"})
invite_id = resp.json()["id"]
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
assert resend.status_code == 400
async def test_list_site_invites(client, db_session, monkeypatch): async def test_list_site_invites(client, db_session, monkeypatch):
_fake_smtp(monkeypatch) _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1") admin = await register_and_login(client, db_session, username="admin1")
+7
View File
@@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' }) return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' })
} }
// #60: same invite row, not a new one -- a fresh token/expiry, and the old
// link stops working the moment this is called (see the backend's own
// resend_site_invite for why).
export function resendSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}/resend`, { method: 'POST' })
}
export function getSmtpSettings(): Promise<SmtpSettings | null> { export function getSmtpSettings(): Promise<SmtpSettings | null> {
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp') return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
} }
+8
View File
@@ -217,6 +217,14 @@
cursor: pointer; cursor: pointer;
} }
.admin-token-resend {
background: transparent;
border: none;
color: var(--ds-accent);
font-size: 0.76rem;
cursor: pointer;
}
.admin-issue-token { .admin-issue-token {
display: flex; display: flex;
align-items: center; align-items: center;
+18
View File
@@ -15,6 +15,7 @@ import {
listSiteInvites, listSiteInvites,
promoteUser, promoteUser,
reactivateUser, reactivateUser,
resendSiteInvite,
resetUserPassword, resetUserPassword,
revokeSiteInvite, revokeSiteInvite,
sendTestSmtpEmail, sendTestSmtpEmail,
@@ -312,6 +313,15 @@ export function AdminPage() {
}) })
} }
async function handleResendSiteInvite(invite: SiteInvite) {
await withBusy(invite.id, async () => {
const updated = await resendSiteInvite(invite.id)
// Same row, refreshed expiry (a new token/link went out, see
// resend_site_invite) -- update in place rather than a full refetch.
setSiteInvites((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))
})
}
async function handleSaveSmtpSettings(e: FormEvent) { async function handleSaveSmtpSettings(e: FormEvent) {
e.preventDefault() e.preventDefault()
setSmtpSaving(true) setSmtpSaving(true)
@@ -422,6 +432,14 @@ export function AdminPage() {
<span className="admin-token-meta"> <span className="admin-token-meta">
Expires {new Date(invite.expires_at).toLocaleDateString()} Expires {new Date(invite.expires_at).toLocaleDateString()}
</span> </span>
<button
type="button"
className="admin-token-resend"
disabled={busyId === invite.id}
onClick={() => handleResendSiteInvite(invite)}
>
Resend
</button>
<button <button
type="button" type="button"
className="admin-token-revoke" className="admin-token-revoke"