"use client"; import { useEffect, useState, type FormEvent } from "react"; import { apiUrl, requestJson } from "../lib/api-client"; import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth"; import MagentMark from "../ui/MagentMark"; import { serviceStatusLabel } from "../admin/configNavigation"; import { ALL_FIELDS, APPS, PREFERENCES, configuredApp, settingsPayload, settingsValues, type AppDefinition, type Field, type Setting, type SetupState, type SetupStatus, type SetupStep, type Values, } from "./setup-model"; import styles from "./setup.module.css"; type Check = { status: string; message?: string }; type CollectorOptions = { rootFolders: { path: string }[]; qualityProfiles: { id: number; name: string }[] }; const steps: { id: SetupStep; label: string }[] = [ { id: "administrator", label: "Administrator" }, { id: "apps", label: "Apps" }, { id: "preferences", label: "Preferences" }, { id: "review", label: "Review" }, ]; const json = (body: unknown, method = "POST"): RequestInit => ({ method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const message = (error: unknown) => error instanceof Error ? error.message : "Something went wrong. Please try again."; export default function SetupPage() { const [status, setStatus] = useState(null); const [state, setState] = useState(null); const [step, setStep] = useState("administrator"); const [settings, setSettings] = useState([]); const [draft, setDraft] = useState({}); const [ready, setReady] = useState(false); const [admin, setAdmin] = useState(false); const [forbidden, setForbidden] = useState(false); const [busy, setBusy] = useState(""); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [username, setUsername] = useState("admin"); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [setupToken, setSetupToken] = useState(""); const [checks, setChecks] = useState>({}); const [options, setOptions] = useState>({}); const [accepted, setAccepted] = useState(false); const values = { ...settingsValues(settings), ...draft }; useEffect(() => { const controller = new AbortController(); const load = async () => { try { const current = await requestJson( "/setup/status", { signal: controller.signal, cache: "no-store" }, authFetch, ); setStatus(current); if (current.needs_admin) return; const response = await authFetch(apiUrl("/auth/me"), { signal: controller.signal }); if (!response.ok) return; const user = await response.json(); if (user.role !== "admin") { setForbidden(true); return; } const [progress, config] = await Promise.all([ requestJson("/setup/state", { signal: controller.signal }), requestJson<{ settings: Setting[] }>("/admin/settings", { signal: controller.signal }), ]); setAdmin(true); setState(progress); setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step); setSettings(config.settings); } catch (failure) { if (!controller.signal.aborted) setError(message(failure)); } finally { if (!controller.signal.aborted) setReady(true); } }; void load(); return () => controller.abort(); }, []); useEffect(() => { if (!Object.keys(draft).length) return; const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [draft]); const run = async (name: string, action: () => Promise) => { if (busy) return; setBusy(name); setError(""); setNotice(""); try { await action(); } catch (failure) { if (failure instanceof UnauthorizedError) { setAdmin(false); setForbidden(false); setAccepted(false); setPassword(""); setError("Your session expired. Sign in to continue; your unsaved changes are still here."); } else if (failure instanceof ForbiddenError) { setAdmin(false); setForbidden(true); setAccepted(false); } else setError(message(failure)); } finally { setBusy(""); } }; const signIn = async (loginPassword = password) => { const result = await requestJson<{ authenticated: boolean; user?: { role: string } }>( "/auth/login", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ username: username.trim(), password: loginPassword }), }, authFetch, ); if (!result.authenticated) throw new Error("Could not sign in. Try your administrator credentials again."); setToken("cookie"); setPassword(""); setConfirmation(""); const user = await requestJson<{ role: string }>("/auth/me"); if (user.role !== "admin") { setForbidden(true); return; } const [progress, config] = await Promise.all([ requestJson("/setup/state"), requestJson<{ settings: Setting[] }>("/admin/settings"), ]); setAdmin(true); setForbidden(false); setNotice(""); setState(progress); setSettings(config.settings); setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step); }; const authenticate = (event: FormEvent) => { event.preventDefault(); void run("account", async () => { let loginPassword = password; if (status?.needs_admin) { if (password !== confirmation) throw new Error("The passwords do not match."); loginPassword = password.trim(); if (loginPassword.length < 12) throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces."); await requestJson( "/setup/bootstrap", json({ setup_token: setupToken, username: username.trim(), password }), authFetch, ); setPassword(loginPassword); setSetupToken(""); setStatus({ setup_required: true, needs_admin: false }); setNotice("Administrator created. Signing in..."); } await signIn(loginPassword); }); }; const switchAccount = () => void run("switch-account", async () => { await logout(); setAdmin(false); setForbidden(false); setAccepted(false); setUsername(""); setPassword(""); setConfirmation(""); setDraft({}); setSettings([]); setChecks({}); setOptions({}); setNotice("Sign in with a Magent administrator account to continue setup."); }); const save = async (fields: Field[] = ALL_FIELDS) => { const payload = settingsPayload(draft, fields); if (!Object.keys(payload).length) return; if (values.site_login_show_local_login === false && values.site_login_show_jellyfin_login === false) { throw new Error("Keep at least one sign-in method enabled."); } if (values.magent_notify_email_use_tls === true && values.magent_notify_email_use_ssl === true) { throw new Error("Choose STARTTLS or implicit TLS, not both."); } await requestJson("/admin/settings", json(payload, "PUT")); const config = await requestJson<{ settings: Setting[] }>("/admin/settings"); setSettings(config.settings); setDraft((previous) => Object.fromEntries(Object.entries(previous).filter(([key]) => !fields.some((field) => field.key === key))), ); }; const go = (next: SetupStep) => void run("save", async () => { await save(); if (!state?.completed) setState(await requestJson("/setup/state", json({ step: next }, "PUT"))); setStep(next); setNotice("Settings saved. You can return to finish setup later."); }); const test = (app: AppDefinition) => void run(app.id, async () => { await save(app.fields); const check = await requestJson(`/status/services/${app.id}/test`, { method: "POST" }); setChecks((previous) => ({ ...previous, [app.id]: check })); setNotice(`${app.name}: ${serviceStatusLabel(check.status)}${check.message ? ` — ${check.message}` : ""}`); if ((app.id === "sonarr" || app.id === "radarr") && check.status === "up") { const choices = await requestJson(`/admin/${app.id}/options`); setOptions((previous) => ({ ...previous, [app.id]: choices })); } }); const update = (field: Field, value: string | boolean) => { setDraft((previous) => ({ ...previous, [field.key]: value })); setAccepted(false); setNotice(""); const app = APPS.find((candidate) => candidate.fields.some((item) => item.key === field.key)); if (app) setChecks((previous) => ({ ...previous, [app.id]: { status: "unchecked" } })); }; const fieldControl = (field: Field) => { const saved = settings.some((setting) => setting.key === field.key && setting.isSet); const collectorId = field.key.startsWith("sonarr_") ? "sonarr" : "radarr"; const choices = options[collectorId]; const profile = field.key.endsWith("_quality_profile_id") && choices?.qualityProfiles.length; const folders = field.key.endsWith("_root_folder") && choices?.rootFolders.length; return (
{field.type === "checkbox" ? ( update(field, event.target.checked)} disabled={!!busy} /> ) : field.type === "textarea" ? (