admin docs and layout refresh, build 2702261314

This commit is contained in:
2026-02-27 13:17:50 +13:00
parent b84c27c698
commit 05a3d1e3b0
10 changed files with 1400 additions and 274 deletions

View File

@@ -70,6 +70,21 @@ type ProfileForm = {
}
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph'
type InviteTraceRow = {
username: string
role: string
authProvider: string
level: number
inviterUsername: string | null
inviteCode: string | null
inviteLabel: string | null
createdAt: string | null
childCount: number
isCycle?: boolean
}
type InvitePolicy = {
master_invite_id?: number | null
@@ -105,6 +120,9 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString()
}
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
export default function AdminInviteManagementPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
@@ -135,6 +153,8 @@ export default function AdminInviteManagementPage() {
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('graph')
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
@@ -698,74 +718,116 @@ export default function AdminInviteManagementPage() {
)
}, [invites, traceFilter, users])
const scopedInviteTraceRows = useMemo(() => {
if (traceScope === 'invited') return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row))
if (traceScope === 'direct') return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row))
return inviteTraceRows
}, [inviteTraceRows, traceScope])
const traceInvitedCount = useMemo(
() => inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)).length,
[inviteTraceRows]
)
const traceDirectCount = inviteTraceRows.length - traceInvitedCount
const inviteTraceGraphColumns = useMemo(() => {
if (scopedInviteTraceRows.length === 0) return [] as Array<{ level: number; rows: InviteTraceRow[] }>
const minLevel = Math.min(...scopedInviteTraceRows.map((row) => row.level))
const grouped = new Map<number, InviteTraceRow[]>()
scopedInviteTraceRows.forEach((row) => {
const level = Math.max(0, row.level - minLevel)
const bucket = grouped.get(level) ?? []
bucket.push(row)
grouped.set(level, bucket)
})
return Array.from(grouped.entries())
.sort((a, b) => a[0] - b[0])
.map(([level, rows]) => ({
level,
rows: [...rows].sort((a, b) =>
String(a.username || '').localeCompare(String(b.username || ''), undefined, {
sensitivity: 'base',
})
),
}))
}, [scopedInviteTraceRows])
const inviteManagementRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<span className="admin-rail-eyebrow">Overview</span>
<h2>Invite stats</h2>
<p className="lede">Live counts for invites, profiles, and managed user defaults.</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>
{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
</div>
</div>
</div>
)
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-panel invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<h2>Overview</h2>
<p className="lede">
Quick counts for invite links, profiles, and managed user defaults.
</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
</div>
</div>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
@@ -833,8 +895,8 @@ export default function AdminInviteManagementPage() {
</div>
{activeTab === 'bulk' && (
<div className="admin-split-grid invite-admin-bulk-grid">
<div className="admin-panel">
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Blanket controls</h2>
@@ -843,13 +905,6 @@ export default function AdminInviteManagementPage() {
</p>
</div>
</div>
<div className="admin-meta-row">
<span>Local non-admin users: {nonAdminUsers.length}</span>
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
<span>Invite access enabled: {inviteAccessEnabledUsers}</span>
<span>Profile assigned: {profiledUsers}</span>
<span>Custom expiry set: {expiringUsers}</span>
</div>
<div className="user-bulk-groups">
<div className="user-bulk-group">
<div className="user-bulk-group-meta">
@@ -961,46 +1016,6 @@ export default function AdminInviteManagementPage() {
</div>
</div>
</div>
<div className="admin-panel">
<div className="user-directory-panel-header">
<div>
<h2>How this page is organized</h2>
<p className="lede">Use tabs to switch between blanket controls, reusable profiles, and invite links.</p>
</div>
</div>
<div className="admin-list">
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Profiles</strong>
</div>
<p className="admin-list-item-text">
Create reusable account defaults and apply them to invite links or existing users.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('profiles')}>
Open
</button>
</div>
</div>
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Invites</strong>
</div>
<p className="admin-list-item-text">
Create and manage signup links, assign profiles, and copy shareable URLs.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('invites')}>
Open
</button>
</div>
</div>
</div>
</div>
</div>
)}
@@ -1410,19 +1425,105 @@ export default function AdminInviteManagementPage() {
placeholder="Search by username, inviter, or invite code"
/>
</label>
<div className="invite-trace-controls">
<label className="invite-trace-scope">
<span>Scope</span>
<select
value={traceScope}
onChange={(e) =>
setTraceScope(e.target.value as InviteTraceScope)
}
>
<option value="all">All users</option>
<option value="invited">Invited only</option>
<option value="direct">Direct / root only</option>
</select>
</label>
<div className="invite-trace-view-toggle" role="group" aria-label="Trace view mode">
<button
type="button"
className={traceView === 'graph' ? 'is-active' : ''}
onClick={() => setTraceView('graph')}
>
Graph
</button>
<button
type="button"
className={traceView === 'list' ? 'is-active' : ''}
onClick={() => setTraceView('list')}
>
List
</button>
</div>
</div>
<div className="invite-trace-summary">
<span>{inviteTraceRows.length} rows shown</span>
<span>{scopedInviteTraceRows.length} rows shown</span>
<span>{traceInvitedCount} invited</span>
<span>{traceDirectCount} direct/root</span>
<span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : inviteTraceRows.length === 0 ? (
) : scopedInviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div>
) : traceView === 'graph' ? (
<div className="invite-trace-graph">
{inviteTraceGraphColumns.map((column) => (
<section key={`trace-level-${column.level}`} className="invite-trace-column">
<header className="invite-trace-column-header">
<span>Level {column.level}</span>
<strong>{column.rows.length}</strong>
</header>
<div className="invite-trace-column-body">
{column.rows.map((row) => (
<article
key={`${row.username}-${column.level}-${row.inviteCode || 'direct'}`}
className="invite-trace-node"
>
<div className="invite-trace-node-main">
<div className="invite-trace-node-title">
<span className="invite-trace-user">{row.username}</span>
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<p className={`invite-trace-node-arrow ${isInviteTraceRowInvited(row) ? '' : 'is-root'}`}>
{row.inviterUsername
? `\u2190 Invited by ${row.inviterUsername}`
: row.inviteCode
? `\u2190 Invited via code ${row.inviteCode}`
: 'Direct/root account'}
</p>
</div>
<div className="invite-trace-node-meta">
<span className="invite-trace-node-meta-item">
<span className="label">Invite code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</article>
))}
</div>
</section>
))}
</div>
) : (
<div className="invite-trace-map">
{inviteTraceRows.map((row) => (
{scopedInviteTraceRows.map((row) => (
<div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row">
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
<span className="invite-trace-branch" aria-hidden="true" />