Refresh invite operations workspace
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user