233 lines
8.6 KiB
TypeScript
233 lines
8.6 KiB
TypeScript
"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";
|
|
|
|
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;
|
|
};
|
|
};
|
|
|
|
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);
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
// 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>
|
|
);
|
|
}
|