feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}/`));
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default function ApplicationChrome() {
|
||||
"/welcome",
|
||||
"/coming-soon",
|
||||
"/login",
|
||||
"/setup",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/signup",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user