Add admin review and confirmation of cross-service user IDs
This commit is contained in:
@@ -18,6 +18,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
|
||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
|
||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
|
||||
{ href: '/admin/identities', label: 'User identities', description: 'Review and confirm IDs across your media services', symbol: 'ID' },
|
||||
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
|
||||
]},
|
||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.identity-review { display: grid; gap: 20px; min-width: 0; }
|
||||
.identity-review p { margin: 0; line-height: 1.65; }
|
||||
.identity-review code { overflow-wrap: anywhere; font-size: .8rem; }
|
||||
.identity-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px; }
|
||||
.identity-intro h2 { margin: 0 0 8px; font-size: 1.1rem; }
|
||||
.identity-intro p { max-width: 760px; color: var(--ops-muted); }
|
||||
.identity-intro button { flex-shrink: 0; }
|
||||
.identity-service-strip { display: flex; flex-wrap: wrap; gap: 14px 24px; }
|
||||
.identity-service-strip span { font-size: .88rem; color: var(--ops-muted); }
|
||||
.identity-service-strip strong { color: var(--ops-text); margin-right: 6px; }
|
||||
.identity-meta { color: var(--ops-muted); font-size: .82rem; }
|
||||
.identity-counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
|
||||
.identity-counts > div { border: 1px solid var(--ops-line); border-radius: 12px; padding: 16px; display: grid; gap: 4px; }
|
||||
.identity-counts strong { font-size: 1.8rem; }
|
||||
.identity-counts span { color: var(--ops-muted); font-size: .78rem; }
|
||||
.identity-filters { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 16px; }
|
||||
.identity-filters label { display: grid; gap: 8px; font-size: .85rem; }
|
||||
.identity-filters input, .identity-filters select { width: 100%; min-width: 0; }
|
||||
.identity-selection, .identity-confirm-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.identity-selection > span { color: var(--ops-muted); font-size: .85rem; margin-right: auto; }
|
||||
.identity-accounts { display: grid; gap: 16px; }
|
||||
.identity-account { border: 1px solid var(--ops-line); border-radius: 14px; padding: 22px; min-width: 0; }
|
||||
.identity-account > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||
.identity-account-name { display: flex; gap: 12px; align-items: center; min-width: 0; }
|
||||
.identity-account-name h2 { font-size: 1.05rem; margin: 0 0 3px; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.identity-account-name span { font-size: .8rem; color: var(--ops-muted); }
|
||||
.identity-review input[type=checkbox] { width: 20px; height: 20px; flex-shrink: 0; accent-color: var(--ops-primary-2); }
|
||||
.identity-badge { border-radius: 100px; padding: 5px 10px; font-size: .73rem; background: #263242; color: #d9e2ef; white-space: nowrap; }
|
||||
.identity-badge.is-ready { background: #18374c; color: #a3dbff; }
|
||||
.identity-badge.is-confirmed { background: #17382d; color: #9ce3bd; }
|
||||
.identity-badge.is-conflict { background: #492d28; color: #ffc1ac; }
|
||||
.identity-badge.is-unavailable, .identity-badge.is-unlinked { background: #40391f; color: #ead696; }
|
||||
.identity-mapping { display: grid; grid-template-columns: 1.3fr .8fr 1.3fr; gap: 22px; margin: 22px 0 0; }
|
||||
.identity-mapping > div { min-width: 0; }
|
||||
.identity-mapping dt { color: var(--ops-muted); font-size: .75rem; margin-bottom: 8px; }
|
||||
.identity-mapping dd { display: grid; gap: 5px; margin: 0; overflow-wrap: anywhere; }
|
||||
.identity-mapping dd small { color: var(--ops-muted); line-height: 1.5; }
|
||||
.identity-issues { margin: 20px 0 0; padding: 14px 14px 14px 30px; color: #ffc1ac; background: #492d2833; border-radius: 8px; font-size: .83rem; line-height: 1.7; }
|
||||
.identity-account > .identity-meta { margin-top: 16px; }
|
||||
.identity-confirm-panel { border: 1px solid var(--ops-primary-2); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
|
||||
.identity-confirm-panel h2 { margin: 0; font-size: 1.15rem; }
|
||||
.identity-confirm-panel ul { margin: 0; padding-left: 20px; max-height: 260px; overflow: auto; }
|
||||
.identity-confirm-panel li { line-height: 1.9; overflow-wrap: anywhere; }
|
||||
.identity-upstream { border-top: 1px solid var(--ops-line); padding-top: 20px; }
|
||||
.identity-upstream summary { cursor: pointer; }
|
||||
.identity-upstream ul { padding-left: 20px; }
|
||||
.identity-upstream li { margin: 16px 0; overflow-wrap: anywhere; }
|
||||
@media (max-width: 980px) {
|
||||
.identity-intro { align-items: flex-start; flex-direction: column; }
|
||||
.identity-counts { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.identity-mapping { grid-template-columns: 1fr; gap: 16px; }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.identity-filters { grid-template-columns: 1fr; }
|
||||
.identity-account { padding: 16px; }
|
||||
.identity-account > header { flex-direction: column; }
|
||||
.identity-intro, .identity-confirm-panel { padding: 16px; }
|
||||
.identity-counts { gap: 8px; }
|
||||
.identity-counts > div { padding: 10px; }
|
||||
.identity-counts strong { font-size: 1.4rem; }
|
||||
.identity-selection button { width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'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'
|
||||
|
||||
type Identity = { id: string; name: string }
|
||||
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>
|
||||
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 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 [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' && <p className="identity-meta">A matching account is missing in one or more services. This account cannot be confirmed yet.</p>}
|
||||
{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>}
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
</AdminShell>
|
||||
}
|
||||
Reference in New Issue
Block a user