Build 2602261605: invite trace and cross-system user lifecycle
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user