12 Commits

62 changed files with 2176 additions and 196 deletions

View File

@ -1 +1,14 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cms?schema=public"
BETTER_AUTH_SECRET="replace-with-long-random-secret"
BETTER_AUTH_URL="http://localhost:3001"
CMS_ADMIN_ORIGIN="http://localhost:3001"
CMS_WEB_ORIGIN="http://localhost:3000"
CMS_ADMIN_SELF_REGISTRATION_ENABLED="false"
# Bootstrap system users (used only when creating missing users)
CMS_SUPPORT_USERNAME="support"
CMS_SUPPORT_EMAIL="support@cms.local"
CMS_SUPPORT_PASSWORD="change-me-support-password"
CMS_SUPPORT_NAME="Technical Support"
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
# CMS_DEV_ROLE="admin"

View File

@ -1 +1,11 @@
DATABASE_URL="postgresql://cms:cms_production_password@localhost:65432/cms_production?schema=public"
BETTER_AUTH_SECRET="replace-with-production-secret"
BETTER_AUTH_URL="https://admin.example.com"
CMS_ADMIN_ORIGIN="https://admin.example.com"
CMS_WEB_ORIGIN="https://www.example.com"
CMS_ADMIN_SELF_REGISTRATION_ENABLED="false"
CMS_SUPPORT_USERNAME="support"
CMS_SUPPORT_EMAIL="support@admin.example.com"
CMS_SUPPORT_PASSWORD="replace-with-production-support-password"
CMS_SUPPORT_NAME="Technical Support"
CMS_SUPPORT_LOGIN_KEY="replace-with-production-support-login-key"

View File

@ -1 +1,11 @@
DATABASE_URL="postgresql://cms:cms_staging_password@localhost:55432/cms_staging?schema=public"
BETTER_AUTH_SECRET="replace-with-staging-secret"
BETTER_AUTH_URL="https://staging-admin.example.com"
CMS_ADMIN_ORIGIN="https://staging-admin.example.com"
CMS_WEB_ORIGIN="https://staging-web.example.com"
CMS_ADMIN_SELF_REGISTRATION_ENABLED="false"
CMS_SUPPORT_USERNAME="support"
CMS_SUPPORT_EMAIL="support@staging-admin.example.com"
CMS_SUPPORT_PASSWORD="replace-with-staging-support-password"
CMS_SUPPORT_NAME="Technical Support"
CMS_SUPPORT_LOGIN_KEY="replace-with-staging-support-login-key"

1
.gitignore vendored
View File

@ -27,6 +27,7 @@ test-results
# prisma
packages/db/prisma/dev.db*
packages/db/prisma/generated/
# misc
.DS_Store

View File

@ -1,3 +1,13 @@
## 0.1.0 (2026-02-10)
### Features
* **auth:** add better-auth core wiring for admin and db ([ba8abb3](https://git.fellies.net/Citali/cms.fellies.org/commit/ba8abb3b1bc42f87bc19460107311f53b27799d8))
* **rbac:** enforce admin access checks and document permission model ([947cb0a](https://git.fellies.net/Citali/cms.fellies.org/commit/947cb0a3d79104d82c4b97fb6584633b4c6a7c92))
### Bug Fixes
* **next:** migrate admin middleware to proxy convention ([efb93f2](https://git.fellies.net/Citali/cms.fellies.org/commit/efb93f212bc8d8976fc6b443e415be812d12961a))
# Changelog
All notable changes to this project will be documented in this file.

View File

@ -38,6 +38,8 @@ bun install
cp .env.example .env
```
Set `BETTER_AUTH_SECRET` before production use.
3. Generate Prisma client and run migrations:
```bash
@ -54,6 +56,7 @@ bun run dev
- Web: http://localhost:3000
- Admin: http://localhost:3001
- Admin login: http://localhost:3001/login
## Useful scripts

36
TODO.md
View File

@ -21,14 +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)
- [ ] [P1] Integrate Better Auth core configuration and session wiring
- [ ] [P1] Bootstrap first-run owner account creation when users table is empty
- [ ] [P1] Enforce invariant: exactly one owner user must always exist
- [ ] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
- [ ] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
- [~] [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] 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] 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
- [ ] [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
@ -39,8 +42,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] App Router + TypeScript + `src/` structure
- [x] [P1] Shared DB access via `@cms/db`
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
- [~] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [ ] [P1] Protected admin routes and session handling
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [x] [P1] Protected admin routes and session handling
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
### Public App
@ -93,6 +96,12 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
- [x] [P2] Changelog scaffold and generation scripts (`CHANGELOG.md`, `bun run changelog:*`)
- [ ] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
- [ ] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
- [ ] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
- [ ] [P1] App footer implementation plan for version + commit hash (admin + web)
- [ ] [P2] Automated version injection in CI (stamping build from tag + commit hash)
- [ ] [P2] Validation tests for displayed version/hash consistency per deployment
- [ ] [P1] Release tagging and changelog publication policy in CI
## MVP 1: Core CMS Business Features
@ -106,7 +115,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
- [ ] [P1] Users management (invite, roles, status)
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
- [ ] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window)
@ -180,6 +189,11 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] Prisma client must be generated before app/e2e startup to avoid runtime module errors.
- [2026-02-10] `bun test` conflicts with Playwright-style test files; keep e2e files on `*.pw.ts` and run e2e via Playwright.
- [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`.
- [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 baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later.
## How We Use This File

View File

@ -7,6 +7,7 @@
"dev": "bun --env-file=../../.env next dev --port 3001",
"build": "bun --env-file=../../.env next build",
"start": "bun --env-file=../../.env next start --port 3001",
"auth:seed:support": "bun --env-file=../../.env ./scripts/seed-support-user.ts",
"lint": "biome check src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
@ -14,23 +15,24 @@
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-form": "latest",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"@tanstack/react-table": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest"
"@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-table": "8.21.3",
"better-auth": "1.4.18",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "latest",
"typescript": "latest"
"@biomejs/biome": "2.3.14",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.2.2",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.9.3"
}
}

View File

@ -0,0 +1,11 @@
import { ensureSupportUserBootstrap } from "../src/lib/auth/server"
async function main() {
await ensureSupportUserBootstrap()
console.log("Support user bootstrap completed")
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@ -0,0 +1,252 @@
import {
auth,
authRouteHandlers,
canDeleteUserAccount,
canUserSelfRegister,
ensureSupportUserBootstrap,
ensureUserUsername,
hasOwnerUser,
promoteFirstRegisteredUserToOwner,
resolveEmailFromLoginIdentifier,
} from "@/lib/auth/server"
export const runtime = "nodejs"
type AuthPostResponse = {
user?: {
id?: string
role?: string
email?: string
name?: string
username?: string
}
message?: string
}
function jsonResponse(payload: unknown, status: number): Response {
return Response.json(payload, { status })
}
async function parseJsonBody(request: Request): Promise<Record<string, unknown> | null> {
return (await request.json().catch(() => null)) as Record<string, unknown> | null
}
function buildJsonRequest(request: Request, body: Record<string, unknown>): Request {
const headers = new Headers(request.headers)
headers.set("content-type", "application/json")
return new Request(request.url, {
method: request.method,
headers,
body: JSON.stringify(body),
})
}
function isDeleteUserAuthPath(pathname: string): boolean {
const actionPrefix = "/api/auth/"
const actionIndex = pathname.indexOf(actionPrefix)
if (actionIndex === -1) {
return false
}
const actionPath = pathname.slice(actionIndex + actionPrefix.length)
return actionPath === "delete-user" || actionPath.startsWith("delete-user/")
}
async function guardProtectedAccountDeletion(request: Request): Promise<Response | null> {
const pathname = new URL(request.url).pathname
if (!isDeleteUserAuthPath(pathname)) {
return null
}
const session = await auth.api
.getSession({
headers: request.headers,
})
.catch(() => null)
const userId = session?.user?.id
if (!userId) {
return null
}
const allowed = await canDeleteUserAccount(userId)
if (allowed) {
return null
}
return jsonResponse(
{
message: "This account is protected and cannot be deleted.",
},
403,
)
}
async function handleSignInPost(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const body = await parseJsonBody(request)
const identifier = typeof body?.identifier === "string" ? body.identifier : null
const rawEmail = typeof body?.email === "string" ? body.email : null
const resolvedEmail = await resolveEmailFromLoginIdentifier(identifier ?? rawEmail)
if (!resolvedEmail) {
return jsonResponse(
{
message: "Invalid email or username.",
},
401,
)
}
const rewrittenBody = {
...(body ?? {}),
email: resolvedEmail,
}
return authRouteHandlers.POST(buildJsonRequest(request, rewrittenBody))
}
async function handleSignUpPost(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const signUpBody = await parseJsonBody(request)
const preferredUsername =
typeof signUpBody?.username === "string" ? signUpBody.username : undefined
const { username: _ignoredUsername, ...signUpBodyWithoutUsername } = signUpBody ?? {}
const hadOwnerBeforeSignUp = await hasOwnerUser()
const registrationEnabled = await canUserSelfRegister()
if (!registrationEnabled) {
return jsonResponse(
{
message: "Registration is currently disabled.",
},
403,
)
}
const response = await authRouteHandlers.POST(
buildJsonRequest(request, {
...signUpBodyWithoutUsername,
}),
)
if (!response.ok) {
return response
}
const payload = (await response
.clone()
.json()
.catch(() => null)) as AuthPostResponse | null
const userId = payload?.user?.id
if (!userId) {
return response
}
await ensureUserUsername(userId, {
preferred: preferredUsername,
fallbackEmail: payload?.user?.email,
fallbackName: payload?.user?.name,
})
if (hadOwnerBeforeSignUp || !payload?.user) {
return response
}
const promoted = await promoteFirstRegisteredUserToOwner(userId)
if (!promoted) {
return jsonResponse(
{
message: "Initial owner registration window has just closed. Please sign in instead.",
},
409,
)
}
payload.user.role = "owner"
return new Response(JSON.stringify(payload), {
status: response.status,
headers: response.headers,
})
}
export async function GET(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.GET(request)
}
export async function POST(request: Request): Promise<Response> {
const pathname = new URL(request.url).pathname
if (pathname.endsWith("/sign-in/email")) {
return handleSignInPost(request)
}
if (pathname.endsWith("/sign-up/email")) {
return handleSignUpPost(request)
}
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.POST(request)
}
export async function PATCH(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.PATCH(request)
}
export async function PUT(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.PUT(request)
}
export async function DELETE(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.DELETE(request)
}

View File

@ -0,0 +1,286 @@
"use client"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { type FormEvent, useMemo, useState } from "react"
type LoginFormProps = {
mode: "signin" | "signup-owner" | "signup-user"
}
type AuthResponse = {
user?: {
role?: string
}
message?: string
}
function persistRoleCookie(role: unknown) {
if (typeof role !== "string") {
return
}
// biome-ignore lint/suspicious/noDocumentCookie: Temporary fallback for middleware role resolution.
document.cookie = `cms_role=${encodeURIComponent(role)}; Path=/; SameSite=Lax`
}
export function LoginForm({ mode }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
const [name, setName] = useState("Admin User")
const [username, setUsername] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [isBusy, setIsBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setIsBusy(true)
setError(null)
setSuccess(null)
try {
const response = await fetch("/api/auth/sign-in/email", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
identifier: email,
password,
callbackURL: nextPath,
}),
})
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? "Sign in failed")
return
}
persistRoleCookie(payload?.user?.role)
router.push(nextPath)
router.refresh()
} catch {
setError("Network error while signing in")
} finally {
setIsBusy(false)
}
}
async function handleSignUp(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) {
setError("Name is required for account creation")
return
}
setIsBusy(true)
setError(null)
setSuccess(null)
try {
const response = await fetch("/api/auth/sign-up/email", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name,
username,
email,
password,
callbackURL: nextPath,
}),
})
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? "Sign up failed")
return
}
persistRoleCookie(payload?.user?.role)
setSuccess(
mode === "signup-owner"
? "Owner account created. Registration is now disabled."
: "Account created.",
)
router.push(nextPath)
router.refresh()
} catch {
setError("Network error while signing up")
} finally {
setIsBusy(false)
}
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
<div className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin Auth</p>
<h1 className="text-3xl font-semibold tracking-tight">
{mode === "signin"
? "Sign in to CMS Admin"
: mode === "signup-owner"
? "Welcome to CMS Admin"
: "Create an admin account"}
</h1>
<p className="text-sm text-neutral-600">
{mode === "signin" ? (
<>
Better Auth is active on this app via <code>/api/auth</code>.
</>
) : mode === "signup-owner" ? (
"Create the first owner account to initialize this admin instance."
) : (
"Self-registration is enabled for admin users."
)}
</p>
</div>
{mode === "signin" ? (
<form
onSubmit={handleSignIn}
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
Email or username
</label>
<input
id="email"
type="text"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Password
</label>
<input
id="password"
type="password"
minLength={8}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={isBusy}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isBusy ? "Signing in..." : "Sign in"}
</button>
<p className="text-xs text-neutral-600">
Need an account?{" "}
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
Register
</Link>
</p>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</form>
) : (
<form
onSubmit={handleSignUp}
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="name">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="username">
Username (optional)
</label>
<input
id="username"
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Password
</label>
<input
id="password"
type="password"
minLength={8}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={isBusy}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isBusy
? "Creating account..."
: mode === "signup-owner"
? "Create owner account"
: "Create account"}
</button>
<p className="text-xs text-neutral-600">
Already have an account?{" "}
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
Go to sign in
</Link>
</p>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{success ? <p className="text-sm text-green-700">{success}</p> : null}
</form>
)}
</main>
)
}

View File

@ -0,0 +1,36 @@
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { hasOwnerUser } from "@/lib/auth/server"
import { LoginForm } from "./login-form"
export const dynamic = "force-dynamic"
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function getSingleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0]
}
return input
}
export default async function LoginPage({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams
const nextPath = getSingleValue(params.next) ?? "/"
const role = await resolveRoleFromServerContext()
if (role) {
redirect("/")
}
const hasOwner = await hasOwnerUser()
if (!hasOwner) {
redirect(`/welcome?next=${encodeURIComponent(nextPath)}`)
}
return <LoginForm mode="signin" />
}

View File

@ -0,0 +1,36 @@
"use client"
import { Button } from "@cms/ui/button"
import { useRouter } from "next/navigation"
import { useState } from "react"
export function LogoutButton() {
const router = useRouter()
const [isBusy, setIsBusy] = useState(false)
async function handleLogout() {
setIsBusy(true)
try {
await fetch("/api/auth/sign-out", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ callbackURL: "/login" }),
})
} finally {
// biome-ignore lint/suspicious/noDocumentCookie: Temporary cookie fallback until role resolution no longer needs this cookie.
document.cookie = "cms_role=; Path=/; Max-Age=0; SameSite=Lax"
router.push("/login")
router.refresh()
setIsBusy(false)
}
}
return (
<Button type="button" onClick={() => void handleLogout()} disabled={isBusy} variant="secondary">
{isBusy ? "Signing out..." : "Sign out"}
</Button>
)
}

View File

@ -4,14 +4,19 @@ import { Button } from "@cms/ui/button"
import Link from "next/link"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { LogoutButton } from "./logout-button"
export const dynamic = "force-dynamic"
export default async function AdminHomePage() {
const role = await resolveRoleFromServerContext()
if (!role || !hasPermission(role, "news:read", "team")) {
if (!role) {
redirect("/login?next=/")
}
if (!hasPermission(role, "news:read", "team")) {
redirect("/unauthorized?required=news:read&scope=team")
}
@ -24,13 +29,14 @@ export default async function AdminHomePage() {
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
<div className="pt-2">
<div className="flex items-center gap-3 pt-2">
<Link
href="/todo"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Open roadmap and progress
</Link>
<LogoutButton />
</div>
</header>

View File

@ -0,0 +1,40 @@
import { redirect } from "next/navigation"
import { LoginForm } from "@/app/login/login-form"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { hasOwnerUser, isSelfRegistrationEnabled } from "@/lib/auth/server"
export const dynamic = "force-dynamic"
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function getSingleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0]
}
return input
}
export default async function RegisterPage({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams
const nextPath = getSingleValue(params.next) ?? "/"
const role = await resolveRoleFromServerContext()
if (role) {
redirect("/")
}
const hasOwner = await hasOwnerUser()
if (!hasOwner) {
redirect(`/welcome?next=${encodeURIComponent(nextPath)}`)
}
const enabled = await isSelfRegistrationEnabled()
if (!enabled) {
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
}
return <LoginForm mode="signup-user" />
}

View File

@ -0,0 +1,23 @@
import { notFound, redirect } from "next/navigation"
import { LoginForm } from "@/app/login/login-form"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { resolveSupportLoginKey } from "@/lib/auth/server"
export const dynamic = "force-dynamic"
type Params = Promise<{ key: string }>
export default async function SupportLoginPage({ params }: { params: Params }) {
const { key } = await params
const role = await resolveRoleFromServerContext()
if (role) {
redirect("/")
}
if (key !== resolveSupportLoginKey()) {
notFound()
}
return <LoginForm mode="signin" />
}

View File

@ -4,7 +4,7 @@ import { hasPermission } from "@cms/content/rbac"
import Link from "next/link"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access"
import { resolveRoleFromServerContext } from "@/lib/access-server"
export const dynamic = "force-dynamic"
@ -407,7 +407,11 @@ export default async function AdminTodoPage(props: {
}) {
const role = await resolveRoleFromServerContext()
if (!role || !hasPermission(role, "roadmap:read", "global")) {
if (!role) {
redirect("/login?next=/todo")
}
if (!hasPermission(role, "roadmap:read", "global")) {
redirect("/unauthorized?required=roadmap:read&scope=global")
}

View File

@ -0,0 +1,34 @@
import { redirect } from "next/navigation"
import { LoginForm } from "@/app/login/login-form"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { hasOwnerUser } from "@/lib/auth/server"
export const dynamic = "force-dynamic"
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function getSingleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0]
}
return input
}
export default async function WelcomePage({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams
const nextPath = getSingleValue(params.next) ?? "/"
const role = await resolveRoleFromServerContext()
if (role) {
redirect("/")
}
const hasOwner = await hasOwnerUser()
if (hasOwner) {
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
}
return <LoginForm mode="signup-owner" />
}

View File

@ -0,0 +1,42 @@
import "server-only"
import type { Role } from "@cms/content/rbac"
import { cookies, headers } from "next/headers"
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
import { resolveDefaultRole, resolveRoleFromRawValue } from "./access"
export async function resolveRoleFromServerContext(): Promise<Role | null> {
const roleFromAuthSession = await resolveRoleFromAuthSessionInServerContext()
if (roleFromAuthSession) {
return roleFromAuthSession
}
const cookieStore = await cookies()
const headerStore = await headers()
const roleFromCookie = cookieStore.get("cms_role")?.value
const roleFromHeader = headerStore.get("x-cms-role")
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
if (resolved) {
return resolved
}
return resolveDefaultRole()
}
async function resolveRoleFromAuthSessionInServerContext(): Promise<Role | null> {
try {
const headerStore = await headers()
const session = await auth.api.getSession({
headers: headerStore,
})
return resolveRoleFromAuthSession(session)
} catch {
return null
}
}

View File

@ -1,5 +1,4 @@
import { hasPermission, normalizeRole, type PermissionScope, type Role } from "@cms/content/rbac"
import { cookies, headers } from "next/headers"
import type { NextRequest } from "next/server"
type RoutePermission = {
@ -17,6 +16,26 @@ const guardRules: GuardRule[] = [
route: /^\/unauthorized(?:\/|$)/,
requirement: null,
},
{
route: /^\/api\/auth(?:\/|$)/,
requirement: null,
},
{
route: /^\/login(?:\/|$)/,
requirement: null,
},
{
route: /^\/register(?:\/|$)/,
requirement: null,
},
{
route: /^\/welcome(?:\/|$)/,
requirement: null,
},
{
route: /^\/support\/[^/]+(?:\/|$)/,
requirement: null,
},
{
route: /^\/todo(?:\/|$)/,
requirement: {
@ -33,15 +52,15 @@ const guardRules: GuardRule[] = [
},
]
function resolveDefaultRole(): Role | null {
export function resolveDefaultRole(): Role | null {
if (process.env.NODE_ENV === "production") {
return null
}
return normalizeRole(process.env.CMS_DEV_ROLE ?? "admin")
return normalizeRole(process.env.CMS_DEV_ROLE)
}
function resolveRoleFromRawValue(raw: string | null | undefined): Role | null {
export function resolveRoleFromRawValue(raw: string | null | undefined): Role | null {
return normalizeRole(raw)
}
@ -58,22 +77,6 @@ export function resolveRoleFromRequest(request: NextRequest): Role | null {
return resolveDefaultRole()
}
export async function resolveRoleFromServerContext(): Promise<Role | null> {
const cookieStore = await cookies()
const headerStore = await headers()
const roleFromCookie = cookieStore.get("cms_role")?.value
const roleFromHeader = headerStore.get("x-cms-role")
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
if (resolved) {
return resolved
}
return resolveDefaultRole()
}
export function getRequiredPermission(pathname: string): RoutePermission {
for (const rule of guardRules) {
if (rule.route.test(pathname)) {
@ -103,3 +106,9 @@ export function canAccessRoute(role: Role, pathname: string): boolean {
return hasPermission(role, requirement.permission, requirement.scope)
}
export function isPublicRoute(pathname: string): boolean {
const rule = guardRules.find((item) => item.route.test(pathname))
return rule?.requirement === null
}

View File

@ -0,0 +1,523 @@
import { normalizeRole, type Role } from "@cms/content/rbac"
import { db } from "@cms/db"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { toNextJsHandler } from "better-auth/next-js"
const FALLBACK_DEV_SECRET = "dev-only-change-me-for-production"
const isProduction = process.env.NODE_ENV === "production"
const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001"
const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
const DEFAULT_SUPPORT_USERNAME = "support"
const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password"
const DEFAULT_SUPPORT_NAME = "Technical Support"
const DEFAULT_SUPPORT_LOGIN_KEY = "support-access"
const USERNAME_MAX_LENGTH = 32
function resolveAuthSecret(): string {
const value = process.env.BETTER_AUTH_SECRET
if (value) {
return value
}
if (isProduction) {
throw new Error("BETTER_AUTH_SECRET is required in production")
}
return FALLBACK_DEV_SECRET
}
export async function hasOwnerUser(): Promise<boolean> {
const ownerCount = await db.user.count({
where: { role: "owner" },
})
return ownerCount > 0
}
export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
return !(await hasOwnerUser())
}
export async function isSelfRegistrationEnabled(): Promise<boolean> {
// Temporary fallback until registration policy is managed from admin settings.
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
}
export async function canUserSelfRegister(): Promise<boolean> {
if (!(await hasOwnerUser())) {
return true
}
return isSelfRegistrationEnabled()
}
export function resolveSupportLoginKey(): string {
const value = process.env.CMS_SUPPORT_LOGIN_KEY
if (value) {
return value
}
if (isProduction) {
throw new Error("CMS_SUPPORT_LOGIN_KEY is required in production")
}
return DEFAULT_SUPPORT_LOGIN_KEY
}
function resolveBootstrapValue(
envKey: string,
fallback: string,
options: {
requiredInProduction?: boolean
} = {},
): string {
const value = process.env[envKey]
if (value) {
return value
}
if (isProduction && options.requiredInProduction) {
throw new Error(`${envKey} is required in production`)
}
return fallback
}
function normalizeUsernameCandidate(input: string | null | undefined): string | null {
if (!input) {
return null
}
const normalized = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, USERNAME_MAX_LENGTH)
if (!normalized) {
return null
}
return normalized
}
function extractEmailLocalPart(email: string): string {
return email.split("@")[0] ?? email
}
async function getAvailableUsername(base: string): Promise<string> {
const normalizedBase = normalizeUsernameCandidate(base) ?? "user"
for (let suffix = 0; suffix < 1000; suffix += 1) {
const candidate =
suffix === 0 ? normalizedBase : `${normalizedBase}-${suffix}`.slice(0, USERNAME_MAX_LENGTH)
const existing = await db.user.findUnique({
where: { username: candidate },
select: { id: true },
})
if (!existing) {
return candidate
}
}
throw new Error("Unable to allocate unique username")
}
export async function ensureUserUsername(
userId: string,
options: {
preferred?: string | null | undefined
fallbackEmail?: string | null | undefined
fallbackName?: string | null | undefined
} = {},
): Promise<string | null> {
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, username: true, email: true, name: true },
})
if (!user) {
return null
}
if (user.username) {
return user.username
}
const baseCandidate =
normalizeUsernameCandidate(options.preferred) ??
normalizeUsernameCandidate(
options.fallbackEmail ? extractEmailLocalPart(options.fallbackEmail) : null,
) ??
normalizeUsernameCandidate(options.fallbackName) ??
normalizeUsernameCandidate(extractEmailLocalPart(user.email)) ??
normalizeUsernameCandidate(user.name) ??
"user"
const username = await getAvailableUsername(baseCandidate)
await db.user.update({
where: { id: user.id },
data: { username },
})
return username
}
export async function resolveEmailFromLoginIdentifier(
identifier: string | null | undefined,
): Promise<string | null> {
const value = identifier?.trim()
if (!value) {
return null
}
if (value.includes("@")) {
return value.toLowerCase()
}
const username = normalizeUsernameCandidate(value)
if (!username) {
return null
}
const user = await db.user.findUnique({
where: { username },
select: { email: true },
})
return user?.email ?? null
}
export const auth = betterAuth({
appName: "CMS Admin",
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
secret: resolveAuthSecret(),
trustedOrigins: [adminOrigin, webOrigin],
database: prismaAdapter(db, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
// Sign-up gating is handled in route layer so we can close registration
// automatically after the first owner account is created.
disableSignUp: false,
},
user: {
additionalFields: {
role: {
type: "string",
required: true,
defaultValue: "editor",
input: false,
},
username: {
type: "string",
required: false,
input: false,
},
isBanned: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
isSystem: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
isHidden: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
isProtected: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
},
},
})
export const authRouteHandlers = toNextJsHandler(auth)
export type AuthSession = typeof auth.$Infer.Session
let supportBootstrapPromise: Promise<void> | null = null
type BootstrapUserConfig = {
email: string
username: string
name: string
password: string
role: Role
isHidden: boolean
}
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
const ctx = await auth.$context
const normalizedEmail = config.email.toLowerCase()
const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, {
includeAccounts: true,
})
if (existing?.user) {
await db.user.update({
where: { id: existing.user.id },
data: {
name: config.name,
role: config.role,
isBanned: false,
isSystem: true,
isHidden: config.isHidden,
isProtected: true,
},
})
const hasCredentialAccount = existing.accounts.some(
(account) => account.providerId === "credential",
)
if (!hasCredentialAccount) {
const passwordHash = await ctx.password.hash(config.password)
await ctx.internalAdapter.linkAccount({
userId: existing.user.id,
providerId: "credential",
accountId: existing.user.id,
password: passwordHash,
})
}
await ensureUserUsername(existing.user.id, {
preferred: config.username,
fallbackEmail: existing.user.email,
fallbackName: config.name,
})
return
}
const availableUsername = await getAvailableUsername(config.username)
const passwordHash = await ctx.password.hash(config.password)
const createdUser = await ctx.internalAdapter.createUser({
name: config.name,
email: normalizedEmail,
username: availableUsername,
emailVerified: true,
role: config.role,
isBanned: false,
isSystem: true,
isHidden: config.isHidden,
isProtected: true,
})
await ctx.internalAdapter.linkAccount({
userId: createdUser.id,
providerId: "credential",
accountId: createdUser.id,
password: passwordHash,
})
}
async function bootstrapSystemUsers(): Promise<void> {
const supportUsername = resolveBootstrapValue("CMS_SUPPORT_USERNAME", DEFAULT_SUPPORT_USERNAME)
const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", `${supportUsername}@cms.local`)
const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, {
requiredInProduction: true,
})
const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME)
await ensureCredentialUser({
email: supportEmail,
username: supportUsername,
name: supportName,
password: supportPassword,
role: "support",
isHidden: true,
})
}
export async function ensureSupportUserBootstrap(): Promise<void> {
if (supportBootstrapPromise) {
await supportBootstrapPromise
return
}
supportBootstrapPromise = (async () => {
await bootstrapSystemUsers()
await enforceOwnerInvariant()
})()
try {
await supportBootstrapPromise
} catch (error) {
supportBootstrapPromise = null
throw error
}
}
type OwnerInvariantState = {
ownerId: string | null
ownerCount: number
repaired: boolean
}
export async function enforceOwnerInvariant(): Promise<OwnerInvariantState> {
return db.$transaction(async (tx) => {
const owners = await tx.user.findMany({
where: { role: "owner" },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true, isProtected: true, isBanned: true },
})
if (owners.length === 0) {
const candidate = await tx.user.findFirst({
where: {
role: {
not: "support",
},
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true },
})
if (!candidate) {
return {
ownerId: null,
ownerCount: 0,
repaired: false,
}
}
await tx.user.update({
where: { id: candidate.id },
data: {
role: "owner",
isProtected: true,
isBanned: false,
},
})
return {
ownerId: candidate.id,
ownerCount: 1,
repaired: true,
}
}
const canonicalOwner = owners[0]
const extraOwnerIds = owners.slice(1).map((owner) => owner.id)
if (extraOwnerIds.length > 0) {
await tx.user.updateMany({
where: { id: { in: extraOwnerIds } },
data: {
role: "admin",
isProtected: false,
},
})
}
if (!canonicalOwner.isProtected || canonicalOwner.isBanned) {
await tx.user.update({
where: { id: canonicalOwner.id },
data: {
isProtected: true,
isBanned: false,
},
})
}
return {
ownerId: canonicalOwner.id,
ownerCount: 1,
repaired: extraOwnerIds.length > 0 || !canonicalOwner.isProtected || canonicalOwner.isBanned,
}
})
}
export async function canDeleteUserAccount(userId: string): Promise<boolean> {
const user = await db.user.findUnique({
where: { id: userId },
select: { role: true, isProtected: true },
})
if (!user) {
return false
}
// Protected/system users (support + canonical owner) are never deletable
// through self-service endpoints.
if (user.isProtected) {
return false
}
if (user.role !== "owner") {
return true
}
// Defensive fallback for drifted data; normal flow should already keep one owner.
const ownerCount = await db.user.count({
where: { role: "owner" },
})
return ownerCount > 1
}
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
const promoted = await db.$transaction(async (tx) => {
const existingOwner = await tx.user.findFirst({
where: { role: "owner" },
select: { id: true },
})
if (existingOwner) {
return false
}
await tx.user.update({
where: { id: userId },
data: {
role: "owner",
isSystem: false,
isHidden: false,
isProtected: true,
isBanned: false,
},
})
return true
})
if (promoted) {
await enforceOwnerInvariant()
}
return promoted
}
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
const sessionUserRole = session?.user?.role
if (typeof sessionUserRole !== "string") {
return null
}
return normalizeRole(sessionUserRole)
}

View File

@ -1,18 +1,27 @@
import { type NextRequest, NextResponse } from "next/server"
import { canAccessRoute, getRequiredPermission, resolveRoleFromRequest } from "@/lib/access"
import {
canAccessRoute,
getRequiredPermission,
isPublicRoute,
resolveRoleFromRequest,
} from "@/lib/access"
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (isPublicRoute(pathname)) {
return NextResponse.next()
}
const role = resolveRoleFromRequest(request)
if (!role) {
const unauthorizedUrl = request.nextUrl.clone()
unauthorizedUrl.pathname = "/unauthorized"
unauthorizedUrl.searchParams.set("reason", "missing-role")
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = "/login"
loginUrl.searchParams.set("next", pathname)
return NextResponse.redirect(unauthorizedUrl)
return NextResponse.redirect(loginUrl)
}
if (!canAccessRoute(role, pathname)) {

View File

@ -1,7 +1,10 @@
import type { NextConfig } from "next"
import createNextIntlPlugin from "next-intl/plugin"
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts")
const nextConfig: NextConfig = {
transpilePackages: ["@cms/ui", "@cms/content", "@cms/db"],
transpilePackages: ["@cms/ui", "@cms/content", "@cms/db", "@cms/i18n"],
}
export default nextConfig
export default withNextIntl(nextConfig)

View File

@ -13,22 +13,24 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest"
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"next": "16.1.6",
"next-intl": "4.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "latest",
"typescript": "latest"
"@biomejs/biome": "2.3.14",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.2.2",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.9.3"
}
}

View File

@ -0,0 +1,27 @@
import { notFound } from "next/navigation"
import { hasLocale, NextIntlClientProvider } from "next-intl"
import type { ReactNode } from "react"
import { routing } from "@/i18n/routing"
import { Providers } from "../providers"
type LocaleLayoutProps = {
children: ReactNode
params: Promise<{
locale: string
}>
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) {
notFound()
}
return (
<NextIntlClientProvider locale={locale}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
)
}

View File

@ -1,25 +1,29 @@
import { listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
import { LanguageSwitcher } from "@/components/language-switcher"
export const dynamic = "force-dynamic"
export default async function HomePage() {
const posts = await listPosts()
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
return (
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Web App</p>
<h1 className="text-4xl font-semibold tracking-tight">Your Next.js CMS Frontend</h1>
<p className="text-neutral-600">
This page reads posts through the shared database package.
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<LanguageSwitcher />
</div>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">Latest posts</h2>
<Button variant="secondary">Explore</Button>
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
<Button variant="secondary">{t("explore")}</Button>
</div>
<ul className="space-y-3">
@ -27,7 +31,7 @@ export default async function HomePage() {
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li>
))}
</ul>

View File

@ -2,7 +2,6 @@ import type { Metadata } from "next"
import type { ReactNode } from "react"
import "./globals.css"
import { Providers } from "./providers"
export const metadata: Metadata = {
title: "CMS Web",
@ -12,9 +11,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
<body>{children}</body>
</html>
)
}

View File

@ -0,0 +1,50 @@
"use client"
import { type AppLocale, localeLabels, locales } from "@cms/i18n"
import { useLocale, useTranslations } from "next-intl"
import { useEffect, useTransition } from "react"
import { usePathname, useRouter } from "@/i18n/navigation"
import { useLocaleStore } from "@/store/locale"
export function LanguageSwitcher() {
const t = useTranslations("LanguageSwitcher")
const currentLocale = useLocale() as AppLocale
const pathname = usePathname()
const router = useRouter()
const [isPending, startTransition] = useTransition()
const locale = useLocaleStore((state) => state.locale)
const setLocale = useLocaleStore((state) => state.setLocale)
useEffect(() => {
if (locale !== currentLocale) {
setLocale(currentLocale)
}
}, [currentLocale, locale, setLocale])
return (
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<span>{t("label")}</span>
<select
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm"
value={locale}
disabled={isPending}
onChange={(event) => {
const nextLocale = event.target.value as AppLocale
setLocale(nextLocale)
startTransition(() => {
router.replace(pathname, { locale: nextLocale })
})
}}
>
{locales.map((value) => (
<option key={value} value={value}>
{t(`localeNames.${value}`)} ({localeLabels[value]})
</option>
))}
</select>
</label>
)
}

View File

@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation"
import { routing } from "./routing"
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)

View File

@ -0,0 +1,14 @@
import { hasLocale } from "next-intl"
import { getRequestConfig } from "next-intl/server"
import { routing } from "./routing"
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})

View File

@ -0,0 +1,8 @@
import { defaultLocale, locales } from "@cms/i18n"
import { defineRouting } from "next-intl/routing"
export const routing = defineRouting({
locales: [...locales],
defaultLocale,
localePrefix: "never",
})

View File

@ -0,0 +1,19 @@
{
"Home": {
"badge": "Web-App",
"title": "Dein Next.js CMS Frontend",
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
"latestPosts": "Neueste Beiträge",
"explore": "Entdecken",
"noExcerpt": "Kein Auszug"
},
"LanguageSwitcher": {
"label": "Sprache",
"localeNames": {
"de": "Deutsch",
"en": "Englisch",
"es": "Spanisch",
"fr": "Französisch"
}
}
}

View File

@ -0,0 +1,19 @@
{
"Home": {
"badge": "Web App",
"title": "Your Next.js CMS Frontend",
"description": "This page reads posts through the shared database package.",
"latestPosts": "Latest posts",
"explore": "Explore",
"noExcerpt": "No excerpt"
},
"LanguageSwitcher": {
"label": "Language",
"localeNames": {
"de": "German",
"en": "English",
"es": "Spanish",
"fr": "French"
}
}
}

View File

@ -0,0 +1,19 @@
{
"Home": {
"badge": "Aplicación Web",
"title": "Tu Frontend CMS con Next.js",
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
"latestPosts": "Últimas publicaciones",
"explore": "Explorar",
"noExcerpt": "Sin extracto"
},
"LanguageSwitcher": {
"label": "Idioma",
"localeNames": {
"de": "Alemán",
"en": "Inglés",
"es": "Español",
"fr": "Francés"
}
}
}

View File

@ -0,0 +1,19 @@
{
"Home": {
"badge": "Application Web",
"title": "Votre Frontend CMS Next.js",
"description": "Cette page lit les publications via le package base de données partagé.",
"latestPosts": "Dernières publications",
"explore": "Explorer",
"noExcerpt": "Aucun extrait"
},
"LanguageSwitcher": {
"label": "Langue",
"localeNames": {
"de": "Allemand",
"en": "Anglais",
"es": "Espagnol",
"fr": "Français"
}
}
}

14
apps/web/src/proxy.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextRequest } from "next/server"
import createMiddleware from "next-intl/middleware"
import { routing } from "@/i18n/routing"
const handleI18nRouting = createMiddleware(routing)
export function proxy(request: NextRequest) {
return handleI18nRouting(request)
}
export const config = {
matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"],
}

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest"
import { useLocaleStore } from "./locale"
describe("web locale store", () => {
it("sets locale", () => {
useLocaleStore.setState({ locale: "en" })
useLocaleStore.getState().setLocale("de")
expect(useLocaleStore.getState().locale).toBe("de")
})
})

View File

@ -0,0 +1,12 @@
import { type AppLocale, defaultLocale } from "@cms/i18n"
import { create } from "zustand"
type LocaleStore = {
locale: AppLocale
setLocale: (value: AppLocale) => void
}
export const useLocaleStore = create<LocaleStore>((set) => ({
locale: defaultLocale,
setLocale: (value) => set({ locale: value }),
}))

View File

@ -10,6 +10,7 @@
"!**/coverage",
"!**/playwright-report",
"!**/test-results",
"!**/prisma/generated",
"!**/next-env.d.ts",
"!**/.vitepress/cache",
"!**/.vitepress/dist"

196
bun.lock
View File

@ -5,23 +5,23 @@
"": {
"name": "cms-monorepo",
"devDependencies": {
"@biomejs/biome": "latest",
"@commitlint/cli": "latest",
"@commitlint/config-conventional": "latest",
"@playwright/test": "latest",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "latest",
"@testing-library/user-event": "latest",
"@vitejs/plugin-react": "latest",
"@vitest/coverage-istanbul": "latest",
"conventional-changelog-cli": "latest",
"jsdom": "latest",
"msw": "latest",
"turbo": "latest",
"typescript": "latest",
"vite-tsconfig-paths": "latest",
"vitepress": "latest",
"vitest": "latest",
"@biomejs/biome": "2.3.14",
"@commitlint/cli": "20.4.1",
"@commitlint/config-conventional": "20.4.1",
"@playwright/test": "1.58.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "5.1.3",
"@vitest/coverage-istanbul": "4.0.18",
"conventional-changelog-cli": "5.0.0",
"jsdom": "28.0.0",
"msw": "2.12.9",
"turbo": "2.8.3",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.1.0",
"vitepress": "1.6.4",
"vitest": "4.0.18",
},
},
"apps/admin": {
@ -31,24 +31,25 @@
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-form": "latest",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"@tanstack/react-table": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest",
"@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-table": "8.21.3",
"better-auth": "1.4.18",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "latest",
"typescript": "latest",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.2.2",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.9.3",
},
},
"apps/web": {
@ -57,23 +58,25 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"next": "16.1.6",
"next-intl": "4.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "latest",
"typescript": "latest",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.2.2",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.9.3",
},
},
"packages/config": {
@ -84,12 +87,12 @@
"name": "@cms/content",
"version": "0.0.1",
"dependencies": {
"zod": "latest",
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "latest",
"typescript": "5.9.3",
},
},
"packages/db": {
@ -97,38 +100,47 @@
"version": "0.0.1",
"dependencies": {
"@cms/content": "workspace:*",
"@prisma/adapter-pg": "latest",
"@prisma/client": "latest",
"pg": "latest",
"zod": "latest",
"@prisma/adapter-pg": "7.3.0",
"@prisma/client": "7.3.0",
"pg": "8.18.0",
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@types/node": "latest",
"@types/pg": "latest",
"prisma": "latest",
"typescript": "latest",
"@types/node": "25.2.2",
"@types/pg": "8.16.0",
"prisma": "7.3.0",
"typescript": "5.9.3",
},
},
"packages/i18n": {
"name": "@cms/i18n",
"version": "0.0.1",
"devDependencies": {
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "5.9.3",
},
},
"packages/ui": {
"name": "@cms/ui",
"version": "0.0.1",
"dependencies": {
"class-variance-authority": "latest",
"clsx": "latest",
"tailwind-merge": "latest",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"tailwind-merge": "3.4.0",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3",
},
"peerDependencies": {
"react": "latest",
"react-dom": "latest",
"react": "19.2.4",
"react-dom": "19.2.4",
},
},
},
@ -221,6 +233,14 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@biomejs/biome": ["@biomejs/biome@2.3.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.14", "@biomejs/cli-darwin-x64": "2.3.14", "@biomejs/cli-linux-arm64": "2.3.14", "@biomejs/cli-linux-arm64-musl": "2.3.14", "@biomejs/cli-linux-x64": "2.3.14", "@biomejs/cli-linux-x64-musl": "2.3.14", "@biomejs/cli-win32-arm64": "2.3.14", "@biomejs/cli-win32-x64": "2.3.14" }, "bin": { "biome": "bin/biome" } }, "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A=="],
@ -255,6 +275,8 @@
"@cms/db": ["@cms/db@workspace:packages/db"],
"@cms/i18n": ["@cms/i18n@workspace:packages/i18n"],
"@cms/ui": ["@cms/ui@workspace:packages/ui"],
"@cms/web": ["@cms/web@workspace:apps/web"],
@ -375,6 +397,16 @@
"@exodus/bytes": ["@exodus/bytes@1.12.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw=="],
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.1.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "@formatjs/intl-localematcher": "0.8.1", "decimal.js": "^10.6.0", "tslib": "^2.8.1" } }, "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q=="],
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.0", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg=="],
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/icu-skeleton-parser": "2.1.1", "tslib": "^2.8.1" } }, "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA=="],
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "tslib": "^2.8.1" } }, "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q=="],
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@hutson/parse-repository-url": ["@hutson/parse-repository-url@5.0.0", "", {}, "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg=="],
@ -477,6 +509,10 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
"@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
@ -563,6 +599,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
@ -767,6 +805,10 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="],
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
@ -997,6 +1039,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"icu-minify": ["icu-minify@4.8.2", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-LHBQV+skKkjZSPd590pZ7ZAHftUgda3eFjeuNwA8/15L8T8loCNBktKQyTlkodAU86KovFXeg/9WntlAo5wA5A=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@ -1007,6 +1051,8 @@
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
"intl-messageformat": ["intl-messageformat@11.1.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/fast-memoize": "3.1.0", "@formatjs/icu-messageformat-parser": "3.5.1", "tslib": "^2.8.1" } }, "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@ -1035,6 +1081,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@ -1049,6 +1097,8 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@ -1143,10 +1193,16 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
"next-intl": ["next-intl@4.4.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^4.4.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
@ -1271,6 +1327,8 @@
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@ -1283,6 +1341,8 @@
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@ -1415,6 +1475,8 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-intl": ["use-intl@4.8.2", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.8.2", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-3VNXZgDnPFqhIYosQ9W1Hc6K5q+ZelMfawNbexdwL/dY7BTHbceLUBX5Eeex9lgogxTp0pf1SjHuhYNAjr9H3g=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
@ -1481,6 +1543,8 @@
"@conventional-changelog/git-client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "tslib": "^2.8.1" } }, "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="],

View File

@ -19,6 +19,8 @@ export default defineConfig({
{ text: "Section Overview", link: "/product-engineering/" },
{ text: "Getting Started", link: "/getting-started" },
{ text: "Architecture", link: "/architecture" },
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" },
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
{ text: "Workflow", link: "/workflow" },
],

View File

@ -7,6 +7,7 @@
- `packages/db`: prisma + data access
- `packages/content`: shared schemas and domain contracts
- `packages/ui`: shared UI layer
- `packages/i18n`: shared locale definitions and i18n helpers
- `packages/config`: shared TS config
## Design Principles
@ -14,6 +15,7 @@
- Shared contracts before feature implementation
- RBAC and CRUD base as prerequisites for MVP1 feature work
- Keep admin and public responsibilities clearly separated
- Public routing is path-stable; locale is resolved via `next-intl` middleware + cookie
## Pending Documentation

View File

@ -20,6 +20,18 @@ bun run db:migrate
bun run db:seed
```
Create a named migration:
```bash
bun run db:migrate:named -- --name your_migration_name
```
Reset local dev DB:
```bash
bun run db:reset:dev
```
## Run apps
```bash
@ -27,7 +39,11 @@ bun run dev
```
- Web: `http://localhost:3000`
- Web locale switching: use the language switcher in the page header
- Admin: `http://localhost:3001`
- Admin welcome (first start): `http://localhost:3001/welcome`
- Admin login: `http://localhost:3001/login`
- Admin register (when enabled): `http://localhost:3001/register`
## Run docs

View File

@ -0,0 +1,44 @@
# Better Auth Baseline
## Scope
This baseline activates Better Auth for the admin app with email/password login and Prisma-backed sessions.
Implemented in MVP0:
- Admin-local auth config: `apps/admin/src/lib/auth/server.ts`
- Admin auth API routes: `apps/admin/src/app/api/auth/[...all]/route.ts`
- Admin auth pages: `/welcome`, `/login`, `/register`
- Support fallback sign-in page: `/support/<CMS_SUPPORT_LOGIN_KEY>`
- Prisma auth models (`user`, `session`, `account`, `verification`)
- First registration creates owner; subsequent registrations are disabled
- Owner invariant reconciliation is enforced in auth bootstrap and owner promotion flow
- Protected accounts (support + canonical owner) are blocked from delete-account auth endpoints
## Environment
Required variables:
- `BETTER_AUTH_SECRET`
- `BETTER_AUTH_URL`
- `CMS_ADMIN_ORIGIN`
- `CMS_WEB_ORIGIN`
- `DATABASE_URL`
Optional:
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED`
- `CMS_SUPPORT_USERNAME`
- `CMS_SUPPORT_EMAIL`
- `CMS_SUPPORT_PASSWORD`
- `CMS_SUPPORT_NAME`
- `CMS_SUPPORT_LOGIN_KEY`
- `CMS_DEV_ROLE` (development-only middleware bypass)
## Notes
- 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.
- 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.

View File

@ -0,0 +1,20 @@
# i18n Baseline
## Scope
MVP0 introduces i18n runtime only for the public app (`@cms/web`) using `next-intl`.
Current baseline:
- Shared locale contract in `@cms/i18n` (`de`, `en`, `es`, `fr`; default `en`)
- Path-stable routing (no locale in URL) via `apps/web/src/proxy.ts`
- Message loading through `apps/web/src/i18n/request.ts`
- Locale-aware navigation helpers in `apps/web/src/i18n/navigation.ts`
- Public language switcher component backed by Zustand store
## Notes
- Public app locale is resolved through `next-intl` middleware + cookie.
- Enabled locales are currently static in code and will later be managed from admin settings.
- Admin app i18n provider/message loading is still pending.
- Translation key conventions and workflow docs are tracked in `TODO.md`.

View File

@ -6,6 +6,7 @@ This section covers platform and implementation documentation for engineers and
- [Getting Started](/getting-started)
- [Architecture](/architecture)
- [Better Auth Baseline](/product-engineering/auth-baseline)
- [RBAC And Permissions](/product-engineering/rbac-permission-model)
- [Workflow](/workflow)

View File

@ -40,7 +40,7 @@ Scope hierarchy (higher includes lower):
## Enforcement Layers
- Route-level: `apps/admin/src/middleware.ts`
- Route-level: `apps/admin/src/proxy.ts`
- Action-level: server component checks in admin pages (`/` and `/todo`)
- Shared model + checks: `packages/content/src/rbac.ts`

View File

@ -8,5 +8,12 @@ test("smoke", async ({ page }, testInfo) => {
return
}
await expect(page.getByRole("heading", { name: /content dashboard/i })).toBeVisible()
const dashboardHeading = page.getByRole("heading", { name: /content dashboard/i })
if (await dashboardHeading.isVisible({ timeout: 2000 })) {
await expect(dashboardHeading).toBeVisible()
return
}
await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
})

View File

@ -1,5 +1,6 @@
{
"name": "cms-monorepo",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.3.5",
"workspaces": [
@ -29,32 +30,35 @@
"check": "biome check .",
"db:generate": "bun --filter @cms/db db:generate",
"db:migrate": "bun --filter @cms/db db:migrate",
"db:migrate:named": "bun --filter @cms/db db:migrate:named",
"db:migrate:named": "cd packages/db && bun --env-file=../../.env prisma migrate dev",
"db:migrate:deploy": "bun --filter @cms/db db:migrate:deploy",
"db:reset:dev": "bun --filter @cms/db db:reset:dev && bun run auth:seed:support",
"db:push": "bun --filter @cms/db db:push",
"db:studio": "bun --filter @cms/db db:studio",
"db:seed": "bun --filter @cms/db db:seed",
"db:seed": "bun --filter @cms/db db:seed && bun --filter @cms/admin auth:seed:support",
"auth:seed:support": "bun --filter @cms/admin auth:seed:support",
"docker:staging:up": "docker compose -f docker-compose.staging.yml up -d --build",
"docker:staging:down": "docker compose -f docker-compose.staging.yml down",
"docker:production:up": "docker compose -f docker-compose.production.yml up -d --build",
"docker:production:down": "docker compose -f docker-compose.production.yml down"
},
"devDependencies": {
"@playwright/test": "latest",
"@commitlint/cli": "latest",
"@commitlint/config-conventional": "latest",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "latest",
"@testing-library/user-event": "latest",
"@vitejs/plugin-react": "latest",
"@vitest/coverage-istanbul": "latest",
"@biomejs/biome": "latest",
"jsdom": "latest",
"msw": "latest",
"conventional-changelog-cli": "latest",
"turbo": "latest",
"typescript": "latest",
"vitepress": "latest",
"vite-tsconfig-paths": "latest",
"vitest": "latest"
"@playwright/test": "1.58.2",
"@commitlint/cli": "20.4.1",
"@commitlint/config-conventional": "20.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "5.1.3",
"@vitest/coverage-istanbul": "4.0.18",
"@biomejs/biome": "2.3.14",
"jsdom": "28.0.0",
"msw": "2.12.9",
"conventional-changelog-cli": "5.0.0",
"turbo": "2.8.3",
"typescript": "5.9.3",
"vitepress": "1.6.4",
"vite-tsconfig-paths": "6.1.0",
"vitest": "4.0.18"
}
}

View File

@ -13,11 +13,11 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "latest"
"zod": "4.3.6"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"typescript": "latest"
"@biomejs/biome": "2.3.14",
"typescript": "5.9.3"
}
}

View File

@ -4,12 +4,16 @@ import { hasPermission, normalizeRole, permissionMatrix } from "./rbac"
describe("rbac model", () => {
it("normalizes valid roles", () => {
expect(normalizeRole("OWNER")).toBe("owner")
expect(normalizeRole("support")).toBe("support")
expect(normalizeRole("ADMIN")).toBe("admin")
expect(normalizeRole("manager")).toBe("manager")
expect(normalizeRole("unknown")).toBeNull()
})
it("grants admin full access", () => {
expect(hasPermission("owner", "users:manage_roles", "global")).toBe(true)
expect(hasPermission("support", "news:publish", "global")).toBe(true)
expect(hasPermission("admin", "users:manage_roles", "global")).toBe(true)
expect(hasPermission("admin", "news:publish", "global")).toBe(true)
})

View File

@ -1,6 +1,6 @@
import { z } from "zod"
export const roleSchema = z.enum(["admin", "editor", "manager"])
export const roleSchema = z.enum(["owner", "support", "admin", "editor", "manager"])
export const permissionScopeSchema = z.enum(["own", "team", "global"])
export const permissionSchema = z.enum([
@ -44,6 +44,8 @@ const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({
}))
export const permissionMatrix: Record<Role, PermissionGrant[]> = {
owner: allGlobalGrants,
support: allGlobalGrants,
admin: allGlobalGrants,
manager: [
{ permission: "dashboard:read", scopes: ["global"] },

View File

@ -13,24 +13,26 @@
"db:generate": "bun --env-file=../../.env prisma generate",
"db:migrate": "bun --env-file=../../.env prisma migrate dev --name init",
"db:migrate:named": "bun --env-file=../../.env prisma migrate dev",
"db:migrate:deploy": "bun --env-file=../../.env prisma migrate deploy",
"db:reset:dev": "bun --env-file=../../.env prisma migrate reset --force",
"db:push": "bun --env-file=../../.env prisma db push",
"db:studio": "bun --env-file=../../.env prisma studio",
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
},
"dependencies": {
"@cms/content": "workspace:*",
"@prisma/adapter-pg": "latest",
"@prisma/client": "latest",
"pg": "latest",
"zod": "latest"
"@prisma/adapter-pg": "7.3.0",
"@prisma/client": "7.3.0",
"pg": "8.18.0",
"zod": "4.3.6"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"@types/node": "latest",
"@types/pg": "latest",
"prisma": "latest",
"typescript": "latest"
"@biomejs/biome": "2.3.14",
"@types/node": "25.2.2",
"@types/pg": "8.16.0",
"prisma": "7.3.0",
"typescript": "5.9.3"
},
"prisma": {
"seed": "bun --env-file=../../.env prisma/seed.ts"

View File

@ -0,0 +1,80 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"role" TEXT NOT NULL DEFAULT 'editor',
"isBanned" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "user" ADD COLUMN "username" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "user"
ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isProtected" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "user_role_idx" ON "user"("role");

View File

@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "./generated/client"
}
datasource db {
@ -16,3 +17,73 @@ model Post {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id
name String
email String
username String? @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role String @default("editor")
isBanned Boolean @default(false)
isSystem Boolean @default(false)
isHidden Boolean @default(false)
isProtected Boolean @default(false)
sessions Session[]
accounts Account[]
@@unique([email])
@@index([role])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}

View File

@ -1,6 +1,6 @@
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { Pool } from "pg"
import { PrismaClient } from "../prisma/generated/client/client"
const connectionString = process.env.DATABASE_URL

View File

@ -0,0 +1,19 @@
{
"name": "@cms/i18n",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "biome check src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "2.3.14",
"typescript": "5.9.3"
}
}

View File

@ -0,0 +1,16 @@
export const locales = ["de", "en", "es", "fr"] as const
export type AppLocale = (typeof locales)[number]
export const defaultLocale: AppLocale = "en"
export const localeLabels: Record<AppLocale, string> = {
de: "Deutsch",
en: "English",
es: "Español",
fr: "Français",
}
export function isAppLocale(value: string): value is AppLocale {
return locales.includes(value as AppLocale)
}

View File

@ -0,0 +1,8 @@
{
"extends": "@cms/config/tsconfig/base",
"compilerOptions": {
"noEmit": false,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

View File

@ -14,19 +14,19 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"class-variance-authority": "latest",
"clsx": "latest",
"tailwind-merge": "latest"
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"tailwind-merge": "3.4.0"
},
"peerDependencies": {
"react": "latest",
"react-dom": "latest"
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest"
"@biomejs/biome": "2.3.14",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
}
}