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:
@@ -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),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user