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
+7
View File
@@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
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> {
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
}
+8
View File
@@ -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;
+18
View File
@@ -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() {
<span className="admin-token-meta">
Expires {new Date(invite.expires_at).toLocaleDateString()}
</span>
<button
type="button"
className="admin-token-resend"
disabled={busyId === invite.id}
onClick={() => handleResendSiteInvite(invite)}
>
Resend
</button>
<button
type="button"
className="admin-token-revoke"