From c49a149cfd7b12693e8a4fa6c82209a8eaed3285 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 1 Sep 2026 15:12:37 +1200 Subject: [PATCH] Refresh invite operations workspace --- backend/app/routers/admin.py | 36 +++- backend/tests/test_backend_quality.py | 32 +++ frontend/app/admin/invites/page.tsx | 285 +++++++++++++++++++++----- frontend/app/globals.css | 210 +++++++++++++++++++ frontend/app/ui/HeaderActions.tsx | 12 +- 5 files changed, 518 insertions(+), 57 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 4b4efdd..ec2da5f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1695,9 +1695,34 @@ async def get_invites() -> Dict[str, Any]: results = [] for invite in invites: profile = profiles.get(invite.get("profile_id")) + if not invite.get("enabled"): + operational_state = "disabled" + state_label = "Disabled" + attention_reason = "This invite has been switched off." + elif invite.get("is_expired"): + operational_state = "expired" + state_label = "Expired" + attention_reason = "The invite has passed its expiry date." + elif invite.get("remaining_uses") == 0: + operational_state = "exhausted" + state_label = "Fully used" + attention_reason = "Every permitted sign-up has been used." + elif invite.get("profile_id") is not None and ( + profile is None or profile.get("is_active") is False + ): + operational_state = "profile_unavailable" + state_label = "Profile unavailable" + attention_reason = "The assigned profile is missing or disabled." + else: + operational_state = "ready" + state_label = "Ready to use" + attention_reason = None results.append( { **invite, + "operational_state": operational_state, + "state_label": state_label, + "attention_reason": attention_reason, "profile": ( { "id": profile.get("id"), @@ -1708,7 +1733,16 @@ async def get_invites() -> Dict[str, Any]: ), } ) - return {"invites": results} + return { + "invites": results, + "summary": { + "total": len(results), + "ready": sum(1 for invite in results if invite["operational_state"] == "ready"), + "attention": sum(1 for invite in results if invite["operational_state"] != "ready"), + "used_signups": sum(int(invite.get("use_count") or 0) for invite in results), + "with_recipient": sum(1 for invite in results if invite.get("recipient_email")), + }, + } @router.get("/invites/policy") diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 09d6c10..791d38e 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -17,6 +17,7 @@ from backend.app.config import settings from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from backend.app.routers import auth as auth_router +from backend.app.routers import admin as admin_router from backend.app.routers import portal as portal_router from backend.app.routers import requests as requests_router from backend.app.routers import site as site_router @@ -1604,6 +1605,37 @@ class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase): self.assertNotIn("secret upstream failure", str(result)) +class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + async def test_invite_list_reports_automatic_operational_states(self) -> None: + ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com") + db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com") + used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com") + db.increment_signup_invite_use(int(used["id"])) + db.create_signup_invite( + code="EXPIRED", + expires_at="2000-01-01T00:00:00+00:00", + recipient_email="expired@example.com", + ) + db.create_signup_invite( + code="NO-PROFILE", + profile_id=999, + recipient_email="profile@example.com", + ) + + payload = await admin_router.get_invites() + states = {invite["code"]: invite["operational_state"] for invite in payload["invites"]} + + self.assertEqual(states[ready["code"]], "ready") + self.assertEqual(states["DISABLED"], "disabled") + self.assertEqual(states["USED"], "exhausted") + self.assertEqual(states["EXPIRED"], "expired") + self.assertEqual(states["NO-PROFILE"], "profile_unavailable") + self.assertEqual(payload["summary"]["total"], 5) + self.assertEqual(payload["summary"]["ready"], 1) + self.assertEqual(payload["summary"]["attention"], 4) + self.assertEqual(payload["summary"]["used_signups"], 1) + + class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase): def test_legacy_request_status_maps_to_workflow(self) -> None: item = {"kind": "request", "status": "in_progress"} diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 42a4550..c87234a 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -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([]) @@ -170,6 +191,7 @@ export default function AdminInviteManagementPage() { const [loading, setLoading] = useState(true) const [inviteSaving, setInviteSaving] = useState(false) + const [sendingInviteId, setSendingInviteId] = useState(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(null) const [inviteForm, setInviteForm] = useState(defaultInviteForm()) + const [inviteSummary, setInviteSummary] = useState(null) + const [inviteView, setInviteView] = useState('all') const [profileEditingId, setProfileEditingId] = useState(null) const [profileForm, setProfileForm] = useState(defaultProfileForm()) @@ -192,7 +216,7 @@ export default function AdminInviteManagementPage() { const [bulkExpiryDays, setBulkExpiryDays] = useState('') const [masterInviteSelection, setMasterInviteSelection] = useState('') const [invitePolicy, setInvitePolicy] = useState(null) - const [activeTab, setActiveTab] = useState('bulk') + const [activeTab, setActiveTab] = useState('invites') const [emailTemplates, setEmailTemplates] = useState([]) const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null) const [selectedTemplateKey, setSelectedTemplateKey] = useState('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() @@ -992,7 +1081,7 @@ export default function AdminInviteManagementPage() { Invites
{invites.length} - {usableInvites} usable • {disabledInvites} disabled + {inviteSummary?.ready ?? usableInvites} ready • {inviteAttentionCount} need attention
@@ -1052,29 +1141,77 @@ export default function AdminInviteManagementPage() {
+
+
+ Automatic setup +

Readiness checks

+

Magent validates each link and applies safer defaults to new invitations.

+
+
+ + + + +
+
) return (
{error &&
{error}
} {status &&
{status}
} +
+ + + + +
+
- +
@@ -1444,24 +1581,47 @@ export default function AdminInviteManagementPage() { {activeTab === 'invites' && (
-

Invite links

-

Copy and share invite links. Profiles can be applied per invite.

+
+
+

Invite links

+

Magent checks availability, usage, expiry, and assigned profiles automatically.

+
+
+ Filter invite links + {([ + ['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]) => ( + + ))} +
+
{loading ? (
Loading invites…
- ) : invites.length === 0 ? ( -
No invites created yet.
+ ) : filteredInvites.length === 0 ? ( +
No invite links match this view.
) : (
- {invites.map((invite) => ( -
+ {filteredInvites.map((invite) => ( +
{invite.code} - - {invite.is_usable ? 'Usable' : 'Unavailable'} + + {invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')} {invite.profile?.name && {invite.profile.name}}
+ {invite.attention_reason ?

{invite.attention_reason}

: null} {invite.label &&

{invite.label}

} {invite.description && (

@@ -1483,8 +1643,16 @@ export default function AdminInviteManagementPage() { + + + + + +

+ {inviteForm.expires_at ? `Expires ${formatDate(inviteForm.expires_at)}` : 'This invite will not expire.'} +
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 1694cd1..de38290 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4557,6 +4557,51 @@ button:hover:not(:disabled) { margin-bottom: 12px; } +.invite-operations-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.invite-operations-strip > button { + display: grid; + gap: 5px; + min-height: 112px; + padding: 14px; + border: 1px solid rgba(126, 215, 255, 0.18); + border-radius: 8px; + background: linear-gradient(135deg, rgba(14, 165, 233, 0.055), rgba(255, 255, 255, 0.018)); + text-align: left; +} + +.invite-operations-strip > button:hover { + border-color: rgba(126, 215, 255, 0.42); + background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(255, 255, 255, 0.026)); +} + +.invite-operations-strip span, +.invite-operations-strip small { + color: #9ea7b6; + font-size: 0.7rem; +} + +.invite-operations-strip span { + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.invite-operations-strip strong { + color: #eef7ff; + font-size: 1.55rem; +} + +.invite-operations-strip small { + align-self: end; + line-height: 1.35; +} + .invite-admin-summary-tile { min-height: 96px; } @@ -4685,6 +4730,128 @@ button:hover:not(:disabled) { gap: 12px; } +.invite-automation-card { + gap: 12px; +} + +.invite-automation-card > div:first-child { + display: grid; + gap: 4px; +} + +.invite-readiness-list { + display: grid; + gap: 6px; +} + +.invite-readiness-list > button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 9px 10px; + border: 1px solid rgba(255, 255, 255, 0.055); + background: rgba(255, 255, 255, 0.018); + text-align: left; +} + +.invite-readiness-list span { + color: #9ea7b6; + font-size: 0.7rem; +} + +.invite-readiness-list strong { + color: #dceafa; + font-size: 0.7rem; + white-space: nowrap; +} + +.invite-list-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.invite-list-heading h2, +.invite-list-heading p { + margin: 0; +} + +.invite-view-filter { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.invite-view-filter legend { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.invite-view-filter button { + padding: 7px 10px; + border-color: rgba(255, 255, 255, 0.07); + background: rgba(255, 255, 255, 0.02); + color: #aeb8c7; + font-size: 0.7rem; +} + +.invite-view-filter button.is-active { + border-color: rgba(126, 215, 255, 0.38); + background: rgba(14, 165, 233, 0.1); + color: #eaf8ff; +} + +.invite-list-item { + border-left: 3px solid rgba(126, 215, 255, 0.4); +} + +.invite-list-item.is-ready { + border-left-color: #48e0b2; +} + +.invite-list-item.is-expired, +.invite-list-item.is-exhausted, +.invite-list-item.is-profile_unavailable { + border-left-color: #ffc56d; +} + +.invite-list-item.is-disabled { + border-left-color: #7e8999; +} + +.invite-state-pill.is-ready { + border-color: rgba(72, 224, 178, 0.32); + color: #69edc5; +} + +.invite-state-pill.is-expired, +.invite-state-pill.is-exhausted, +.invite-state-pill.is-profile_unavailable { + border-color: rgba(255, 197, 109, 0.34); + color: #ffd38a; +} + +.invite-attention-reason { + margin: 5px 0 0; + color: #ffc56d; + font-size: 0.76rem; +} + .invite-admin-bulk-panel .user-bulk-groups { display: grid; gap: 10px; @@ -4772,6 +4939,32 @@ button:hover:not(:disabled) { gap: 10px; } +.invite-expiry-presets { + display: grid; + gap: 7px; +} + +.invite-expiry-presets > span { + color: #9ea7b6; + font-size: 0.76rem; +} + +.invite-expiry-presets > div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.invite-expiry-presets button { + padding: 7px 9px; + font-size: 0.7rem; +} + +.invite-expiry-presets small { + color: #aeb8c7; + font-size: 0.7rem; +} + .invite-email-template-picker { display: flex; flex-wrap: wrap; @@ -4831,6 +5024,10 @@ button:hover:not(:disabled) { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .invite-operations-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .invite-admin-bulk-grid { grid-template-columns: 1fr; } @@ -4880,6 +5077,19 @@ button:hover:not(:disabled) { grid-template-columns: 1fr; } + .invite-operations-strip { + grid-template-columns: 1fr; + } + + .invite-list-heading { + align-items: stretch; + flex-direction: column; + } + + .invite-view-filter { + justify-content: flex-start; + } + .invite-admin-summary-row { grid-template-columns: 1fr; align-items: start; diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx index 0c6d6d7..86cd477 100644 --- a/frontend/app/ui/HeaderActions.tsx +++ b/frontend/app/ui/HeaderActions.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' import { usePathname } from 'next/navigation' +import { useEffect, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' export default function HeaderActions() { @@ -55,10 +55,18 @@ export default function HeaderActions() { ? [] : role === 'admin' ? [ + { + href: '/admin/invites', + label: 'Invites', + match: (path: string) => path.startsWith('/admin/invites') || path.startsWith('/admin/profiles'), + }, { href: '/admin', label: 'Config', - match: (path: string) => path.startsWith('/admin'), + match: (path: string) => + path.startsWith('/admin') && + !path.startsWith('/admin/invites') && + !path.startsWith('/admin/profiles'), }, ] : [