diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3bba977..1848d92 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -38,6 +38,7 @@ from app.services.site_invite_service import ( SiteInviteNotPendingError, create_site_invite, list_site_invites, + resend_site_invite, revoke_site_invite, ) 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") +@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) async def get_smtp_settings_endpoint( current_user: User = Depends(get_current_user), diff --git a/backend/app/services/site_invite_service.py b/backend/app/services/site_invite_service.py index e0d8583..1730a28 100644 --- a/backend/app/services/site_invite_service.py +++ b/backend/app/services/site_invite_service.py @@ -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)) diff --git a/backend/tests/test_site_invites.py b/backend/tests/test_site_invites.py index db9855b..a9fd06b 100644 --- a/backend/tests/test_site_invites.py +++ b/backend/tests/test_site_invites.py @@ -191,6 +191,79 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc 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): _fake_smtp(monkeypatch) admin = await register_and_login(client, db_session, username="admin1") diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 4d5f363..43b5305 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise { return apiFetch(`/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 { + return apiFetch(`/api/admin/invites/${inviteId}/resend`, { method: 'POST' }) +} + export function getSmtpSettings(): Promise { return apiFetch('/api/admin/settings/smtp') } diff --git a/frontend/src/pages/AdminPage.css b/frontend/src/pages/AdminPage.css index a99ac79..8db10c6 100644 --- a/frontend/src/pages/AdminPage.css +++ b/frontend/src/pages/AdminPage.css @@ -217,6 +217,14 @@ cursor: pointer; } +.admin-token-resend { + background: transparent; + border: none; + color: var(--ds-accent); + font-size: 0.76rem; + cursor: pointer; +} + .admin-issue-token { display: flex; align-items: center; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 5070de4..f16660e 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -15,6 +15,7 @@ import { listSiteInvites, promoteUser, reactivateUser, + resendSiteInvite, resetUserPassword, revokeSiteInvite, 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) { e.preventDefault() setSmtpSaving(true) @@ -422,6 +432,14 @@ export function AdminPage() { Expires {new Date(invite.expires_at).toLocaleDateString()} +