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.")}

@@ -154,7 +162,7 @@ export function LoginForm({ mode }: LoginFormProps) { >
- setPassword(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- - - -

- Need an account?{" "} - - Register - -

- - {error ?

{error}

: null} - - ) : ( -
-
- - setName(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- -
- - setEmail(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- -
- - setUsername(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- -
- {isBusy - ? "Creating account..." - : mode === "signup-owner" - ? "Create owner account" - : "Create account"} + ? t("auth.actions.signInBusy", "Signing in...") + : t("auth.actions.signInIdle", "Sign in")}

- Already have an account?{" "} + {t("auth.links.needAccount", "Need an account?")}{" "} + + {t("auth.links.register", "Register")} + +

+ + {error ?

{error}

: null} + + ) : ( +
+
+ + setName(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setEmail(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setUsername(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setPassword(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ + + +

+ {t("auth.links.alreadyHaveAccount", "Already have an account?")}{" "} - Go to sign in + {t("auth.links.goToSignIn", "Go to sign in")}

diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index 564a275..b55c5a2 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache" import Link from "next/link" import { redirect } from "next/navigation" +import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher" +import { translateMessage } from "@/i18n/messages" +import { getAdminMessages, resolveAdminLocale } from "@/i18n/server" import { resolveRoleFromServerContext } from "@/lib/access-server" import { LogoutButton } from "./logout-button" @@ -58,10 +61,18 @@ function redirectWithState(params: { notice?: string; error?: string }) { redirect(value ? `/?${value}` : "/") } +async function getDashboardTranslator() { + const locale = await resolveAdminLocale() + const messages = await getAdminMessages(locale) + + return (key: string, fallback: string) => translateMessage(messages, key, fallback) +} + async function createPostAction(formData: FormData) { "use server" await requireNewsWritePermission() + const t = await getDashboardTranslator() const status = readRequiredField(formData, "status") @@ -74,23 +85,28 @@ async function createPostAction(formData: FormData) { status: status === "published" ? "published" : "draft", }) } catch { - redirectWithState({ error: "Create failed. Please check your input." }) + redirectWithState({ + error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."), + }) } revalidatePath("/") - redirectWithState({ notice: "Post created." }) + redirectWithState({ notice: t("dashboard.posts.success.created", "Post created.") }) } async function updatePostAction(formData: FormData) { "use server" await requireNewsWritePermission() + const t = await getDashboardTranslator() const id = readRequiredField(formData, "id") const status = readRequiredField(formData, "status") if (!id) { - redirectWithState({ error: "Update failed. Missing post id." }) + redirectWithState({ + error: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."), + }) } try { @@ -102,32 +118,37 @@ async function updatePostAction(formData: FormData) { status: status === "published" ? "published" : "draft", }) } catch { - redirectWithState({ error: "Update failed. Please check your input." }) + redirectWithState({ + error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."), + }) } revalidatePath("/") - redirectWithState({ notice: "Post updated." }) + redirectWithState({ notice: t("dashboard.posts.success.updated", "Post updated.") }) } async function deletePostAction(formData: FormData) { "use server" await requireNewsWritePermission() + const t = await getDashboardTranslator() const id = readRequiredField(formData, "id") if (!id) { - redirectWithState({ error: "Delete failed. Missing post id." }) + redirectWithState({ + error: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."), + }) } try { await deletePost(id) } catch { - redirectWithState({ error: "Delete failed." }) + redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") }) } revalidatePath("/") - redirectWithState({ notice: "Post deleted." }) + redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") }) } export default async function AdminHomePage({ @@ -145,24 +166,39 @@ export default async function AdminHomePage({ redirect("/unauthorized?required=news:read&scope=team") } - const resolvedSearchParams = await searchParams + const [resolvedSearchParams, locale, posts] = await Promise.all([ + searchParams, + resolveAdminLocale(), + listPosts(), + ]) + const messages = await getAdminMessages(locale) + const t = (key: string, fallback: string) => translateMessage(messages, key, fallback) + const notice = readFirstValue(resolvedSearchParams.notice) const error = readFirstValue(resolvedSearchParams.error) const canCreatePost = hasPermission(role, "news:write", "team") - const posts = await listPosts() return (
-

Admin App

-

Content Dashboard

-

Manage posts from a dedicated admin surface.

+
+

+ {t("dashboard.badge", "Admin App")} +

+ +
+

+ {t("dashboard.title", "Content Dashboard")} +

+

+ {t("dashboard.description", "Manage posts from a dedicated admin surface.")} +

- Open roadmap and progress + {t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
@@ -183,8 +219,12 @@ export default async function AdminHomePage({
-

Posts CRUD Sandbox

-

MVP0 functional test

+

+ {t("dashboard.posts.title", "Posts CRUD Sandbox")} +

+

+ {t("dashboard.notices.crudSandboxTag", "MVP0 functional test")} +

{canCreatePost ? ( @@ -192,10 +232,14 @@ export default async function AdminHomePage({ action={createPostAction} className="space-y-3 rounded-lg border border-neutral-200 p-4" > -

Create post

+

+ {t("dashboard.posts.createTitle", "Create post")} +