Files
Magent/frontend/app/setup/page.tsx
T

624 lines
25 KiB
TypeScript

"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,
bootstrapApplicationUrl,
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<SetupStatus | null>(null);
const [state, setState] = useState<SetupState | null>(null);
const [step, setStep] = useState<SetupStep>("administrator");
const [settings, setSettings] = useState<Setting[]>([]);
const [draft, setDraft] = useState<Values>({});
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 [applicationUrl, setApplicationUrl] = useState("");
const [checks, setChecks] = useState<Record<string, Check>>({});
const [options, setOptions] = useState<Record<string, CollectorOptions>>({});
const [accepted, setAccepted] = useState(false);
const values = { ...settingsValues(settings), ...draft };
useEffect(() => {
setApplicationUrl(window.location.origin);
const controller = new AbortController();
const load = async () => {
try {
const current = await requestJson<SetupStatus>(
"/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<SetupState>("/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<void>) => {
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<SetupState>("/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) {
const confirmedApplicationUrl = bootstrapApplicationUrl(applicationUrl, window.location.origin);
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,
application_url: confirmedApplicationUrl,
}),
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<SetupState>("/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<Check>(`/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<CollectorOptions>(`/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 (
<div key={field.key} className={`${styles.field} ${field.type === "checkbox" ? styles.toggle : ""}`}>
<label htmlFor={`setup-${field.key}`}>
{field.label}
{field.type === "password" && saved && <small>Saved securely</small>}
</label>
{field.type === "checkbox" ? (
<input
id={`setup-${field.key}`}
type="checkbox"
checked={values[field.key] === true}
onChange={(event) => update(field, event.target.checked)}
disabled={!!busy}
/>
) : field.type === "textarea" ? (
<textarea
id={`setup-${field.key}`}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
rows={3}
/>
) : profile ? (
<select
id={`setup-${field.key}`}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
>
<option value="">Choose a profile</option>
{choices.qualityProfiles.map((choice) => (
<option key={choice.id} value={choice.id}>
{choice.name}
</option>
))}
</select>
) : (
<input
id={`setup-${field.key}`}
type={field.type || "text"}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
autoComplete={field.type === "password" ? "new-password" : "off"}
min={field.min}
max={field.max}
placeholder={
field.type === "password" && saved ? "Leave blank to keep saved credential" : field.placeholder
}
list={folders ? `options-${field.key}` : undefined}
/>
)}
{folders ? (
<datalist id={`options-${field.key}`}>
{choices.rootFolders.map((folder) => (
<option key={folder.path} value={folder.path} />
))}
</datalist>
) : null}
{field.hint && <p>{field.hint}</p>}
</div>
);
};
return (
<main className={styles.setup}>
<header className={styles.heading}>
<div className={styles.brand}>
<MagentMark />
<span>Magent / Installation</span>
</div>
<h1>Set up Magent</h1>
<p>Connect your media apps, choose your settings and make yourself at home.</p>
</header>
{!ready ? (
<p role="status">Checking installation...</p>
) : (
<>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{notice && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
{!status ? (
<button type="button" onClick={() => window.location.reload()}>
Retry
</button>
) : forbidden ? (
<section className={styles.panel}>
<h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}>
{busy === "switch-account" ? "Signing out..." : "Sign in with an administrator account"}
</button>
</section>
) : !admin ? (
<section className={styles.panel}>
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
<p>
{status.needs_admin ? (
<>
Enter the SETUP_TOKEN from your deployment environment. For a managed Portainer install, open the
Magent container console and run <code>python -m app.container_bootstrap setup-token</code> to
retrieve it. Only the server operator can create the first administrator; the console command stops
returning the token after that account exists.
</>
) : (
"Use your local Magent administrator account. Settings are never available to unauthenticated visitors."
)}
</p>
<form onSubmit={authenticate} className={styles.account}>
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-application-url">Public Magent URL</label>
<input
id="setup-application-url"
type="url"
autoComplete="url"
required
maxLength={2048}
value={applicationUrl}
onChange={(event) => setApplicationUrl(event.target.value)}
aria-describedby="setup-application-url-hint"
disabled={!!busy}
/>
<p id="setup-application-url-hint">
Confirm the address your users will open. Use HTTPS for internet-facing installs. This must match
the address currently open in your browser; if you plan to use another domain, open Magent there
before creating your administrator. Managed installs use this address for links and sign-in
security.
</p>
</div>
)}
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-token">Setup token</label>
<input
id="setup-token"
type="password"
autoComplete="off"
required
minLength={32}
maxLength={1024}
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
disabled={!!busy}
/>
</div>
)}
<div className={styles.field}>
<label htmlFor="setup-username">Username</label>
<input
id="setup-username"
autoComplete="username"
required
maxLength={100}
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={!!busy}
/>
</div>
<div className={styles.field}>
<label htmlFor="setup-password">Password</label>
<input
id="setup-password"
type="password"
autoComplete={status.needs_admin ? "new-password" : "current-password"}
required
minLength={status.needs_admin ? 12 : undefined}
maxLength={1024}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={!!busy}
/>
</div>
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-confirm">Confirm password</label>
<input
id="setup-confirm"
type="password"
autoComplete="new-password"
required
minLength={12}
maxLength={1024}
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
disabled={!!busy}
/>
<p>Use at least 12 characters and a unique password.</p>
</div>
)}
<button type="submit" disabled={!!busy}>
{busy ? "Working..." : status.needs_admin ? "Create administrator" : "Sign in to continue"}
</button>
</form>
{!status.setup_required && <a href="/login?next=/setup">Use Jellyfin sign-in instead</a>}
</section>
) : (
<>
{state?.completed ? (
<p className={styles.notice}>
This installation is already set up. You can use this guide to update its connections.{" "}
<a href="/admin">Back to settings</a>
</p>
) : (
<p className={styles.notice}>
Your administrator is ready. Background imports and automation are paused until you finish. Already
have a backup? <a href="/admin/backups">Restore it here</a>.
</p>
)}
<nav aria-label="Setup steps" className={styles.steps}>
{steps.map((item, index) => (
<button
key={item.id}
type="button"
aria-current={step === item.id ? "step" : undefined}
disabled={!!busy || item.id === "administrator"}
onClick={() => go(item.id)}
>
<span>{index + 1}</span>
{item.id === "administrator" ? "Administrator ready" : item.label}
</button>
))}
</nav>
<form
onSubmit={(event) => {
event.preventDefault();
go(step === "apps" ? "preferences" : "review");
}}
>
{step === "apps" && (
<section aria-labelledby="apps-title">
<h2 id="apps-title">Connect your apps</h2>
<p>
Each app is optional. Expand the apps you use, save and test their connections, then continue. In
Docker, localhost means the Magent container itself.
</p>
<div className={styles.apps}>
{APPS.map((app) => (
<details key={app.id} className={styles.panel}>
<summary>
<span>
<strong>{app.name}</strong>
<small>{app.description}</small>
</span>
<span className={styles.badge}>
{checks[app.id]
? serviceStatusLabel(checks[app.id].status)
: configuredApp(app, settings)
? "Configured"
: "Optional / not set up"}
</span>
</summary>
<div className={styles.fields}>{app.fields.map(fieldControl)}</div>
<button type="button" disabled={!!busy} onClick={() => test(app)}>
{busy === app.id ? "Testing..." : `Save & test ${app.name}`}
</button>
{checks[app.id]?.message && <p role="status">{checks[app.id].message}</p>}
</details>
))}
</div>
</section>
)}
{step === "preferences" && (
<section aria-labelledby="preferences-title">
<h2 id="preferences-title">Choose your preferences</h2>
<p>
Defaults are loaded from your installation. Advanced notification channels, branding and invite
policies are available in Settings afterwards.
</p>
{PREFERENCES.map((group) => (
<section key={group.title} className={styles.panel}>
<h3>{group.title}</h3>
<div className={styles.fields}>{group.fields.map(fieldControl)}</div>
</section>
))}
</section>
)}
{step === "review" && (
<section className={styles.panel} aria-labelledby="review-title">
<h2 id="review-title">Ready to finish?</h2>
<p>Unconfigured apps remain disconnected. You can change every connection later in Settings.</p>
<ul className={styles.review}>
{APPS.map((app) => (
<li key={app.id}>
<span>{app.name}</span>
<span>
{checks[app.id]
? serviceStatusLabel(checks[app.id].status)
: configuredApp(app, settings)
? "Configured (not tested this session)"
: "Not configured"}
</span>
</li>
))}
</ul>
<p>
Finishing starts the configured background imports and automation, unless disabled in your
deployment. Save an encrypted backup once you have checked the installation.
</p>
<label className={styles.confirm}>
<input
type="checkbox"
checked={accepted}
onChange={(event) => setAccepted(event.target.checked)}
disabled={!!busy}
/>
I have reviewed the connections and want to finish setup.
</label>
<p className={styles.hint}>
You may remove a manually configured SETUP_TOKEN from your environment after completion. Managed
installs keep their generated keys in the data volume; do not remove that file or volume. Existing
users and invites are preserved.
</p>
</section>
)}
<div className={styles.actions}>
{step !== "apps" && (
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => go(step === "review" ? "preferences" : "apps")}
>
Back
</button>
)}
<span>{Object.keys(draft).length ? "Unsaved changes" : "Progress is saved"}</span>
{step !== "review" ? (
<button type="submit" disabled={!!busy}>
{busy === "save" ? "Saving..." : "Save & continue"}
</button>
) : (
<button
type="button"
disabled={!!busy || !accepted}
onClick={() =>
void run("finish", async () => {
await save();
await requestJson("/setup/complete", { method: "POST" });
window.location.assign("/admin");
})
}
>
{busy === "finish" ? "Finishing..." : "Finish setup"}
</button>
)}
</div>
</form>
</>
)}
</>
)}
</main>
);
}