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

@@ -154,7 +170,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} + + ) : canSubmitSignUp ? ( +
+
+ + 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")}

{error ?

{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 564a275..03e3216 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,45 @@ 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")} + + + {t("settings.title", "Settings")}
@@ -183,8 +225,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 +238,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")} +