chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-17 20:03:47 +12:00
parent 5639dbcb83
commit f852e7c941
127 changed files with 17928 additions and 10741 deletions
@@ -1,171 +1,506 @@
'use client'
"use client";
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
import './identities.css'
import DuplicateAccountRepair from './DuplicateAccountRepair'
import ResolveIdentityLink from './ResolveIdentityLink'
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../../lib/auth";
import "./identities.css";
import DuplicateAccountRepair from "./DuplicateAccountRepair";
import ResolveIdentityLink from "./ResolveIdentityLink";
type Identity = { id: string; name: string }
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
}
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' }
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 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 [duplicates, setDuplicates] = useState<Row | null>(null)
const [resolving, setResolving] = useState<Row | null>(null)
const [reviewing, setReviewing] = useState(false)
const controller = useRef<AbortController | null>(null)
const reviewPanel = useRef<HTMLElement | null>(null)
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 [duplicates, setDuplicates] = useState<Row | null>(null);
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(() => {
setQuery(new URLSearchParams(window.location.search).get('user') ?? '')
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])
setQuery(new URLSearchParams(window.location.search).get("user") ?? "");
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])
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
}
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)
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)
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) }
}
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('')
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.`)
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)
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) }
}
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]) }
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>}
{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. 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>{row.issues.some((issue) => issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</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>)}
{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. 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>
{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>}
</>}
</>}
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />}
{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.')
}} />}
{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>
{row.issues.some(
(issue) =>
issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username"),
) && (
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setDuplicates(row)}
>
Repair duplicate accounts
</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>
)}
</>
)}
</>
)}
{duplicates && (
<DuplicateAccountRepair
row={duplicates}
onClose={() => setDuplicates(null)}
onSaved={() => {
setDuplicates(null);
void runCheck().then(() => setNotice("Duplicate accounts repaired. History retained and links rechecked."));
}}
/>
)}
{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>
)
);
}