Build 2602261605: invite trace and cross-system user lifecycle

This commit is contained in:
2026-02-26 16:06:09 +13:00
parent bd3c0bdade
commit 1b1a3e233b
13 changed files with 976 additions and 16 deletions

View File

@@ -9,8 +9,12 @@ type AdminUserLite = {
id: number
username: string
role: string
auth_provider?: string | null
profile_id?: number | null
expires_at?: string | null
created_at?: string | null
invited_by_code?: string | null
invited_at?: string | null
}
type Profile = {
@@ -41,6 +45,7 @@ type Invite = {
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
created_by?: string | null
}
type InviteForm = {
@@ -63,7 +68,7 @@ type ProfileForm = {
is_active: boolean
}
type InviteManagementTab = 'bulk' | 'profiles' | 'invites'
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
const defaultInviteForm = (): InviteForm => ({
code: '',
@@ -116,6 +121,7 @@ export default function AdminInviteManagementPage() {
const [bulkProfileId, setBulkProfileId] = useState('')
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('')
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
@@ -468,6 +474,133 @@ export default function AdminInviteManagementPage() {
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
invites.forEach((invite) => {
const code = String(invite.code || '').trim()
if (code) inviteByCode.set(code.toLowerCase(), invite)
})
const userByName = new Map<string, AdminUserLite>()
users.forEach((user) => {
const username = String(user.username || '').trim()
if (username) userByName.set(username.toLowerCase(), user)
})
const childrenByInviter = new Map<string, AdminUserLite[]>()
const inviterMetaByUser = new Map<
string,
{ inviterUsername: string | null; inviteCode: string | null; inviteLabel: string | null }
>()
users.forEach((user) => {
const username = String(user.username || '').trim()
if (!username) return
const inviteCodeRaw = String(user.invited_by_code || '').trim()
let inviterUsername: string | null = null
let inviteLabel: string | null = null
if (inviteCodeRaw) {
const invite = inviteByCode.get(inviteCodeRaw.toLowerCase())
inviteLabel = (invite?.label as string | undefined) || null
const createdBy = String(invite?.created_by || '').trim()
if (createdBy) inviterUsername = createdBy
}
inviterMetaByUser.set(username.toLowerCase(), {
inviterUsername,
inviteCode: inviteCodeRaw || null,
inviteLabel,
})
const key = (inviterUsername || '__root__').toLowerCase()
const bucket = childrenByInviter.get(key) ?? []
bucket.push(user)
childrenByInviter.set(key, bucket)
})
childrenByInviter.forEach((bucket) =>
bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' }))
)
const rows: Array<{
username: string
role: string
authProvider: string
level: number
inviterUsername: string | null
inviteCode: string | null
inviteLabel: string | null
createdAt: string | null
childCount: number
isCycle?: boolean
}> = []
const visited = new Set<string>()
const walk = (user: AdminUserLite, level: number, path: Set<string>) => {
const username = String(user.username || '').trim()
const userKey = username.toLowerCase()
if (!username) return
const meta = inviterMetaByUser.get(userKey) ?? {
inviterUsername: null,
inviteCode: null,
inviteLabel: null,
}
const childCount = (childrenByInviter.get(userKey) ?? []).length
if (path.has(userKey)) {
rows.push({
username,
role: String(user.role || 'user'),
authProvider: String(user.auth_provider || 'local'),
level,
inviterUsername: meta.inviterUsername,
inviteCode: meta.inviteCode,
inviteLabel: meta.inviteLabel,
createdAt: (user.created_at as string | null) ?? null,
childCount,
isCycle: true,
})
return
}
rows.push({
username,
role: String(user.role || 'user'),
authProvider: String(user.auth_provider || 'local'),
level,
inviterUsername: meta.inviterUsername,
inviteCode: meta.inviteCode,
inviteLabel: meta.inviteLabel,
createdAt: (user.created_at as string | null) ?? null,
childCount,
})
visited.add(userKey)
const nextPath = new Set(path)
nextPath.add(userKey)
;(childrenByInviter.get(userKey) ?? []).forEach((child) => walk(child, level + 1, nextPath))
}
;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => walk(rootUser, 0, new Set()))
users.forEach((user) => {
const key = String(user.username || '').toLowerCase()
if (key && !visited.has(key)) {
walk(user, 0, new Set())
}
})
const filter = traceFilter.trim().toLowerCase()
if (!filter) return rows
return rows.filter((row) =>
[
row.username,
row.inviterUsername || '',
row.inviteCode || '',
row.inviteLabel || '',
row.role || '',
row.authProvider || '',
]
.join(' ')
.toLowerCase()
.includes(filter)
)
}, [invites, traceFilter, users])
return (
<AdminShell
title="Invite management"
@@ -547,6 +680,15 @@ export default function AdminInviteManagementPage() {
>
Invites
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Trace map
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
@@ -1064,6 +1206,78 @@ export default function AdminInviteManagementPage() {
</div>
</div>
)}
{activeTab === 'trace' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Invite trace map</h2>
<p className="lede">
Visual lineage of who invited who, including the invite code used for each sign-up.
</p>
</div>
</div>
<div className="invite-trace-toolbar">
<label className="invite-trace-filter">
<span>Find user / inviter / code</span>
<input
type="search"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder="Search by username, inviter, or invite code"
/>
</label>
<div className="invite-trace-summary">
<span>{inviteTraceRows.length} rows shown</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 ? (
<div className="status-banner">No trace matches found.</div>
) : (
<div className="invite-trace-map">
{inviteTraceRows.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" />
<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>
<div className="invite-trace-row-meta">
<span className="invite-trace-meta-item">
<span className="label">Invited by</span>
<strong>{row.inviterUsername || 'Root/direct'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Via code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</section>
</AdminShell>
)

View File

@@ -4757,3 +4757,143 @@ textarea {
.theme-toggle {
border-radius: 50%;
}
.invite-trace-toolbar {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto;
gap: 10px 14px;
align-items: end;
margin-bottom: 10px;
}
.invite-trace-filter {
display: grid;
gap: 6px;
}
.invite-trace-filter > span {
color: #9ea7b6;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-summary {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
color: #aeb7c4;
font-size: 0.8rem;
}
.invite-trace-summary span {
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 5px;
}
.invite-trace-map {
display: grid;
gap: 8px;
}
.invite-trace-row {
display: grid;
grid-template-columns: minmax(260px, 420px) minmax(0, 1fr);
gap: 10px 12px;
align-items: start;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
border-radius: 6px;
}
.invite-trace-row-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 28px;
}
.invite-trace-branch {
width: 12px;
height: 1px;
background: rgba(138, 163, 196, 0.55);
position: relative;
margin-right: 2px;
}
.invite-trace-branch::before {
content: '';
position: absolute;
left: -8px;
top: -7px;
width: 8px;
height: 8px;
border-left: 1px solid rgba(138, 163, 196, 0.38);
border-bottom: 1px solid rgba(138, 163, 196, 0.38);
}
.invite-trace-user {
color: #edf2f8;
font-weight: 700;
letter-spacing: 0.01em;
}
.invite-trace-row-meta {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.invite-trace-meta-item {
display: grid;
gap: 3px;
align-content: start;
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.01);
border-radius: 5px;
min-height: 48px;
}
.invite-trace-meta-item .label {
color: #8f9aac;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-meta-item strong {
color: #e7edf6;
font-size: 0.82rem;
word-break: break-word;
}
@media (max-width: 1180px) {
.invite-trace-toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.invite-trace-summary {
justify-content: flex-start;
}
.invite-trace-row {
grid-template-columns: 1fr;
}
.invite-trace-row-meta {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.invite-trace-row-meta {
grid-template-columns: 1fr;
}
}

View File

@@ -52,7 +52,7 @@ export default function LoginPage() {
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p>
<p className="lede">Use your Jellyfin account, or sign in with a local Magent admin account.</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
<label>
Username
@@ -86,7 +86,7 @@ export default function LoginPage() {
Sign in with Magent account
</button>
<a className="ghost-button" href="/signup">
Have an invite? Create a Magent account
Have an invite? Create your account (Jellyfin + Magent)
</a>
</form>
</main>

View File

@@ -135,7 +135,7 @@ function SignupPageContent() {
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create a Magent account.</p>
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form">
<label>
Invite code
@@ -203,7 +203,7 @@ function SignupPageContent() {
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'}
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>

View File

@@ -29,8 +29,24 @@ type AdminUser = {
profile_id?: number | null
expires_at?: string | null
is_expired?: boolean
invited_by_code?: string | null
invited_at?: string | null
}
type UserLineage = {
invite_code?: string | null
invited_by?: string | null
invite?: {
id?: number
code?: string
label?: string | null
created_by?: string | null
created_at?: string | null
enabled?: boolean
is_usable?: boolean
} | null
} | null
type UserProfileOption = {
id: number
name: string
@@ -85,7 +101,9 @@ export default function UserDetailPage() {
const [expiryInput, setExpiryInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false)
const [systemActionBusy, setSystemActionBusy] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null)
const [lineage, setLineage] = useState<UserLineage>(null)
const loadProfiles = async () => {
try {
@@ -138,6 +156,7 @@ export default function UserDetailPage() {
const nextUser = data?.user ?? null
setUser(nextUser)
setStats(normalizeStats(data?.stats))
setLineage((data?.lineage ?? null) as UserLineage)
setProfileSelection(
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
? ''
@@ -315,6 +334,59 @@ export default function UserDetailPage() {
}
}
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
)
if (!confirmed) return
}
setSystemActionBusy(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Cross-system action failed')
}
const state = data?.status === 'partial' ? 'partial' : 'complete'
if (action === 'remove') {
setActionStatus(`User removed (${state}).`)
router.push('/users')
return
}
await loadUser()
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
} finally {
setSystemActionBusy(false)
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -378,6 +450,14 @@ export default function UserDetailPage() {
<span className="label">Assigned profile</span>
<strong>{user.profile_id ?? 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invited by</span>
<strong>{lineage?.invited_by || 'Direct / unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invite code used</span>
<strong>{lineage?.invite_code || user.invited_by_code || 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Last login</span>
<strong>{formatDateTime(user.last_login_at)}</strong>
@@ -463,9 +543,32 @@ export default function UserDetailPage() {
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download access.