yiekheng 4ddf5c094e 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>
2026-05-10 18:30:58 +08:00

94 lines
3.3 KiB
TypeScript

import Link from "next/link";
import { ShieldCheckIcon, ChevronRightIcon } from "lucide-react";
import { getSeededOperator } from "@/lib/operator";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/theme-toggle";
import { NotificationsToggle } from "@/components/notifications-toggle";
import { PageShell } from "@/components/page-shell";
export default async function SettingsPage() {
const op = await getSeededOperator();
const isAdmin = op.role === "admin";
return (
<PageShell title="Settings" narrow>
<Card>
<CardHeader>
<CardTitle>Operator</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<Row label="Display name" value={op.displayName} />
<Separator />
<Row label="Username" value={op.username} mono />
<Separator />
<Row label="Default timezone" value={op.defaultTimezone} mono />
<Separator />
<Row label="Role" value={op.role} mono />
</CardContent>
</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>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Browser notifications when a reminder fires successfully or a
test message is sent. Uses the in-tab Notification API works
while the app is open. Background push is on the roadmap.
</CardDescription>
</CardHeader>
<CardContent>
<NotificationsToggle />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Theme</div>
<ThemeToggle />
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground">
cm WhatsApp Bot · self-hosted
</p>
</PageShell>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-3">
<dt className="text-muted-foreground">{label}</dt>
<dd className={mono ? "font-mono text-xs" : ""}>{value}</dd>
</div>
);
}