feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user