624 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|