291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
"use client";
|
||
|
||
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;
|
||
};
|
||
|
||
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("");
|
||
|
||
useEffect(() => {
|
||
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();
|
||
};
|
||
}, []);
|
||
|
||
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);
|
||
}
|
||
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.",
|
||
);
|
||
if (!abort.signal.aborted) {
|
||
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);
|
||
}
|
||
} 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 user’s 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>
|
||
);
|
||
}
|