From b618c8cb5161eef46e80d622156a1e53a732ea35 Mon Sep 17 00:00:00 2001
From: Citali
Date: Tue, 10 Feb 2026 20:56:03 +0100
Subject: [PATCH 1/4] feat(admin-i18n): add cookie-based locale runtime and
switcher baseline
---
TODO.md | 9 +-
apps/admin/package.json | 1 +
apps/admin/src/app/layout.tsx | 12 +-
apps/admin/src/app/login/login-form.tsx | 220 +++++++++---------
apps/admin/src/app/page.tsx | 145 +++++++++---
apps/admin/src/app/providers.tsx | 19 +-
.../src/components/admin-locale-switcher.tsx | 41 ++++
apps/admin/src/i18n/messages.test.ts | 117 ++++++++++
apps/admin/src/i18n/messages.ts | 27 +++
apps/admin/src/i18n/server.ts | 20 ++
apps/admin/src/i18n/shared.ts | 1 +
apps/admin/src/messages/de.json | 102 ++++++++
apps/admin/src/messages/en.json | 102 ++++++++
apps/admin/src/messages/es.json | 102 ++++++++
apps/admin/src/messages/fr.json | 102 ++++++++
.../src/providers/admin-i18n-provider.tsx | 53 +++++
bun.lock | 1 +
docs/product-engineering/i18n-baseline.md | 13 +-
18 files changed, 931 insertions(+), 156 deletions(-)
create mode 100644 apps/admin/src/components/admin-locale-switcher.tsx
create mode 100644 apps/admin/src/i18n/messages.test.ts
create mode 100644 apps/admin/src/i18n/messages.ts
create mode 100644 apps/admin/src/i18n/server.ts
create mode 100644 apps/admin/src/i18n/shared.ts
create mode 100644 apps/admin/src/messages/de.json
create mode 100644 apps/admin/src/messages/en.json
create mode 100644 apps/admin/src/messages/es.json
create mode 100644 apps/admin/src/messages/fr.json
create mode 100644 apps/admin/src/providers/admin-i18n-provider.tsx
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) {
>
- Email or username
+ {t("auth.fields.emailOrUsername", "Email or username")}
- Password
-
- setPassword(event.target.value)}
- className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
- />
-
-
-
- {isBusy ? "Signing in..." : "Sign in"}
-
-
-
- Need an account?{" "}
-
- Register
-
-
-
- {error ? {error}
: null}
-
- ) : (
-
- ) : (
+ ) : canSubmitSignUp ? (
{error}
: null}
{success ? {success}
: null}
+ ) : (
+
+
+ {t(
+ "auth.messages.registrationDisabled",
+ "Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration.",
+ )}
+
+
+
+ {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 b55c5a2..03e3216 100644
--- a/apps/admin/src/app/page.tsx
+++ b/apps/admin/src/app/page.tsx
@@ -200,6 +200,12 @@ export default async function AdminHomePage({
>
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
+
+ {t("settings.title", "Settings")}
+
diff --git a/apps/admin/src/app/register/page.tsx b/apps/admin/src/app/register/page.tsx
index 648587b..2790154 100644
--- a/apps/admin/src/app/register/page.tsx
+++ b/apps/admin/src/app/register/page.tsx
@@ -33,7 +33,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Sea
const enabled = await isSelfRegistrationEnabled()
if (!enabled) {
- redirect(`/login?next=${encodeURIComponent(nextPath)}`)
+ return
}
return
diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx
new file mode 100644
index 0000000..e1793d0
--- /dev/null
+++ b/apps/admin/src/app/settings/page.tsx
@@ -0,0 +1,188 @@
+import { hasPermission } from "@cms/content/rbac"
+import { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "@cms/db"
+import { Button } from "@cms/ui/button"
+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"
+
+type SearchParamsInput = Promise>
+
+function toSingleValue(input: string | string[] | undefined): string | null {
+ if (Array.isArray(input)) {
+ return input[0] ?? null
+ }
+
+ return input ?? null
+}
+
+async function requireSettingsPermission() {
+ const role = await resolveRoleFromServerContext()
+
+ if (!role) {
+ redirect("/login?next=/settings")
+ }
+
+ if (!hasPermission(role, "users:manage_roles", "global")) {
+ redirect("/unauthorized?required=users:manage_roles&scope=global")
+ }
+}
+
+async function getSettingsTranslator() {
+ const locale = await resolveAdminLocale()
+ const messages = await getAdminMessages(locale)
+
+ return (key: string, fallback: string) => translateMessage(messages, key, fallback)
+}
+
+async function updateRegistrationPolicyAction(formData: FormData) {
+ "use server"
+
+ await requireSettingsPermission()
+ const t = await getSettingsTranslator()
+ const enabled = formData.get("enabled") === "on"
+
+ try {
+ await setAdminSelfRegistrationEnabled(enabled)
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : ""
+ const normalizedMessage = errorMessage.toLowerCase()
+ const isDatabaseUnavailable = errorMessage.includes("P1001")
+ const isSchemaMissing =
+ errorMessage.includes("P2021") ||
+ normalizedMessage.includes("system_setting") ||
+ normalizedMessage.includes("does not exist")
+
+ const userMessage = isDatabaseUnavailable
+ ? t(
+ "settings.registration.errors.databaseUnavailable",
+ "Saving settings failed. The database is currently unreachable.",
+ )
+ : isSchemaMissing
+ ? t(
+ "settings.registration.errors.schemaMissing",
+ "Saving settings failed. Apply the latest database migrations and try again.",
+ )
+ : t(
+ "settings.registration.errors.updateFailed",
+ "Saving settings failed. Ensure database migrations are applied.",
+ )
+
+ redirect(`/settings?error=${encodeURIComponent(userMessage)}`)
+ }
+
+ revalidatePath("/settings")
+ revalidatePath("/register")
+ redirect(
+ `/settings?notice=${encodeURIComponent(
+ t("settings.registration.success.updated", "Registration policy updated."),
+ )}`,
+ )
+}
+
+export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) {
+ await requireSettingsPermission()
+
+ const [params, locale, isRegistrationEnabled] = await Promise.all([
+ searchParams,
+ resolveAdminLocale(),
+ isAdminSelfRegistrationEnabled(),
+ ])
+ const messages = await getAdminMessages(locale)
+ const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
+
+ const notice = toSingleValue(params.notice)
+ const error = toSingleValue(params.error)
+
+ return (
+
+
+
+
+ {t("settings.badge", "Admin Settings")}
+
+
+
+ {t("settings.title", "Settings")}
+
+ {t(
+ "settings.description",
+ "Manage runtime policies for the admin authentication and onboarding flow.",
+ )}
+
+
+
+ {t("settings.actions.backToDashboard", "Back to dashboard")}
+
+
+
+
+ {notice ? (
+
+ ) : null}
+
+ {error ? (
+
+ ) : null}
+
+
+
+
+
+ {t("settings.registration.title", "Admin self-registration")}
+
+
+ {t(
+ "settings.registration.description",
+ "When enabled, /register can create additional admin accounts after initial owner bootstrap.",
+ )}
+
+
+
+
+
+ {t("settings.registration.currentStatusLabel", "Current status")}:{" "}
+
+ {isRegistrationEnabled
+ ? t("settings.registration.status.enabled", "Enabled")
+ : t("settings.registration.status.disabled", "Disabled")}
+
+
+
+
+
+
+
+
+ {t(
+ "settings.registration.checkboxLabel",
+ "Allow self-registration on /register for admin users",
+ )}
+
+
+
+
+ {t("settings.registration.actions.save", "Save registration policy")}
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/i18n/messages.test.ts b/apps/admin/src/i18n/messages.test.ts
index 4b2e987..6f9852e 100644
--- a/apps/admin/src/i18n/messages.test.ts
+++ b/apps/admin/src/i18n/messages.test.ts
@@ -19,11 +19,13 @@ const messages: AdminMessages = {
signIn: "Sign in",
signUpOwner: "Welcome",
signUpUser: "Create account",
+ signUpDisabled: "Registration disabled",
},
descriptions: {
signIn: "Sign in description",
signUpOwner: "Owner description",
signUpUser: "User description",
+ signUpDisabled: "Disabled description",
},
fields: {
name: "Name",
@@ -48,6 +50,7 @@ const messages: AdminMessages = {
messages: {
ownerCreated: "Owner account created.",
accountCreated: "Account created.",
+ registrationDisabled: "Registration is disabled.",
},
errors: {
nameRequired: "Name is required.",
@@ -57,6 +60,33 @@ const messages: AdminMessages = {
networkSignUp: "Network sign up error",
},
},
+ settings: {
+ badge: "Admin Settings",
+ title: "Settings",
+ description: "Settings description",
+ actions: {
+ backToDashboard: "Back to dashboard",
+ },
+ registration: {
+ title: "Registration",
+ description: "Registration description",
+ currentStatusLabel: "Current status",
+ status: {
+ enabled: "Enabled",
+ disabled: "Disabled",
+ },
+ checkboxLabel: "Allow registration",
+ actions: {
+ save: "Save",
+ },
+ success: {
+ updated: "Updated",
+ },
+ errors: {
+ updateFailed: "Update failed",
+ },
+ },
+ },
dashboard: {
badge: "Admin App",
title: "Content Dashboard",
diff --git a/apps/admin/src/lib/access.ts b/apps/admin/src/lib/access.ts
index 068ca14..dec492c 100644
--- a/apps/admin/src/lib/access.ts
+++ b/apps/admin/src/lib/access.ts
@@ -43,6 +43,13 @@ const guardRules: GuardRule[] = [
scope: "global",
},
},
+ {
+ route: /^\/settings(?:\/|$)/,
+ requirement: {
+ permission: "users:manage_roles",
+ scope: "global",
+ },
+ },
{
route: /^\/(?:$|\?)/,
requirement: {
diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts
index e12fb11..8707f79 100644
--- a/apps/admin/src/lib/auth/server.ts
+++ b/apps/admin/src/lib/auth/server.ts
@@ -1,5 +1,5 @@
import { normalizeRole, type Role } from "@cms/content/rbac"
-import { db } from "@cms/db"
+import { db, isAdminSelfRegistrationEnabled } from "@cms/db"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { toNextJsHandler } from "better-auth/next-js"
@@ -43,8 +43,7 @@ export async function isInitialOwnerRegistrationOpen(): Promise {
}
export async function isSelfRegistrationEnabled(): Promise {
- // Temporary fallback until registration policy is managed from admin settings.
- return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
+ return isAdminSelfRegistrationEnabled()
}
export async function canUserSelfRegister(): Promise {
diff --git a/apps/admin/src/messages/de.json b/apps/admin/src/messages/de.json
index 267ff3c..7554210 100644
--- a/apps/admin/src/messages/de.json
+++ b/apps/admin/src/messages/de.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Bei CMS Admin anmelden",
"signUpOwner": "Willkommen bei CMS Admin",
- "signUpUser": "Admin-Konto erstellen"
+ "signUpUser": "Admin-Konto erstellen",
+ "signUpDisabled": "Registrierung ist deaktiviert"
},
"descriptions": {
"signIn": "Better Auth ist in dieser App über /api/auth aktiv.",
"signUpOwner": "Erstelle das erste Owner-Konto, um diese Admin-Instanz zu initialisieren.",
- "signUpUser": "Selbstregistrierung für Admin-Benutzer ist aktiviert."
+ "signUpUser": "Selbstregistrierung für Admin-Benutzer ist aktiviert.",
+ "signUpDisabled": "Selbstregistrierung wurde von einer Administratorin oder einem Administrator deaktiviert."
},
"fields": {
"name": "Name",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Owner-Konto erstellt. Registrierung ist jetzt deaktiviert.",
- "accountCreated": "Konto erstellt."
+ "accountCreated": "Konto erstellt.",
+ "registrationDisabled": "Für diese Admin-Instanz ist die Registrierung deaktiviert. Bitte wende dich an eine Administratorin oder einen Administrator."
},
"errors": {
"nameRequired": "Name ist für die Kontoerstellung erforderlich",
@@ -52,6 +55,33 @@
"networkSignUp": "Netzwerkfehler bei der Registrierung"
}
},
+ "settings": {
+ "badge": "Admin-Einstellungen",
+ "title": "Einstellungen",
+ "description": "Verwalte Laufzeitrichtlinien für Authentifizierung und Onboarding im Admin-Bereich.",
+ "actions": {
+ "backToDashboard": "Zurück zum Dashboard"
+ },
+ "registration": {
+ "title": "Admin-Selbstregistrierung",
+ "description": "Wenn aktiviert, können über /register nach der initialen Owner-Erstellung weitere Admin-Konten erstellt werden.",
+ "currentStatusLabel": "Aktueller Status",
+ "status": {
+ "enabled": "Aktiviert",
+ "disabled": "Deaktiviert"
+ },
+ "checkboxLabel": "Selbstregistrierung auf /register für Admin-Benutzer erlauben",
+ "actions": {
+ "save": "Registrierungsrichtlinie speichern"
+ },
+ "success": {
+ "updated": "Registrierungsrichtlinie aktualisiert."
+ },
+ "errors": {
+ "updateFailed": "Speichern der Einstellungen fehlgeschlagen. Stelle sicher, dass Datenbankmigrationen angewendet wurden."
+ }
+ }
+ },
"dashboard": {
"badge": "Admin-App",
"title": "Content-Dashboard",
diff --git a/apps/admin/src/messages/en.json b/apps/admin/src/messages/en.json
index b23adfb..6507ce4 100644
--- a/apps/admin/src/messages/en.json
+++ b/apps/admin/src/messages/en.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Sign in to CMS Admin",
"signUpOwner": "Welcome to CMS Admin",
- "signUpUser": "Create an admin account"
+ "signUpUser": "Create an admin account",
+ "signUpDisabled": "Registration is disabled"
},
"descriptions": {
"signIn": "Better Auth is active on this app via /api/auth.",
"signUpOwner": "Create the first owner account to initialize this admin instance.",
- "signUpUser": "Self-registration is enabled for admin users."
+ "signUpUser": "Self-registration is enabled for admin users.",
+ "signUpDisabled": "Self-registration is currently turned off by an administrator."
},
"fields": {
"name": "Name",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Owner account created. Registration is now disabled.",
- "accountCreated": "Account created."
+ "accountCreated": "Account created.",
+ "registrationDisabled": "Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration."
},
"errors": {
"nameRequired": "Name is required for account creation",
@@ -52,6 +55,33 @@
"networkSignUp": "Network error while signing up"
}
},
+ "settings": {
+ "badge": "Admin Settings",
+ "title": "Settings",
+ "description": "Manage runtime policies for the admin authentication and onboarding flow.",
+ "actions": {
+ "backToDashboard": "Back to dashboard"
+ },
+ "registration": {
+ "title": "Admin self-registration",
+ "description": "When enabled, /register can create additional admin accounts after initial owner bootstrap.",
+ "currentStatusLabel": "Current status",
+ "status": {
+ "enabled": "Enabled",
+ "disabled": "Disabled"
+ },
+ "checkboxLabel": "Allow self-registration on /register for admin users",
+ "actions": {
+ "save": "Save registration policy"
+ },
+ "success": {
+ "updated": "Registration policy updated."
+ },
+ "errors": {
+ "updateFailed": "Saving settings failed. Ensure database migrations are applied."
+ }
+ }
+ },
"dashboard": {
"badge": "Admin App",
"title": "Content Dashboard",
diff --git a/apps/admin/src/messages/es.json b/apps/admin/src/messages/es.json
index 52764f3..698e634 100644
--- a/apps/admin/src/messages/es.json
+++ b/apps/admin/src/messages/es.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Iniciar sesión en CMS Admin",
"signUpOwner": "Bienvenido a CMS Admin",
- "signUpUser": "Crear una cuenta de admin"
+ "signUpUser": "Crear una cuenta de admin",
+ "signUpDisabled": "El registro está deshabilitado"
},
"descriptions": {
"signIn": "Better Auth está activo en esta app mediante /api/auth.",
"signUpOwner": "Crea la primera cuenta owner para inicializar esta instancia de administración.",
- "signUpUser": "El registro automático está habilitado para usuarios admin."
+ "signUpUser": "El registro automático está habilitado para usuarios admin.",
+ "signUpDisabled": "El auto-registro está desactivado actualmente por un administrador."
},
"fields": {
"name": "Nombre",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Cuenta owner creada. El registro ahora está deshabilitado.",
- "accountCreated": "Cuenta creada."
+ "accountCreated": "Cuenta creada.",
+ "registrationDisabled": "El registro está deshabilitado para esta instancia de administración. Pide a un administrador que cree una cuenta o habilite el auto-registro."
},
"errors": {
"nameRequired": "El nombre es obligatorio para crear la cuenta",
@@ -52,6 +55,33 @@
"networkSignUp": "Error de red al registrarse"
}
},
+ "settings": {
+ "badge": "Ajustes de Admin",
+ "title": "Ajustes",
+ "description": "Gestiona políticas de ejecución para autenticación y onboarding del panel admin.",
+ "actions": {
+ "backToDashboard": "Volver al panel"
+ },
+ "registration": {
+ "title": "Auto-registro de admin",
+ "description": "Cuando está habilitado, /register puede crear cuentas admin adicionales después del bootstrap inicial del owner.",
+ "currentStatusLabel": "Estado actual",
+ "status": {
+ "enabled": "Habilitado",
+ "disabled": "Deshabilitado"
+ },
+ "checkboxLabel": "Permitir auto-registro en /register para usuarios admin",
+ "actions": {
+ "save": "Guardar política de registro"
+ },
+ "success": {
+ "updated": "Política de registro actualizada."
+ },
+ "errors": {
+ "updateFailed": "No se pudieron guardar los ajustes. Asegúrate de que las migraciones de base de datos estén aplicadas."
+ }
+ }
+ },
"dashboard": {
"badge": "App Admin",
"title": "Panel de Contenido",
diff --git a/apps/admin/src/messages/fr.json b/apps/admin/src/messages/fr.json
index 85acb74..50daaf6 100644
--- a/apps/admin/src/messages/fr.json
+++ b/apps/admin/src/messages/fr.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Se connecter à CMS Admin",
"signUpOwner": "Bienvenue sur CMS Admin",
- "signUpUser": "Créer un compte admin"
+ "signUpUser": "Créer un compte admin",
+ "signUpDisabled": "L’inscription est désactivée"
},
"descriptions": {
"signIn": "Better Auth est actif sur cette application via /api/auth.",
"signUpOwner": "Créez le premier compte owner pour initialiser cette instance d’administration.",
- "signUpUser": "L’auto-inscription est activée pour les utilisateurs admin."
+ "signUpUser": "L’auto-inscription est activée pour les utilisateurs admin.",
+ "signUpDisabled": "L’auto-inscription est actuellement désactivée par un administrateur."
},
"fields": {
"name": "Nom",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Compte owner créé. L’inscription est maintenant désactivée.",
- "accountCreated": "Compte créé."
+ "accountCreated": "Compte créé.",
+ "registrationDisabled": "L’inscription est désactivée pour cette instance admin. Demandez à un administrateur de créer un compte ou de réactiver l’auto-inscription."
},
"errors": {
"nameRequired": "Le nom est requis pour créer un compte",
@@ -52,6 +55,33 @@
"networkSignUp": "Erreur réseau lors de l’inscription"
}
},
+ "settings": {
+ "badge": "Paramètres Admin",
+ "title": "Paramètres",
+ "description": "Gérez les politiques d’exécution pour l’authentification et l’onboarding de l’admin.",
+ "actions": {
+ "backToDashboard": "Retour au tableau de bord"
+ },
+ "registration": {
+ "title": "Auto-inscription admin",
+ "description": "Lorsqu’elle est activée, /register peut créer des comptes admin supplémentaires après l’initialisation du premier owner.",
+ "currentStatusLabel": "Statut actuel",
+ "status": {
+ "enabled": "Activé",
+ "disabled": "Désactivé"
+ },
+ "checkboxLabel": "Autoriser l’auto-inscription sur /register pour les utilisateurs admin",
+ "actions": {
+ "save": "Enregistrer la politique d’inscription"
+ },
+ "success": {
+ "updated": "Politique d’inscription mise à jour."
+ },
+ "errors": {
+ "updateFailed": "Échec de l’enregistrement des paramètres. Vérifiez que les migrations de base de données sont appliquées."
+ }
+ }
+ },
"dashboard": {
"badge": "Application Admin",
"title": "Tableau de bord contenu",
diff --git a/docs/product-engineering/auth-baseline.md b/docs/product-engineering/auth-baseline.md
index d604b2b..7a01497 100644
--- a/docs/product-engineering/auth-baseline.md
+++ b/docs/product-engineering/auth-baseline.md
@@ -39,6 +39,7 @@ Optional:
- Support user bootstrap is available via `bun run auth:seed:support`.
- Root `bun run db:seed` runs DB seed and support-user seed.
-- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy.
+- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is now a fallback/default only.
+- Runtime source of truth is admin settings (`/settings`) backed by `system_setting`.
- Owner/support checks for future admin user-management mutations remain tracked in `TODO.md`.
- Email verification and forgot/reset password pipelines are tracked for MVP2.
diff --git a/packages/db/prisma/migrations/20260210200148_admin_registration_policy_setting/migration.sql b/packages/db/prisma/migrations/20260210200148_admin_registration_policy_setting/migration.sql
new file mode 100644
index 0000000..4dbabbc
--- /dev/null
+++ b/packages/db/prisma/migrations/20260210200148_admin_registration_policy_setting/migration.sql
@@ -0,0 +1,9 @@
+-- CreateTable
+CREATE TABLE "system_setting" (
+ "key" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "system_setting_pkey" PRIMARY KEY ("key")
+);
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 33ce8dd..2154806 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -87,3 +87,12 @@ model Verification {
@@index([identifier])
@@map("verification")
}
+
+model SystemSetting {
+ key String @id
+ value String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@map("system_setting")
+}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 91418cb..4f14908 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -7,3 +7,4 @@ export {
registerPostCrudAuditHook,
updatePost,
} from "./posts"
+export { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "./settings"
diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts
new file mode 100644
index 0000000..fb3cd12
--- /dev/null
+++ b/packages/db/src/settings.ts
@@ -0,0 +1,56 @@
+import { db } from "./client"
+
+const ADMIN_SELF_REGISTRATION_KEY = "admin.self_registration_enabled"
+
+function resolveEnvFallback(): boolean {
+ return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
+}
+
+function parseStoredBoolean(value: string): boolean | null {
+ if (value === "true") {
+ return true
+ }
+
+ if (value === "false") {
+ return false
+ }
+
+ return null
+}
+
+export async function isAdminSelfRegistrationEnabled(): Promise {
+ try {
+ const setting = await db.systemSetting.findUnique({
+ where: { key: ADMIN_SELF_REGISTRATION_KEY },
+ select: { value: true },
+ })
+
+ if (!setting) {
+ return resolveEnvFallback()
+ }
+
+ const parsed = parseStoredBoolean(setting.value)
+
+ if (parsed === null) {
+ return resolveEnvFallback()
+ }
+
+ return parsed
+ } catch {
+ // Fallback while migrations are not yet applied in a local environment.
+ return resolveEnvFallback()
+ }
+}
+
+export async function setAdminSelfRegistrationEnabled(enabled: boolean): Promise {
+ await db.systemSetting.upsert({
+ where: { key: ADMIN_SELF_REGISTRATION_KEY },
+ create: {
+ key: ADMIN_SELF_REGISTRATION_KEY,
+ value: enabled ? "true" : "false",
+ },
+ update: {
+ value: enabled ? "true" : "false",
+ },
+ })
+}
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index be5462f..ab10d0e 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -8,7 +8,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
- default: "bg-neutral-900 text-neutral-50 hover:bg-neutral-800",
+ default: "bg-neutral-900 text-white hover:bg-neutral-800",
secondary: "bg-neutral-100 text-neutral-900 hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
},
From 4ac74101487e0bb220215328510fd4344c9110e3 Mon Sep 17 00:00:00 2001
From: Citali
Date: Tue, 10 Feb 2026 21:11:49 +0100
Subject: [PATCH 3/4] test(admin): cover support fallback route and mark todo
complete
---
TODO.md | 2 +-
apps/admin/src/lib/access.test.ts | 24 ++++++++++++++++++++++++
e2e/support-auth.pw.ts | 20 ++++++++++++++++++++
3 files changed, 45 insertions(+), 1 deletion(-)
create mode 100644 apps/admin/src/lib/access.test.ts
create mode 100644 e2e/support-auth.pw.ts
diff --git a/TODO.md b/TODO.md
index ee0fa67..0d71663 100644
--- a/TODO.md
+++ b/TODO.md
@@ -31,7 +31,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
-- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
+- [x] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
- [~] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
- [~] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
- [~] [P1] Shared error and audit hooks for CRUD mutations
diff --git a/apps/admin/src/lib/access.test.ts b/apps/admin/src/lib/access.test.ts
new file mode 100644
index 0000000..717e061
--- /dev/null
+++ b/apps/admin/src/lib/access.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+
+import { canAccessRoute, getRequiredPermission, isPublicRoute } from "./access"
+
+describe("admin route access rules", () => {
+ it("treats support fallback route as public", () => {
+ expect(isPublicRoute("/support/support-access")).toBe(true)
+ expect(canAccessRoute("editor", "/support/support-access")).toBe(true)
+ })
+
+ it("keeps settings route restricted to role with users:manage_roles", () => {
+ expect(isPublicRoute("/settings")).toBe(false)
+ expect(canAccessRoute("manager", "/settings")).toBe(false)
+ expect(canAccessRoute("admin", "/settings")).toBe(true)
+ expect(canAccessRoute("owner", "/settings")).toBe(true)
+ })
+
+ it("resolves route-specific permission requirements", () => {
+ expect(getRequiredPermission("/todo")).toEqual({
+ permission: "roadmap:read",
+ scope: "global",
+ })
+ })
+})
diff --git a/e2e/support-auth.pw.ts b/e2e/support-auth.pw.ts
new file mode 100644
index 0000000..d334213
--- /dev/null
+++ b/e2e/support-auth.pw.ts
@@ -0,0 +1,20 @@
+import { expect, test } from "@playwright/test"
+
+const SUPPORT_LOGIN_KEY = process.env.CMS_SUPPORT_LOGIN_KEY ?? "support-access"
+
+test.describe("support fallback route", () => {
+ test("valid support key opens sign-in page", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "admin-chromium")
+
+ await page.goto(`/support/${SUPPORT_LOGIN_KEY}`)
+
+ await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
+ })
+
+ test("invalid support key returns not found", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "admin-chromium")
+
+ const response = await page.goto("/support/invalid-key")
+ expect(response?.status()).toBe(404)
+ })
+})
From 4d4b583cf4dcb0f3b99f74c666af15d3b1a0fc59 Mon Sep 17 00:00:00 2001
From: Citali
Date: Tue, 10 Feb 2026 21:17:41 +0100
Subject: [PATCH 4/4] test(ci): add quality gates, e2e data prep, and i18n
integration coverage
---
.gitea/workflows/ci.yml | 70 ++++++++++++++++++++
README.md | 3 +
TODO.md | 10 ++-
docs/.vitepress/config.mts | 1 +
docs/product-engineering/index.md | 1 +
docs/product-engineering/testing-strategy.md | 33 +++++++++
docs/workflow.md | 4 +-
e2e/i18n.pw.ts | 29 ++++++++
package.json | 7 +-
9 files changed, 150 insertions(+), 8 deletions(-)
create mode 100644 .gitea/workflows/ci.yml
create mode 100644 docs/product-engineering/testing-strategy.md
create mode 100644 e2e/i18n.pw.ts
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..fe71858
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,70 @@
+name: CMS CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - dev
+ - staging
+ - main
+ workflow_dispatch:
+
+env:
+ BUN_VERSION: "1.3.5"
+ NODE_ENV: "test"
+ DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/cms?schema=public"
+ BETTER_AUTH_SECRET: "ci-test-secret-change-me"
+ BETTER_AUTH_URL: "http://localhost:3001"
+ CMS_ADMIN_ORIGIN: "http://127.0.0.1:3001"
+ CMS_WEB_ORIGIN: "http://127.0.0.1:3000"
+ CMS_ADMIN_SELF_REGISTRATION_ENABLED: "false"
+ CMS_SUPPORT_USERNAME: "support"
+ CMS_SUPPORT_EMAIL: "support@cms.local"
+ CMS_SUPPORT_PASSWORD: "support-ci-password"
+ CMS_SUPPORT_NAME: "Technical Support"
+ CMS_SUPPORT_LOGIN_KEY: "support-access"
+
+jobs:
+ quality:
+ name: Lint Typecheck Unit E2E
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: cms
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d cms"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: ${{ env.BUN_VERSION }}
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Install Playwright browser deps
+ run: bunx playwright install --with-deps chromium
+
+ - name: Lint and format checks
+ run: bun run check
+
+ - name: Typecheck
+ run: bun run typecheck
+
+ - name: Unit and integration tests
+ run: bun run test
+
+ - name: E2E tests
+ run: bun run test:e2e
diff --git a/README.md b/README.md
index 12eb566..ac3377f 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,7 @@ bun run dev
- `bun run test`
- `bun run test:watch`
- `bun run test:coverage`
+- `bun run test:e2e:prepare`
- `bun run test:e2e`
- `bun run lint`
- `bun run typecheck`
@@ -85,6 +86,7 @@ bun run dev
- Unit/integration/component: Vitest + Testing Library + MSW
- E2E: Playwright (separate projects for `web` and `admin`)
- Use `bun run test` and `bun run test:e2e` (not plain `bun test`, which uses Bun's runner)
+- E2E data prep (migrations + seed): `bun run test:e2e:prepare`
One-time Playwright browser install:
@@ -97,6 +99,7 @@ bunx playwright install
The repo includes a theoretical CI/CD and deployment baseline:
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
+- Real quality gate workflow: `.gitea/workflows/ci.yml`
- App images:
- `apps/web/Dockerfile`
- `apps/admin/Dockerfile`
diff --git a/TODO.md b/TODO.md
index 0d71663..7eff558 100644
--- a/TODO.md
+++ b/TODO.md
@@ -61,11 +61,11 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Vitest + Testing Library + MSW baseline
- [x] [P1] Playwright baseline with web/admin projects
-- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
-- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
+- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
+- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [~] [P1] RBAC policy unit tests and permission regression suite
- [ ] [P1] i18n unit tests (locale resolution, fallback, message key loading)
-- [ ] [P1] i18n integration tests (admin/public locale switch and persistence)
+- [x] [P1] i18n integration tests (admin/public locale switch and persistence)
- [ ] [P1] i18n e2e smoke tests (localized headings/content per route)
- [ ] [P1] CRUD contract tests for shared service patterns
@@ -160,6 +160,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Forgot password/reset password pipeline and support tooling
- [ ] [P2] GUI page to edit role-permission mappings with safety guardrails
- [ ] [P2] Translation management UI for admin (language toggles, key coverage, missing translation markers)
+- [ ] [P2] Time-boxed support access keys generated by privileged admins; while active, disable direct support-user password login on the regular auth form
+- [ ] [P2] Keep permanent emergency support key fallback via env (`CMS_SUPPORT_LOGIN_KEY`)
- [ ] [P2] Error boundaries and UX fallback states
### Public App
@@ -199,6 +201,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [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.
- [2026-02-10] Admin self-registration policy is now managed via `/settings` and persisted in `system_setting`; env var is fallback/default only.
+- [2026-02-10] E2E now runs with deterministic preparation (`test:e2e:prepare`: generate + migrate deploy + seed) before Playwright execution.
+- [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service.
## How We Use This File
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 92a27bf..92e508e 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -23,6 +23,7 @@ export default defineConfig({
{ text: "CRUD Baseline", link: "/product-engineering/crud-baseline" },
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
+ { text: "Testing Strategy", link: "/product-engineering/testing-strategy" },
{ text: "Workflow", link: "/workflow" },
],
},
diff --git a/docs/product-engineering/index.md b/docs/product-engineering/index.md
index 2d5fd11..5735cc1 100644
--- a/docs/product-engineering/index.md
+++ b/docs/product-engineering/index.md
@@ -8,6 +8,7 @@ This section covers platform and implementation documentation for engineers and
- [Architecture](/architecture)
- [Better Auth Baseline](/product-engineering/auth-baseline)
- [RBAC And Permissions](/product-engineering/rbac-permission-model)
+- [Testing Strategy Baseline](/product-engineering/testing-strategy)
- [Workflow](/workflow)
## Scope
diff --git a/docs/product-engineering/testing-strategy.md b/docs/product-engineering/testing-strategy.md
new file mode 100644
index 0000000..0df677a
--- /dev/null
+++ b/docs/product-engineering/testing-strategy.md
@@ -0,0 +1,33 @@
+# Testing Strategy Baseline
+
+## Goals
+
+- Keep lint, typecheck, unit/integration, and e2e as mandatory quality gates.
+- Make e2e runs deterministic by preparing schema and seeded data before test execution.
+- Keep test data isolated per environment (`dev` local, CI database service in workflow).
+
+## Current Gate Stack
+
+- `bun run check`
+- `bun run typecheck`
+- `bun run test`
+- `bun run test:e2e`
+
+## Data Preparation
+
+- `bun run test:e2e:prepare` runs:
+ - Prisma client generation
+ - migration deploy
+ - seed data (including support user bootstrap)
+- `bun run test:e2e` and related scripts call `test:e2e:prepare` automatically.
+
+## Locale Integration Coverage
+
+- `e2e/i18n.pw.ts` covers:
+ - web locale switch + persistence
+ - admin locale switch + persistence
+
+## CI
+
+- Real quality workflow: `.gitea/workflows/ci.yml`
+- Uses a PostgreSQL service container and runs the full gate stack, including e2e.
diff --git a/docs/workflow.md b/docs/workflow.md
index c72cdf7..4946754 100644
--- a/docs/workflow.md
+++ b/docs/workflow.md
@@ -15,10 +15,10 @@ Follow `BRANCHING.md`:
## Quality Gates
-- `bun run lint`
+- `bun run check`
- `bun run typecheck`
- `bun run test`
-- `bun run test:e2e --list`
+- `bun run test:e2e`
## Changelog
diff --git a/e2e/i18n.pw.ts b/e2e/i18n.pw.ts
new file mode 100644
index 0000000..01d6ff9
--- /dev/null
+++ b/e2e/i18n.pw.ts
@@ -0,0 +1,29 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("i18n integration", () => {
+ test("web language switcher updates and persists locale", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "web-chromium")
+
+ await page.goto("/")
+ await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible()
+
+ await page.locator("select").first().selectOption("de")
+ await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible()
+
+ await page.reload()
+ await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible()
+ })
+
+ test("admin language switcher updates and persists locale", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "admin-chromium")
+
+ await page.goto("/login")
+ await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
+
+ await page.locator("select").first().selectOption("de")
+ await expect(page.getByRole("heading", { name: /bei cms admin anmelden/i })).toBeVisible()
+
+ await page.reload()
+ await expect(page.getByRole("heading", { name: /bei cms admin anmelden/i })).toBeVisible()
+ })
+})
diff --git a/package.json b/package.json
index d6b2c84..28f8647 100644
--- a/package.json
+++ b/package.json
@@ -18,9 +18,10 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
- "test:e2e": "bun run db:generate && playwright test",
- "test:e2e:headed": "bun run db:generate && playwright test --headed",
- "test:e2e:ui": "bun run db:generate && playwright test --ui",
+ "test:e2e:prepare": "bun run db:generate && bun run db:migrate:deploy && bun run db:seed",
+ "test:e2e": "bun run test:e2e:prepare && playwright test",
+ "test:e2e:headed": "bun run test:e2e:prepare && playwright test --headed",
+ "test:e2e:ui": "bun run test:e2e:prepare && playwright test --ui",
"commitlint": "commitlint --last",
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0",
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s",