admin docs and layout refresh, build 2702261314
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user