diff --git a/TODO.md b/TODO.md
index b0f218b..97913f8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -21,9 +21,9 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
- [x] [P1] RBAC enforcement at route and action level in admin
- [x] [P1] Permission matrix documented and tested
-- [~] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
-- [~] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
-- [~] [P1] Locale persistence and switcher base component (cookie/header + UI)
+- [x] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
+- [x] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
+- [x] [P1] Locale persistence and switcher base component (cookie/header + UI)
- [x] [P1] Integrate Better Auth core configuration and session wiring
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
- [x] [P1] Enforce invariant: exactly one owner user must always exist
@@ -193,10 +193,11 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
-- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending.
+- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes.
- [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later.
- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far.
- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI.
+- [2026-02-10] Admin i18n baseline now resolves locale from cookie and loads runtime message dictionaries in root layout; admin locale switcher is active on auth and dashboard views.
## How We Use This File
diff --git a/apps/admin/package.json b/apps/admin/package.json
index c61d95a..5d90b51 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
+ "@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "5.90.20",
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index c8ace86..72f119b 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next"
import type { ReactNode } from "react"
+import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import "./globals.css"
import { Providers } from "./providers"
@@ -9,11 +10,16 @@ export const metadata: Metadata = {
description: "Admin dashboard for the CMS monorepo",
}
-export default function RootLayout({ children }: { children: ReactNode }) {
+export default async function RootLayout({ children }: { children: ReactNode }) {
+ const locale = await resolveAdminLocale()
+ const messages = await getAdminMessages(locale)
+
return (
-
+
- {children}
+
+ {children}
+
)
diff --git a/apps/admin/src/app/login/login-form.tsx b/apps/admin/src/app/login/login-form.tsx
index 16679be..df017ec 100644
--- a/apps/admin/src/app/login/login-form.tsx
+++ b/apps/admin/src/app/login/login-form.tsx
@@ -4,6 +4,9 @@ import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { type FormEvent, useMemo, useState } from "react"
+import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
+import { useAdminT } from "@/providers/admin-i18n-provider"
+
type LoginFormProps = {
mode: "signin" | "signup-owner" | "signup-user"
}
@@ -27,6 +30,7 @@ function persistRoleCookie(role: unknown) {
export function LoginForm({ mode }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
+ const t = useAdminT()
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
@@ -60,7 +64,7 @@ export function LoginForm({ mode }: LoginFormProps) {
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
- setError(payload?.message ?? "Sign in failed")
+ setError(payload?.message ?? t("auth.errors.signInFailed", "Sign in failed"))
return
}
@@ -68,7 +72,7 @@ export function LoginForm({ mode }: LoginFormProps) {
router.push(nextPath)
router.refresh()
} catch {
- setError("Network error while signing in")
+ setError(t("auth.errors.networkSignIn", "Network error while signing in"))
} finally {
setIsBusy(false)
}
@@ -78,7 +82,7 @@ export function LoginForm({ mode }: LoginFormProps) {
event.preventDefault()
if (!name.trim()) {
- setError("Name is required for account creation")
+ setError(t("auth.errors.nameRequired", "Name is required for account creation"))
return
}
@@ -104,20 +108,20 @@ export function LoginForm({ mode }: LoginFormProps) {
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
- setError(payload?.message ?? "Sign up failed")
+ setError(payload?.message ?? t("auth.errors.signUpFailed", "Sign up failed"))
return
}
persistRoleCookie(payload?.user?.role)
setSuccess(
mode === "signup-owner"
- ? "Owner account created. Registration is now disabled."
- : "Account created.",
+ ? t("auth.messages.ownerCreated", "Owner account created. Registration is now disabled.")
+ : t("auth.messages.accountCreated", "Account created."),
)
router.push(nextPath)
router.refresh()
} catch {
- setError("Network error while signing up")
+ setError(t("auth.errors.networkSignUp", "Network error while signing up"))
} finally {
setIsBusy(false)
}
@@ -126,24 +130,28 @@ export function LoginForm({ mode }: LoginFormProps) {
return (
-
Admin Auth
+
+
+ {t("auth.badge", "Admin Auth")}
+
+
+
{mode === "signin"
- ? "Sign in to CMS Admin"
+ ? t("auth.titles.signIn", "Sign in to CMS Admin")
: mode === "signup-owner"
- ? "Welcome to CMS Admin"
- : "Create an admin account"}
+ ? t("auth.titles.signUpOwner", "Welcome to CMS Admin")
+ : t("auth.titles.signUpUser", "Create an admin account")}
- {mode === "signin" ? (
- <>
- Better Auth is active on this app via /api/auth.
- >
- ) : mode === "signup-owner" ? (
- "Create the first owner account to initialize this admin instance."
- ) : (
- "Self-registration is enabled for admin users."
- )}
+ {mode === "signin"
+ ? t("auth.descriptions.signIn", "Better Auth is active on this app via /api/auth.")
+ : mode === "signup-owner"
+ ? t(
+ "auth.descriptions.signUpOwner",
+ "Create the first owner account to initialize this admin instance.",
+ )
+ : t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")}