feat(phase-1): role-gated app shell + dashboard placeholder

- app/[locale]/(app)/layout.tsx: Server gate — getServerSession()
  redirects unauth users (defense in depth) and signs out + redirects
  the authed-but-no-role edge case. Renders AppShell with user/role/locale.
- app/[locale]/(app)/admin/layout.tsx: second gate via requireRole;
  non-admin → /[locale]/dashboard (not 404 — avoids confirming routes).
- app/[locale]/(app)/dashboard/page.tsx: placeholder showing email +
  role badge.
- lib/auth/role-menu.ts: ROLE_MENU mapping per AppRole → MenuItem[]
  with href + i18n labelKey + lucide icon.
- lib/auth/require-role.ts: server helper for the admin gate.
- components/app-shell/{app-shell,sidebar,sign-out-button,locale-switcher}.tsx:
  Server-rendered shell + sidebar that reads ROLE_MENU[role]; client
  locale switcher (native <select> fallback) and sign-out (<form action>
  with useFormStatus pending UX).
- components/ui/{badge,separator}.tsx: shadcn primitives.
- messages/{vi,en}.json: nav.*, app.*, dashboard.* keys (parity).

Gating chain: proxy.ts redirects unauth → /sign-in for /dashboard +
/admin prefixes; (app) layout enforces session+role; (app)/admin layout
additionally enforces role === 'admin'.
This commit is contained in:
2026-05-25 17:53:44 +07:00
parent eb1af9013e
commit 9afd68a741
13 changed files with 538 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
// WARNING: Do NOT add `'use cache'` here — requireRole() reads cookies().
/**
* Admin route-group layout — Server Component.
*
* Second gate for all routes under /admin/**. The parent (app)/layout.tsx
* already validated that a session exists; this layout additionally enforces
* role === 'admin'.
*
* Non-admin authenticated users are redirected to /dashboard rather than
* receiving a 404, which would confirm that restricted admin routes exist.
*/
import type { ReactNode } from "react";
import { requireRole } from "@/lib/auth/require-role";
export default async function AdminLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Redirects to /dashboard if role !== 'admin'.
await requireRole(["admin"], locale);
return <>{children}</>;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Dashboard placeholder — Server Component.
*
* Displays a welcome message with the user's email and role.
* Real dashboard content (stats, queues, etc.) lands in Phase 2+.
*/
import { getTranslations } from "next-intl/server";
import { getServerSession } from "@/lib/auth/get-server-session";
export default async function DashboardPage({ params }: { params: Promise<{ locale: string }> }) {
// params must be awaited in Next.js 16 App Router.
await params;
const t = await getTranslations("dashboard");
const session = await getServerSession();
// session is guaranteed non-null by the parent (app)/layout.tsx gate,
// but we guard here to satisfy TypeScript's strict null checks.
const email = session?.user.email ?? "";
const role = session?.role ?? "";
return (
<div className="px-8 py-10">
<h1 className="text-foreground text-2xl font-semibold">{t("welcome", { email })}</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("roleLabel")}{" "}
<span className="bg-muted text-foreground rounded px-1.5 py-0.5 text-xs font-medium">
{role}
</span>
</p>
<p className="text-muted-foreground mt-6 text-sm">{t("placeholder")}</p>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
// WARNING: Do NOT add `'use cache'` here — getServerSession() reads cookies().
// Caching this scope would serve stale auth state across users.
/**
* (app) route-group layout — Server Component.
*
* Gate: requires a valid session AND a non-null role.
* - No session (unauthenticated) → redirect to /sign-in
* - Session but role is null → user is authed but unenrolled (e.g. admin
* deleted their app_users row mid-session). Sign out and redirect to /sign-in
* so the user is not silently stuck in a broken state.
* - Session + role present → render the AppShell.
*
* The proxy (middleware) already redirects unauthenticated requests away from
* /dashboard and /admin. This layout is defense-in-depth and handles the
* "authed but unenrolled" edge case the proxy cannot detect.
*/
import type { ReactNode } from "react";
import { redirect } from "@/i18n/navigation";
import { getServerSession } from "@/lib/auth/get-server-session";
import { signOutAction } from "@/app/[locale]/(auth)/sign-in/actions";
import { AppShell } from "@/components/app-shell/app-shell";
export default async function AppLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const session = await getServerSession();
// Unauthenticated — defense-in-depth (proxy already handles most cases).
if (!session) {
redirect({ href: `/${locale}/sign-in`, locale });
// TypeScript: redirect() throws a Next.js redirect symbol; unreachable.
return null;
}
// Authed but unenrolled — sign out so the Supabase cookie is cleared, then
// redirect. Prevents a user whose app_users row was deleted from looping on
// the dashboard with a valid JWT but no BSK role.
if (!session.role) {
await signOutAction();
// signOutAction calls redirect() internally and never returns normally;
// the line below satisfies TypeScript's control-flow analysis.
return null;
}
const { user, role } = session;
return (
<AppShell email={user.email ?? ""} role={role} locale={locale}>
{children}
</AppShell>
);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* AppShell — Server Component.
*
* Composes the full authenticated layout: fixed sidebar on the left,
* scrollable main content area on the right. Receives user info from the
* (app) layout which has already validated session + role.
*
* No client state here — sidebar is server-rendered, top-bar lives inside
* the sidebar's bottom section for Phase 1 simplicity.
*/
import type { ReactNode } from "react";
import type { AppRole } from "@/lib/db/roles";
import { Sidebar } from "@/components/app-shell/sidebar";
type AppShellProps = {
email: string;
role: AppRole;
locale: string;
children: ReactNode;
};
export function AppShell({ email, role, locale, children }: AppShellProps) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar email={email} role={role} locale={locale} />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
/**
* Locale switcher — Client Component.
*
* Uses a native <select> (no shadcn dependency) to swap vi ↔ en while
* preserving the current pathname. next-intl's useRouter().replace() handles
* locale prefix rewriting transparently.
*/
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing } from "@/i18n/routing";
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("app.localeSwitcher");
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const nextLocale = e.target.value as (typeof routing.locales)[number];
router.replace(pathname, { locale: nextLocale });
}
return (
<div className="flex items-center gap-1.5">
<label htmlFor="locale-select" className="text-muted-foreground sr-only text-xs">
{t("label")}
</label>
<select
id="locale-select"
value={locale}
onChange={handleChange}
aria-label={t("label")}
className="border-border bg-background text-foreground rounded-md border px-2 py-1 text-xs focus:outline-none"
>
{routing.locales.map((l) => (
<option key={l} value={l}>
{t(`locales.${l}`)}
</option>
))}
</select>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* App sidebar — Server Component.
*
* Receives user email, role, and locale as props from the (app) layout.
* Renders role-filtered menu items from ROLE_MENU using locale-aware Links.
* No client state; active-link highlighting deferred to a later phase.
*/
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { ROLE_MENU } from "@/lib/auth/role-menu";
import type { AppRole } from "@/lib/db/roles";
import { SignOutButton } from "@/components/app-shell/sign-out-button";
import { LocaleSwitcher } from "@/components/app-shell/locale-switcher";
type SidebarProps = {
email: string;
role: AppRole;
locale: string;
};
export async function Sidebar({ email, role, locale }: SidebarProps) {
const t = await getTranslations();
const items = ROLE_MENU[role];
return (
<aside className="bg-background border-border flex h-full w-56 shrink-0 flex-col border-r">
{/* Brand */}
<div className="border-border border-b px-4 py-4">
<span className="text-foreground text-lg font-bold tracking-tight">BSK</span>
<p className="text-muted-foreground text-xs">Clinic Management</p>
</div>
{/* User info */}
<div className="border-border border-b px-4 py-3">
<p className="text-foreground truncate text-sm font-medium">{email}</p>
<span className="bg-muted text-muted-foreground mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-medium">
{role}
</span>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Main navigation">
<ul className="space-y-0.5">
{items.map((item) => {
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
locale={locale as "vi" | "en"}
className="text-foreground hover:bg-muted flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors"
>
<Icon className="size-4 shrink-0" />
{t(item.labelKey as Parameters<typeof t>[0])}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Bottom actions */}
<div className="border-border space-y-1 border-t px-2 py-3">
<LocaleSwitcher />
<SignOutButton />
</div>
</aside>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
/**
* Sign-out button — Client Component.
*
* Wraps signOutAction in a <form> so it works as a native form submission
* (no JS required for the redirect). useFormStatus() disables the button
* while the server action is in-flight to prevent double-submit.
*/
import { useFormStatus } from "react-dom";
import { useTranslations } from "next-intl";
import { signOutAction } from "@/app/[locale]/(auth)/sign-in/actions";
import { Button } from "@/components/ui/button";
import { LogOut } from "lucide-react";
function SignOutButtonInner() {
const { pending } = useFormStatus();
const t = useTranslations("app");
return (
<Button
type="submit"
variant="ghost"
size="sm"
disabled={pending}
className="w-full justify-start gap-2"
>
<LogOut className="size-4" />
{pending ? "…" : t("signOut")}
</Button>
);
}
export function SignOutButton() {
return (
<form action={signOutAction}>
<SignOutButtonInner />
</form>
);
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+42
View File
@@ -0,0 +1,42 @@
import "server-only";
/**
* Server-side role gate helper.
*
* Call from route layouts that restrict access to one or more roles.
* Redirects unauthenticated users to /sign-in and unauthorized users
* (wrong role) to /dashboard.
*
* redirect() is intentionally called OUTSIDE any try/catch — Next.js 16
* implements redirect() via a thrown symbol; catching it silently drops the
* redirect.
*
* Usage:
* const session = await requireRole(['admin'], locale);
* // session.user and session.role are guaranteed non-null here
*/
import { redirect } from "@/i18n/navigation";
import { getServerSession } from "@/lib/auth/get-server-session";
import type { AppRole } from "@/lib/db/roles";
import type { ServerSession } from "@/lib/auth/get-server-session";
export async function requireRole(
allowed: AppRole[],
locale: string,
): Promise<ServerSession & { role: AppRole }> {
const session = await getServerSession();
if (!session?.user) {
redirect({ href: `/${locale}/sign-in`, locale });
// TypeScript: redirect() throws, this line is unreachable
throw new Error("unreachable");
}
if (!session.role || !allowed.includes(session.role)) {
redirect({ href: `/${locale}/dashboard`, locale });
throw new Error("unreachable");
}
return session as ServerSession & { role: AppRole };
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Role-based navigation menu mapping.
*
* ROLE_MENU is the single source of truth for what routes each role can access.
* Server-rendered sidebar reads this; client never performs its own gating.
*
* labelKey values are i18n message keys resolved by the sidebar via
* next-intl's `getTranslations`. Keep key names stable — renaming a key
* requires updating messages/{vi,en}.json simultaneously.
*
* Add new items here when a new page lands; the sidebar renders them
* automatically. Phase 2+ will append items for doctors, nurses, etc.
*/
import type { AppRole } from "@/lib/db/roles";
import type { ComponentType } from "react";
import { LayoutDashboard, UserPlus } from "lucide-react";
export type MenuItem = {
/** Locale-relative path, e.g. "/dashboard". Sidebar prefixes with locale. */
href: string;
/** Key into the "nav" namespace in messages/{vi,en}.json */
labelKey: string;
/** Icon component from lucide-react */
icon: ComponentType<{ className?: string }>;
};
/**
* Menu items per role — Phase 1 minimal set.
*
* Only routes that exist are listed. Future phases append items here as
* pages land. Non-existent routes are intentionally absent to avoid dead links.
*
* Phase 1 existing routes:
* /dashboard — this phase
* /admin/invite — phase 05
*/
export const ROLE_MENU: Record<AppRole, MenuItem[]> = {
admin: [
{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard },
{ href: "/admin/invite", labelKey: "nav.invite", icon: UserPlus },
],
doctor: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
nurse: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
receptionist: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
cashier: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
patient: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
};
+31
View File
@@ -32,5 +32,36 @@
"invalidCredentials": "Invalid email or password.",
"genericError": "Something went wrong. Please try again."
}
},
"nav": {
"dashboard": "Dashboard",
"invite": "Invite Users",
"queue": "Queue",
"patients": "Patients",
"doctors": "Doctors",
"services": "Services",
"medicines": "Medicines",
"reports": "Reports",
"register": "Register Patient",
"invoices": "Invoices",
"payments": "Payments",
"checkups": "Checkups",
"settings": "Settings"
},
"app": {
"signOut": "Sign out",
"unenrolledError": "Your account is not enrolled. Please contact an administrator.",
"localeSwitcher": {
"label": "Language",
"locales": {
"vi": "Tiếng Việt",
"en": "English"
}
}
},
"dashboard": {
"welcome": "Welcome, {email}",
"roleLabel": "Your role:",
"placeholder": "Dashboard content is coming in a future phase."
}
}
+31
View File
@@ -32,5 +32,36 @@
"invalidCredentials": "Email hoặc mật khẩu không đúng.",
"genericError": "Đã xảy ra lỗi. Vui lòng thử lại."
}
},
"nav": {
"dashboard": "Trang chủ",
"invite": "Mời người dùng",
"queue": "Hàng chờ",
"patients": "Bệnh nhân",
"doctors": "Bác sĩ",
"services": "Dịch vụ",
"medicines": "Thuốc",
"reports": "Báo cáo",
"register": "Đăng ký bệnh nhân",
"invoices": "Hoá đơn",
"payments": "Thanh toán",
"checkups": "Khám bệnh",
"settings": "Cài đặt"
},
"app": {
"signOut": "Đăng xuất",
"unenrolledError": "Tài khoản của bạn chưa được đăng ký. Vui lòng liên hệ quản trị viên.",
"localeSwitcher": {
"label": "Ngôn ngữ",
"locales": {
"vi": "Tiếng Việt",
"en": "English"
}
}
},
"dashboard": {
"welcome": "Xin chào, {email}",
"roleLabel": "Vai trò của bạn:",
"placeholder": "Nội dung trang chủ sẽ được bổ sung trong giai đoạn tiếp theo."
}
}