feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -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