Refresh invite operations workspace
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s

This commit is contained in:
2026-09-01 15:12:37 +12:00
parent 5de14b1cb7
commit c49a149cfd
5 changed files with 518 additions and 57 deletions
+231 -54
View File
@@ -1,9 +1,9 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type AdminUserLite = {
id: number
@@ -46,10 +46,21 @@ type Invite = {
recipient_email?: string | null
is_expired?: boolean
is_usable?: boolean
operational_state?: 'ready' | 'disabled' | 'expired' | 'exhausted' | 'profile_unavailable'
state_label?: string
attention_reason?: string | null
created_at?: string | null
created_by?: string | null
}
type InviteSummary = {
total: number
ready: number
attention: number
used_signups: number
with_recipient: number
}
type InviteForm = {
code: string
label: string
@@ -75,6 +86,7 @@ type ProfileForm = {
type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
type InviteView = 'all' | 'ready' | 'attention' | 'used'
type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph'
@@ -117,15 +129,21 @@ type InvitePolicy = {
invite_access_enabled_users?: number
}
const defaultInviteForm = (): InviteForm => ({
const futureInviteExpiry = (days: number) => {
const expires = new Date()
expires.setDate(expires.getDate() + days)
return expires.toISOString()
}
const defaultInviteForm = (profileId = '', safeDefaults = false): InviteForm => ({
code: '',
label: '',
description: '',
profile_id: '',
profile_id: profileId,
role: '',
max_uses: '',
max_uses: safeDefaults ? '1' : '',
enabled: true,
expires_at: '',
expires_at: safeDefaults ? futureInviteExpiry(7) : '',
recipient_email: '',
send_email: false,
message: '',
@@ -161,6 +179,9 @@ const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
const isInviteOperationallyReady = (invite: Invite) =>
invite.operational_state ? invite.operational_state === 'ready' : invite.is_usable !== false
export default function AdminInviteManagementPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
@@ -170,6 +191,7 @@ export default function AdminInviteManagementPage() {
const [loading, setLoading] = useState(true)
const [inviteSaving, setInviteSaving] = useState(false)
const [sendingInviteId, setSendingInviteId] = useState<number | null>(null)
const [profileSaving, setProfileSaving] = useState(false)
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
@@ -184,6 +206,8 @@ export default function AdminInviteManagementPage() {
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
const [inviteSummary, setInviteSummary] = useState<InviteSummary | null>(null)
const [inviteView, setInviteView] = useState<InviteView>('all')
const [profileEditingId, setProfileEditingId] = useState<number | null>(null)
const [profileForm, setProfileForm] = useState<ProfileForm>(defaultProfileForm())
@@ -192,7 +216,7 @@ export default function AdminInviteManagementPage() {
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [activeTab, setActiveTab] = useState<InviteManagementTab>('invites')
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
@@ -286,7 +310,20 @@ export default function AdminInviteManagementPage() {
])
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
setInviteSummary((inviteData?.summary ?? null) as InviteSummary | null)
const nextProfiles = Array.isArray(profileData?.profiles) ? profileData.profiles : []
setProfiles(nextProfiles)
setInviteForm((current) => {
const isPristine =
!current.code &&
!current.label &&
!current.description &&
!current.profile_id &&
!current.recipient_email
if (!isPristine) return current
const defaultProfile = nextProfiles.find((profile: Profile) => profile.is_active !== false)
return defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true)
})
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
setInvitePolicy(nextPolicy)
setMasterInviteSelection(
@@ -324,7 +361,15 @@ export default function AdminInviteManagementPage() {
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultInviteForm())
const defaultProfile = profiles.find((profile) => profile.is_active !== false)
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true))
}
const setInviteExpiryPreset = (days: number | null) => {
setInviteForm((current) => ({
...current,
expires_at: days == null ? '' : futureInviteExpiry(days),
}))
}
const editInvite = (invite: Invite) => {
@@ -472,6 +517,44 @@ export default function AdminInviteManagementPage() {
setError(null)
}
const sendSavedInvite = async (invite: Invite) => {
if (!invite.recipient_email) {
editInvite(invite)
setError('Add a recipient email before sending this invite.')
return
}
if (!emailConfigured?.configured) {
setActiveTab('emails')
setError(emailConfigured?.detail ?? 'Configure SMTP before sending invite emails.')
return
}
setSendingInviteId(invite.id)
setError(null)
setStatus(null)
try {
const response = await authFetch(`${getApiBase()}/admin/invites/email/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_key: 'invited',
invite_id: invite.id,
recipient_email: invite.recipient_email,
}),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const payload = await response.json().catch(() => null)
throw new Error(payload?.detail || `Email delivery failed (${response.status})`)
}
setStatus(`Invite ${invite.code} was sent to ${invite.recipient_email}.`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not send the invite email.')
} finally {
setSendingInviteId(null)
}
}
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
setSelectedTemplateKey(templateKey)
loadTemplateEditor(templateKey, emailTemplates)
@@ -803,12 +886,18 @@ export default function AdminInviteManagementPage() {
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const masterInvite = invitePolicy?.master_invite ?? null
const selectedTemplate =
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
const inviteAttentionCount = inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length
const filteredInvites = useMemo(() => {
if (inviteView === 'ready') return invites.filter(isInviteOperationallyReady)
if (inviteView === 'attention') return invites.filter((invite) => !isInviteOperationallyReady(invite))
if (inviteView === 'used') return invites.filter((invite) => invite.use_count > 0)
return invites
}, [inviteView, invites])
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
@@ -992,7 +1081,7 @@ export default function AdminInviteManagementPage() {
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
<span>{inviteSummary?.ready ?? usableInvites} ready {inviteAttentionCount} need attention</span>
</div>
</div>
<div className="invite-admin-summary-row">
@@ -1052,29 +1141,77 @@ export default function AdminInviteManagementPage() {
</div>
</div>
</div>
<div className="admin-rail-card invite-automation-card">
<div>
<span className="admin-rail-eyebrow">Automatic setup</span>
<h2>Readiness checks</h2>
<p>Magent validates each link and applies safer defaults to new invitations.</p>
</div>
<div className="invite-readiness-list">
<button type="button" onClick={() => setActiveTab('invites')}>
<span>New invite defaults</span>
<strong>1 use · 7 days</strong>
</button>
<button type="button" onClick={() => setActiveTab('profiles')}>
<span>Active access profiles</span>
<strong>{activeProfiles > 0 ? `${activeProfiles} ready` : 'Needs setup'}</strong>
</button>
<button type="button" onClick={() => setActiveTab('bulk')}>
<span>Self-service policy</span>
<strong>{masterInvite ? 'Configured' : 'Not configured'}</strong>
</button>
<button type="button" onClick={() => setActiveTab('emails')}>
<span>Email delivery</span>
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
</button>
</div>
</div>
</div>
)
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
title="Invites"
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<section className="invite-operations-strip" aria-label="Invite operations overview">
<button type="button" onClick={() => { setInviteView('ready'); setActiveTab('invites') }}>
<span>Ready</span>
<strong>{inviteSummary?.ready ?? usableInvites}</strong>
<small>can be used now</small>
</button>
<button type="button" onClick={() => { setInviteView('attention'); setActiveTab('invites') }}>
<span>Needs attention</span>
<strong>{inviteAttentionCount}</strong>
<small>expired, used, disabled, or misconfigured</small>
</button>
<button type="button" onClick={() => { setInviteView('used'); setActiveTab('invites') }}>
<span>Successful sign-ups</span>
<strong>{inviteSummary?.used_signups ?? invites.reduce((total, invite) => total + invite.use_count, 0)}</strong>
<small>recorded from invite links</small>
</button>
<button type="button" onClick={() => setActiveTab('emails')}>
<span>Email delivery</span>
<strong>{emailConfigured?.configured ? 'Ready' : 'Setup'}</strong>
<small>{emailConfigured?.configured ? 'one-click sending available' : 'SMTP needs attention'}</small>
</button>
</section>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
Blanket controls
Invite links
</button>
<button
type="button"
@@ -1088,20 +1225,11 @@ export default function AdminInviteManagementPage() {
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
>
Invites
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Trace map
Automation
</button>
<button
type="button"
@@ -1110,7 +1238,16 @@ export default function AdminInviteManagementPage() {
className={activeTab === 'emails' ? 'is-active' : ''}
onClick={() => setActiveTab('emails')}
>
Email
Delivery
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Lineage
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
@@ -1444,24 +1581,47 @@ export default function AdminInviteManagementPage() {
{activeTab === 'invites' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<h2>Invite links</h2>
<p className="lede">Copy and share invite links. Profiles can be applied per invite.</p>
<div className="invite-list-heading">
<div>
<h2>Invite links</h2>
<p className="lede">Magent checks availability, usage, expiry, and assigned profiles automatically.</p>
</div>
<fieldset className="invite-view-filter">
<legend>Filter invite links</legend>
{([
['all', `All ${invites.length}`],
['ready', `Ready ${inviteSummary?.ready ?? usableInvites}`],
['attention', `Attention ${inviteAttentionCount}`],
['used', `Used ${invites.filter((invite) => invite.use_count > 0).length}`],
] as Array<[InviteView, string]>).map(([value, label]) => (
<button
key={value}
type="button"
className={inviteView === value ? 'is-active' : ''}
onClick={() => setInviteView(value)}
>
{label}
</button>
))}
</fieldset>
</div>
{loading ? (
<div className="status-banner">Loading invites</div>
) : invites.length === 0 ? (
<div className="status-banner">No invites created yet.</div>
) : filteredInvites.length === 0 ? (
<div className="status-banner">No invite links match this view.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
{filteredInvites.map((invite) => (
<div key={invite.id} className={`admin-list-item invite-list-item is-${invite.operational_state ?? 'ready'}`}>
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
<span className={`small-pill invite-state-pill is-${invite.operational_state ?? 'ready'}`}>
{invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')}
</span>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
</div>
{invite.attention_reason ? <p className="invite-attention-reason">{invite.attention_reason}</p> : null}
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
@@ -1483,8 +1643,16 @@ export default function AdminInviteManagementPage() {
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link
</button>
<button
type="button"
className="ghost-button"
disabled={sendingInviteId === invite.id || !isInviteOperationallyReady(invite)}
onClick={() => void sendSavedInvite(invite)}
>
{sendingInviteId === invite.id ? 'Sending…' : 'Send email'}
</button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Email invite
Delivery options
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
@@ -1599,25 +1767,34 @@ export default function AdminInviteManagementPage() {
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
<select
value={inviteForm.max_uses}
onChange={(e) =>
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(e) =>
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
>
<option value="">Unlimited</option>
<option value="1">One person</option>
<option value="2">Two people</option>
<option value="5">Five people</option>
<option value="10">Ten people</option>
<option value="25">Twenty-five people</option>
{inviteForm.max_uses && !['1', '2', '5', '10', '25'].includes(inviteForm.max_uses) ? (
<option value={inviteForm.max_uses}>{inviteForm.max_uses} uses</option>
) : null}
</select>
</label>
<div className="invite-expiry-presets">
<span>Invite lifetime</span>
<div>
<button type="button" onClick={() => setInviteExpiryPreset(1)}>24 hours</button>
<button type="button" onClick={() => setInviteExpiryPreset(7)}>7 days</button>
<button type="button" onClick={() => setInviteExpiryPreset(30)}>30 days</button>
<button type="button" onClick={() => setInviteExpiryPreset(90)}>90 days</button>
<button type="button" className="ghost-button" onClick={() => setInviteExpiryPreset(null)}>No expiry</button>
</div>
<small>{inviteForm.expires_at ? `Expires ${formatDate(inviteForm.expires_at)}` : 'This invite will not expire.'}</small>
</div>
</div>
</div>