mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-21 08:25:09 +00:00
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:
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }],
|
||||
};
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user