feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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. Set CORS_ALLOW_ORIGIN in your environment to the same origin; changing this field does not change CORS.",
|
||||
},
|
||||
{ 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 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));
|
||||
}
|
||||
Reference in New Issue
Block a user