feat: add backup recovery, setup wizard and user-view guards
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s

This commit is contained in:
2026-09-18 17:23:03 +12:00
parent a6a4a9aa24
commit fd6671cf7e
44 changed files with 4650 additions and 114 deletions
+9 -7
View File
@@ -1,16 +1,16 @@
"use client";
import PageHeading from "./ui/PageHeading";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth";
import {
normalizeRecentResults,
normalizeSearchResults,
type RecentRequest,
type RequestSearchResult,
} from "./lib/request-results";
import { useEffectiveRole } from "./lib/viewMode";
import PageHeading from "./ui/PageHeading";
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
export default function HomePage() {
@@ -22,6 +22,8 @@ export default function HomePage() {
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const effectiveRole = useEffectiveRole(role);
const isAdmin = effectiveRole === "admin";
const [recentDays, setRecentDays] = useState(90);
const [recentStage, setRecentStage] = useState<RequestStage>("all");
const [authReady, setAuthReady] = useState(false);
@@ -62,7 +64,7 @@ export default function HomePage() {
const userRole = me?.role ?? null;
setRole(userRole);
setAuthReady(true);
const take = userRole === "admin" ? 50 : 6;
const take = isAdmin ? 50 : 6;
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
@@ -96,7 +98,7 @@ export default function HomePage() {
return () => {
cancelled = true;
};
}, [recentDays, recentStage, router]);
}, [isAdmin, recentDays, recentStage, router]);
useEffect(() => {
if (!authReady) {
@@ -305,7 +307,7 @@ export default function HomePage() {
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
<h2>{isAdmin ? "Recent requests" : "My recent requests"}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
@@ -337,7 +339,7 @@ export default function HomePage() {
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
(isAdmin ? recent : recent.slice(0, 6)).map((item) => (
<button
key={item.id}
type="button"
@@ -0,0 +1,133 @@
.page {
display: grid;
gap: 24px;
min-width: 0;
}
.page h2 {
margin: 0;
font-size: 1.15rem;
}
.page p {
margin: 0;
line-height: 1.65;
}
.summary,
.pending,
.panel {
display: grid;
align-content: start;
gap: 16px;
min-width: 0;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
}
.summary p,
.panel > p,
.muted,
.help {
color: var(--ink-muted);
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
align-items: start;
}
.pending {
border-color: var(--accent);
border-inline-start-width: 4px;
}
.notice {
padding: 16px;
border: 1px solid var(--accent);
border-radius: 8px;
}
.fields {
display: grid;
gap: 16px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-bottom: 16px;
color: var(--ink-muted);
font-size: 0.85rem;
}
.field {
display: grid;
gap: 8px;
font-weight: 600;
}
.field input {
width: 100%;
min-width: 0;
min-height: 44px;
box-sizing: border-box;
}
.field input[type="file"] {
padding: 10px;
font-size: 0.85rem;
}
.checkbox {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
}
.checkbox input {
flex: 0 0 auto;
width: 20px;
height: 20px;
margin: 0;
}
.help {
font-size: 0.85rem;
}
.page button {
justify-self: start;
min-height: 44px;
white-space: normal;
}
.page input:focus-visible,
.page button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
@media (max-width: 1000px) {
.columns {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 600px) {
.summary,
.pending,
.panel {
padding: 18px;
}
.page button {
width: 100%;
}
}
+395
View File
@@ -0,0 +1,395 @@
"use client";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { apiUrl, requestJson } from "../../lib/api-client";
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
import AdminShell from "../../ui/AdminShell";
import styles from "./backups.module.css";
type BackupDetails = {
created_at: string;
build: string;
include_cache: boolean;
};
type BackupStatus = {
format_version: number;
max_upload_bytes: number;
max_expanded_bytes: number;
include_cache_default: boolean;
pending_restore: (BackupDetails & { staged_at: string }) | null;
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
};
type RestoreResult = {
status: "staged";
restart_required: true;
backup: BackupDetails;
message: string;
};
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
export default function BackupsPage() {
const router = useRouter();
const [data, setData] = useState<BackupStatus | null>(null);
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
const [includeCache, setIncludeCache] = useState(false);
const [exportPassphrase, setExportPassphrase] = useState("");
const [confirmPassphrase, setConfirmPassphrase] = useState("");
const [restorePassphrase, setRestorePassphrase] = useState("");
const [confirmation, setConfirmation] = useState("");
const [file, setFile] = useState<File | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const handleError = useCallback(
(cause: unknown, fallback: string) => {
if (cause instanceof UnauthorizedError) {
router.replace("/login?next=%2Fadmin%2Fbackups");
return;
}
if (cause instanceof ForbiddenError) {
router.replace("/");
return;
}
setError(cause instanceof Error ? cause.message : fallback);
},
[router],
);
useEffect(() => {
void revision;
const abort = new AbortController();
setLoading(true);
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
.then((result) => {
if (abort.signal.aborted) return;
setData(result);
setIncludeCache(result.include_cache_default);
})
.catch((cause: unknown) => {
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
})
.finally(() => {
if (!abort.signal.aborted) setLoading(false);
});
return () => abort.abort();
}, [revision, handleError]);
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (busy) return;
setError("");
setNotice("");
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
setError("Choose a backup passphrase between 12 and 1,024 characters.");
return;
}
if (exportPassphrase !== confirmPassphrase) {
setError("The backup passphrases do not match.");
return;
}
setBusy("export");
try {
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
});
if (!response.ok) {
const payload: unknown = await response.json().catch(() => null);
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
link.href = downloadUrl;
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
link.hidden = true;
document.body.appendChild(link);
try {
link.click();
} finally {
link.remove();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
}
setExportPassphrase("");
setConfirmPassphrase("");
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
} catch (cause) {
handleError(cause, "Could not create the backup.");
} finally {
setBusy(null);
}
};
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (busy || !data || data.pending_restore) return;
setError("");
setNotice("");
if (!file || file.size === 0) {
setError("Choose a Magent backup file to restore.");
return;
}
if (file.size > data.max_upload_bytes) {
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
return;
}
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
return;
}
if (confirmation !== "RESTORE") {
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
return;
}
setBusy("restore");
try {
const form = new FormData();
form.append("file", file);
form.append("passphrase", restorePassphrase);
form.append("confirmation", confirmation);
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
setData((current) =>
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
);
setRestorePassphrase("");
setConfirmation("");
setFile(null);
if (fileInput.current) fileInput.current.value = "";
setNotice(
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
);
} catch (cause) {
handleError(cause, "Could not prepare the restore.");
} finally {
setBusy(null);
}
};
const cancelRestore = async () => {
if (busy) return;
setBusy("cancel");
setError("");
setNotice("");
try {
await requestJson("/admin/backups/restore", { method: "DELETE" });
setData((current) => (current ? { ...current, pending_restore: null } : current));
setNotice("Pending restore cancelled. Your current data is unchanged.");
} catch (cause) {
handleError(cause, "Could not cancel the restore.");
} finally {
setBusy(null);
}
};
return (
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
<div className={styles.page}>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
{loading && <p role="status">Loading backup settings...</p>}
{!loading && !data && (
<button
type="button"
className="ghost-button"
onClick={() => {
setError("");
setRevision((current) => current + 1);
}}
>
Try again
</button>
)}
{data && (
<>
<section className={styles.summary} aria-labelledby="backup-contents">
<h2 id="backup-contents">What is saved</h2>
<p>
Every backup includes Magent settings, app connection credentials, branding, and the complete database
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
</p>
<p>
Connected apps and media files need their own backups. App credentials configured through the
environment are included, but the deployment environment file, host paths, and signing or encryption
keys are not.
</p>
{data.last_restore && (
<p className={styles.muted}>
{data.last_restore.status === "rolled_back"
? "Last restore was rolled back"
: "Last restore completed"}
: {dateLabel(data.last_restore.restored_at)}.
{data.last_restore.status === "rolled_back" &&
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
</p>
)}
</section>
{data.pending_restore && (
<section className={styles.pending} aria-labelledby="pending-restore-title">
<h2 id="pending-restore-title">Restore ready restart required</h2>
<p>
Backup from {dateLabel(data.pending_restore.created_at)}
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
{data.pending_restore.include_cache ? "included" : "not included"}.
</p>
<p>
Restart the Magent container or service to apply this backup. Changes made since the backup was
created will be replaced. Afterwards, sign in again with an administrator account from the restored
backup.
</p>
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
</button>
</section>
)}
<div className={styles.columns}>
<section className={styles.panel} aria-labelledby="create-backup-title">
<h2 id="create-backup-title">Create a backup</h2>
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
<p>
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
artwork caches unchecked.
</p>
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
<fieldset className={styles.fields} disabled={!!busy}>
<legend className={styles.legend}>Backup options</legend>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={includeCache}
onChange={(event) => setIncludeCache(event.target.checked)}
aria-describedby="cache-help"
/>
Include artwork caches
</label>
<p id="cache-help" className={styles.help}>
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
again. Database caches are always included.
</p>
<label className={styles.field}>
Backup passphrase
<input
type="password"
autoComplete="new-password"
value={exportPassphrase}
onChange={(event) => setExportPassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
aria-describedby="backup-passphrase-help"
/>
</label>
<p id="backup-passphrase-help" className={styles.help}>
Use at least 12 characters. This passphrase is separate from your login password. A lost
passphrase cannot be recovered.
</p>
<label className={styles.field}>
Confirm backup passphrase
<input
type="password"
autoComplete="new-password"
value={confirmPassphrase}
onChange={(event) => setConfirmPassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
/>
</label>
<button type="submit">
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
</button>
</fieldset>
</form>
</section>
<section className={styles.panel} aria-labelledby="restore-backup-title">
<h2 id="restore-backup-title">Restore a backup</h2>
<p>
Restoring replaces Magent&apos;s settings and database, including users and invites. Download a
current backup first if you want to keep these changes.
</p>
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
<legend className={styles.legend}>Choose and confirm a backup</legend>
<label className={styles.field}>
Backup file
<input
ref={fileInput}
type="file"
accept=".magent-backup,application/octet-stream"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
required
aria-describedby="backup-file-help"
/>
</label>
<p id="backup-file-help" className={styles.help}>
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
</p>
<label className={styles.field}>
Backup passphrase
<input
type="password"
autoComplete="off"
value={restorePassphrase}
onChange={(event) => setRestorePassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
/>
</label>
<label className={styles.field}>
Type RESTORE to confirm replacement
<input
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
pattern="RESTORE"
required
aria-describedby="restore-restart-help"
/>
</label>
<p id="restore-restart-help" className={styles.help}>
The backup is checked before being queued. It only takes effect when you restart Magent; you can
cancel before then. You will need to sign in using an account from the backup.
</p>
<button type="submit" className="danger-button">
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
</button>
</fieldset>
</form>
</section>
</div>
</>
)}
</div>
</AdminShell>
);
}
+6
View File
@@ -82,6 +82,12 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
advanced: true,
items: [
{ href: "/admin/general", label: "Hosting & proxy", description: "Public addresses and deployment options" },
{ href: "/setup", label: "Setup wizard", description: "Guided app connections and installation preferences" },
{
href: "/admin/backups",
label: "Backup & restore",
description: "Encrypted settings, database and cache backups",
},
{ href: "/admin/diagnostics", label: "System health", description: "Service checks and diagnostics" },
{ href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" },
{ href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" },
+4 -2
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../lib/auth";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import {
type Stats,
@@ -20,6 +21,7 @@ export default function InsightsPage() {
const router = useRouter();
const [days, setDays] = useState(30);
const [data, setData] = useState<Stats | null>(null);
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
@@ -126,11 +128,11 @@ export default function InsightsPage() {
</span>
<h2>Your viewing story starts here</h2>
<p>
{data.is_admin
{isAdmin
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
: "Viewing stats will appear here once your administrator connects Jellystat."}
</p>
{data.is_admin && (
{isAdmin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
+5 -3
View File
@@ -5,6 +5,7 @@ import EmailReportControl from "./EmailReportControl";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../../lib/auth";
import { useEffectiveRole } from "../../lib/viewMode";
import PageHeading from "../../ui/PageHeading";
import {
type Stats,
@@ -67,6 +68,7 @@ export default function MonthlyReportsPage() {
const [monthReady, setMonthReady] = useState(false);
const [months, setMonths] = useState<string[]>([]);
const [data, setData] = useState<MonthlyReport | null>(null);
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
@@ -267,11 +269,11 @@ export default function MonthlyReportsPage() {
<section className="stats-state">
<h2>Your monthly story starts here</h2>
<p>
{data.is_admin
{isAdmin
? "Connect Jellystat to bring your monthly viewing reports into Magent."
: "Monthly reports will appear once your administrator connects Jellystat."}
</p>
{data.is_admin && (
{isAdmin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
@@ -285,7 +287,7 @@ export default function MonthlyReportsPage() {
Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
your user identities.
</p>
{data.is_admin && (
{isAdmin && (
<a className="stats-action" href="/admin/identities">
Review user identities
</a>
+8 -2
View File
@@ -9,6 +9,8 @@ import type { ReactNode } from "react";
import BrandingFavicon from "./ui/BrandingFavicon";
import FeatureGate from "./ui/FeatureGate";
import ApplicationChrome from "./ui/ApplicationChrome";
import SetupGate from "./ui/SetupGate";
import AdminViewGate from "./ui/AdminViewGate";
export const metadata = {
title: "Magent",
@@ -26,8 +28,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body>
<BrandingFavicon />
<div className="page">
<ApplicationChrome />
<FeatureGate>{children}</FeatureGate>
<SetupGate>
<ApplicationChrome />
<AdminViewGate>
<FeatureGate>{children}</FeatureGate>
</AdminViewGate>
</SetupGate>
</div>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { getEffectiveRole, isAdminPage } from "./user-view-policy";
describe("user view preview policy", () => {
it("downgrades only the displayed administrator role during preview", () => {
expect(getEffectiveRole("admin", true)).toBe("user");
expect(getEffectiveRole("admin", false)).toBe("admin");
for (const role of ["user", null, undefined]) {
expect(getEffectiveRole(role, true)).toBe(role);
expect(getEffectiveRole(role, false)).toBe(role);
}
});
it("covers configuration, nested admin pages, user management and setup", () => {
for (const path of [
"/admin",
"/admin/",
"/admin/backups",
"/admin/recaps",
"/users",
"/users/42",
"/setup",
"/admin?section=site",
"/%61dmin/diagnostics",
]) {
expect(isAdminPage(path), path).toBe(true);
}
});
it("does not restrict normal member pages or similarly named paths", () => {
for (const path of [
"/",
"/profile",
"/profile/invites",
"/portal/issues",
"/requests/3580",
"/insights",
"/administrator",
"/users-guide",
]) {
expect(isAdminPage(path), path).toBe(false);
}
});
it("keeps public first-install setup separate from admin authentication", () => {
expect(isAdminPage("/setup", false)).toBe(false);
expect(isAdminPage("/admin/backups", false)).toBe(true);
});
});
+16
View File
@@ -0,0 +1,16 @@
// Preview never promotes a user or changes server-side account permissions.
export function getEffectiveRole(role: string | null | undefined, preview: boolean) {
return preview && role === "admin" ? "user" : role;
}
export function isAdminPage(pathname: string, includeSetup = true): boolean {
let path = pathname.split(/[?#]/, 1)[0];
try {
path = decodeURIComponent(path);
} catch {
// Let the router handle malformed URLs; never infer a more privileged role.
}
path = path.replace(/\/{2,}/g, "/");
const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"];
return roots.some((root) => path === root || path.startsWith(`${root}/`));
}
+42 -22
View File
@@ -1,13 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { getEffectiveRole } from "./user-view-policy";
const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
const USER_VIEW_EVENT = "magent:user-view-change";
let fallbackPreview = false;
const readUserViewPreview = () => {
if (typeof window === "undefined") return false;
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
try {
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
} catch {
return fallbackPreview;
}
};
const applyDocumentMode = (enabled: boolean) => {
@@ -17,32 +23,46 @@ const applyDocumentMode = (enabled: boolean) => {
export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === "undefined") return;
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
fallbackPreview = enabled;
try {
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
}
} catch {
// Preview still works for this document when browser storage is unavailable.
}
applyDocumentMode(enabled);
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
};
export const useUserViewPreview = () => {
const [enabled, setEnabled] = useState(false);
const subscribe = (notify: () => void) => {
window.addEventListener(USER_VIEW_EVENT, notify);
window.addEventListener("storage", notify);
return () => {
window.removeEventListener(USER_VIEW_EVENT, notify);
window.removeEventListener("storage", notify);
};
};
// Unknown during server rendering/initial hydration: admin pages must not mount
// and fetch privileged data before the saved per-tab preview mode is known.
const serverSnapshot = (): boolean | null => null;
export const useUserViewState = () => {
const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot);
useEffect(() => {
const sync = () => {
const nextValue = readUserViewPreview();
applyDocumentMode(nextValue);
setEnabled(nextValue);
};
sync();
window.addEventListener(USER_VIEW_EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(USER_VIEW_EVENT, sync);
window.removeEventListener("storage", sync);
};
}, []);
if (value !== null) applyDocumentMode(value);
}, [value]);
return enabled;
return { enabled: value === true, ready: value !== null };
};
export const useUserViewPreview = () => useUserViewState().enabled;
export const useEffectiveRole = (role?: string | null) => {
const { enabled, ready } = useUserViewState();
return getEffectiveRole(role, !ready || enabled);
};
+2
View File
@@ -100,6 +100,8 @@ export default function LoginPage() {
"/profile#newsletters",
"/admin/recaps",
"/admin/newsletters",
"/setup",
"/admin/backups",
].includes(next) ||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
/^\/issues\/confirm\/\d+$/.test(next);
+46 -26
View File
@@ -1,12 +1,12 @@
"use client";
import ResolutionChoice from "../ui/ResolutionChoice";
import PageHeading from "../ui/PageHeading";
import IssueFlowStep from "./IssueFlowStep";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import ResolutionChoice from "../ui/ResolutionChoice";
import IssueFlowStep from "./IssueFlowStep";
type PortalPermissions = {
can_edit?: boolean;
@@ -536,7 +536,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
const isAdmin = me?.role === "admin";
const effectiveRole = useEffectiveRole(me?.role);
const isAdmin = effectiveRole === "admin";
const isOwner = (item: PortalItem) => me?.username === item.created_by_username;
const canConfirmResolution = (item: PortalItem) =>
Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item)));
const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem)));
const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate);
const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete);
const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal);
const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added");
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
const workspaceLabel = workspace === "request" ? "request" : "issue";
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
@@ -610,6 +619,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
});
const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
useEffect(() => {
if (isAdmin) return;
setDeleteConfirming(false);
if (commentInternal) {
// Do not turn an unfinished internal note into a public comment when preview changes.
setCommentText("");
setCommentInternal(false);
}
}, [isAdmin, commentInternal]);
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
@@ -1335,7 +1354,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const saveItem = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
if (!selectedItem || !canEditSelected) return;
setSaving(true);
setError(null);
setStatus(null);
@@ -1347,7 +1366,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
year: editYear.trim() ? toPositiveInt(editYear) : null,
external_ref: editExternalRef || null,
};
if (selectedItem.permissions?.can_moderate) {
if (canModerateSelected) {
if (selectedItem.kind === "request") {
payload.request_status = editRequestStatus;
payload.media_status = editMediaStatus;
@@ -1390,6 +1409,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const postComment = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
if (commentInternal && !isAdmin) return;
if (!commentText.trim()) {
setError("Comment message is required.");
return;
@@ -1404,7 +1424,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: commentText,
is_internal: commentInternal,
is_internal: isAdmin && commentInternal,
}),
});
if (!response.ok) {
@@ -1429,7 +1449,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
};
const respondToResolution = async (resolved: boolean) => {
if (!selectedItem) return;
if (!selectedItem || !canConfirmResolution(selectedItem)) return;
setRespondingResolution(true);
setError(null);
setStatus(null);
@@ -1465,7 +1485,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
};
const deleteIssue = async () => {
if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return;
if (selectedItem?.kind !== "issue" || !canDeleteSelected) return;
setDeleting(true);
setError(null);
setStatus(null);
@@ -1550,7 +1570,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
.filter(
(item) =>
item.status === "awaiting_confirmation" &&
item.permissions?.can_confirm_resolution &&
canConfirmResolution(item) &&
item.created_by_username === me?.username,
)
.map((item) => (
@@ -2416,7 +2436,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</strong>
</div>
<div className="issue-modal-toolbar-actions">
{selectedItem?.permissions?.can_delete ? (
{canDeleteSelected ? (
<button
type="button"
className="danger-button"
@@ -2442,7 +2462,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<>
{selectedItem.kind === "issue" &&
selectedItem.status === "awaiting_confirmation" &&
selectedItem.permissions?.can_confirm_resolution && (
canConfirmResolution(selectedItem) && (
<ResolutionChoice
title={selectedItem.title}
busy={respondingResolution}
@@ -2478,7 +2498,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</div>
{selectedItem.kind === "issue" && deleteConfirming ? (
{selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
<section className="issue-delete-confirmation" aria-live="polite">
<div>
<span className="section-kicker">Permanent deletion</span>
@@ -2529,7 +2549,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
<label className="portal-field-span-2">
@@ -2538,7 +2558,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
rows={4}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
{selectedItem.kind === "request" ? (
@@ -2548,7 +2568,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<select
value={editMediaType}
onChange={(event) => setEditMediaType(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || "none"} value={option.value}>
@@ -2563,7 +2583,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
value={editYear}
onChange={(event) => setEditYear(event.target.value)}
inputMode="numeric"
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
</>
@@ -2573,10 +2593,10 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<input
value={editExternalRef}
onChange={(event) => setEditExternalRef(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
{selectedItem.permissions?.can_moderate && (
{canModerateSelected && (
<>
{selectedItem.kind === "request" ? (
<>
@@ -2652,7 +2672,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</>
)}
<div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={saving || !selectedItem.permissions?.can_edit}>
<button type="submit" disabled={saving || !canEditSelected}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
@@ -2665,13 +2685,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<span className="section-kicker">Recorded work</span>
<h3>Issue activity</h3>
</div>
<span className="small-pill">{activity.length} events</span>
<span className="small-pill">{visibleActivity.length} events</span>
</div>
{activity.length === 0 ? (
{visibleActivity.length === 0 ? (
<div className="status-banner">No issue activity has been recorded yet.</div>
) : (
<ol className="issue-activity-list">
{activity.map((entry) => (
{visibleActivity.map((entry) => (
<li key={entry.id}>
<i aria-hidden="true" />
<div>
@@ -2690,11 +2710,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<div className="portal-comments-block">
<h3>Comments</h3>
{comments.length === 0 ? (
{visibleComments.length === 0 ? (
<div className="status-banner">No comments yet.</div>
) : (
<div className="portal-comment-list">
{comments.map((comment) => (
{visibleComments.map((comment) => (
<article key={comment.id} className="portal-comment-card">
<header>
<strong>{comment.author_username}</strong>
+10 -5
View File
@@ -1,12 +1,11 @@
"use client";
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import PageHeading from "../../ui/PageHeading";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import { useEffectiveRole } from "../../lib/viewMode";
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import PageHeading from "../../ui/PageHeading";
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
type OwnedInvite = {
@@ -77,6 +76,10 @@ export default function ProfileInvitesPage() {
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
const effectiveRole = useEffectiveRole(profile?.role);
const canManageInvites =
effectiveRole === "admin" ||
(profile?.role === "admin" ? Boolean(profile.invite_management_enabled) : inviteAccessEnabled);
const signupBaseUrl = useMemo(() => {
if (typeof window === "undefined") return "/signup";
@@ -158,6 +161,7 @@ export default function ProfileInvitesPage() {
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault();
if (!canManageInvites) return;
const inviteName = inviteForm.label.trim();
const recipientEmail = inviteForm.recipient_email.trim();
if (!inviteName) {
@@ -225,6 +229,7 @@ export default function ProfileInvitesPage() {
};
const deleteInvite = async (invite: OwnedInvite) => {
if (!canManageInvites) return;
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
setError(null);
try {
@@ -240,6 +245,7 @@ export default function ProfileInvitesPage() {
};
const copyInviteLink = async (invite: OwnedInvite) => {
if (!canManageInvites) return;
try {
let usableInvite = invite;
if (!invite.code_available) {
@@ -263,7 +269,6 @@ export default function ProfileInvitesPage() {
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
const canManageInvites = profile?.role === "admin" || inviteAccessEnabled;
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
if (loading) return <main className="card">Loading invite workspace</main>;
+9 -6
View File
@@ -1,14 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
import { canAccess, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import MonthlyRecapPreference from "./MonthlyRecapPreference";
import NewsletterPreference from "./NewsletterPreference";
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type ProfileInfo = {
features?: FeatureAccess;
username: string;
@@ -214,6 +214,7 @@ export default function ProfilePage() {
};
const user = data?.user;
const effectiveRole = useEffectiveRole(user?.role);
const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
const canChangePassword =
user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
@@ -239,7 +240,7 @@ export default function ProfilePage() {
</span>
<div>
<strong>{user.username}</strong>
<span>{user.role === "admin" ? "Administrator" : "Member"}</span>
<span>{effectiveRole === "admin" ? "Administrator" : "Member"}</span>
</div>
</div>
)
@@ -340,7 +341,9 @@ export default function ProfilePage() {
: "Signed in with your media account"}
</span>
</div>
{canAccess(user, "stats") && <MonthlyRecapPreference key={user.email || "no-email"} />}
{canAccess({ ...user, role: effectiveRole ?? undefined }, "stats") && (
<MonthlyRecapPreference key={user.email || "no-email"} />
)}
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
</section>
+38 -23
View File
@@ -4,8 +4,9 @@ import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import { canAccess } from "../../lib/features";
import { canAccess, type FeatureAccess } from "../../lib/features";
import { lockBodyScroll } from "../../lib/scrollLock";
import { useEffectiveRole } from "../../lib/viewMode";
import PageHeading from "../../ui/PageHeading";
import LatestActivity from "./LatestActivity";
import RequestLanguage from "./RequestLanguage";
@@ -329,8 +330,10 @@ export default function RequestTimelinePage() {
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]);
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]);
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [canReportIssues, setCanReportIssues] = useState(false);
const [viewer, setViewer] = useState<{ role?: string; features?: Partial<FeatureAccess> } | null>(null);
const effectiveRole = useEffectiveRole(viewer?.role);
const isAdmin = effectiveRole === "admin";
const canReportIssues = canAccess(viewer ? { ...viewer, role: effectiveRole ?? undefined } : null, "issues");
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([]);
const awaitingMediaIndex = Boolean(
snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"),
@@ -389,32 +392,13 @@ export default function RequestTimelinePage() {
throw new Error("Unable to verify your request access.");
}
const me = await meResponse.json();
const viewerIsAdmin = me?.role === "admin";
setIsAdmin(viewerIsAdmin);
setCanReportIssues(canAccess(me, "issues"));
setViewer(me);
if (!snapshotResponse.ok) {
throw new Error(await readApiError(snapshotResponse, "Unable to load this request."));
}
const snapshotData = await snapshotResponse.json();
if (!isSnapshotPayload(snapshotData)) throw new Error("Unable to load this request.");
setSnapshot(snapshotData);
if (viewerIsAdmin) {
const [historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
]);
if (historyResponse.ok) {
const historyData = await historyResponse.json();
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots);
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json();
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions);
}
} else {
setHistorySnapshots([]);
setHistoryActions([]);
}
} catch (error) {
console.error(error);
setLoadError(error instanceof Error ? error.message : "Unable to load this request.");
@@ -425,6 +409,37 @@ export default function RequestTimelinePage() {
void load();
}, [requestId, router]);
useEffect(() => {
if (!isAdmin || !requestId) {
setShowDetails(false);
setHistorySnapshots([]);
setHistoryActions([]);
return;
}
const controller = new AbortController();
const loadHistory = async () => {
try {
const baseUrl = getApiBase();
const [historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`, { signal: controller.signal }),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`, { signal: controller.signal }),
]);
if (historyResponse.ok) {
const data = await historyResponse.json();
if (!controller.signal.aborted && Array.isArray(data.snapshots)) setHistorySnapshots(data.snapshots);
}
if (actionsResponse.ok) {
const data = await actionsResponse.json();
if (!controller.signal.aborted && Array.isArray(data.actions)) setHistoryActions(data.actions);
}
} catch (error) {
if (!controller.signal.aborted) console.error(error);
}
};
void loadHistory();
return () => controller.abort();
}, [isAdmin, requestId]);
useEffect(() => {
if (!getToken() || !requestId) return;
let stopped = false;
+584
View File
@@ -0,0 +1,584 @@
"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,
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 [checks, setChecks] = useState<Record<string, Check>>({});
const [options, setOptions] = useState<Record<string, CollectorOptions>>({});
const [accepted, setAccepted] = useState(false);
const values = { ...settingsValues(settings), ...draft };
useEffect(() => {
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) {
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 }),
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. Only the server operator can create the first administrator."
: "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-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 SETUP_TOKEN from your environment after completion. 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>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { APPS, configuredApp, settingsPayload, settingsValues } from "./setup-model";
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,
);
});
});
+263
View File
@@ -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));
}
+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; }
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { isAdminPage } from "../lib/user-view-policy";
import { setUserViewPreview, useUserViewState } from "../lib/viewMode";
export default function AdminViewGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { enabled, ready } = useUserViewState();
if (!isAdminPage(pathname)) return children;
if (!ready)
return (
<main className="card" role="status">
Checking view mode...
</main>
);
if (!enabled) return children;
return (
<main className="card">
<h1>Administrator tools are hidden</h1>
<p>Configuration, user management and other admin tools are unavailable while previewing user view.</p>
<p>Your account is unchanged. Exit the preview to return to this page.</p>
<div className="config-inline-controls">
<a href="/">Go to My Requests</a>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
</main>
);
}
+1
View File
@@ -16,6 +16,7 @@ export default function ApplicationChrome() {
"/welcome",
"/coming-soon",
"/login",
"/setup",
"/forgot-password",
"/reset-password",
"/signup",
+17 -1
View File
@@ -4,6 +4,8 @@ import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import { isAdminPage } from "../lib/user-view-policy";
export function useFeatureUser() {
const pathname = usePathname();
@@ -11,6 +13,7 @@ export function useFeatureUser() {
path: string;
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
}>({ path: "", user: null });
const role = useEffectiveRole(state.user?.role);
useEffect(() => {
let active = true;
const load = async () => {
@@ -33,13 +36,26 @@ export function useFeatureUser() {
window.removeEventListener("focus", load);
};
}, [pathname]);
return { user: state.user, ready: state.path === pathname };
return { user: state.user ? { ...state.user, role: role ?? undefined } : null, ready: state.path === pathname };
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const feature = featureForPath(pathname);
if (isAdminPage(pathname, false)) {
if (!ready) return <main className="card">Checking administrator access...</main>;
if (user?.role !== "admin") {
return (
<main className="card">
<h1>Administrator access required</h1>
<p>Sign in with an administrator account to use configuration and administration tools.</p>
<a href="/login">Sign in</a>
</main>
);
}
return children;
}
if (!feature) return children;
if (!ready) return <main className="card">Loading account access...</main>;
if (!getToken()) return children;
+3 -2
View File
@@ -2,13 +2,14 @@
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
import { setUserViewPreview, useEffectiveRole, useUserViewPreview } from "../lib/viewMode";
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
const [buildNumber, setBuildNumber] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const viewAsUser = useUserViewPreview();
const visibleRole = useEffectiveRole(identity?.role);
useEffect(() => {
const token = getToken();
@@ -102,7 +103,7 @@ export default function HeaderIdentity() {
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{identity.role === "admin" ? (
{visibleRole === "admin" ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { requestJson } from "../lib/api-client";
import { authFetch } from "../lib/auth";
// Backup access stays available so a fresh installation can be restored before
// connecting any apps. This is navigation only; the API enforces admin access.
export default function SetupGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const bypass = pathname === "/setup" || pathname === "/admin/backups";
const [checked, setChecked] = useState(false);
useEffect(() => {
if (bypass) return;
const controller = new AbortController();
void requestJson<{ setup_required: boolean }>(
"/setup/status",
{ signal: controller.signal, cache: "no-store" },
authFetch,
)
.then((status) => {
if (controller.signal.aborted) return;
if (status.setup_required) router.replace("/setup");
else setChecked(true);
})
.catch(() => {
// Never hide an existing installation during an API outage or rollout.
if (!controller.signal.aborted) setChecked(true);
});
return () => controller.abort();
}, [bypass, router]);
if (bypass || checked) return children;
return (
<main className="card" role="status">
Checking installation...
</main>
);
}
+3 -1
View File
@@ -11,7 +11,9 @@ export default function UserViewBanner() {
<div className="user-view-banner" role="status">
<div>
<strong>User view</strong>
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
<span>
Admin controls are hidden. You are still using your own account and data; backend permissions are unchanged.
</span>
</div>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view