diff --git a/app/[locale]/(app)/admin/layout.tsx b/app/[locale]/(app)/admin/layout.tsx new file mode 100644 index 0000000..e326549 --- /dev/null +++ b/app/[locale]/(app)/admin/layout.tsx @@ -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}; +} diff --git a/app/[locale]/(app)/dashboard/page.tsx b/app/[locale]/(app)/dashboard/page.tsx new file mode 100644 index 0000000..5ec1bdc --- /dev/null +++ b/app/[locale]/(app)/dashboard/page.tsx @@ -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 ( +
+

{t("welcome", { email })}

+

+ {t("roleLabel")}{" "} + + {role} + +

+

{t("placeholder")}

+
+ ); +} diff --git a/app/[locale]/(app)/layout.tsx b/app/[locale]/(app)/layout.tsx new file mode 100644 index 0000000..b9d5082 --- /dev/null +++ b/app/[locale]/(app)/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/components/app-shell/app-shell.tsx b/components/app-shell/app-shell.tsx new file mode 100644 index 0000000..1da9769 --- /dev/null +++ b/components/app-shell/app-shell.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/components/app-shell/locale-switcher.tsx b/components/app-shell/locale-switcher.tsx new file mode 100644 index 0000000..0e9432c --- /dev/null +++ b/components/app-shell/locale-switcher.tsx @@ -0,0 +1,46 @@ +"use client"; + +/** + * Locale switcher — Client Component. + * + * Uses a native + {routing.locales.map((l) => ( + + ))} + + + ); +} diff --git a/components/app-shell/sidebar.tsx b/components/app-shell/sidebar.tsx new file mode 100644 index 0000000..baf2acc --- /dev/null +++ b/components/app-shell/sidebar.tsx @@ -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 ( + + ); +} diff --git a/components/app-shell/sign-out-button.tsx b/components/app-shell/sign-out-button.tsx new file mode 100644 index 0000000..289470f --- /dev/null +++ b/components/app-shell/sign-out-button.tsx @@ -0,0 +1,41 @@ +"use client"; + +/** + * Sign-out button — Client Component. + * + * Wraps signOutAction in a
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 ( + + ); +} + +export function SignOutButton() { + return ( + + + + ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..d15c436 --- /dev/null +++ b/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..2e35219 --- /dev/null +++ b/components/ui/separator.tsx @@ -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) { + return ( + + ); +} + +export { Separator }; diff --git a/lib/auth/require-role.ts b/lib/auth/require-role.ts new file mode 100644 index 0000000..f00993e --- /dev/null +++ b/lib/auth/require-role.ts @@ -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 { + 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 }; +} diff --git a/lib/auth/role-menu.ts b/lib/auth/role-menu.ts new file mode 100644 index 0000000..d62c01a --- /dev/null +++ b/lib/auth/role-menu.ts @@ -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 = { + 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 }], +}; diff --git a/messages/en.json b/messages/en.json index db43166..056b8a5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." } } diff --git a/messages/vi.json b/messages/vi.json index ef74c1d..5a8e1b3 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -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." } }