feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
"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";
|
||||
|
||||
type Identity = { id: string; name: string };
|
||||
export type Row = {
|
||||
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null };
|
||||
jellyfin: Identity | null;
|
||||
candidate_jellyfin_id: string | null;
|
||||
stored_jellyfin_id: string | null;
|
||||
seerr: { id: number; name: string; jellyfin_id: string }[];
|
||||
jellystat: { state: string; id?: string; name?: string };
|
||||
basis: string;
|
||||
issues: string[];
|
||||
state: string;
|
||||
can_confirm: boolean;
|
||||
confirmed_at: string | null;
|
||||
};
|
||||
type Report = {
|
||||
revision: string;
|
||||
checked_at: string;
|
||||
server_id: string | null;
|
||||
services: Record<string, string>;
|
||||
counts: Record<string, number>;
|
||||
jellyfin_users: Identity[];
|
||||
rows: Row[];
|
||||
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[];
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
ready: "Ready to review",
|
||||
confirmed: "Confirmed",
|
||||
conflict: "Conflict",
|
||||
unlinked: "Missing link",
|
||||
unavailable: "Check incomplete",
|
||||
};
|
||||
const serviceLabels: Record<string, string> = {
|
||||
available: "Checked",
|
||||
unavailable: "Unavailable",
|
||||
not_configured: "Not configured",
|
||||
not_checked: "No IDs to check",
|
||||
};
|
||||
const basisLabels: Record<string, string> = {
|
||||
confirmed_id: "Confirmed Jellyfin ID",
|
||||
stored_jellyfin_id: "Stored Jellyfin ID",
|
||||
stored_seerr_id: "Seerr’s Jellyfin ID",
|
||||
suggested_username: "Suggested from Jellyfin username — review before saving",
|
||||
none: "No identity match",
|
||||
};
|
||||
const statsLabels: Record<string, string> = {
|
||||
matched: "ID matches",
|
||||
missing: "ID not found",
|
||||
unavailable: "Could not check",
|
||||
not_configured: "Not configured",
|
||||
not_checked: "No ID to check",
|
||||
};
|
||||
|
||||
export default function IdentityReviewPanel() {
|
||||
const router = useRouter();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewing) reviewPanel.current?.focus();
|
||||
}, [reviewing]);
|
||||
|
||||
const responseData = async (response: Response) => {
|
||||
if (response.status === 401) {
|
||||
router.replace("/login");
|
||||
throw new Error("Your session has ended. Sign in again.");
|
||||
}
|
||||
if (response.status === 403) {
|
||||
router.replace("/");
|
||||
throw new Error("Administrator access is required.");
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof data.detail === "string" ? data.detail : "The identity check could not complete. Try again.",
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
const runCheck = async () => {
|
||||
controller.current?.abort();
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
setSelected([]);
|
||||
setReviewing(false);
|
||||
setReport(null);
|
||||
try {
|
||||
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }));
|
||||
if (!abort.signal.aborted) setReport(data);
|
||||
} catch (err) {
|
||||
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not check identities.");
|
||||
} finally {
|
||||
if (!abort.signal.aborted) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!report || saving || !selected.length) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const data = await responseData(
|
||||
await authFetch(`${getApiBase()}/admin/identities/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
|
||||
}),
|
||||
);
|
||||
setNotice(
|
||||
`${data.confirmed} account ${data.confirmed === 1 ? "link" : "links"} confirmed and saved. Run another check to see the updated mappings.`,
|
||||
);
|
||||
// The scan describes the previous database state and cannot be reused for another write.
|
||||
setReport(null);
|
||||
setSelected([]);
|
||||
setReviewing(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save identity links.");
|
||||
setReport(null);
|
||||
setSelected([]);
|
||||
setReviewing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
const filtered =
|
||||
report?.rows.filter(
|
||||
(row) =>
|
||||
(filter === "all" || row.state === filter) &&
|
||||
[
|
||||
row.user.username,
|
||||
row.user.id,
|
||||
row.candidate_jellyfin_id,
|
||||
row.user.jellyseerr_user_id,
|
||||
...row.seerr.map((entry) => entry.id),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(needle),
|
||||
) ?? [];
|
||||
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? [];
|
||||
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id);
|
||||
const toggle = (id: number) => {
|
||||
setReviewing(false);
|
||||
setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="identity-review">
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="status-banner" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{!ready && !error && <p role="status">Checking administrator access…</p>}
|
||||
{ready && (
|
||||
<>
|
||||
<section className="identity-intro admin-panel">
|
||||
<div>
|
||||
<h2>Confirm user IDs</h2>
|
||||
<p>
|
||||
Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same
|
||||
user ID.
|
||||
</p>
|
||||
<p>
|
||||
Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs.
|
||||
Duplicate ownership and upstream changes require individual review.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={runCheck} disabled={busy || saving}>
|
||||
{busy ? "Checking all accounts…" : report ? "Run check again" : "Check all user IDs"}
|
||||
</button>
|
||||
</section>
|
||||
{busy && (
|
||||
<p role="status">
|
||||
Reading the live user directories and checking Jellystat IDs. This can take up to a minute.
|
||||
</p>
|
||||
)}
|
||||
{report && (
|
||||
<>
|
||||
<div className="identity-service-strip">
|
||||
{Object.entries(report.services).map(([service, state]) => (
|
||||
<span key={service}>
|
||||
<strong>{service === "seerr" ? "Seerr" : service === "jellyfin" ? "Jellyfin" : "Jellystat"}</strong>{" "}
|
||||
{serviceLabels[state] ?? state}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="identity-meta">
|
||||
Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server{" "}
|
||||
<code>{report.server_id ?? "Unavailable"}</code>
|
||||
</p>
|
||||
<div className="identity-counts">
|
||||
{["magent", "ready", "confirmed", "conflict", "unlinked", "unavailable"].map((state) => (
|
||||
<div key={state}>
|
||||
<strong>{report.counts[state]}</strong>
|
||||
<span>{state === "magent" ? "Magent accounts" : labels[state]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="identity-meta">
|
||||
Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in
|
||||
Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.
|
||||
</p>
|
||||
<div className="identity-filters">
|
||||
<label>
|
||||
Find an account
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Username or user ID"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Show
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}>
|
||||
<option value="all">All accounts</option>
|
||||
{Object.entries(labels).map(([state, label]) => (
|
||||
<option key={state} value={state}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="identity-selection">
|
||||
<span>
|
||||
{filtered.length} accounts shown · {selected.length} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={saving || !eligible.length}
|
||||
onClick={() => {
|
||||
setSelected((current) => [...new Set([...current, ...eligible])]);
|
||||
setReviewing(false);
|
||||
}}
|
||||
>
|
||||
Select ready accounts shown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={saving || !selected.length}
|
||||
onClick={() => {
|
||||
setSelected([]);
|
||||
setReviewing(false);
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>
|
||||
Review selected links ({selected.length})
|
||||
</button>
|
||||
</div>
|
||||
{reviewing && (
|
||||
<section
|
||||
className="identity-confirm-panel"
|
||||
ref={reviewPanel}
|
||||
tabIndex={-1}
|
||||
aria-label="Review links before saving"
|
||||
>
|
||||
<h2>Save these {selected.length} account links?</h2>
|
||||
<p>
|
||||
Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live
|
||||
IDs will be checked again before saving.
|
||||
</p>
|
||||
<ul>
|
||||
{selectedRows.map((row) => (
|
||||
<li key={row.user.id}>
|
||||
<strong>{row.user.username}</strong> · Magent {row.user.id} → Jellyfin{" "}
|
||||
<code>{row.candidate_jellyfin_id}</code> → Seerr {row.seerr[0].id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
Saving links does not merge or delete accounts. Existing requests and playback history stay with
|
||||
their service IDs.
|
||||
</p>
|
||||
<div className="identity-confirm-actions">
|
||||
<button type="button" onClick={save} disabled={saving}>
|
||||
{saving ? "Rechecking and saving…" : "Confirm and save links"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={saving}
|
||||
onClick={() => setReviewing(false)}
|
||||
>
|
||||
Back to review
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="identity-accounts" aria-label="Account identity results">
|
||||
{!filtered.length && <p>No accounts match these filters.</p>}
|
||||
{filtered.map((row) => (
|
||||
<article className="identity-account" key={row.user.id}>
|
||||
<header>
|
||||
<div className="identity-account-name">
|
||||
{row.can_confirm && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${row.user.username} (Magent ${row.user.id})`}
|
||||
checked={selected.includes(row.user.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggle(row.user.id)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h2>{row.user.username}</h2>
|
||||
<span>
|
||||
Magent {row.user.id} ·{" "}
|
||||
{row.user.auth_provider === "jellyseerr" ? "Seerr" : row.user.auth_provider} sign-in
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span>
|
||||
</header>
|
||||
<dl className="identity-mapping">
|
||||
<div>
|
||||
<dt>Jellyfin user ID</dt>
|
||||
<dd>
|
||||
<code>{row.candidate_jellyfin_id ?? "No match"}</code>
|
||||
{row.jellyfin && <span>{row.jellyfin.name}</span>}
|
||||
<small>{basisLabels[row.basis]}</small>
|
||||
{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && (
|
||||
<small>Stored: {row.stored_jellyfin_id}</small>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Seerr user ID</dt>
|
||||
<dd>
|
||||
<strong>
|
||||
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(", ") : "No match"}
|
||||
</strong>
|
||||
<span>{row.seerr.map((entry) => entry.name).join(", ")}</span>
|
||||
<small>Stored in Magent: {row.user.jellyseerr_user_id ?? "Not linked"}</small>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Jellystat user ID</dt>
|
||||
<dd>
|
||||
<code>{row.jellystat.id ?? "Not verified"}</code>
|
||||
<span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{row.issues.length > 0 && (
|
||||
<ul className="identity-issues">
|
||||
{row.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{(row.state === "unlinked" || row.state === "conflict") && (
|
||||
<div className="identity-resolution-entry">
|
||||
<p className="identity-meta">
|
||||
Compare the correct Jellyfin identity with the stored links and review the smallest safe
|
||||
repair.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={saving || report.services.jellyfin !== "available"}
|
||||
onClick={() => setResolving(row)}
|
||||
>
|
||||
Review repair
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user