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,74 +1,232 @@
'use client'
"use client";
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
import { FEATURES, type FeatureAccess } from '../../lib/features'
import type { Row } from './IdentityReviewPanel'
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
import { FEATURES, type FeatureAccess } from "../../lib/features";
import type { Row } from "./IdentityReviewPanel";
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null }
type Account = {
id: number;
username: string;
email: string | null;
profile_id: number | null;
last_login_at: string | null;
};
type Preview = {
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[]
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean }
}
accounts: Account[];
keep_id: number;
recommended_id: number;
revision: string;
can_confirm: boolean;
issues: string[];
proposed: Account & {
jellyfin_user_id: string;
seerr_user_id: number;
features: FeatureAccess;
expires_at: string | null;
is_blocked: boolean;
auto_search_enabled: boolean;
};
};
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) {
const dialog = useRef<HTMLDialogElement>(null)
const controller = useRef<AbortController | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [acknowledged, setAcknowledged] = useState(false)
const [error, setError] = useState('')
export default function DuplicateAccountRepair({
row,
onClose,
onSaved,
}: {
row: Row;
onClose: () => void;
onSaved: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [error, setError] = useState("");
const submit = async (confirm = false, keepId?: number) => {
const abort = new AbortController()
controller.current?.abort(); controller.current = abort
setError(''); setAcknowledged(false)
if (confirm) setSaving(true)
else setBusy(true)
const abort = new AbortController();
controller.current?.abort();
controller.current = abort;
setError("");
setAcknowledged(false);
if (confirm) setSaving(true);
else setBusy(true);
try {
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }),
})
const data = await response.json()
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.')
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) }
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } }
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
}
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? "confirm" : "check"}`, {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: row.user.id,
...(keepId ? { keep_id: keepId } : {}),
...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}),
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(typeof data.detail === "string" ? data.detail : "Could not review these accounts.");
if (!abort.signal.aborted) {
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : "Repair failed. Preview again.");
setPreview(null);
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: The dialog preview runs once when this keyed modal mounts.
useEffect(() => {
const previous = document.activeElement as HTMLElement | null
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'; dialog.current?.showModal()
void submit()
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() }
}, [])
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
<div className="identity-resolve-content">
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header>
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p>
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
{error && <p className="error-banner" role="alert">{error}</p>}
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>}
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}>
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
</select></label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
<h3>Resulting account</h3>
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p>
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p>
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul>
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p>
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p>
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p>
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label>
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button>
</section>}
</div>
</dialog>
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.current?.showModal();
void submit();
return () => {
controller.current?.abort();
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
return (
<dialog
ref={dialog}
className="identity-resolve-dialog"
aria-labelledby="duplicates-title"
onCancel={(event) => {
event.preventDefault();
if (!saving) onClose();
}}
>
<div className="identity-resolve-content">
<header>
<h2 id="duplicates-title">Repair duplicate accounts</h2>
<button type="button" className="ghost-button" disabled={saving} onClick={onClose}>
Close
</button>
</header>
<p>
Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to
the verified Jellyfin identity.
</p>
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{!preview && !busy && (
<button type="button" disabled={saving} onClick={() => void submit()}>
Check again
</button>
)}
{preview && (
<section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>
Magent account to keep
<select
disabled={busy || saving}
value={preview.keep_id}
onChange={(event) => void submit(false, Number(event.target.value))}
>
{preview.accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.username} Magent {account.id}
{account.id === preview.recommended_id ? " (recommended)" : ""}
</option>
))}
</select>
</label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">
{preview.accounts.map((account) => (
<div key={account.id}>
<strong>
Magent {account.id}
{account.id === preview.keep_id ? " · Keep" : " · Consolidate"}
</strong>
<p>{account.username}</p>
<p>
{account.email || "No email"} · Profile {account.profile_id ?? "None"}
</p>
<p>
Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : "Never"}
</p>
</div>
))}
</div>
<h3>Resulting account</h3>
<p>
<strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr{" "}
{preview.proposed.seerr_user_id ?? "Not verified"}
</p>
<p>
Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? "Not verified"}</code>
</p>
<p>
Email: {preview.proposed.email || "None"} · Profile: {preview.proposed.profile_id ?? "None"}
</p>
<p>
Access: {preview.proposed.is_blocked ? "Blocked" : "Not blocked"} · Expiry:{" "}
{preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : "None"} ·
Automatic search: {preview.proposed.auto_search_enabled ? "Enabled" : "Disabled"}
</p>
<ul>
{FEATURES.map((feature) => (
<li key={feature.key}>
{feature.label}: {preview.proposed.features[feature.key] ? "Enabled" : "Disabled"}
</li>
))}
</ul>
<p>
Request, issue, invitation and login activity history is retained. The selected account keeps its email
and profile. Any block, earlier expiry or disabled permission on either row is preserved.
</p>
<p>
Extra Magent rows are removed from the active directory after their details are archived. Their
outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains
its own subscriptions where still eligible. Password reset links must be requested again.
</p>
<p>
Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different
Jellyfin identities or delete upstream users.
</p>
{preview.issues.length > 0 && (
<ul className="identity-issues">
{preview.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={acknowledged}
disabled={busy || saving || !preview.can_confirm}
onChange={(event) => setAcknowledged(event.target.checked)}
/>{" "}
I confirm these rows belong to the same person and have reviewed the account to keep.
</span>
</label>
<button
type="button"
disabled={!preview.can_confirm || !acknowledged || busy || saving}
onClick={() => void submit(true)}
>
{saving ? "Rechecking and repairing..." : "Confirm duplicate repair"}
</button>
</section>
)}
</div>
</dialog>
);
}
@@ -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>
)
);
}
@@ -1,106 +1,290 @@
'use client'
"use client";
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
import type { Row } from './IdentityReviewPanel'
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
import type { Row } from "./IdentityReviewPanel";
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
}
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
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(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)
const [error, setError] = useState('')
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
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);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialog.current?.showModal()
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.current?.showModal();
return () => {
controller.current?.abort()
document.body.style.overflow = overflow
previous?.focus()
}
}, [])
controller.current?.abort();
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
const submit = async (confirm: boolean) => {
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return
const abort = new AbortController()
controller.current = abort
setError('')
if (confirm) setSaving(true)
else { setBusy(true); setPreview(null) }
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return;
const abort = new AbortController();
controller.current = abort;
setError("");
if (confirm) setSaving(true);
else {
setBusy(true);
setPreview(null);
}
try {
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, 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.')
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,
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.",
);
if (!abort.signal.aborted) {
if (confirm) onSaved()
else setPreview(data)
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : 'Could not resolve the link.')
setPreview(null)
setError(err instanceof Error ? err.message : "Could not resolve the link.");
setPreview(null);
}
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
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">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(''); 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>
<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 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 users Jellyfin account link in Seerr, then check again.'}</dd></div>
<div><dt>Jellystat</dt><dd><code>{preview.row.jellystat.id ?? 'Not verified'}</code>{preview.row.jellystat.state === 'matched' ? 'Same Jellyfin ID verified' : preview.row.jellystat.state === 'missing' ? 'This ID is missing from Jellystat. Check its Jellyfin sync, then check again.' : 'Could not verify this ID. Check the Jellystat connection and try again.'}</dd></div>
</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>}
{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>
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">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("");
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>
<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 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 users Jellyfin account link in Seerr, then check again."}
</dd>
</div>
<div>
<dt>Jellystat</dt>
<dd>
<code>{preview.row.jellystat.id ?? "Not verified"}</code>
{preview.row.jellystat.state === "matched"
? "Same Jellyfin ID verified"
: preview.row.jellystat.state === "missing"
? "This ID is missing from Jellystat. Check its Jellyfin sync, then check again."
: "Could not verify this ID. Check the Jellystat connection and try again."}
</dd>
</div>
</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>
)}
{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>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
import { redirect } from "next/navigation";
export default function IdentityReviewPage() {
redirect('/users?view=identities')
redirect("/users?view=identities");
}