From 9afd68a741b04fc6c8379617e81e7ff3130fabda Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 25 May 2026 17:53:44 +0700 Subject: [PATCH] feat(phase-1): role-gated app shell + dashboard placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (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) { + const nextLocale = e.target.value as (typeof routing.locales)[number]; + router.replace(pathname, { locale: nextLocale }); + } + + return ( +
+ + +
+ ); +} 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." } }