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

This commit is contained in:
2026-09-18 17:23:03 +12:00
parent a6a4a9aa24
commit fd6671cf7e
44 changed files with 4650 additions and 114 deletions
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { isAdminPage } from "../lib/user-view-policy";
import { setUserViewPreview, useUserViewState } from "../lib/viewMode";
export default function AdminViewGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { enabled, ready } = useUserViewState();
if (!isAdminPage(pathname)) return children;
if (!ready)
return (
<main className="card" role="status">
Checking view mode...
</main>
);
if (!enabled) return children;
return (
<main className="card">
<h1>Administrator tools are hidden</h1>
<p>Configuration, user management and other admin tools are unavailable while previewing user view.</p>
<p>Your account is unchanged. Exit the preview to return to this page.</p>
<div className="config-inline-controls">
<a href="/">Go to My Requests</a>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
</main>
);
}
+1
View File
@@ -16,6 +16,7 @@ export default function ApplicationChrome() {
"/welcome",
"/coming-soon",
"/login",
"/setup",
"/forgot-password",
"/reset-password",
"/signup",
+17 -1
View File
@@ -4,6 +4,8 @@ import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import { isAdminPage } from "../lib/user-view-policy";
export function useFeatureUser() {
const pathname = usePathname();
@@ -11,6 +13,7 @@ export function useFeatureUser() {
path: string;
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
}>({ path: "", user: null });
const role = useEffectiveRole(state.user?.role);
useEffect(() => {
let active = true;
const load = async () => {
@@ -33,13 +36,26 @@ export function useFeatureUser() {
window.removeEventListener("focus", load);
};
}, [pathname]);
return { user: state.user, ready: state.path === pathname };
return { user: state.user ? { ...state.user, role: role ?? undefined } : null, ready: state.path === pathname };
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const feature = featureForPath(pathname);
if (isAdminPage(pathname, false)) {
if (!ready) return <main className="card">Checking administrator access...</main>;
if (user?.role !== "admin") {
return (
<main className="card">
<h1>Administrator access required</h1>
<p>Sign in with an administrator account to use configuration and administration tools.</p>
<a href="/login">Sign in</a>
</main>
);
}
return children;
}
if (!feature) return children;
if (!ready) return <main className="card">Loading account access...</main>;
if (!getToken()) return children;
+3 -2
View File
@@ -2,13 +2,14 @@
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
import { setUserViewPreview, useEffectiveRole, useUserViewPreview } from "../lib/viewMode";
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
const [buildNumber, setBuildNumber] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const viewAsUser = useUserViewPreview();
const visibleRole = useEffectiveRole(identity?.role);
useEffect(() => {
const token = getToken();
@@ -102,7 +103,7 @@ export default function HeaderIdentity() {
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{identity.role === "admin" ? (
{visibleRole === "admin" ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { requestJson } from "../lib/api-client";
import { authFetch } from "../lib/auth";
// Backup access stays available so a fresh installation can be restored before
// connecting any apps. This is navigation only; the API enforces admin access.
export default function SetupGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const bypass = pathname === "/setup" || pathname === "/admin/backups";
const [checked, setChecked] = useState(false);
useEffect(() => {
if (bypass) return;
const controller = new AbortController();
void requestJson<{ setup_required: boolean }>(
"/setup/status",
{ signal: controller.signal, cache: "no-store" },
authFetch,
)
.then((status) => {
if (controller.signal.aborted) return;
if (status.setup_required) router.replace("/setup");
else setChecked(true);
})
.catch(() => {
// Never hide an existing installation during an API outage or rollout.
if (!controller.signal.aborted) setChecked(true);
});
return () => controller.abort();
}, [bypass, router]);
if (bypass || checked) return children;
return (
<main className="card" role="status">
Checking installation...
</main>
);
}
+3 -1
View File
@@ -11,7 +11,9 @@ export default function UserViewBanner() {
<div className="user-view-banner" role="status">
<div>
<strong>User view</strong>
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
<span>
Admin controls are hidden. You are still using your own account and data; backend permissions are unchanged.
</span>
</div>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view