feat: add resend capability for platform and account invite codes

Revoke-and-recreate flow for both invite systems with email delivery
via Resend API. Includes account invite email template and audit logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-11 23:45:23 -05:00
parent a1f5127e98
commit 3c47292eaf
8 changed files with 390 additions and 7 deletions

View File

@@ -43,6 +43,11 @@ export const accountsApi = {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data
},
async resendInvite(inviteId: string): Promise<AccountInvite> {
const response = await apiClient.post<AccountInvite>(`/accounts/me/invites/${inviteId}/resend`)
return response.data
},
}
export default accountsApi

View File

@@ -56,6 +56,8 @@ export const adminApi = {
api.post<InviteCodeResponse>('/invites', data).then(r => r.data),
deleteInviteCode: (code: string) =>
api.delete(`/invites/${code}`),
resendInviteCode: (code: string) =>
api.post<InviteCodeResponse>(`/invites/${code}/resend`).then(r => r.data),
// Audit Logs
listAuditLogs: (params?: Record<string, unknown>) =>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, RefreshCw } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { cn } from '@/lib/utils'
@@ -102,6 +102,22 @@ export function AccountSettingsPage() {
}
}
const [resendingId, setResendingId] = useState<string | null>(null)
const handleResendInvite = async (inviteId: string) => {
setResendingId(inviteId)
try {
await accountsApi.resendInvite(inviteId)
toast.success('Invite resent with a new code')
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch {
toast.error('Failed to resend invite')
} finally {
setResendingId(null)
}
}
const handleRemoveMember = async (userId: string) => {
try {
await accountsApi.removeMember(userId)
@@ -433,12 +449,28 @@ export function AccountSettingsPage() {
<div>
<p className="text-sm text-white">{invite.email}</p>
<p className="text-xs text-white/40">
Expires {new Date(invite.expires_at).toLocaleDateString()}
{invite.expires_at
? `Expires ${new Date(invite.expires_at).toLocaleDateString()}`
: 'No expiration'}
</p>
</div>
<span className="rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/70">
{invite.role}
</span>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/70">
{invite.role}
</span>
<button
onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id}
className="text-white/40 hover:text-white disabled:opacity-50"
title="Resend invite"
>
{resendingId === invite.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
))}
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Copy, Trash2, Ticket, Mail, MailCheck } from 'lucide-react'
import { Plus, Copy, Trash2, Ticket, Mail, MailCheck, RefreshCw } from 'lucide-react'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
@@ -97,6 +97,16 @@ export function InviteCodesPage() {
}
}
const handleResend = async (code: string) => {
try {
const newInvite = await adminApi.resendInviteCode(code)
toast.success(`New code ${newInvite.code} sent to ${newInvite.email}`)
fetchCodes()
} catch {
toast.error('Failed to resend invite code')
}
}
const inputClass = cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
@@ -183,6 +193,11 @@ export function InviteCodesPage() {
icon: <Copy className="h-4 w-4" />,
onClick: () => handleCopy(c.code),
},
...(c.is_valid && c.email ? [{
label: 'Resend',
icon: <RefreshCw className="h-4 w-4" />,
onClick: () => handleResend(c.code),
}] : []),
...(!c.is_used ? [{
label: 'Delete',
icon: <Trash2 className="h-4 w-4" />,