feat(web): admin nav entry + role-aware AppShell
- Add an Admin nav item (key 'admin', href /settings/users) with visibleTo=['admin'] so signed-in users with role='user' don't see it. - nav-config exposes navItemsForRole(role) helper that filters NAV_ITEMS by visibleTo. - Root layout fetches getCurrentUser() and forwards role into AppShell. AppShell narrows the role gate to the rendered nav (sidebar + drawer); /login still short-circuits to the bare header. Unknown role falls back to 'user' visibility (defense-in-depth). - Settings page renders an admin-only card linking to Users so admins have a discoverable in-app entry point too. Tests: - nav-config: navItemsForRole admin/user matrix + admin entry shape. - app-shell: admin link visible for admin, hidden for user, hidden for null/unauthenticated, /login bare header strips nav entirely. - actions/auth: cookie payload encodes role=user, unknown role rejected, AUTH_SECRET-unset path, whitespace-only username rejected, rate-limit key contains client IP, unknown-user path still hits DB+bcrypt. 440 tests now (was 423). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
797326e062
commit
4ddf5c094e
@ -209,4 +209,77 @@ describe("logoutAction", () => {
|
|||||||
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/login");
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("is idempotent — clears the cookie even when no session exists", async () => {
|
||||||
|
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
|
||||||
|
// doesn't care about pre-existing state and we still issue the redirect.
|
||||||
|
cookiesDeleteMock.mockReset();
|
||||||
|
await logoutAction().catch(() => {});
|
||||||
|
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loginAction — additional cases", () => {
|
||||||
|
it("issues a cookie with role='user' encoded in the payload for a non-admin user", async () => {
|
||||||
|
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
|
||||||
|
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
|
||||||
|
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
|
||||||
|
// Cookie shape: <base64url(json)>.<base64url(sig)>. Decode payload and
|
||||||
|
// assert the role round-trips, so the middleware/AppShell role gate gets
|
||||||
|
// accurate data without us having to import auth-cookie.
|
||||||
|
const [payloadEnc] = (cookieValue as string).split(".");
|
||||||
|
const json = Buffer.from(
|
||||||
|
payloadEnc!.replace(/-/g, "+").replace(/_/g, "/") +
|
||||||
|
"=".repeat((4 - (payloadEnc!.length % 4)) % 4),
|
||||||
|
"base64",
|
||||||
|
).toString("utf8");
|
||||||
|
const decoded = JSON.parse(json);
|
||||||
|
expect(decoded.role).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the user row has an unrecognised role string", async () => {
|
||||||
|
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
|
||||||
|
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
||||||
|
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
|
||||||
|
expect(cookiesSetMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
|
||||||
|
findUserMock.mockResolvedValue(ADMIN_ROW);
|
||||||
|
const prev = process.env.AUTH_SECRET;
|
||||||
|
delete process.env.AUTH_SECRET;
|
||||||
|
try {
|
||||||
|
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
||||||
|
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
|
||||||
|
expect(cookiesSetMock).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
process.env.AUTH_SECRET = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats whitespace-only username as missing input", async () => {
|
||||||
|
const r = await loginAction(fd({ username: " ", password: "x" }));
|
||||||
|
expect(r).toEqual({ ok: false, error: "Username and password are required." });
|
||||||
|
expect(findUserMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rate limit key includes the client IP so a hostile IP can't lock everyone out", async () => {
|
||||||
|
findUserMock.mockResolvedValue(ADMIN_ROW);
|
||||||
|
headersGetMock.mockReturnValue("198.51.100.42");
|
||||||
|
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
||||||
|
const [key] = checkRateLimitMock.mock.calls[0]!;
|
||||||
|
expect(key).toContain("198.51.100.42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
|
||||||
|
findUserMock.mockResolvedValue(undefined);
|
||||||
|
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
||||||
|
await loginAction(fd({ username: "ghost", password: "anything" }));
|
||||||
|
// findFirst was called even though we know the user doesn't exist.
|
||||||
|
expect(findUserMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cmpSpy).toHaveBeenCalled();
|
||||||
|
cmpSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { NotificationManager } from "@/components/notification-manager";
|
import { NotificationManager } from "@/components/notification-manager";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -33,7 +34,12 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
// Pass the role into AppShell so the nav can hide admin-only entries
|
||||||
|
// for the 'user' role. On /login getCurrentUser returns null and
|
||||||
|
// AppShell short-circuits to the bare header anyway.
|
||||||
|
const me = await getCurrentUser();
|
||||||
|
const role = me?.role ?? null;
|
||||||
return (
|
return (
|
||||||
// `suppressHydrationWarning` here is for *attribute* differences only.
|
// `suppressHydrationWarning` here is for *attribute* differences only.
|
||||||
// Two sources legitimately mutate <html>/<body> attributes after the
|
// Two sources legitimately mutate <html>/<body> attributes after the
|
||||||
@ -46,7 +52,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
||||||
<body suppressHydrationWarning>
|
<body suppressHydrationWarning>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell role={role}>{children}</AppShell>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
{/* SSE → browser notification bridge. Renders no DOM. */}
|
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||||
<NotificationManager />
|
<NotificationManager />
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ShieldCheckIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@ -7,6 +9,7 @@ import { PageShell } from "@/components/page-shell";
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
|
const isAdmin = op.role === "admin";
|
||||||
return (
|
return (
|
||||||
<PageShell title="Settings" narrow>
|
<PageShell title="Settings" narrow>
|
||||||
<Card>
|
<Card>
|
||||||
@ -24,6 +27,31 @@ export default async function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheckIcon className="size-4" />
|
||||||
|
Admin
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage which usernames can sign in and what role each
|
||||||
|
one has. Visible to admins only.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={"/settings/users" as any}
|
||||||
|
className="flex items-center justify-between gap-3 px-6 py-3 text-sm font-medium hover:bg-muted focus-visible:bg-muted rounded-b-xl"
|
||||||
|
>
|
||||||
|
<span>Users</span>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notifications</CardTitle>
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<main>page</main>
|
<main>page</main>
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("brand mark on the left links to /", () => {
|
it("brand mark on the left links to /", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
pathnameMock.mockReturnValue(c.path);
|
pathnameMock.mockReturnValue(c.path);
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
||||||
pathnameMock.mockReturnValue("/unknown-route");
|
pathnameMock.mockReturnValue("/unknown-route");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("menu button on the right uses aria-label='Open menu'", () => {
|
it("menu button on the right uses aria-label='Open menu'", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("renders one nav link per NAV_ITEM, in order", () => {
|
it("renders one nav link per NAV_ITEM, in order", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
it("marks the active route's link with aria-current='page'", () => {
|
it("marks the active route's link with aria-current='page'", () => {
|
||||||
pathnameMock.mockReturnValue("/reminders");
|
pathnameMock.mockReturnValue("/reminders");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
// every page. The header uses an exact-match check for "/".
|
// every page. The header uses an exact-match check for "/".
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("drawer header carries the brand wording and a screen-reader description", () => {
|
it("drawer header carries the brand wording and a screen-reader description", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
|
|
||||||
it("renders the sidebar nav with every NAV_ITEM", () => {
|
it("renders the sidebar nav with every NAV_ITEM", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -234,7 +234,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
|
|
||||||
it("keeps the theme toggle in the sidebar footer", () => {
|
it("keeps the theme toggle in the sidebar footer", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -246,7 +246,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -264,7 +264,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
// reader users on a wide-window split-screen don't hear two
|
// reader users on a wide-window split-screen don't hear two
|
||||||
// identical announcements when both are visible.
|
// identical announcements when both are visible.
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell>
|
<AppShell role="admin">
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -273,6 +273,79 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role-gated nav (admin panel)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("AppShell — role-based nav filtering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pathnameMock.mockReset();
|
||||||
|
pathnameMock.mockReturnValue("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppShell role="admin">
|
||||||
|
<div />
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
expect(html).toContain('href="/settings/users"');
|
||||||
|
// A label appears in both the sidebar and the drawer; either way the
|
||||||
|
// count must be >=2 (sidebar copy + drawer copy).
|
||||||
|
const occurrences = (html.match(/href="\/settings\/users"/g) ?? []).length;
|
||||||
|
expect(occurrences).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Admin entry from BOTH surfaces when role=user", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppShell role="user">
|
||||||
|
<div />
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain('href="/settings/users"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
|
||||||
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppShell role={null}>
|
||||||
|
<div />
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain('href="/settings/users"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
|
||||||
|
const adminHtml = renderToStaticMarkup(
|
||||||
|
<AppShell role="admin">
|
||||||
|
<div />
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
const userHtml = renderToStaticMarkup(
|
||||||
|
<AppShell role="user">
|
||||||
|
<div />
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
for (const item of NAV_ITEMS) {
|
||||||
|
if (item.visibleTo) continue;
|
||||||
|
expect(adminHtml).toContain(`href="${item.href}"`);
|
||||||
|
expect(userHtml).toContain(`href="${item.href}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
|
||||||
|
pathnameMock.mockReturnValue("/login");
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppShell role={null}>
|
||||||
|
<div />
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("<aside");
|
||||||
|
expect(html).not.toContain('data-testid="sheet-content"');
|
||||||
|
expect(html).not.toContain('href="/settings/users"');
|
||||||
|
expect(html).toContain("WhatsApp Bot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// helpers
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -14,7 +14,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { NAV_ITEMS } from "@/components/nav-config";
|
import {
|
||||||
|
NAV_ITEMS,
|
||||||
|
navItemsForRole,
|
||||||
|
type NavItem,
|
||||||
|
type NavRole,
|
||||||
|
} from "@/components/nav-config";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -30,7 +35,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
// waiting for the page content to render. The menu button on the right
|
// waiting for the page content to render. The menu button on the right
|
||||||
// opens a Sheet with the full nav list and the theme toggle.
|
// opens a Sheet with the full nav list and the theme toggle.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function MobileHeader() {
|
function MobileHeader({ items }: { items: NavItem[] }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -41,6 +46,10 @@ function MobileHeader() {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Use the full list (not the role-filtered one) for the title lookup
|
||||||
|
// so the page title still shows up correctly when a 'user' role hits
|
||||||
|
// a route they wouldn't normally see in the nav (e.g. arrives via a
|
||||||
|
// direct link), even though they can't navigate there from the menu.
|
||||||
const currentItem = NAV_ITEMS.find(({ href }) =>
|
const currentItem = NAV_ITEMS.find(({ href }) =>
|
||||||
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
||||||
);
|
);
|
||||||
@ -92,7 +101,7 @@ function MobileHeader() {
|
|||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
className="flex flex-col gap-0.5 p-2 flex-1"
|
className="flex flex-col gap-0.5 p-2 flex-1"
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
{items.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -126,7 +135,7 @@ function MobileHeader() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar (desktop only — hidden below sm)
|
// Sidebar (desktop only — hidden below sm)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function Sidebar() {
|
function Sidebar({ items }: { items: NavItem[] }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -150,7 +159,7 @@ function Sidebar() {
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
||||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
{items.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -209,9 +218,11 @@ function BareHeader() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/** Role of the signed-in user, or null when unauthenticated. */
|
||||||
|
role: NavRole | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children, role }: AppShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isAuthRoute = pathname === "/login";
|
const isAuthRoute = pathname === "/login";
|
||||||
|
|
||||||
@ -224,13 +235,18 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Treat unauthenticated render of a protected route (shouldn't happen
|
||||||
|
// because middleware redirects, but defense-in-depth) as 'user': hides
|
||||||
|
// the admin-only entries.
|
||||||
|
const items = navItemsForRole(role ?? "user");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<Sidebar />
|
<Sidebar items={items} />
|
||||||
|
|
||||||
{/* Mobile header (single row: brand · title · menu) */}
|
{/* Mobile header (single row: brand · title · menu) */}
|
||||||
<MobileHeader />
|
<MobileHeader items={items} />
|
||||||
|
|
||||||
{/* Main content
|
{/* Main content
|
||||||
Mobile: push down for the h-14 header (56px) plus a small gap
|
Mobile: push down for the h-14 header (56px) plus a small gap
|
||||||
|
|||||||
33
apps/web/src/components/nav-config.test.ts
Normal file
33
apps/web/src/components/nav-config.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { NAV_ITEMS, navItemsForRole } from "./nav-config";
|
||||||
|
|
||||||
|
describe("navItemsForRole", () => {
|
||||||
|
it("includes every NAV_ITEM for an admin", () => {
|
||||||
|
const items = navItemsForRole("admin");
|
||||||
|
expect(items).toHaveLength(NAV_ITEMS.length);
|
||||||
|
for (const original of NAV_ITEMS) {
|
||||||
|
expect(items.find((i) => i.key === original.key)).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides admin-only entries for the 'user' role", () => {
|
||||||
|
const items = navItemsForRole("user");
|
||||||
|
const keys = items.map((i) => i.key);
|
||||||
|
expect(keys).not.toContain("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the un-restricted entries (no visibleTo) for the 'user' role", () => {
|
||||||
|
const items = navItemsForRole("user");
|
||||||
|
const keys = items.map((i) => i.key);
|
||||||
|
expect(keys).toEqual(
|
||||||
|
expect.arrayContaining(["dashboard", "accounts", "reminders", "activity", "settings"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin nav entry routes to /settings/users", () => {
|
||||||
|
const admin = NAV_ITEMS.find((i) => i.key === "admin");
|
||||||
|
expect(admin).toBeDefined();
|
||||||
|
expect(admin!.href).toBe("/settings/users");
|
||||||
|
expect(admin!.visibleTo).toEqual(["admin"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,11 +1,22 @@
|
|||||||
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
|
import {
|
||||||
|
Home,
|
||||||
|
Smartphone,
|
||||||
|
Calendar,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export type NavRole = "admin" | "user";
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
/** When set, only roles listed here will see this nav entry. */
|
||||||
|
visibleTo?: NavRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
@ -13,5 +24,18 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||||
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
||||||
|
{
|
||||||
|
key: "admin",
|
||||||
|
href: "/settings/users",
|
||||||
|
label: "Admin",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
visibleTo: ["admin"],
|
||||||
|
},
|
||||||
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function navItemsForRole(role: NavRole): NavItem[] {
|
||||||
|
return NAV_ITEMS.filter(
|
||||||
|
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user