Refresh invite operations workspace
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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">
|
||||
<div className="invite-list-heading">
|
||||
<div>
|
||||
<h2>Invite links</h2>
|
||||
<p className="lede">Copy and share invite links. Profiles can be applied per invite.</p>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
]
|
||||
: [
|
||||
|
||||
Reference in New Issue
Block a user