'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 counts: Record jellyfin_users: Identity[] rows: Row[] upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[] } const labels: Record = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' } const serviceLabels: Record = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' } const basisLabels: Record = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerr’s Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' } const statsLabels: Record = { 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(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([]) const [resolving, setResolving] = useState(null) const [reviewing, setReviewing] = useState(false) const controller = useRef(null) const reviewPanel = useRef(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 Back to users}>
{error &&

{error}

} {notice &&

{notice}

} {!ready && !error &&

Checking administrator access…

} {ready && <>

Confirm user IDs

Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.

Review the proposed links before saving. Duplicate and conflicting accounts need individual investigation.

{busy &&

Reading the live user directories and checking Jellystat IDs. This can take up to a minute.

} {report && <>
{Object.entries(report.services).map(([service, state]) => {service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'} {serviceLabels[state] ?? state})}

Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server {report.server_id ?? 'Unavailable'}

{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) =>
{report.counts[state]}{state === 'magent' ? 'Magent accounts' : labels[state]}
)}

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.

{filtered.length} accounts shown · {selected.length} selected
{reviewing &&

Save these {selected.length} account links?

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.

    {selectedRows.map((row) =>
  • {row.user.username} · Magent {row.user.id} → Jellyfin {row.candidate_jellyfin_id} → Seerr {row.seerr[0].id}
  • )}

Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.

}
{!filtered.length &&

No accounts match these filters.

} {filtered.map((row) =>
{row.can_confirm && toggle(row.user.id)} />}

{row.user.username}

Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in
{labels[row.state]}
Jellyfin user ID
{row.candidate_jellyfin_id ?? 'No match'}{row.jellyfin && {row.jellyfin.name}}{basisLabels[row.basis]}{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && Stored: {row.stored_jellyfin_id}}
Seerr user ID
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}{row.seerr.map((entry) => entry.name).join(', ')}Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}
Jellystat user ID
{row.jellystat.id ?? 'Not verified'}{statsLabels[row.jellystat.state] ?? row.jellystat.state}
{row.issues.length > 0 &&
    {row.issues.map((issue) =>
  • {issue}
  • )}
} {row.state === 'unlinked' &&

Choose the correct Jellyfin account and check its Seerr and Jellystat links before saving.

} {row.state === 'unavailable' &&

A required service could not be checked. Check its connection and run this again.

} {row.confirmed_at &&

Last confirmed {new Date(row.confirmed_at).toLocaleString()}

}
)}
{report.upstream.length > 0 &&
{report.upstream.length} upstream accounts need review
    {report.upstream.map((entry) =>
  • {entry.platform}: {entry.name} · ID {entry.id}{entry.jellyfin_id && · Jellyfin {entry.jellyfin_id}}

    {entry.detail}

  • )}
} } } {resolving && report && setResolving(null)} onSaved={() => { setResolving(null); setReport(null); setSelected([]); setReviewing(false) setNotice('Account links confirmed and saved. Run another check to see the updated mappings.') }} />}
}