Unify user management and add reviewed identity repairs
This commit is contained in:
@@ -19,8 +19,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
{ href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' },
|
||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
|
||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
|
||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions' },
|
||||
{ href: '/admin/identities', label: 'User identities', description: 'Review and confirm IDs across your media services', symbol: 'ID' },
|
||||
{ href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' },
|
||||
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' },
|
||||
]},
|
||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
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: 'Seerr’s 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 IdentityReviewPanel() {
|
||||
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 (
|
||||
<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>Jellyfin’s 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. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.</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' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</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 repaired and saved. Run another check to see the updated mappings.')
|
||||
}} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import type { Row } from './page'
|
||||
import type { Row } from './IdentityReviewPanel'
|
||||
|
||||
type Preview = { revision: string; server_id: string; row: Row }
|
||||
type Preview = {
|
||||
revision: string; server_id: string; row: Row
|
||||
before: { jellyfin_user_id: string | null; seerr_user_id: number | null }
|
||||
seerr_users: { id: number; name: string; jellyfin_id: string | null }[]
|
||||
scope: string
|
||||
action: string
|
||||
}
|
||||
|
||||
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: {
|
||||
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const [chosen, setChosen] = useState('')
|
||||
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? '')
|
||||
const [inspectSeerr, setInspectSeerr] = useState('')
|
||||
const [createSeerr, setCreateSeerr] = useState(false)
|
||||
const [preview, setPreview] = useState<Preview | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -37,9 +45,9 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }:
|
||||
if (confirm) setSaving(true)
|
||||
else { setBusy(true); setPreview(null) }
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/admin/identities/resolve/${confirm ? 'confirm' : 'check'}`, {
|
||||
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? 'confirm' : 'check'}`, {
|
||||
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, ...(confirm ? { revision: preview?.revision } : {}) }),
|
||||
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, create_seerr: createSeerr, ...(confirm ? { revision: preview?.revision } : {}) }),
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.')
|
||||
@@ -57,17 +65,22 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }:
|
||||
|
||||
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
||||
<div className="identity-resolve-content">
|
||||
<header><h2 id="resolve-title">Resolve missing link</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
||||
<p>Link <strong>{row.user.username}</strong> (Magent {row.user.id}) to their Jellyfin account. Review the IDs below to confirm this is the same person.</p>
|
||||
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
||||
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p>
|
||||
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
|
||||
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setChosen(event.target.value)
|
||||
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value)
|
||||
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} — {account.id}</option>)}</select></label>
|
||||
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Check selected account'}</button>
|
||||
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label>
|
||||
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</button>
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
|
||||
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
|
||||
<h3>{preview.row.can_confirm ? 'Ready to confirm' : 'This link needs attention'}</h3>
|
||||
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3>
|
||||
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
|
||||
<div className="identity-mapping">
|
||||
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div>
|
||||
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div>
|
||||
</div>
|
||||
<dl className="identity-mapping">
|
||||
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
|
||||
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again.'}</dd></div>
|
||||
@@ -75,8 +88,18 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }:
|
||||
</dl>
|
||||
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
|
||||
<p>Saving stores the verified Jellyfin and Seerr links in Magent, with your administrator name and confirmation time. All platform IDs and duplicate ownership are checked again before saving.</p>
|
||||
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : 'Confirm and save link'}</button>
|
||||
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance">
|
||||
<h3>Check the existing Seerr account</h3>
|
||||
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
|
||||
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label>
|
||||
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)}
|
||||
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p>
|
||||
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p>
|
||||
</div>}
|
||||
<p>{preview.scope}</p>
|
||||
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p>
|
||||
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>}
|
||||
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button>
|
||||
</section>}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -71,3 +71,8 @@
|
||||
.identity-counts strong { font-size: 1.4rem; }
|
||||
.identity-selection button { width: 100%; }
|
||||
}
|
||||
|
||||
.identity-upstream-guidance { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ops-line); }
|
||||
|
||||
.identity-import-option > span { display: flex; align-items: flex-start; gap: 10px; line-height: 1.6; }
|
||||
.identity-import-option input[type=checkbox] { flex: 0 0 20px; margin: 3px 0 0; }
|
||||
|
||||
@@ -1,168 +1,5 @@
|
||||
'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: 'Seerr’s 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' }
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
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>Jellyfin’s 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>
|
||||
redirect('/users?view=identities')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
import './users.css'
|
||||
import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel'
|
||||
|
||||
type AdminUser = {
|
||||
id: number
|
||||
@@ -82,6 +83,17 @@ const normalizeStats = (stats: any): UserStats => ({
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter()
|
||||
const [view, setView] = useState('directory')
|
||||
useEffect(() => {
|
||||
const update = () => setView(new URLSearchParams(window.location.search).get('view') === 'identities' ? 'identities' : 'directory')
|
||||
update()
|
||||
window.addEventListener('popstate', update)
|
||||
return () => window.removeEventListener('popstate', update)
|
||||
}, [])
|
||||
const changeView = (next: string) => {
|
||||
setView(next)
|
||||
window.history.pushState(null, '', next === 'identities' ? '/users?view=identities' : '/users')
|
||||
}
|
||||
const [controlsOpen, setControlsOpen] = useState(false)
|
||||
const controlsDialog = useRef<HTMLDialogElement>(null)
|
||||
const controlsTrigger = useRef<HTMLButtonElement>(null)
|
||||
@@ -350,8 +362,8 @@ export default function UsersPage() {
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="Users"
|
||||
subtitle="Directory, access status, and request activity."
|
||||
title="User management"
|
||||
subtitle="Accounts, access, request activity and verified service links."
|
||||
actions={<button ref={controlsTrigger} type="button" className="ghost-button" aria-haspopup="dialog" aria-expanded={controlsOpen} aria-controls="user-management-dialog" onClick={() => setControlsOpen(true)}>Manage users</button>}
|
||||
>
|
||||
<dialog id="user-management-dialog" ref={controlsDialog} className="user-management-dialog" aria-labelledby="user-management-title" onCancel={() => setControlsOpen(false)} onClose={() => setControlsOpen(false)}>
|
||||
@@ -362,7 +374,7 @@ export default function UsersPage() {
|
||||
{inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
||||
<div className="user-management-grid">
|
||||
<section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
|
||||
<div className="user-management-action"><Link className="ghost-button" href="/admin/identities" aria-describedby="identity-help">Review account links ↗</Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
|
||||
<div className="user-management-action"><Link className="ghost-button" href="/users?view=identities" aria-describedby="identity-help">Review account links ↗</Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
|
||||
<div className="user-management-action"><Link className="ghost-button" href="/admin/invites" aria-describedby="invitation-help">Manage invitations ↗</Link><p id="invitation-help">Create invitations, review issued links and set invitation defaults.</p></div>
|
||||
<div className="user-management-action"><button type="button" className="ghost-button" onClick={() => void loadUsers()} disabled={controlsBusy} aria-describedby="reload-help">{refreshing ? 'Refreshing…' : 'Refresh user list'}</button><p id="reload-help">Reload account status and request totals from Magent. Your search stays in place.</p></div>
|
||||
</section>
|
||||
@@ -376,7 +388,11 @@ export default function UsersPage() {
|
||||
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
|
||||
</div>
|
||||
</dialog>
|
||||
<section className="admin-section users-directory-centered">
|
||||
<nav className="identity-selection" aria-label="User management sections">
|
||||
<button type="button" className="ghost-button" aria-pressed={view === 'directory'} onClick={() => changeView('directory')}>User directory</button>
|
||||
<button type="button" className="ghost-button" aria-pressed={view === 'identities'} onClick={() => changeView('identities')}>Account links & repairs</button>
|
||||
</nav>
|
||||
{view === 'identities' ? <IdentityReviewPanel /> : <section className="admin-section users-directory-centered">
|
||||
{!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
|
||||
{!controlsOpen && inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
||||
@@ -472,7 +488,7 @@ export default function UsersPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>}
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user