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 b0f218b..7eff558 100644
--- a/TODO.md
+++ b/TODO.md
@@ -21,17 +21,17 @@ 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
- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
-- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
+- [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
@@ -45,7 +45,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [x] [P1] Protected admin routes and session handling
- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
-- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
+- [~] [P1] Core admin IA (pages/media/users/commissions/settings)
### Public App
@@ -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
@@ -193,10 +195,14 @@ 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.
+- [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/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..473d81a 100644
--- a/apps/admin/src/app/login/login-form.tsx
+++ b/apps/admin/src/app/login/login-form.tsx
@@ -4,8 +4,11 @@ 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"
+ mode: "signin" | "signup-owner" | "signup-user" | "signup-disabled"
}
type AuthResponse = {
@@ -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])
@@ -37,6 +41,7 @@ export function LoginForm({ mode }: LoginFormProps) {
const [isBusy, setIsBusy] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
+ const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
async function handleSignIn(event: FormEvent) {
event.preventDefault()
@@ -60,7 +65,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 +73,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 +83,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 +109,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 +131,35 @@ 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")
+ : mode === "signup-user"
+ ? t("auth.titles.signUpUser", "Create an admin account")
+ : t("auth.titles.signUpDisabled", "Registration is disabled")}
- {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.",
+ )
+ : mode === "signup-user"
+ ? t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")
+ : t(
+ "auth.descriptions.signUpDisabled",
+ "Self-registration is currently turned off by an administrator.",
+ )}