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
+35 -1
View File
@@ -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")
+32
View File
@@ -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"}
+230 -53
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">
<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>
+210
View File
@@ -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;
+10 -2
View File
@@ -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'),
},
]
: [