feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
+623
View File
@@ -0,0 +1,623 @@
"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>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { APPS, bootstrapApplicationUrl, configuredApp, settingsPayload, settingsValues } from "./setup-model";
describe("first administrator application URL confirmation", () => {
it.each([
["https://magent.example.com", "https://magent.example.com"],
[" https://MAGENT.example.com:443/ ", "https://magent.example.com"],
["http://192.0.2.10:3000", "http://192.0.2.10:3000"],
["http://[fd00::10]:3000/", "http://[fd00::10]:3000"],
])("confirms a canonical same-origin address %s", (value, browserOrigin) => {
expect(bootstrapApplicationUrl(value, browserOrigin)).toBe(browserOrigin);
});
it.each([
"",
"magent.example.com",
"//magent.example.com",
"https:/magent.example.com",
"ftp://magent.example.com",
"javascript:alert(1)",
"https://user:password@magent.example.com",
"https://magent.example.com/setup",
"https://magent.example.com/../",
"https://magent.example.com?query=1",
"https://magent.example.com?",
"https://magent.example.com#fragment",
"https://magent.example.com#",
"https://magent.example.com\\path",
"https://magent.\texample.com",
])("rejects a non-origin or unsafe URL %j", (value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow("Public Magent URL must");
});
it.each(["https://other.example.com", "http://magent.example.com", "https://magent.example.com:8443"])(
"requires the intended browser origin before claiming %s",
(value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow(
"Open Magent at your intended address",
);
},
);
});
describe("installation settings", () => {
it("offers every supported media integration", () => {
expect(APPS.map((app) => app.id).sort()).toEqual([
"bazarr",
"jellyfin",
"jellystat",
"prowlarr",
"qbittorrent",
"radarr",
"seerr",
"sonarr",
]);
});
it("never copies saved secrets into the form or overwrites them with a blank", () => {
expect(settingsValues([{ key: "sonarr_api_key", value: "secret", sensitive: true, isSet: true }])).toEqual({
sonarr_api_key: "",
});
expect(settingsPayload({ sonarr_api_key: "", sonarr_base_url: "http://sonarr:8989" })).toEqual({
sonarr_base_url: "http://sonarr:8989",
});
});
it("sends only editable fields and validates numeric settings", () => {
expect(
settingsPayload({ jwt_secret: "no", requests_cleanup_days: "90", site_login_show_signup_link: false }),
).toEqual({ requests_cleanup_days: 90, site_login_show_signup_link: false });
expect(() => settingsPayload({ requests_cleanup_days: "-1" })).toThrow("whole number");
expect(() => settingsPayload({ sonarr_quality_profile_id: "1.5" })).toThrow("whole number");
});
it("can save just one app without accidentally saving another draft", () => {
expect(
settingsPayload(
{ sonarr_base_url: "http://sonarr:8989", radarr_api_key: "draft-secret" },
APPS.find((app) => app.id === "sonarr")?.fields,
),
).toEqual({ sonarr_base_url: "http://sonarr:8989" });
});
it("validates URL drafts even when app testing bypasses browser form validation", () => {
for (const value of [
"sonarr:8989",
"/sonarr",
"ftp://sonarr:8989",
"javascript:alert(1)",
"http://sonarr/my library",
]) {
expect(() => settingsPayload({ sonarr_base_url: value })).toThrow("HTTP or HTTPS URL");
}
expect(() => settingsPayload({ sonarr_base_url: "https://user:secret@sonarr.test" })).toThrow("credential fields");
expect(
settingsPayload({
sonarr_base_url: " http://sonarr:8989 ",
magent_application_url: "https://magent.example.test",
}),
).toEqual({ sonarr_base_url: "http://sonarr:8989", magent_application_url: "https://magent.example.test" });
expect(settingsPayload({ sonarr_base_url: "" })).toEqual({ sonarr_base_url: "" });
});
it("validates sender email and sync time before step navigation saves", () => {
for (const value of ["not-an-email", "two@@example.test", "name@example test", "Name <name@example.test>"]) {
expect(() => settingsPayload({ magent_notify_email_from_address: value })).toThrow("valid email address");
}
for (const value of ["24:00", "12:60", "2:30", "02:30:00"]) {
expect(() => settingsPayload({ requests_full_sync_time: value })).toThrow("HH:MM");
}
expect(
settingsPayload({
magent_notify_email_from_address: " alerts+admin@example.test ",
requests_full_sync_time: "23:59",
}),
).toEqual({ magent_notify_email_from_address: "alerts+admin@example.test", requests_full_sync_time: "23:59" });
expect(settingsPayload({ magent_notify_email_from_address: "", requests_full_sync_time: "" })).toEqual({
magent_notify_email_from_address: "",
requests_full_sync_time: "",
});
});
it("does not call a URL-only app configured", () => {
const app = APPS[0];
const url = { key: "jellyfin_base_url", value: "http://jellyfin:8096", sensitive: false, isSet: true };
expect(configuredApp(app, [url])).toBe(false);
expect(configuredApp(app, [url, { key: "jellyfin_api_key", value: null, sensitive: true, isSet: true }])).toBe(
true,
);
});
});
+294
View File
@@ -0,0 +1,294 @@
export type SetupStep = "administrator" | "apps" | "preferences" | "review";
export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null };
export type SetupStatus = { setup_required: boolean; needs_admin: boolean };
export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean };
export type Values = Record<string, string | boolean>;
export type Field = {
key: string;
label: string;
type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea";
hint?: string;
placeholder?: string;
min?: number;
max?: number;
};
export type AppDefinition = { id: string; name: string; description: string; fields: Field[] };
const connection = (prefix: string, placeholder: string): Field[] => [
{
key: `${prefix}_base_url`,
label: "Server URL",
type: "url",
placeholder,
hint: "Use an address reachable from the Magent server, not your browser.",
},
{ key: `${prefix}_api_key`, label: "API key", type: "password" },
];
const collector = (prefix: string): Field[] => [
{
key: `${prefix}_quality_profile_id`,
label: "Quality profile ID",
type: "number",
min: 1,
hint: "Save and test the connection to load available profiles.",
},
{
key: `${prefix}_root_folder`,
label: "Root folder",
hint: "The library path as seen by this app, for example /tv or /movies.",
},
{
key: `${prefix}_qbittorrent_category`,
label: "Download category",
hint: "Match the category configured in the app's download client.",
},
];
export const APPS: AppDefinition[] = [
{
id: "jellyfin",
name: "Jellyfin",
description: "Playback, library availability and Jellyfin sign-in.",
fields: [
...connection("jellyfin", "http://jellyfin:8096"),
{
key: "jellyfin_public_url",
label: "Public playback URL",
type: "url",
hint: "The address your users open to watch media.",
},
{
key: "jellyfin_sync_to_arr",
label: "Sync Jellyfin library into Sonarr / Radarr",
type: "checkbox",
hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.",
},
],
},
{
id: "seerr",
name: "Seerr",
description: "Requests, approvals and request history (including Jellyseerr).",
fields: connection("jellyseerr", "http://seerr:5055"),
},
{
id: "sonarr",
name: "Sonarr",
description: "TV requests, seasons and collection progress.",
fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")],
},
{
id: "radarr",
name: "Radarr",
description: "Movie requests and collection progress.",
fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")],
},
{
id: "prowlarr",
name: "Prowlarr",
description: "Indexer searches and release discovery.",
fields: connection("prowlarr", "http://prowlarr:9696"),
},
{
id: "qbittorrent",
name: "qBittorrent",
description: "Download progress and recovery actions.",
fields: [
{ key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" },
{ key: "qbittorrent_username", label: "Username" },
{ key: "qbittorrent_password", label: "Password", type: "password" },
],
},
{
id: "bazarr",
name: "Bazarr",
description: "Optional subtitle searches and repairs.",
fields: [
...connection("bazarr", "http://bazarr:6767"),
{ key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" },
],
},
{
id: "jellystat",
name: "Jellystat",
description: "Optional personal viewing statistics.",
fields: connection("jellystat", "http://jellystat:3000"),
},
];
export const PREFERENCES: { title: string; fields: Field[] }[] = [
{
title: "Site & access",
fields: [
{
key: "magent_application_url",
label: "Public Magent URL",
type: "url",
hint: "Used in invite and notification links. Managed installs also use this address for CORS and sign-in; changing it changes the allowed browser origin. Manual installs keep their environment-configured CORS policy.",
},
{ key: "site_login_message", label: "Login page message", type: "textarea" },
{
key: "site_login_show_local_login",
label: "Show Magent account sign-in",
type: "checkbox",
hint: "Keep this enabled for local administrator access.",
},
{ key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" },
{
key: "site_login_show_signup_link",
label: "Show invite signup link",
type: "checkbox",
hint: "Account creation still requires a valid invite. This does not open public registration.",
},
],
},
{
title: "Request updates",
fields: [
{ key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 },
{
key: "requests_delta_sync_interval_minutes",
label: "Incremental sync interval (minutes)",
type: "number",
min: 1,
},
{ key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" },
{ key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 },
],
},
{
title: "Email (optional)",
fields: [
{ key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" },
{
key: "magent_notify_email_enabled",
label: "Enable email delivery",
type: "checkbox",
hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.",
},
{ key: "magent_notify_email_smtp_host", label: "SMTP hostname" },
{ key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 },
{ key: "magent_notify_email_smtp_username", label: "SMTP username" },
{ key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" },
{ key: "magent_notify_email_from_address", label: "Sender email", type: "email" },
{ key: "magent_notify_email_from_name", label: "Sender name" },
{ key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" },
{
key: "magent_notify_email_use_ssl",
label: "Use implicit TLS (usually port 465)",
type: "checkbox",
hint: "Choose either STARTTLS or implicit TLS, not both.",
},
],
},
];
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
export function bootstrapApplicationUrl(value: string, browserOrigin: string): string {
const configured = value.trim();
let url: URL;
try {
url = new URL(configured);
} catch {
throw new Error("Public Magent URL must be a full HTTP or HTTPS origin, for example https://magent.example.com.");
}
if (
!/^https?:\/\/[^/?#]+\/?$/i.test(configured) ||
/[\s\\]/.test(configured) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error(
"Public Magent URL must be an HTTP or HTTPS origin without credentials, a path, query or fragment.",
);
}
if (url.origin !== browserOrigin) {
throw new Error(
"Public Magent URL must match the address open in this browser. Open Magent at your intended address, then confirm it and create the administrator there.",
);
}
return url.origin;
}
export function settingsValues(settings: Setting[]): Values {
const values: Values = {};
for (const field of ALL_FIELDS) {
const setting = settings.find((candidate) => candidate.key === field.key);
if (!setting) continue;
values[field.key] =
field.type === "password" || setting.sensitive
? ""
: field.type === "checkbox"
? setting.value === true || setting.value === "true" || setting.value === "1"
: String(setting.value ?? "");
}
return values;
}
// Only explicitly edited fields are sent. A blank password never clears a saved
// secret (masked values from the settings endpoint are not actual credentials).
export function settingsPayload(
draft: Values,
fields: Field[] = ALL_FIELDS,
): Record<string, string | boolean | number> {
const payload: Record<string, string | boolean | number> = {};
for (const field of fields) {
const value = draft[field.key];
if (value === undefined || (field.type === "password" && !String(value).trim())) continue;
if (field.type === "url" || field.type === "email" || field.type === "time") {
const text = String(value).trim();
if (text && field.type === "url") {
let url: URL;
try {
url = new URL(text);
} catch {
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
}
if (
!/^https?:\/\//i.test(text) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
/\s/.test(text)
) {
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
}
if (url.username || url.password)
throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`);
}
if (
text &&
field.type === "email" &&
!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
text,
)
) {
throw new Error(`${field.label} must be a valid email address.`);
}
if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
throw new Error(`${field.label} must be a valid time in HH:MM format.`);
}
payload[field.key] = text;
} else if (field.type === "number" && value !== "") {
const number = Number(value);
if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) {
throw new Error(
`${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`,
);
}
payload[field.key] = number;
} else payload[field.key] = value;
}
return payload;
}
export function configuredApp(app: AppDefinition, settings: Setting[]): boolean {
return app.fields
.filter((field) => field.key.endsWith("_base_url") || field.type === "password")
.every((field) => settings.some((setting) => setting.key === field.key && setting.isSet));
}
+49
View File
@@ -0,0 +1,49 @@
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
.heading { margin-bottom: 30px; }
.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
.setup p { color: var(--ops-muted); line-height: 1.6; }
.brand { display: flex; align-items: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
.brand svg { width: 38px; height: 38px; }
.panel { padding: 24px; margin: 16px 0; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); min-width: 0; }
.panel h2, .panel h3 { margin-top: 0; }
.panel summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; list-style: none; }
.panel summary::after { content: "+"; color: var(--ops-primary-2); }
.panel[open] summary::after { content: ""; }
.panel summary > span:first-child { flex: 1; }
.panel summary strong { display: block; font-size: 17px; }
.panel summary small { display: block; margin-top: 6px; color: var(--ops-muted); line-height: 1.5; }
.panel[open] summary { margin-bottom: 24px; }
.badge { font-size: 12px; color: var(--ops-primary-2); }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
.field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.field label { color: var(--ops-text); font-size: 13px; }
.field label small { margin-left: 8px; color: var(--ops-green); }
.field p, .hint { font-size: 12px; margin: 0; }
.field input:not([type=checkbox]), .field textarea, .field select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); border-radius: 8px; font: inherit; font-size: 14px; }
.field textarea { resize: vertical; }
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
.toggle p { grid-column: 1 / -1; }
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
.account { display: grid; gap: 20px; max-width: 440px; margin: 24px 0; }
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 30px; }
.steps button { flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px; background: var(--ops-panel); color: var(--ops-muted); border: 1px solid var(--ops-line); box-shadow: none; }
.steps button[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
.steps button span { font-size: 12px; }
.actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--ops-line); }
.actions > span { flex: 1; color: var(--ops-muted); font-size: 12px; }
.error, .notice { padding: 16px 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-bg-2); overflow-wrap: anywhere; }
.setup .error { border-color: var(--ops-red); color: var(--ops-red); }
.review { list-style: none; padding: 0; margin: 24px 0; }
.review li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--ops-line); }
.review li span:last-child { font-size: 13px; color: var(--ops-muted); text-align: right; }
.confirm { display: flex; align-items: center; gap: 12px; margin: 24px 0; }
.setup :is(button, input, textarea, select, a, summary):focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 3px; }
.setup button:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 640px) {
.setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; }
}