feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user