Files
Magent/frontend/app/admin/identities/page.tsx
T
Assclaw 77f2c1b42a
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m29s
Add reviewed resolution for missing user identity links
2026-09-10 15:02:04 +12:00

169 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
import './identities.css'
import ResolveIdentityLink from './ResolveIdentityLink'
type Identity = { id: string; name: string }
export type Row = {
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null }
jellyfin: Identity | null
candidate_jellyfin_id: string | null
stored_jellyfin_id: string | null
seerr: { id: number; name: string; jellyfin_id: string }[]
jellystat: { state: string; id?: string; name?: string }
basis: string
issues: string[]
state: string
can_confirm: boolean
confirmed_at: string | null
}
type Report = {
revision: string; checked_at: string; server_id: string | null
services: Record<string, string>
counts: Record<string, number>
jellyfin_users: Identity[]
rows: Row[]
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
}
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerrs Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
export default function IdentityReviewPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [report, setReport] = useState<Report | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const [selected, setSelected] = useState<number[]>([])
const [resolving, setResolving] = useState<Row | null>(null)
const [reviewing, setReviewing] = useState(false)
const controller = useRef<AbortController | null>(null)
const reviewPanel = useRef<HTMLElement | null>(null)
useEffect(() => {
const abort = new AbortController()
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
if (response.status === 401) { router.replace('/login'); return }
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
if (!abort.signal.aborted) setReady(true)
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => { abort.abort(); controller.current?.abort() }
}, [router])
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
const responseData = async (response: Response) => {
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
return data
}
const runCheck = async () => {
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
if (!abort.signal.aborted) setReport(data)
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
} finally { if (!abort.signal.aborted) setBusy(false) }
}
const save = async () => {
if (!report || saving || !selected.length) return
setSaving(true); setError(''); setNotice('')
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}))
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
// The scan describes the previous database state and cannot be reused for another write.
setReport(null); setSelected([]); setReviewing(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save identity links.')
setReport(null); setSelected([]); setReviewing(false)
} finally { setSaving(false) }
}
const needle = query.trim().toLowerCase()
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
return <AdminShell title="User identities" subtitle="Review account links across Jellyfin, Seerr, Jellystat and Magent." actions={<a href="/users" className="ghost-button">Back to users</a>}>
<div className="identity-review">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!ready && !error && <p role="status">Checking administrator access</p>}
{ready && <>
<section className="identity-intro admin-panel">
<div><h2>Confirm user IDs</h2><p>Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Duplicate and conflicting accounts need individual investigation.</p></div>
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
</section>
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
{report && <>
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
<div className="identity-filters">
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
</div>
<div className="identity-selection">
<span>{filtered.length} accounts shown · {selected.length} selected</span>
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
</div>
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
<h2>Save these {selected.length} account links?</h2>
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin <code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}</li>)}</ul>
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
</section>}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
<dl className="identity-mapping">
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
</dl>
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
{row.state === 'unlinked' && <div className="identity-resolution-entry"><p className="identity-meta">Choose the correct Jellyfin account and check its Seerr and Jellystat links before saving.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Resolve missing link</button></div>}
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
</article>)}
</section>
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
</>}
</>}
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
setNotice('Account links confirmed and saved. Run another check to see the updated mappings.')
}} />}
</div>
</AdminShell>
}