Compare commits

...

32 Commits

Author SHA1 Message Date
ad351ed73a feat(media): complete mvp1 media foundation workflows 2026-02-11 22:56:01 +01:00
d727ab8b5b feat(media): scaffold mvp1 media and portfolio foundation 2026-02-11 22:46:24 +01:00
5b47fafe89 docs(product): add cms feature topics, package catalog, and inspiration notes
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m2s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m24s
2026-02-11 22:35:46 +01:00
37fabad1f8 chore(repo): update turbo dependency
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m5s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m40s
2026-02-11 22:08:01 +01:00
637dfd2651 docs(ops): add staging deployment checklist and evidence template 2026-02-11 19:11:45 +01:00
f9f2b4eb15 docs(gitflow): add branch protection verification checklist 2026-02-11 19:09:57 +01:00
ccac669454 feat(release): publish gitea release notes and enable production rollback 2026-02-11 19:09:22 +01:00
af52b8581f feat(ci): stamp build metadata and validate footer version hash 2026-02-11 19:06:55 +01:00
3de4d5732e feat(versioning): show runtime version and git hash in app footers 2026-02-11 19:01:53 +01:00
14c3df623a fix(db): organize imports for biome check
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m2s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m38s
2026-02-11 18:45:30 +01:00
a57464d818 chore(repo): remove theoretical workflow and fix prisma ci generation
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m6s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 1m8s
2026-02-11 18:26:42 +01:00
c174f840bc fix(ci): gitea workflows
Some checks failed
CMS CI/CD (Theoretical) / Lint Typecheck Tests (push) Failing after 35s
CMS CI / Governance Checks (push) Successful in 1m1s
CMS CI/CD (Theoretical) / Build Staging Images (push) Has been skipped
CMS CI/CD (Theoretical) / Build Production Images (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Staging (Placeholder) (push) Has been skipped
CMS CI / Lint Typecheck Unit E2E (push) Failing after 1m25s
CMS CI/CD (Theoretical) / Deploy Production (Placeholder) (push) Has been skipped
2026-02-11 13:12:12 +01:00
334a5e3526 chore(ci): add gitea actions runner compose setup 2026-02-11 12:25:57 +01:00
516b773012 docs(versioning): define release policy and close MVP0 pipeline tasks
Some checks failed
CMS CI/CD (Theoretical) / Lint Typecheck Tests (push) Failing after 5m34s
CMS CI / Governance Checks (push) Failing after 4m47s
CMS CI/CD (Theoretical) / Build Staging Images (push) Has been skipped
CMS CI / Lint Typecheck Unit E2E (push) Has been skipped
CMS CI/CD (Theoretical) / Build Production Images (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Staging (Placeholder) (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Production (Placeholder) (push) Has been skipped
2026-02-11 12:19:50 +01:00
21cc55a1b9 ci(gitflow): enforce branch and PR governance checks 2026-02-11 12:19:39 +01:00
969e88670f ci(delivery): add deploy and release workflow scaffolds 2026-02-11 12:19:31 +01:00
cec87679ca docs(adr): add glossary pages and ADR baseline structure 2026-02-11 12:12:34 +01:00
4d6e17a13b docs(ops): add environment and deployment runbook 2026-02-11 12:11:08 +01:00
7b4b23fc4f docs(crud): add implementation examples and complete docs task 2026-02-11 12:10:28 +01:00
5872593b01 docs(i18n): add conventions guide and wire docs navigation 2026-02-11 12:09:43 +01:00
3b130568e9 test(mvp0): complete remaining i18n, RBAC, and CRUD coverage 2026-02-11 12:06:27 +01:00
8390689c8d feat(web): complete MVP0 public layout, banner, and SEO baseline 2026-02-10 22:04:53 +01:00
bf1a92d129 feat(admin): add IA shell and protected section skeleton routes 2026-02-10 21:34:26 +01:00
36b09cd9d7 test(crud): finalize MVP1 gate CRUD contract coverage 2026-02-10 21:26:49 +01:00
70fc154f97 merge: todo/mvp0-admin-i18n-baseline into dev 2026-02-10 21:21:35 +01:00
c4d0499d12 merge: todo/mvp0-crud-foundation into dev 2026-02-10 21:21:32 +01:00
d16fb6e121 merge: todo/mvp0-i18n-baseline into dev 2026-02-10 21:21:28 +01:00
a508e3203a merge: todo/mvp0-owner-invariant-enforcement into dev 2026-02-10 21:21:25 +01:00
4d4b583cf4 test(ci): add quality gates, e2e data prep, and i18n integration coverage 2026-02-10 21:17:41 +01:00
4ac7410148 test(admin): cover support fallback route and mark todo complete 2026-02-10 21:11:49 +01:00
d0f731743c feat(admin): add registration policy settings and disabled register state 2026-02-10 21:10:39 +01:00
b618c8cb51 feat(admin-i18n): add cookie-based locale runtime and switcher baseline 2026-02-10 20:56:03 +01:00
115 changed files with 6381 additions and 419 deletions

View File

@@ -10,5 +10,7 @@ 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"
NEXT_PUBLIC_APP_VERSION="0.1.0-dev"
NEXT_PUBLIC_GIT_SHA="local"
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
# CMS_DEV_ROLE="admin"

View File

@@ -0,0 +1,4 @@
GITEA_INSTANCE_URL="https://git.example.com"
GITEA_RUNNER_REGISTRATION_TOKEN="replace-with-runner-registration-token"
GITEA_RUNNER_NAME="cms-runner"
GITEA_RUNNER_LABELS="ubuntu-latest:docker://node:20-bookworm"

View File

@@ -0,0 +1,17 @@
## Summary
- TODO item reference (exact text): `...`
- Scope (single primary TODO item): `...`
## Checklist
- [ ] Linked TODO item is in `TODO.md`
- [ ] Branch name follows `todo/*`, `refactor/*`, or `code/*`
- [ ] `bun run check`
- [ ] `bun run typecheck`
- [ ] `bun run test`
- [ ] E2E validation plan included (`bun run test:e2e` or reason if deferred)
## Notes
- Risks / migrations / rollout notes:

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env sh
set -eu
branch="${1:-}"
if [ -z "$branch" ]; then
echo "Missing branch name."
exit 1
fi
case "$branch" in
dev|staging|main)
echo "Long-lived branch detected: $branch"
exit 0
;;
esac
if printf "%s" "$branch" | grep -Eq '^(todo|refactor|code)\/[a-z0-9]+([._-][a-z0-9]+)*$'; then
echo "Branch naming valid: $branch"
exit 0
fi
echo "Invalid branch name: $branch"
echo "Expected: todo/<slug> | refactor/<slug> | code/<slug>"
exit 1

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
body="${1:-}"
if [ -z "$body" ]; then
echo "PR body is empty."
exit 1
fi
if printf "%s" "$body" | grep -Eq 'TODO|todo|\[P[1-3]\]'; then
echo "PR body includes TODO reference."
exit 0
fi
echo "PR body must reference the related TODO item."
exit 1

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env sh
set -eu
if [ "${#}" -ne 4 ]; then
echo "Usage: $0 <base-url> <owner> <repo> <token>"
exit 1
fi
base_url="$1"
owner="$2"
repo="$3"
token="$4"
protect_branch() {
branch="$1"
curl -sS -X POST \
"${base_url}/api/v1/repos/${owner}/${repo}/branch_protections" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "{
\"branch_name\": \"${branch}\",
\"enable_push\": false,
\"enable_push_whitelist\": false,
\"enable_merge_whitelist\": false,
\"enable_status_check\": true,
\"status_check_contexts\": [\"Governance Checks\", \"Lint Typecheck Unit E2E\"]
}" >/dev/null
}
protect_branch "main"
protect_branch "staging"
echo "Branch protection applied for main and staging."

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
tag="${1:-}"
if [ -z "$tag" ]; then
echo "Missing release tag argument (expected vX.Y.Z)."
exit 1
fi
if [ ! -f CHANGELOG.md ]; then
echo "CHANGELOG.md not found."
exit 1
fi
version="${tag#v}"
awk -v version="$version" '
BEGIN {
in_section = 0
started = 0
}
/^## / {
if (in_section == 1) {
exit
}
if (index($0, version) > 0) {
in_section = 1
started = 1
print $0
next
}
}
{
if (in_section == 1) {
print $0
}
}
END {
if (started == 0) {
exit 2
}
}
' CHANGELOG.md

View File

@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs"
const tag = process.env.RELEASE_TAG?.trim()
const releaseName = process.env.RELEASE_NAME?.trim() || tag
const bodyFile = process.env.RELEASE_BODY_FILE?.trim() || ".gitea-release-notes.md"
const serverUrl = process.env.GITHUB_SERVER_URL?.trim()
const repository = process.env.GITHUB_REPOSITORY?.trim()
const token = process.env.GITEA_RELEASE_TOKEN?.trim()
if (!tag) {
throw new Error("RELEASE_TAG is required")
}
if (!serverUrl || !repository) {
throw new Error("GITHUB_SERVER_URL and GITHUB_REPOSITORY are required")
}
if (!token) {
throw new Error("GITEA_RELEASE_TOKEN is required")
}
const body = readFileSync(bodyFile, "utf8")
const baseApi = `${serverUrl.replace(/\/$/, "")}/api/v1/repos/${repository}`
async function request(path, options = {}) {
const response = await fetch(`${baseApi}${path}`, {
...options,
headers: {
"content-type": "application/json",
authorization: `token ${token}`,
...(options.headers ?? {}),
},
})
return response
}
const payload = {
tag_name: tag,
target_commitish: "main",
name: releaseName,
body,
draft: false,
prerelease: false,
}
const existingResponse = await request(`/releases/tags/${encodeURIComponent(tag)}`)
if (existingResponse.ok) {
const existing = await existingResponse.json()
const updateResponse = await request(`/releases/${existing.id}`, {
method: "PATCH",
body: JSON.stringify({
...payload,
target_commitish: existing.target_commitish ?? payload.target_commitish,
}),
})
if (!updateResponse.ok) {
const message = await updateResponse.text()
throw new Error(`Failed to update release: ${updateResponse.status} ${message}`)
}
console.log(`Updated release for tag ${tag}`)
} else if (existingResponse.status === 404) {
const createResponse = await request("/releases", {
method: "POST",
body: JSON.stringify(payload),
})
if (!createResponse.ok) {
const message = await createResponse.text()
throw new Error(`Failed to create release: ${createResponse.status} ${message}`)
}
console.log(`Created release for tag ${tag}`)
} else {
const message = await existingResponse.text()
throw new Error(`Failed to query existing release: ${existingResponse.status} ${message}`)
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu
tag="${1:-}"
if [ -z "$tag" ]; then
echo "Missing tag ref name (expected vX.Y.Z)."
exit 1
fi
version="$(node -p "require('./package.json').version")"
if [ "$tag" != "v$version" ]; then
echo "Tag/version mismatch: tag=$tag package.json=$version"
exit 1
fi
echo "Tag matches package.json version: $tag"

View File

@@ -1,113 +0,0 @@
name: CMS CI/CD (Theoretical)
on:
pull_request:
push:
branches:
- dev
- main
- staging
tags:
- "v*"
workflow_dispatch:
env:
BUN_VERSION: "1.3.5"
jobs:
quality:
name: Lint Typecheck Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate Prisma client
run: bun run db:generate
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Unit and component tests
run: bun run test
- name: E2E suite discovery check
run: bun run test:e2e --list
- name: Conventional commit check (latest commit)
run: bun run commitlint
build_staging_images:
name: Build Staging Images
runs-on: ubuntu-latest
needs: quality
if: github.ref == 'refs/heads/staging'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build web image (staging)
run: docker build -f apps/web/Dockerfile -t cms-web:staging .
- name: Build admin image (staging)
run: docker build -f apps/admin/Dockerfile -t cms-admin:staging .
build_production_images:
name: Build Production Images
runs-on: ubuntu-latest
needs: quality
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build web image (production)
run: docker build -f apps/web/Dockerfile -t cms-web:${{ github.ref_name }} .
- name: Build admin image (production)
run: docker build -f apps/admin/Dockerfile -t cms-admin:${{ github.ref_name }} .
- name: Generate changelog
run: |
bun install --frozen-lockfile
bun run changelog:release
- name: Push images (placeholder)
run: |
echo "TODO: docker login to registry"
echo "TODO: docker push cms-web:${{ github.ref_name }}"
echo "TODO: docker push cms-admin:${{ github.ref_name }}"
echo "TODO: publish CHANGELOG.md content as release notes"
deploy_staging:
name: Deploy Staging (Placeholder)
runs-on: ubuntu-latest
needs: build_staging_images
if: github.ref == 'refs/heads/staging'
steps:
- name: Deploy placeholder
run: |
echo "TODO: Pull and restart staging compose on target host"
echo "docker compose -f docker-compose.staging.yml up -d"
deploy_production:
name: Deploy Production (Placeholder)
runs-on: ubuntu-latest
needs: build_production_images
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy placeholder
run: |
echo "TODO: Pull and restart production compose on target host"
echo "docker compose -f docker-compose.production.yml up -d"

109
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,109 @@
name: CMS CI
on:
pull_request:
push:
branches:
- dev
- staging
- main
workflow_dispatch:
env:
BUN_VERSION: "1.3.5"
NODE_ENV: "test"
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/cms?schema=public"
BETTER_AUTH_SECRET: "ci-test-secret-change-me"
BETTER_AUTH_URL: "http://localhost:3001"
CMS_ADMIN_ORIGIN: "http://127.0.0.1:3001"
CMS_WEB_ORIGIN: "http://127.0.0.1:3000"
CMS_ADMIN_SELF_REGISTRATION_ENABLED: "false"
CMS_SUPPORT_USERNAME: "support"
CMS_SUPPORT_EMAIL: "support@cms.local"
CMS_SUPPORT_PASSWORD: "support-ci-password"
CMS_SUPPORT_NAME: "Technical Support"
CMS_SUPPORT_LOGIN_KEY: "support-access"
jobs:
governance:
name: Governance Checks
runs-on: node22-bun
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate branch naming
run: |
branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
sh .gitea/scripts/check-branch-name.sh "$branch"
- name: Validate PR TODO reference
if: github.event_name == 'pull_request'
run: |
body='${{ github.event.pull_request.body }}'
sh .gitea/scripts/check-pr-todo-reference.sh "$body"
- name: Commit schema check (latest commit)
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies for commitlint
run: bun install --frozen-lockfile
- name: Commitlint
run: bun run commitlint
quality:
name: Lint Typecheck Unit E2E
needs: governance
runs-on: node22-bun
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cms
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d cms"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Resolve build metadata
run: |
version=$(bun -e 'const pkg = JSON.parse(await Bun.file("package.json").text()); console.log(pkg.version)')
echo "NEXT_PUBLIC_APP_VERSION=$version" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV"
- name: Install Playwright browser deps
run: bunx playwright install --with-deps chromium
- name: Lint and format checks
run: bun run check
- name: Generate Prisma client
run: bun run db:generate
- name: Typecheck
run: bun run typecheck
- name: Unit and integration tests
run: bun run test
- name: E2E tests
run: bun run test:e2e

View File

@@ -0,0 +1,54 @@
name: CMS Deploy
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options:
- staging
- production
image_tag:
description: "Image tag to deploy (e.g. v0.1.0)"
required: true
rollback_tag:
description: "Optional rollback tag"
required: false
jobs:
deploy:
name: Deploy Compose Stack
runs-on: node22-bun
steps:
- name: Resolve deployment target
id: target
run: |
if [ "${{ github.event.inputs.environment }}" = "staging" ]; then
echo "host=${{ secrets.CMS_STAGING_HOST }}" >> "$GITHUB_OUTPUT"
echo "user=${{ secrets.CMS_STAGING_USER }}" >> "$GITHUB_OUTPUT"
echo "compose=docker-compose.staging.yml" >> "$GITHUB_OUTPUT"
else
echo "host=${{ secrets.CMS_PRODUCTION_HOST }}" >> "$GITHUB_OUTPUT"
echo "user=${{ secrets.CMS_PRODUCTION_USER }}" >> "$GITHUB_OUTPUT"
echo "compose=docker-compose.production.yml" >> "$GITHUB_OUTPUT"
fi
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ steps.target.outputs.host }}" >> ~/.ssh/known_hosts
- name: Deploy image tag
run: |
ssh "${{ steps.target.outputs.user }}@${{ steps.target.outputs.host }}" \
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.image_tag }} docker compose -f ${{ steps.target.outputs.compose }} up -d"
- name: Optional rollback
if: github.event.inputs.rollback_tag != ''
run: |
ssh "${{ steps.target.outputs.user }}@${{ steps.target.outputs.host }}" \
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_tag }} docker compose -f ${{ steps.target.outputs.compose }} up -d"

View File

@@ -0,0 +1,103 @@
name: CMS Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Release tag in vX.Y.Z format"
required: false
rollback_image_tag:
description: "Optional rollback image tag"
required: false
env:
BUN_VERSION: "1.3.5"
REGISTRY: ${{ secrets.CMS_IMAGE_REGISTRY }}
IMAGE_NAMESPACE: ${{ secrets.CMS_IMAGE_NAMESPACE }}
jobs:
release:
name: Build Push Changelog
if: github.event_name == 'push' || github.event.inputs.rollback_image_tag == ''
runs-on: node22-bun
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Resolve release tag
id: tag
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
if [ -z "${{ github.event.inputs.release_tag }}" ]; then
echo "release_tag input is required when publishing a release manually."
exit 1
fi
echo "value=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
else
echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Validate tag against package version
run: sh .gitea/scripts/validate-tag-version.sh "${{ steps.tag.outputs.value }}"
- name: Generate changelog
run: bun run changelog:release
- name: Build release notes payload
run: |
if ! sh .gitea/scripts/extract-release-notes.sh "${{ steps.tag.outputs.value }}" > .gitea-release-notes.md; then
echo "Could not isolate section for tag ${{ steps.tag.outputs.value }}. Falling back to full CHANGELOG.md."
cp CHANGELOG.md .gitea-release-notes.md
fi
- name: Login to image registry
run: |
echo "${{ secrets.CMS_IMAGE_REGISTRY_PASSWORD }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.CMS_IMAGE_REGISTRY_USER }}" --password-stdin
- name: Build and push web image
run: |
image="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/cms-web:${{ steps.tag.outputs.value }}"
docker build -f apps/web/Dockerfile -t "$image" .
docker push "$image"
- name: Build and push admin image
run: |
image="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/cms-admin:${{ steps.tag.outputs.value }}"
docker build -f apps/admin/Dockerfile -t "$image" .
docker push "$image"
- name: Publish release notes to Gitea
env:
RELEASE_TAG: ${{ steps.tag.outputs.value }}
RELEASE_NAME: ${{ steps.tag.outputs.value }}
RELEASE_BODY_FILE: ".gitea-release-notes.md"
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
run: bun .gitea/scripts/publish-gitea-release.mjs
rollback:
name: Rollback Production (Manual)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_image_tag != ''
runs-on: node22-bun
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ secrets.CMS_PRODUCTION_HOST }}" >> ~/.ssh/known_hosts
- name: Apply rollback image tag on production
run: |
ssh "${{ secrets.CMS_PRODUCTION_USER }}@${{ secrets.CMS_PRODUCTION_HOST }}" \
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml pull && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml up -d"

1
.gitignore vendored
View File

@@ -24,6 +24,7 @@ test-results
!.env.example
!.env.staging.example
!.env.production.example
!.env.gitea-runner.example
# prisma
packages/db/prisma/dev.db*

View File

@@ -96,6 +96,13 @@ Apply in repository settings:
Optional:
- Protect `dev` from direct push if team size/process requires stricter control.
- Automate protection via `.gitea/scripts/configure-branch-protection.sh`.
## Governance Automation
- Branch naming check: `.gitea/scripts/check-branch-name.sh`
- PR TODO reference check: `.gitea/scripts/check-pr-todo-reference.sh`
- PR template: `.gitea/PULL_REQUEST_TEMPLATE.md`
## Commit Signing Notes

View File

@@ -4,6 +4,8 @@
Follow `BRANCHING.md` for long-lived and task branch rules.
Pull requests should use `.gitea/PULL_REQUEST_TEMPLATE.md` and link the exact TODO item.
## Commit Message Schema
This repository uses Conventional Commits.

View File

@@ -3,6 +3,7 @@
Roadmap and progress are tracked in `TODO.md` (also visible in admin at `/todo`).
Branch model and promotion flow are documented in `BRANCHING.md`.
Commit schema and changelog workflow are documented in `CONTRIBUTING.md`.
Versioning and release policy are documented in `VERSIONING.md`.
A baseline monorepo with:
@@ -69,6 +70,7 @@ bun run dev
- `bun run test`
- `bun run test:watch`
- `bun run test:coverage`
- `bun run test:e2e:prepare`
- `bun run test:e2e`
- `bun run lint`
- `bun run typecheck`
@@ -85,6 +87,7 @@ bun run dev
- Unit/integration/component: Vitest + Testing Library + MSW
- E2E: Playwright (separate projects for `web` and `admin`)
- Use `bun run test` and `bun run test:e2e` (not plain `bun test`, which uses Bun's runner)
- E2E data prep (migrations + seed): `bun run test:e2e:prepare`
One-time Playwright browser install:
@@ -94,9 +97,11 @@ bunx playwright install
## Delivery Scaffolding
The repo includes a theoretical CI/CD and deployment baseline:
The repo includes a CI/CD and deployment baseline:
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
- Quality gate workflow: `.gitea/workflows/ci.yml`
- Deployment workflow: `.gitea/workflows/deploy.yml`
- Release workflow: `.gitea/workflows/release.yml`
- App images:
- `apps/web/Dockerfile`
- `apps/admin/Dockerfile`
@@ -115,12 +120,20 @@ Environment examples:
- `.env.staging.example`
- `.env.production.example`
- `.env.gitea-runner.example`
Notes:
- `dev` remains your local non-docker Bun workflow.
- Staging and production compose files are templates and still require real secrets, registry strategy, and deployment host wiring.
Gitea Actions runner compose (self-hosted):
```bash
cp .env.gitea-runner.example .env.gitea-runner
docker compose --env-file .env.gitea-runner -f docker-compose.gitea-runner.yml up -d
```
## Changelog
- Changelog file: `CHANGELOG.md`

179
TODO.md
View File

@@ -21,122 +21,168 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
- [x] [P1] RBAC enforcement at route and action level in admin
- [x] [P1] Permission matrix documented and tested
- [~] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
- [~] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
- [~] [P1] Locale persistence and switcher base component (cookie/header + UI)
- [x] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
- [x] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
- [x] [P1] Locale persistence and switcher base component (cookie/header + UI)
- [x] [P1] Integrate Better Auth core configuration and session wiring
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
- [x] [P1] Enforce invariant: exactly one owner user must always exist
- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
- [x] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
- [~] [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
- [x] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
- [x] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
- [x] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
- [x] [P1] Shared error and audit hooks for CRUD mutations
### Admin App
- [x] [P1] Separate Next.js admin app in monorepo
- [x] [P1] App Router + TypeScript + `src/` structure
- [x] [P1] Shared DB access via `@cms/db`
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
- [x] [P2] Base admin dashboard shell and roadmap page (`/todo`)
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [x] [P1] Protected admin routes and session handling
- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
- [x] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
- [x] [P1] Core admin IA (pages/media/users/commissions/settings)
### Public App
- [x] [P1] Separate Next.js public app in monorepo
- [x] [P1] App Router + TypeScript + `src/` structure
- [~] [P1] Public app connected to shared data layer
- [ ] [P1] Localized route structure and middleware rules
- [ ] [P2] Public layout system (header/footer/navigation)
- [ ] [P1] Header banner rendering from CMS-managed content
- [ ] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
- [x] [P1] Public app connected to shared data layer
- [x] [P1] Localized route structure and middleware rules
- [x] [P2] Public layout system (header/footer/navigation)
- [x] [P1] Header banner rendering from CMS-managed content
- [x] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
### Testing
- [x] [P1] Vitest + Testing Library + MSW baseline
- [x] [P1] Playwright baseline with web/admin projects
- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [~] [P1] RBAC policy unit tests and permission regression suite
- [ ] [P1] i18n unit tests (locale resolution, fallback, message key loading)
- [ ] [P1] i18n integration tests (admin/public locale switch and persistence)
- [ ] [P1] i18n e2e smoke tests (localized headings/content per route)
- [ ] [P1] CRUD contract tests for shared service patterns
- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [x] [P1] RBAC policy unit tests and permission regression suite
- [x] [P1] i18n unit tests (locale resolution, fallback, message key loading)
- [x] [P1] i18n integration tests (admin/public locale switch and persistence)
- [x] [P1] i18n e2e smoke tests (localized headings/content per route)
- [x] [P1] CRUD contract tests for shared service patterns
### Documentation
- [x] [P1] Docs tool baseline added (`docs/` via VitePress)
- [x] [P1] RBAC and permission model documentation in docs site
- [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
- [~] [P1] CRUD base patterns documentation and examples
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
- [ ] [P2] API and domain glossary pages
- [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs
- [x] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
- [x] [P1] CRUD base patterns documentation and examples
- [x] [P1] Environment and deployment runbook docs (dev/staging/production)
- [x] [P2] API and domain glossary pages
- [x] [P2] Architecture Decision Records (ADR) structure and first ADRs
### Delivery Pipeline And Runtime
- [x] [P2] Theoretical Gitea Actions workflow scaffold (`.gitea/workflows/ci-cd-theoretical.yml`)
- [x] [P2] Gitea workflow baseline (`.gitea/workflows/ci.yml`, `.gitea/workflows/deploy.yml`, `.gitea/workflows/release.yml`)
- [x] [P2] Bun-based Dockerfiles for public and admin apps
- [x] [P2] Staging and production docker-compose templates
- [ ] [P1] Registry credentials and image push strategy
- [ ] [P1] Staging deployment automation against real host
- [ ] [P1] Production promotion and rollback procedure
- [x] [P1] Registry credentials and image push strategy
- [~] [P1] Staging deployment automation against real host
- [~] [P1] Production promotion and rollback procedure
### Git Flow And Branching
- [ ] [P1] Protect `main` and `staging` branches in Gitea
- [ ] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
- [ ] [P1] Enforce one todo item per branch naming convention
- [ ] [P2] Add PR template requiring linked TODO step
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
- [~] [P1] Protect `main` and `staging` branches in Gitea
- [x] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
- [x] [P1] Enforce one todo item per branch naming convention
- [x] [P2] Add PR template requiring linked TODO step
- [x] [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
- [x] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
- [x] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
- [x] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
- [x] [P1] App footer implementation plan for version + commit hash (admin + web)
- [x] [P2] Automated version injection in CI (stamping build from tag + commit hash)
- [x] [P2] Validation tests for displayed version/hash consistency per deployment
- [x] [P1] Release tagging and changelog publication policy in CI
### MVP0 Close-Out Checklist
- [~] [P1] Verify and document protected branch rules in Gitea (`main`, `staging`)
- [~] [P1] Run first staging deployment against a real host with deploy workflow and document result
- [x] [P1] Replace release workflow placeholders with real release-notes and rollback execution steps
- [x] [P1] Expose runtime version + short git hash in admin and public app footer
- [x] [P2] Add CI build stamping for version/hash values consumed by app footers
- [x] [P2] Add automated tests validating displayed version/hash format and consistency
## MVP 1: Core CMS Business Features
### MVP1 Suggested Branch Order
- [x] [P1] `todo/mvp1-media-foundation`:
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
- [ ] [P1] `todo/mvp1-media-upload-pipeline`:
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
- [ ] [P1] `todo/mvp1-pages-navigation-builder`:
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
- [ ] [P1] `todo/mvp1-commissions-customers`:
commission request intake + admin CRUD + kanban + customer entity/linking
- [ ] [P1] `todo/mvp1-announcements-news`:
announcement management/rendering + news/blog CRUD and public rendering
- [ ] [P1] `todo/mvp1-public-rendering-integration`:
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
- [ ] [P1] `todo/mvp1-e2e-happy-paths`:
end-to-end scenarios for page publish, media flow, announcement display, commission flow
### Separate Product Ideas Backlog (Non-Blocking)
- [ ] [P2] Smart homepage section presets for artists (featured artwork, latest news, open commissions)
- [ ] [P2] Portfolio narrative mode (series story + process notes + ordered media sequence)
- [ ] [P2] Reusable CTA/form snippets with per-page override tokens
- [ ] [P2] Lightweight CRM timeline per customer (requests, replies, outcomes)
- [ ] [P3] AI-assisted alt text and metadata suggestion workflow (human approval required)
- [ ] [P3] Auto-generated social crops/promo packs from selected artworks
### Admin App (Primary Focus)
- [ ] [P1] Page management (create/edit/publish/unpublish/schedule)
- [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
- [ ] [P1] Navigation management (menus, nested items, order, visibility)
- [ ] [P1] Media library (upload, browse, replace, delete)
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags)
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
- [ ] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
- [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
- [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
- [ ] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes)
- [ ] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules
- [ ] [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] Commissions management (request intake, owner, due date, notes)
- [ ] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
- [ ] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [ ] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window)
- [ ] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
- [ ] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
### Public App
- [ ] [P1] Dynamic page rendering from CMS page entities
- [ ] [P1] Navigation rendering from managed menu structure
- [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow
- [ ] [P1] Header banner render logic and fallbacks
- [ ] [P1] Announcement render slots (homepage + optional global/top banner position)
### News / Blog (Secondary Track)
- [ ] [P2] News/blog content type (not primary CMS domain)
- [ ] [P2] Admin list/editor for news posts
- [ ] [P2] Public news index + detail pages
- [ ] [P3] Tag/category and basic archive support
- [ ] [P1] News/blog content type (editorial content for artist updates and process posts)
- [ ] [P1] Admin list/editor for news posts
- [ ] [P1] Public news index + detail pages
- [ ] [P2] Tag/category and basic archive support
### Testing
@@ -156,10 +202,18 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Audit log for key content operations
- [ ] [P2] Revision history for pages/navigation/media metadata
- [ ] [P1] Permission matrix refinement with granular scopes
- [ ] [P2] Media processing orchestration UI (queue status, retries, processing diagnostics)
- [ ] [P2] Automatic color palette extraction from artworks (stored for theming/filtering)
- [ ] [P2] Watermark pipeline for artwork renditions with configurable watermark asset/position/opacity
- [ ] [P2] Advanced media transforms by type (video transcode profiles, gif optimization, banner safe-area presets)
- [ ] [P2] Announcement targeting refinement (locale/segment targeting rules)
- [ ] [P2] Customer lifecycle tooling (status stages, communication history, export)
- [ ] [P1] Verify email pipeline and operational templates (welcome/verify/resend)
- [ ] [P1] Forgot password/reset password pipeline and support tooling
- [ ] [P2] GUI page to edit role-permission mappings with safety guardrails
- [ ] [P2] Translation management UI for admin (language toggles, key coverage, missing translation markers)
- [ ] [P2] Time-boxed support access keys generated by privileged admins; while active, disable direct support-user password login on the regular auth form
- [ ] [P2] Keep permanent emergency support key fallback via env (`CMS_SUPPORT_LOGIN_KEY`)
- [ ] [P2] Error boundaries and UX fallback states
### Public App
@@ -168,6 +222,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Performance budget checks (Core Web Vitals)
- [ ] [P1] 404/500 content-aware error pages
- [ ] [P1] Accessibility review and fixes
- [ ] [P2] Theme assistance from extracted artwork palettes (opt-in per page/section)
### Platform
@@ -193,10 +248,28 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending.
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes.
- [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later.
- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far.
- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI.
- [2026-02-10] Admin i18n baseline now resolves locale from cookie and loads runtime message dictionaries in root layout; admin locale switcher is active on auth and dashboard views.
- [2026-02-10] Admin self-registration policy is now managed via `/settings` and persisted in `system_setting`; env var is fallback/default only.
- [2026-02-10] E2E now runs with deterministic preparation (`test:e2e:prepare`: generate + migrate deploy + seed) before Playwright execution.
- [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service.
- [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`).
- [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`).
- [2026-02-10] Testing baseline now includes explicit RBAC regression checks, locale-resolution unit tests (admin/web), CRUD service contract tests, and i18n smoke e2e routes.
- [2026-02-10] i18n conventions are now documented as an engineering standard (`docs/product-engineering/i18n-conventions.md`).
- [2026-02-10] Docs now include a domain glossary, public API glossary, and ADR baseline with initial accepted decision (`ADR 0001`).
- [2026-02-10] Delivery and release governance now include branch/PR policy checks, deploy/release workflows, and explicit versioning policy (`VERSIONING.md`).
- [2026-02-11] Release workflow now publishes changelog-derived notes to Gitea releases and supports executable production rollback via SSH + compose tag switch.
- [2026-02-11] Branch protection verification checklist is now documented; final UI-level verification remains environment-specific.
- [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence.
- [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP2 covers advanced automation (watermark, palette extraction, media transform pipelines).
- [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns.
- [2026-02-11] MVP1 media foundation started: portfolio domain models (`MediaAsset`, `Artwork`, galleries/albums/categories/tags, rendition links) plus initial admin `/media` and `/portfolio` data views.
- [2026-02-11] `prisma migrate dev --name media_foundation` can fail when DB endpoint is unreachable; apply this named migration once `DATABASE_URL` host is reachable again.
- [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage.
## How We Use This File

71
VERSIONING.md Normal file
View File

@@ -0,0 +1,71 @@
# Versioning Policy
## Source Of Truth
- Canonical version: root `package.json` field `version`
- Tag format: `vX.Y.Z`
Tag validation is enforced in CI:
- `.gitea/scripts/validate-tag-version.sh`
## SemVer Strategy
- `major`: breaking API/behavior changes
- `minor`: backward-compatible features
- `patch`: backward-compatible fixes
## Build Metadata Policy
Use git metadata in runtime display format:
- `<version>+sha.<short>`
Example:
- `0.1.0+sha.a1b2c3d`
## Footer Display Plan (Admin + Web)
Planned runtime footer fields:
- app name
- version from root `package.json`
- commit hash (short)
- environment (`dev|staging|production`)
Implementation note:
- inject values at build/deploy time through env vars
- render in shared footer components
## CI Version Injection
Release/deploy workflows pass release tag and commit metadata:
- `.gitea/workflows/release.yml`
- `.gitea/workflows/deploy.yml`
Required inputs:
- release tag (`vX.Y.Z`)
- image tag for deployment
## Validation Strategy
CI validations:
- tag equals `v${package.json.version}`
- required checks pass before release builds
Runtime validations (planned):
- smoke tests assert footer version/hash format
- environment-specific deployment checks assert expected image tag
## Changelog and Release Publication
- changelog generation command:
- `bun run changelog:release`
- release workflow generates changelog on tag pipeline
- release notes publication remains a dedicated step in CI workflow.

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "5.90.20",

View File

@@ -0,0 +1,34 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
export default async function CommissionsManagementPage() {
const role = await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:read",
scope: "own",
})
return (
<AdminShell
role={role}
activePath="/commissions"
badge="Admin App"
title="Commissions"
description="Prepare commissions intake and kanban workflow tooling."
>
<AdminSectionPlaceholder
feature="Commissions Workflow"
summary="This route is reserved for request intake, ownership assignment, and kanban transitions."
requiredPermission="commissions:read (own)"
nextSteps={[
"Add commissions board with status columns.",
"Add assignment, due-date, and notes editing.",
"Add transition rules and audit history.",
]}
/>
</AdminShell>
)
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"
import type { ReactNode } from "react"
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import "./globals.css"
import { Providers } from "./providers"
@@ -9,11 +10,16 @@ export const metadata: Metadata = {
description: "Admin dashboard for the CMS monorepo",
}
export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await resolveAdminLocale()
const messages = await getAdminMessages(locale)
return (
<html lang="en">
<html lang={locale}>
<body>
<Providers>{children}</Providers>
<Providers locale={locale} messages={messages}>
{children}
</Providers>
</body>
</html>
)

View File

@@ -4,8 +4,11 @@ import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { type FormEvent, useMemo, useState } from "react"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { useAdminT } from "@/providers/admin-i18n-provider"
type LoginFormProps = {
mode: "signin" | "signup-owner" | "signup-user"
mode: "signin" | "signup-owner" | "signup-user" | "signup-disabled"
}
type AuthResponse = {
@@ -27,6 +30,7 @@ function persistRoleCookie(role: unknown) {
export function LoginForm({ mode }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const t = useAdminT()
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
@@ -37,6 +41,7 @@ export function LoginForm({ mode }: LoginFormProps) {
const [isBusy, setIsBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@@ -60,7 +65,7 @@ export function LoginForm({ mode }: LoginFormProps) {
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? "Sign in failed")
setError(payload?.message ?? t("auth.errors.signInFailed", "Sign in failed"))
return
}
@@ -68,7 +73,7 @@ export function LoginForm({ mode }: LoginFormProps) {
router.push(nextPath)
router.refresh()
} catch {
setError("Network error while signing in")
setError(t("auth.errors.networkSignIn", "Network error while signing in"))
} finally {
setIsBusy(false)
}
@@ -78,7 +83,7 @@ export function LoginForm({ mode }: LoginFormProps) {
event.preventDefault()
if (!name.trim()) {
setError("Name is required for account creation")
setError(t("auth.errors.nameRequired", "Name is required for account creation"))
return
}
@@ -104,20 +109,20 @@ export function LoginForm({ mode }: LoginFormProps) {
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? "Sign up failed")
setError(payload?.message ?? t("auth.errors.signUpFailed", "Sign up failed"))
return
}
persistRoleCookie(payload?.user?.role)
setSuccess(
mode === "signup-owner"
? "Owner account created. Registration is now disabled."
: "Account created.",
? t("auth.messages.ownerCreated", "Owner account created. Registration is now disabled.")
: t("auth.messages.accountCreated", "Account created."),
)
router.push(nextPath)
router.refresh()
} catch {
setError("Network error while signing up")
setError(t("auth.errors.networkSignUp", "Network error while signing up"))
} finally {
setIsBusy(false)
}
@@ -126,24 +131,35 @@ export function LoginForm({ mode }: LoginFormProps) {
return (
<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>
<div className="flex items-center justify-between gap-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">
{t("auth.badge", "Admin Auth")}
</p>
<AdminLocaleSwitcher />
</div>
<h1 className="text-3xl font-semibold tracking-tight">
{mode === "signin"
? "Sign in to CMS Admin"
? t("auth.titles.signIn", "Sign in to CMS Admin")
: mode === "signup-owner"
? "Welcome to CMS Admin"
: "Create an admin account"}
? t("auth.titles.signUpOwner", "Welcome to CMS Admin")
: mode === "signup-user"
? t("auth.titles.signUpUser", "Create an admin account")
: t("auth.titles.signUpDisabled", "Registration is disabled")}
</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."
)}
{mode === "signin"
? t("auth.descriptions.signIn", "Better Auth is active on this app via /api/auth.")
: mode === "signup-owner"
? t(
"auth.descriptions.signUpOwner",
"Create the first owner account to initialize this admin instance.",
)
: mode === "signup-user"
? t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")
: t(
"auth.descriptions.signUpDisabled",
"Self-registration is currently turned off by an administrator.",
)}
</p>
</div>
@@ -154,7 +170,7 @@ export function LoginForm({ mode }: LoginFormProps) {
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
Email or username
{t("auth.fields.emailOrUsername", "Email or username")}
</label>
<input
id="email"
@@ -168,84 +184,7 @@ export function LoginForm({ mode }: LoginFormProps) {
<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
{t("auth.fields.password", "Password")}
</label>
<input
id="password"
@@ -264,22 +203,115 @@ export function LoginForm({ mode }: LoginFormProps) {
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"}
? t("auth.actions.signInBusy", "Signing in...")
: t("auth.actions.signInIdle", "Sign in")}
</button>
<p className="text-xs text-neutral-600">
Already have an account?{" "}
{t("auth.links.needAccount", "Need an account?")}{" "}
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
{t("auth.links.register", "Register")}
</Link>
</p>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</form>
) : canSubmitSignUp ? (
<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">
{t("auth.fields.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">
{t("auth.fields.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">
{t("auth.fields.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">
{t("auth.fields.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
? t("auth.actions.signUpBusy", "Creating account...")
: mode === "signup-owner"
? t("auth.actions.signUpOwnerIdle", "Create owner account")
: t("auth.actions.signUpUserIdle", "Create account")}
</button>
<p className="text-xs text-neutral-600">
{t("auth.links.alreadyHaveAccount", "Already have an account?")}{" "}
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
Go to sign in
{t("auth.links.goToSignIn", "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>
) : (
<section className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6">
<p className="text-sm text-neutral-700">
{t(
"auth.messages.registrationDisabled",
"Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration.",
)}
</p>
<p className="text-xs text-neutral-600">
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
{t("auth.links.goToSignIn", "Go to sign in")}
</Link>
</p>
</section>
)}
</main>
)

View File

@@ -0,0 +1,272 @@
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readField(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readOptionalField(formData: FormData, field: string): string | undefined {
const value = readField(formData, field)
return value.length > 0 ? value : undefined
}
function readTags(formData: FormData, field: string): string[] {
const raw = readField(formData, field)
if (!raw) {
return []
}
return raw
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/media?${value}` : "/media")
}
async function createMediaAssetAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/media",
permission: "media:write",
scope: "team",
})
try {
await createMediaAsset({
title: readField(formData, "title"),
type: readField(formData, "type"),
description: readOptionalField(formData, "description"),
altText: readOptionalField(formData, "altText"),
source: readOptionalField(formData, "source"),
copyright: readOptionalField(formData, "copyright"),
author: readOptionalField(formData, "author"),
tags: readTags(formData, "tags"),
})
} catch {
redirectWithState({
error: "Failed to create media asset. Validate required fields and try again.",
})
}
revalidatePath("/media")
revalidatePath("/portfolio")
redirectWithState({ notice: "Media asset created." })
}
export default async function MediaManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/media",
permission: "media:read",
scope: "team",
})
const [resolvedSearchParams, summary, assets] = await Promise.all([
searchParams,
getMediaFoundationSummary(),
listMediaAssets(20),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/media"
badge="Admin App"
title="Media"
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
<p className="mt-2 text-3xl font-semibold">{summary.mediaAssets}</p>
</article>
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Artworks</p>
<p className="mt-2 text-3xl font-semibold">{summary.artworks}</p>
</article>
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Groups</p>
<p className="mt-2 text-3xl font-semibold">
{summary.galleries + summary.albums + summary.categories + summary.tags}
</p>
<p className="mt-1 text-xs text-neutral-500">
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
categories{" · "}
{summary.tags} tags
</p>
</article>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Media Asset</h2>
<form action={createMediaAssetAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
minLength={1}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Type</span>
<select
name="type"
defaultValue="artwork"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="artwork">artwork</option>
<option value="banner">banner</option>
<option value="promotion">promotion</option>
<option value="video">video</option>
<option value="gif">gif</option>
<option value="generic">generic</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Alt text</span>
<input
name="altText"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Author</span>
<input
name="author"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Source</span>
<input
name="source"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Copyright</span>
<input
name="copyright"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
<input
name="tags"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<Button type="submit">Create media asset</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xl font-medium">Recent Media Assets</h2>
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
MVP1 Foundation
</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">Published</th>
<th className="py-2 pr-4">Updated</th>
</tr>
</thead>
<tbody>
{assets.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={4}>
No media assets yet. Upload workflows land in `todo/mvp1-media-upload-pipeline`.
</td>
</tr>
) : (
assets.map((asset) => (
<tr key={asset.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{asset.title}</td>
<td className="py-3 pr-4">{asset.type}</td>
<td className="py-3 pr-4">{asset.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4 text-neutral-600">
{asset.updatedAt.toLocaleDateString("en-US")}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -5,8 +5,10 @@ import { revalidatePath } from "next/cache"
import Link from "next/link"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { LogoutButton } from "./logout-button"
import { AdminShell } from "@/components/admin-shell"
import { translateMessage } from "@/i18n/messages"
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
@@ -36,11 +38,11 @@ function readOptionalField(formData: FormData, field: string): string | undefine
}
async function requireNewsWritePermission() {
const role = await resolveRoleFromServerContext()
if (!role || !hasPermission(role, "news:write", "team")) {
redirect("/unauthorized?required=news:write&scope=team")
}
await requirePermissionForRoute({
nextPath: "/",
permission: "news:write",
scope: "team",
})
}
function redirectWithState(params: { notice?: string; error?: string }) {
@@ -58,10 +60,18 @@ function redirectWithState(params: { notice?: string; error?: string }) {
redirect(value ? `/?${value}` : "/")
}
async function getDashboardTranslator() {
const locale = await resolveAdminLocale()
const messages = await getAdminMessages(locale)
return (key: string, fallback: string) => translateMessage(messages, key, fallback)
}
async function createPostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const t = await getDashboardTranslator()
const status = readRequiredField(formData, "status")
@@ -74,23 +84,28 @@ async function createPostAction(formData: FormData) {
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Create failed. Please check your input." })
redirectWithState({
error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."),
})
}
revalidatePath("/")
redirectWithState({ notice: "Post created." })
redirectWithState({ notice: t("dashboard.posts.success.created", "Post created.") })
}
async function updatePostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const t = await getDashboardTranslator()
const id = readRequiredField(formData, "id")
const status = readRequiredField(formData, "status")
if (!id) {
redirectWithState({ error: "Update failed. Missing post id." })
redirectWithState({
error: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."),
})
}
try {
@@ -102,32 +117,37 @@ async function updatePostAction(formData: FormData) {
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Update failed. Please check your input." })
redirectWithState({
error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."),
})
}
revalidatePath("/")
redirectWithState({ notice: "Post updated." })
redirectWithState({ notice: t("dashboard.posts.success.updated", "Post updated.") })
}
async function deletePostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const t = await getDashboardTranslator()
const id = readRequiredField(formData, "id")
if (!id) {
redirectWithState({ error: "Delete failed. Missing post id." })
redirectWithState({
error: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."),
})
}
try {
await deletePost(id)
} catch {
redirectWithState({ error: "Delete failed." })
redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") })
}
revalidatePath("/")
redirectWithState({ notice: "Post deleted." })
redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") })
}
export default async function AdminHomePage({
@@ -135,39 +155,48 @@ export default async function AdminHomePage({
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await resolveRoleFromServerContext()
const role = await requirePermissionForRoute({
nextPath: "/",
permission: "news:read",
scope: "team",
})
if (!role) {
redirect("/login?next=/")
}
const [resolvedSearchParams, locale, posts] = await Promise.all([
searchParams,
resolveAdminLocale(),
listPosts(),
])
const messages = await getAdminMessages(locale)
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
if (!hasPermission(role, "news:read", "team")) {
redirect("/unauthorized?required=news:read&scope=team")
}
const resolvedSearchParams = await searchParams
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const canCreatePost = hasPermission(role, "news:write", "team")
const posts = await listPosts()
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-6 py-16">
<header className="space-y-3">
<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="flex items-center gap-3 pt-2">
<AdminShell
role={role}
activePath="/"
badge={t("dashboard.badge", "Admin App")}
title={t("dashboard.title", "Content Dashboard")}
description={t("dashboard.description", "Manage posts from a dedicated admin surface.")}
actions={
<>
<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
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
</Link>
<LogoutButton />
</div>
</header>
<Link
href="/settings"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
{t("settings.title", "Settings")}
</Link>
</>
}
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
@@ -183,8 +212,12 @@ export default async function AdminHomePage({
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">Posts CRUD Sandbox</h2>
<p className="text-xs uppercase tracking-wide text-neutral-500">MVP0 functional test</p>
<h2 className="text-xl font-medium">
{t("dashboard.posts.title", "Posts CRUD Sandbox")}
</h2>
<p className="text-xs uppercase tracking-wide text-neutral-500">
{t("dashboard.notices.crudSandboxTag", "MVP0 functional test")}
</p>
</div>
{canCreatePost ? (
@@ -192,10 +225,14 @@ export default async function AdminHomePage({
action={createPostAction}
className="space-y-3 rounded-lg border border-neutral-200 p-4"
>
<h3 className="text-sm font-semibold">Create post</h3>
<h3 className="text-sm font-semibold">
{t("dashboard.posts.createTitle", "Create post")}
</h3>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
@@ -204,7 +241,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
@@ -214,14 +253,18 @@ export default async function AdminHomePage({
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.excerpt", "Excerpt")}
</span>
<input
name="excerpt"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
@@ -231,21 +274,28 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.status", "Status")}
</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
<option value="published">
{t("dashboard.posts.status.published", "Published")}
</option>
</select>
</label>
<Button type="submit">Create post</Button>
<Button type="submit">{t("dashboard.posts.actions.create", "Create post")}</Button>
</form>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
You can read posts, but your role cannot create/update/delete posts.
{t(
"dashboard.notices.noCrudPermission",
"You can read posts, but your role cannot create/update/delete posts.",
)}
</div>
)}
</div>
@@ -259,7 +309,9 @@ export default async function AdminHomePage({
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
@@ -269,7 +321,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
@@ -280,7 +334,9 @@ export default async function AdminHomePage({
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.excerpt", "Excerpt")}
</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
@@ -288,7 +344,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
@@ -299,22 +357,28 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.status", "Status")}
</span>
<select
name="status"
defaultValue={post.status}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
<option value="published">
{t("dashboard.posts.status.published", "Published")}
</option>
</select>
</label>
<Button type="submit">Save changes</Button>
<Button type="submit">
{t("dashboard.posts.actions.save", "Save changes")}
</Button>
</form>
<form action={deletePostAction} className="mt-3">
<input type="hidden" name="id" value={post.id} />
<Button type="submit" variant="secondary">
Delete
{t("dashboard.posts.actions.delete", "Delete")}
</Button>
</form>
</>
@@ -327,13 +391,15 @@ export default async function AdminHomePage({
</span>
</div>
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
<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("dashboard.posts.fallback.noExcerpt", "No excerpt")}
</p>
</>
)}
</article>
))}
</div>
</section>
</main>
</AdminShell>
)
}

View File

@@ -0,0 +1,34 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
export default async function PagesManagementPage() {
const role = await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:read",
scope: "team",
})
return (
<AdminShell
role={role}
activePath="/pages"
badge="Admin App"
title="Pages"
description="Manage page entities and publication workflows."
>
<AdminSectionPlaceholder
feature="Page Management"
summary="This MVP0 scaffold defines information architecture and access boundaries for future page CRUD."
requiredPermission="pages:read (team)"
nextSteps={[
"Add page entity list and search.",
"Add create/edit draft flows with validation.",
"Add publish/unpublish scheduling controls.",
]}
/>
</AdminShell>
)
}

View File

@@ -0,0 +1,481 @@
import {
attachArtworkRendition,
createAlbum,
createArtwork,
createCategory,
createGallery,
createTag,
linkArtworkToGrouping,
listArtworks,
listMediaAssets,
listMediaFoundationGroups,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
type GroupType = "gallery" | "album" | "category" | "tag"
function readField(formData: FormData, key: string): string {
const value = formData.get(key)
return typeof value === "string" ? value.trim() : ""
}
function readOptionalField(formData: FormData, key: string): string | undefined {
const value = readField(formData, key)
return value.length > 0 ? value : undefined
}
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 180)
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/portfolio?${value}` : "/portfolio")
}
async function requireWritePermission() {
await requirePermissionForRoute({
nextPath: "/portfolio",
permission: "media:write",
scope: "team",
})
}
async function createArtworkAction(formData: FormData) {
"use server"
await requireWritePermission()
const title = readField(formData, "title")
const slug = slugify(readField(formData, "slug") || title)
try {
await createArtwork({
title,
slug,
description: readOptionalField(formData, "description"),
medium: readOptionalField(formData, "medium"),
dimensions: readOptionalField(formData, "dimensions"),
framing: readOptionalField(formData, "framing"),
availability: readOptionalField(formData, "availability"),
year: (() => {
const raw = readField(formData, "year")
return raw ? Number(raw) : undefined
})(),
})
} catch {
redirectWithState({ error: "Failed to create artwork." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: "Artwork created." })
}
async function createGroupAction(formData: FormData) {
"use server"
await requireWritePermission()
const type = readField(formData, "groupType") as GroupType
const name = readField(formData, "name")
const slug = slugify(readField(formData, "slug") || name)
try {
if (type === "gallery") {
await createGallery({
name,
slug,
description: readOptionalField(formData, "description"),
})
} else if (type === "album") {
await createAlbum({
name,
slug,
description: readOptionalField(formData, "description"),
})
} else if (type === "category") {
await createCategory({
name,
slug,
description: readOptionalField(formData, "description"),
})
} else {
await createTag({
name,
slug,
})
}
} catch {
redirectWithState({ error: "Failed to create grouping entity." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: `${type} created.` })
}
async function linkArtworkGroupAction(formData: FormData) {
"use server"
await requireWritePermission()
const artworkId = readField(formData, "artworkId")
const groupType = readField(formData, "groupType") as GroupType
const groupId = readField(formData, "groupId")
try {
await linkArtworkToGrouping({
artworkId,
groupType,
groupId,
})
} catch {
redirectWithState({ error: "Failed to link artwork to grouping." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: "Artwork linked to grouping." })
}
async function attachRenditionAction(formData: FormData) {
"use server"
await requireWritePermission()
try {
await attachArtworkRendition({
artworkId: readField(formData, "artworkId"),
mediaAssetId: readField(formData, "mediaAssetId"),
slot: readField(formData, "slot"),
width: (() => {
const raw = readField(formData, "width")
return raw ? Number(raw) : undefined
})(),
height: (() => {
const raw = readField(formData, "height")
return raw ? Number(raw) : undefined
})(),
isPrimary: readField(formData, "isPrimary") === "true",
})
} catch {
redirectWithState({ error: "Failed to attach artwork rendition." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: "Rendition attached." })
}
export default async function PortfolioPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/portfolio",
permission: "media:read",
scope: "team",
})
const [resolvedSearchParams, artworks, mediaAssets, groups] = await Promise.all([
searchParams,
listArtworks(30),
listMediaAssets(200),
listMediaFoundationGroups(),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/portfolio"
badge="Admin App"
title="Portfolio"
description="Artwork foundation with rendition slots and grouping relations."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Artwork</h2>
<form action={createArtworkAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug (optional)</span>
<input
name="slug"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<input
name="medium"
placeholder="Medium"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="dimensions"
placeholder="Dimensions"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="year"
type="number"
placeholder="Year"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="framing"
placeholder="Framing"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<input
name="availability"
placeholder="Availability"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit">Create artwork</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Group Entity</h2>
<form action={createGroupAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-3">
<select
name="groupType"
defaultValue="gallery"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="gallery">gallery</option>
<option value="album">album</option>
<option value="category">category</option>
<option value="tag">tag</option>
</select>
<input
name="name"
required
placeholder="Name"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="slug"
placeholder="Slug (optional)"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<textarea
name="description"
rows={2}
placeholder="Description (optional)"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit">Create group</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Link Artwork To Group</h2>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{(
[
{ type: "gallery" as const, label: "Gallery", items: groups.galleries },
{ type: "album" as const, label: "Album", items: groups.albums },
{ type: "category" as const, label: "Category", items: groups.categories },
{ type: "tag" as const, label: "Tag", items: groups.tags },
] as const
).map((groupConfig) => (
<form
key={groupConfig.type}
action={linkArtworkGroupAction}
className="space-y-3 rounded border border-neutral-200 p-4"
>
<h3 className="text-sm font-semibold">{groupConfig.label} Link</h3>
<input type="hidden" name="groupType" value={groupConfig.type} />
<select
name="artworkId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{artworks.map((artwork) => (
<option key={`${groupConfig.type}-${artwork.id}`} value={artwork.id}>
{artwork.title}
</option>
))}
</select>
<select
name="groupId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{groupConfig.items.map((group) => (
<option key={`${groupConfig.type}-${group.id}`} value={group.id}>
{group.name}
</option>
))}
</select>
<Button type="submit">Link artwork</Button>
</form>
))}
</div>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Attach Artwork Rendition Slot</h2>
<form
action={attachRenditionAction}
className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6"
>
<select name="artworkId" className="rounded border border-neutral-300 px-3 py-2 text-sm">
{artworks.map((artwork) => (
<option key={artwork.id} value={artwork.id}>
{artwork.title}
</option>
))}
</select>
<select
name="mediaAssetId"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
{mediaAssets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.title}
</option>
))}
</select>
<select
name="slot"
defaultValue="thumbnail"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="thumbnail">thumbnail</option>
<option value="card">card</option>
<option value="full">full</option>
<option value="custom">custom</option>
</select>
<input
name="width"
type="number"
placeholder="width"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="height"
type="number"
placeholder="height"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<select
name="isPrimary"
defaultValue="false"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="false">not primary</option>
<option value="true">primary</option>
</select>
<div className="md:col-span-3 xl:col-span-6">
<Button type="submit">Attach rendition</Button>
</div>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xl font-medium">Artworks</h2>
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
MVP1 Foundation
</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Slug</th>
<th className="py-2 pr-4">Published</th>
<th className="py-2 pr-4">Renditions</th>
<th className="py-2 pr-4">Groups</th>
</tr>
</thead>
<tbody>
{artworks.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={5}>
No artworks yet. Add creation flows after media upload pipeline lands.
</td>
</tr>
) : (
artworks.map((artwork) => (
<tr key={artwork.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{artwork.title}</td>
<td className="py-3 pr-4 font-mono text-xs">{artwork.slug}</td>
<td className="py-3 pr-4">{artwork.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4">{artwork.renditions.length}</td>
<td className="py-3 pr-4 text-neutral-600">
g:{artwork.galleryLinks.length} a:{artwork.albumLinks.length} c:
{artwork.categoryLinks.length} t:{artwork.tagLinks.length}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -1,9 +1,24 @@
"use client"
import type { AppLocale } from "@cms/i18n"
import type { ReactNode } from "react"
import type { AdminMessages } from "@/i18n/messages"
import { AdminI18nProvider } from "@/providers/admin-i18n-provider"
import { QueryProvider } from "@/providers/query-provider"
export function Providers({ children }: { children: ReactNode }) {
return <QueryProvider>{children}</QueryProvider>
export function Providers({
children,
locale,
messages,
}: {
children: ReactNode
locale: AppLocale
messages: AdminMessages
}) {
return (
<AdminI18nProvider locale={locale} messages={messages}>
<QueryProvider>{children}</QueryProvider>
</AdminI18nProvider>
)
}

View File

@@ -33,7 +33,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Sea
const enabled = await isSelfRegistrationEnabled()
if (!enabled) {
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
return <LoginForm mode="signup-disabled" />
}
return <LoginForm mode="signup-user" />

View File

@@ -0,0 +1,180 @@
import { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { translateMessage } from "@/i18n/messages"
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import { requirePermissionForRoute } from "@/lib/route-guards"
type SearchParamsInput = Promise<Record<string, string | string[] | undefined>>
function toSingleValue(input: string | string[] | undefined): string | null {
if (Array.isArray(input)) {
return input[0] ?? null
}
return input ?? null
}
async function requireSettingsPermission() {
await requirePermissionForRoute({
nextPath: "/settings",
permission: "users:manage_roles",
scope: "global",
})
}
async function getSettingsTranslator() {
const locale = await resolveAdminLocale()
const messages = await getAdminMessages(locale)
return (key: string, fallback: string) => translateMessage(messages, key, fallback)
}
async function updateRegistrationPolicyAction(formData: FormData) {
"use server"
await requireSettingsPermission()
const t = await getSettingsTranslator()
const enabled = formData.get("enabled") === "on"
try {
await setAdminSelfRegistrationEnabled(enabled)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : ""
const normalizedMessage = errorMessage.toLowerCase()
const isDatabaseUnavailable = errorMessage.includes("P1001")
const isSchemaMissing =
errorMessage.includes("P2021") ||
normalizedMessage.includes("system_setting") ||
normalizedMessage.includes("does not exist")
const userMessage = isDatabaseUnavailable
? t(
"settings.registration.errors.databaseUnavailable",
"Saving settings failed. The database is currently unreachable.",
)
: isSchemaMissing
? t(
"settings.registration.errors.schemaMissing",
"Saving settings failed. Apply the latest database migrations and try again.",
)
: t(
"settings.registration.errors.updateFailed",
"Saving settings failed. Ensure database migrations are applied.",
)
redirect(`/settings?error=${encodeURIComponent(userMessage)}`)
}
revalidatePath("/settings")
revalidatePath("/register")
redirect(
`/settings?notice=${encodeURIComponent(
t("settings.registration.success.updated", "Registration policy updated."),
)}`,
)
}
export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) {
const role = await requirePermissionForRoute({
nextPath: "/settings",
permission: "users:manage_roles",
scope: "global",
})
const [params, locale, isRegistrationEnabled] = await Promise.all([
searchParams,
resolveAdminLocale(),
isAdminSelfRegistrationEnabled(),
])
const messages = await getAdminMessages(locale)
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
const notice = toSingleValue(params.notice)
const error = toSingleValue(params.error)
return (
<AdminShell
role={role}
activePath="/settings"
badge={t("settings.badge", "Admin Settings")}
title={t("settings.title", "Settings")}
description={t(
"settings.description",
"Manage runtime policies for the admin authentication and onboarding flow.",
)}
actions={
<Link
href="/"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
{t("settings.actions.backToDashboard", "Back to dashboard")}
</Link>
}
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-5">
<div className="space-y-2">
<h2 className="text-xl font-medium">
{t("settings.registration.title", "Admin self-registration")}
</h2>
<p className="text-sm text-neutral-600">
{t(
"settings.registration.description",
"When enabled, /register can create additional admin accounts after initial owner bootstrap.",
)}
</p>
</div>
<div className="rounded-lg border border-neutral-200 p-4 text-sm text-neutral-700">
<p>
{t("settings.registration.currentStatusLabel", "Current status")}:{" "}
<strong>
{isRegistrationEnabled
? t("settings.registration.status.enabled", "Enabled")
: t("settings.registration.status.disabled", "Disabled")}
</strong>
</p>
</div>
<form action={updateRegistrationPolicyAction} className="space-y-4">
<label className="flex items-center gap-3 text-sm">
<input
type="checkbox"
name="enabled"
defaultChecked={isRegistrationEnabled}
className="h-4 w-4 rounded border-neutral-300"
/>
<span>
{t(
"settings.registration.checkboxLabel",
"Allow self-registration on /register for admin users",
)}
</span>
</label>
<Button type="submit">
{t("settings.registration.actions.save", "Save registration policy")}
</Button>
</form>
</div>
</section>
</AdminShell>
)
}

View File

@@ -1,10 +1,9 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { hasPermission } from "@cms/content/rbac"
import Link from "next/link"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
@@ -405,15 +404,11 @@ function filterButtonClass(active: boolean): string {
export default async function AdminTodoPage(props: {
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
}) {
const role = await resolveRoleFromServerContext()
if (!role) {
redirect("/login?next=/todo")
}
if (!hasPermission(role, "roadmap:read", "global")) {
redirect("/unauthorized?required=roadmap:read&scope=global")
}
const role = await requirePermissionForRoute({
nextPath: "/todo",
permission: "roadmap:read",
scope: "global",
})
const content = await getTodoMarkdown()
const sections = parseTodo(content)
@@ -434,26 +429,21 @@ export default async function AdminTodoPage(props: {
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-6 py-12">
<header className="space-y-4">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-2">
<h1 className="text-4xl font-semibold tracking-tight">Roadmap and Progress</h1>
<p className="text-neutral-600">
Structured view from root `TODO.md` (single source of truth).
</p>
</div>
<Link
href="/"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Back to dashboard
</Link>
</div>
</header>
<AdminShell
role={role}
activePath="/todo"
badge="Admin App"
title="Roadmap and Progress"
description="Structured view from root TODO.md (single source of truth)."
actions={
<Link
href="/"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Back to dashboard
</Link>
}
>
<section className="rounded-xl border border-neutral-200 bg-neutral-50 p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<p className="text-sm font-medium text-neutral-600">Weighted completion</p>
@@ -607,6 +597,6 @@ export default async function AdminTodoPage(props: {
{content}
</pre>
</details>
</main>
</AdminShell>
)
}

View File

@@ -0,0 +1,34 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
export default async function UsersManagementPage() {
const role = await requirePermissionForRoute({
nextPath: "/users",
permission: "users:read",
scope: "own",
})
return (
<AdminShell
role={role}
activePath="/users"
badge="Admin App"
title="Users"
description="Prepare user lifecycle and role management operations."
>
<AdminSectionPlaceholder
feature="Users Management"
summary="This route sets the guardrail and UX entrypoint for role assignment, status, and invitation flows."
requiredPermission="users:read (own)"
nextSteps={[
"Add user list, filter, and detail views.",
"Add role and permission editing actions with owner/support safety rules.",
"Add disable/ban and invite workflows.",
]}
/>
</AdminShell>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import { type AppLocale, localeLabels, locales } from "@cms/i18n"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { ADMIN_LOCALE_COOKIE } from "@/i18n/shared"
import { useAdminI18n, useAdminT } from "@/providers/admin-i18n-provider"
export function AdminLocaleSwitcher() {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const { locale } = useAdminI18n()
const t = useAdminT()
return (
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<span>{t("common.language", "Language")}</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
// biome-ignore lint/suspicious/noDocumentCookie: locale preference is intentionally persisted client-side.
document.cookie = `${ADMIN_LOCALE_COOKIE}=${nextLocale}; Path=/; Max-Age=31536000; SameSite=Lax`
startTransition(() => {
router.refresh()
})
}}
>
{locales.map((value) => (
<option key={value} value={value}>
{t(`common.localeNames.${value}`, localeLabels[value])} ({localeLabels[value]})
</option>
))}
</select>
</label>
)
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react"
type AdminSectionPlaceholderProps = {
feature: string
summary: string
requiredPermission: string
nextSteps: string[]
children?: ReactNode
}
export function AdminSectionPlaceholder({
feature,
summary,
requiredPermission,
nextSteps,
children,
}: AdminSectionPlaceholderProps) {
return (
<section className="space-y-5 rounded-xl border border-neutral-200 p-6">
<div className="space-y-2">
<h2 className="text-xl font-medium">{feature}</h2>
<p className="text-sm text-neutral-600">{summary}</p>
<p className="text-xs uppercase tracking-wide text-neutral-500">
Required permission: {requiredPermission}
</p>
</div>
{children}
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<p className="text-sm font-medium text-neutral-800">Planned next steps</p>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-neutral-600">
{nextSteps.map((step) => (
<li key={step}>{step}</li>
))}
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,125 @@
import { hasPermission, type Permission, type PermissionScope, type Role } from "@cms/content/rbac"
import Link from "next/link"
import type { ReactNode } from "react"
import { LogoutButton } from "@/app/logout-button"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { getBuildInfo } from "@/lib/build-info"
type AdminShellProps = {
role: Role
activePath: string
badge: string
title: string
description: string
actions?: ReactNode
children: ReactNode
}
type NavItem = {
href: string
label: string
permission: Permission
scope: PermissionScope
}
const navItems: NavItem[] = [
{ href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
{ href: "/pages", label: "Pages", permission: "pages:read", scope: "team" },
{ href: "/media", label: "Media", permission: "media:read", scope: "team" },
{ href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" },
{ href: "/users", label: "Users", permission: "users:read", scope: "own" },
{ href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" },
{ href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },
{ href: "/todo", label: "Roadmap", permission: "roadmap:read", scope: "global" },
]
function navItemClass(active: boolean): string {
if (active) {
return "bg-neutral-900 text-white border-neutral-900"
}
return "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}
function isActiveRoute(activePath: string, href: string): boolean {
if (href === "/") {
return activePath === "/"
}
return activePath === href || activePath.startsWith(`${href}/`)
}
export function AdminShell({
role,
activePath,
badge,
title,
description,
actions,
children,
}: AdminShellProps) {
const buildInfo = getBuildInfo()
return (
<div className="mx-auto flex min-h-screen w-full max-w-7xl gap-8 px-6 py-10">
<aside className="sticky top-0 hidden h-fit w-64 shrink-0 space-y-4 lg:block">
<div className="rounded-xl border border-neutral-200 bg-white p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">
CMS Admin
</p>
<p className="mt-2 text-sm text-neutral-600">Role: {role}</p>
</div>
<nav className="space-y-2">
{navItems
.filter((item) => hasPermission(role, item.permission, item.scope))
.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block rounded-md border px-3 py-2 text-sm font-medium ${navItemClass(isActiveRoute(activePath, item.href))}`}
>
{item.label}
</Link>
))}
</nav>
</aside>
<div className="min-w-0 flex-1 space-y-8">
<nav className="flex flex-wrap gap-2 lg:hidden">
{navItems
.filter((item) => hasPermission(role, item.permission, item.scope))
.map((item) => (
<Link
key={`mobile-${item.href}`}
href={item.href}
className={`rounded-md border px-3 py-2 text-sm font-medium ${navItemClass(isActiveRoute(activePath, item.href))}`}
>
{item.label}
</Link>
))}
</nav>
<header className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{badge}</p>
<div className="flex items-center gap-2">
<AdminLocaleSwitcher />
<LogoutButton />
</div>
</div>
<h1 className="text-4xl font-semibold tracking-tight">{title}</h1>
<p className="text-neutral-600">{description}</p>
{actions ? <div className="flex flex-wrap items-center gap-3 pt-1">{actions}</div> : null}
</header>
{children}
<footer className="border-t border-neutral-200 pt-4 text-xs text-neutral-500">
Build v{buildInfo.version} +sha.{buildInfo.sha}
</footer>
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest"
import type { AdminMessages } from "./messages"
import { translateMessage } from "./messages"
const messages: AdminMessages = {
common: {
language: "Language",
localeNames: {
de: "German",
en: "English",
es: "Spanish",
fr: "French",
},
},
auth: {
badge: "Admin Auth",
titles: {
signIn: "Sign in",
signUpOwner: "Welcome",
signUpUser: "Create account",
signUpDisabled: "Registration disabled",
},
descriptions: {
signIn: "Sign in description",
signUpOwner: "Owner description",
signUpUser: "User description",
signUpDisabled: "Disabled description",
},
fields: {
name: "Name",
emailOrUsername: "Email or username",
email: "Email",
username: "Username",
password: "Password",
},
actions: {
signInIdle: "Sign in",
signInBusy: "Signing in...",
signUpOwnerIdle: "Create owner account",
signUpUserIdle: "Create account",
signUpBusy: "Creating account...",
},
links: {
needAccount: "Need an account?",
register: "Register",
alreadyHaveAccount: "Already have an account?",
goToSignIn: "Go to sign in",
},
messages: {
ownerCreated: "Owner account created.",
accountCreated: "Account created.",
registrationDisabled: "Registration is disabled.",
},
errors: {
nameRequired: "Name is required.",
signInFailed: "Sign in failed",
signUpFailed: "Sign up failed",
networkSignIn: "Network sign in error",
networkSignUp: "Network sign up error",
},
},
settings: {
badge: "Admin Settings",
title: "Settings",
description: "Settings description",
actions: {
backToDashboard: "Back to dashboard",
},
registration: {
title: "Registration",
description: "Registration description",
currentStatusLabel: "Current status",
status: {
enabled: "Enabled",
disabled: "Disabled",
},
checkboxLabel: "Allow registration",
actions: {
save: "Save",
},
success: {
updated: "Updated",
},
errors: {
updateFailed: "Update failed",
},
},
},
dashboard: {
badge: "Admin App",
title: "Content Dashboard",
description: "Manage content.",
actions: {
openRoadmap: "Open roadmap",
},
notices: {
noCrudPermission: "No permission.",
crudSandboxTag: "MVP0 functional test",
},
posts: {
title: "Posts CRUD Sandbox",
createTitle: "Create post",
fields: {
title: "Title",
slug: "Slug",
excerpt: "Excerpt",
body: "Body",
status: "Status",
},
status: {
draft: "Draft",
published: "Published",
},
actions: {
create: "Create post",
save: "Save changes",
delete: "Delete",
},
errors: {
createFailed: "Create failed.",
updateFailed: "Update failed.",
updateMissingId: "Missing post id.",
deleteFailed: "Delete failed.",
deleteMissingId: "Missing post id.",
},
success: {
created: "Post created.",
updated: "Post updated.",
deleted: "Post deleted.",
},
fallback: {
noExcerpt: "No excerpt",
},
},
},
}
describe("translateMessage", () => {
it("resolves nested keys", () => {
expect(translateMessage(messages, "dashboard.title")).toBe("Content Dashboard")
})
it("returns fallback for unknown keys", () => {
expect(translateMessage(messages, "dashboard.unknown", "Fallback")).toBe("Fallback")
})
})

View File

@@ -0,0 +1,27 @@
import type enMessages from "../messages/en.json"
export type AdminMessages = typeof enMessages
function resolveNestedValue(source: unknown, key: string): unknown {
let current: unknown = source
for (const segment of key.split(".")) {
if (!current || typeof current !== "object") {
return null
}
current = (current as Record<string, unknown>)[segment]
}
return current
}
export function translateMessage(messages: AdminMessages, key: string, fallback?: string): string {
const resolved = resolveNestedValue(messages, key)
if (typeof resolved === "string") {
return resolved
}
return fallback ?? key
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { resolveAdminLocaleFromCookieValue } from "./server"
describe("resolveAdminLocaleFromCookieValue", () => {
it("accepts supported locales", () => {
expect(resolveAdminLocaleFromCookieValue("de")).toBe("de")
expect(resolveAdminLocaleFromCookieValue("en")).toBe("en")
expect(resolveAdminLocaleFromCookieValue("es")).toBe("es")
expect(resolveAdminLocaleFromCookieValue("fr")).toBe("fr")
})
it("falls back to default locale for unknown values", () => {
expect(resolveAdminLocaleFromCookieValue("it")).toBe("en")
expect(resolveAdminLocaleFromCookieValue(undefined)).toBe("en")
})
})

View File

@@ -0,0 +1,23 @@
import { type AppLocale, defaultLocale, isAppLocale } from "@cms/i18n"
import { cookies } from "next/headers"
import type { AdminMessages } from "./messages"
import { ADMIN_LOCALE_COOKIE } from "./shared"
export function resolveAdminLocaleFromCookieValue(value: string | undefined): AppLocale {
if (value && isAppLocale(value)) {
return value
}
return defaultLocale
}
export async function resolveAdminLocale(): Promise<AppLocale> {
const cookieStore = await cookies()
const value = cookieStore.get(ADMIN_LOCALE_COOKIE)?.value
return resolveAdminLocaleFromCookieValue(value)
}
export async function getAdminMessages(locale: AppLocale): Promise<AdminMessages> {
return (await import(`../messages/${locale}.json`)).default as AdminMessages
}

View File

@@ -0,0 +1 @@
export const ADMIN_LOCALE_COOKIE = "cms_admin_locale"

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest"
import { canAccessRoute, getRequiredPermission, isPublicRoute } from "./access"
describe("admin route access rules", () => {
it("treats support fallback route as public", () => {
expect(isPublicRoute("/support/support-access")).toBe(true)
expect(canAccessRoute("editor", "/support/support-access")).toBe(true)
})
it("keeps settings route restricted to role with users:manage_roles", () => {
expect(isPublicRoute("/settings")).toBe(false)
expect(canAccessRoute("manager", "/settings")).toBe(false)
expect(canAccessRoute("admin", "/settings")).toBe(true)
expect(canAccessRoute("owner", "/settings")).toBe(true)
})
it("resolves route-specific permission requirements", () => {
expect(getRequiredPermission("/todo")).toEqual({
permission: "roadmap:read",
scope: "global",
})
})
it("maps new admin IA routes to dedicated permissions", () => {
expect(getRequiredPermission("/pages")).toEqual({
permission: "pages:read",
scope: "team",
})
expect(getRequiredPermission("/media")).toEqual({
permission: "media:read",
scope: "team",
})
expect(getRequiredPermission("/portfolio")).toEqual({
permission: "media:read",
scope: "team",
})
expect(getRequiredPermission("/users")).toEqual({
permission: "users:read",
scope: "own",
})
expect(getRequiredPermission("/commissions")).toEqual({
permission: "commissions:read",
scope: "own",
})
})
})

View File

@@ -43,6 +43,48 @@ const guardRules: GuardRule[] = [
scope: "global",
},
},
{
route: /^\/pages(?:\/|$)/,
requirement: {
permission: "pages:read",
scope: "team",
},
},
{
route: /^\/media(?:\/|$)/,
requirement: {
permission: "media:read",
scope: "team",
},
},
{
route: /^\/portfolio(?:\/|$)/,
requirement: {
permission: "media:read",
scope: "team",
},
},
{
route: /^\/users(?:\/|$)/,
requirement: {
permission: "users:read",
scope: "own",
},
},
{
route: /^\/commissions(?:\/|$)/,
requirement: {
permission: "commissions:read",
scope: "own",
},
},
{
route: /^\/settings(?:\/|$)/,
requirement: {
permission: "users:manage_roles",
scope: "global",
},
},
{
route: /^\/(?:$|\?)/,
requirement: {

View File

@@ -1,5 +1,5 @@
import { normalizeRole, type Role } from "@cms/content/rbac"
import { db } from "@cms/db"
import { db, isAdminSelfRegistrationEnabled } from "@cms/db"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { toNextJsHandler } from "better-auth/next-js"
@@ -43,8 +43,7 @@ export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
}
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"
return isAdminSelfRegistrationEnabled()
}
export async function canUserSelfRegister(): Promise<boolean> {

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { getBuildInfo } from "./build-info"
afterEach(() => {
vi.unstubAllEnvs()
})
describe("getBuildInfo (admin)", () => {
it("returns fallback values when env is missing", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
expect(getBuildInfo()).toEqual({
version: "0.0.1-dev",
sha: "local",
})
})
it("uses env values and truncates git sha", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "abcdef123456")
expect(getBuildInfo()).toEqual({
version: "0.2.0",
sha: "abcdef1",
})
})
})

View File

@@ -0,0 +1,21 @@
const FALLBACK_VERSION = "0.0.1-dev"
const FALLBACK_SHA = "local"
function shortenSha(input: string): string {
const value = input.trim()
if (!value) {
return FALLBACK_SHA
}
return value.slice(0, 7)
}
export function getBuildInfo() {
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
return {
version,
sha,
}
}

View File

@@ -0,0 +1,30 @@
import { hasPermission, type Permission, type PermissionScope, type Role } from "@cms/content/rbac"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access-server"
type RequirePermissionParams = {
nextPath: string
permission: Permission
scope: PermissionScope
}
export async function requireRoleForRoute(nextPath: string): Promise<Role> {
const role = await resolveRoleFromServerContext()
if (!role) {
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
}
return role
}
export async function requirePermissionForRoute(params: RequirePermissionParams): Promise<Role> {
const role = await requireRoleForRoute(params.nextPath)
if (!hasPermission(role, params.permission, params.scope)) {
redirect(`/unauthorized?required=${params.permission}&scope=${params.scope}`)
}
return role
}

View File

@@ -0,0 +1,132 @@
{
"common": {
"language": "Sprache",
"localeNames": {
"de": "Deutsch",
"en": "Englisch",
"es": "Spanisch",
"fr": "Französisch"
}
},
"auth": {
"badge": "Admin-Authentifizierung",
"titles": {
"signIn": "Bei CMS Admin anmelden",
"signUpOwner": "Willkommen bei CMS Admin",
"signUpUser": "Admin-Konto erstellen",
"signUpDisabled": "Registrierung ist deaktiviert"
},
"descriptions": {
"signIn": "Better Auth ist in dieser App über /api/auth aktiv.",
"signUpOwner": "Erstelle das erste Owner-Konto, um diese Admin-Instanz zu initialisieren.",
"signUpUser": "Selbstregistrierung für Admin-Benutzer ist aktiviert.",
"signUpDisabled": "Selbstregistrierung wurde von einer Administratorin oder einem Administrator deaktiviert."
},
"fields": {
"name": "Name",
"emailOrUsername": "E-Mail oder Benutzername",
"email": "E-Mail",
"username": "Benutzername (optional)",
"password": "Passwort"
},
"actions": {
"signInIdle": "Anmelden",
"signInBusy": "Anmeldung läuft...",
"signUpOwnerIdle": "Owner-Konto erstellen",
"signUpUserIdle": "Konto erstellen",
"signUpBusy": "Konto wird erstellt..."
},
"links": {
"needAccount": "Du brauchst ein Konto?",
"register": "Registrieren",
"alreadyHaveAccount": "Du hast bereits ein Konto?",
"goToSignIn": "Zur Anmeldung"
},
"messages": {
"ownerCreated": "Owner-Konto erstellt. Registrierung ist jetzt deaktiviert.",
"accountCreated": "Konto erstellt.",
"registrationDisabled": "Für diese Admin-Instanz ist die Registrierung deaktiviert. Bitte wende dich an eine Administratorin oder einen Administrator."
},
"errors": {
"nameRequired": "Name ist für die Kontoerstellung erforderlich",
"signInFailed": "Anmeldung fehlgeschlagen",
"signUpFailed": "Registrierung fehlgeschlagen",
"networkSignIn": "Netzwerkfehler bei der Anmeldung",
"networkSignUp": "Netzwerkfehler bei der Registrierung"
}
},
"settings": {
"badge": "Admin-Einstellungen",
"title": "Einstellungen",
"description": "Verwalte Laufzeitrichtlinien für Authentifizierung und Onboarding im Admin-Bereich.",
"actions": {
"backToDashboard": "Zurück zum Dashboard"
},
"registration": {
"title": "Admin-Selbstregistrierung",
"description": "Wenn aktiviert, können über /register nach der initialen Owner-Erstellung weitere Admin-Konten erstellt werden.",
"currentStatusLabel": "Aktueller Status",
"status": {
"enabled": "Aktiviert",
"disabled": "Deaktiviert"
},
"checkboxLabel": "Selbstregistrierung auf /register für Admin-Benutzer erlauben",
"actions": {
"save": "Registrierungsrichtlinie speichern"
},
"success": {
"updated": "Registrierungsrichtlinie aktualisiert."
},
"errors": {
"updateFailed": "Speichern der Einstellungen fehlgeschlagen. Stelle sicher, dass Datenbankmigrationen angewendet wurden."
}
}
},
"dashboard": {
"badge": "Admin-App",
"title": "Content-Dashboard",
"description": "Verwalte Beiträge in einer dedizierten Admin-Oberfläche.",
"actions": {
"openRoadmap": "Roadmap und Fortschritt öffnen"
},
"notices": {
"noCrudPermission": "Du kannst Beiträge lesen, aber deine Rolle darf keine Beiträge erstellen/ändern/löschen.",
"crudSandboxTag": "MVP0 Funktionstest"
},
"posts": {
"title": "Beiträge CRUD-Sandbox",
"createTitle": "Beitrag erstellen",
"fields": {
"title": "Titel",
"slug": "Slug",
"excerpt": "Auszug",
"body": "Inhalt",
"status": "Status"
},
"status": {
"draft": "Entwurf",
"published": "Veröffentlicht"
},
"actions": {
"create": "Beitrag erstellen",
"save": "Änderungen speichern",
"delete": "Löschen"
},
"errors": {
"createFailed": "Erstellen fehlgeschlagen. Bitte Eingaben prüfen.",
"updateFailed": "Aktualisierung fehlgeschlagen. Bitte Eingaben prüfen.",
"updateMissingId": "Aktualisierung fehlgeschlagen. Beitrags-ID fehlt.",
"deleteFailed": "Löschen fehlgeschlagen.",
"deleteMissingId": "Löschen fehlgeschlagen. Beitrags-ID fehlt."
},
"success": {
"created": "Beitrag erstellt.",
"updated": "Beitrag aktualisiert.",
"deleted": "Beitrag gelöscht."
},
"fallback": {
"noExcerpt": "Kein Auszug"
}
}
}
}

View File

@@ -0,0 +1,132 @@
{
"common": {
"language": "Language",
"localeNames": {
"de": "German",
"en": "English",
"es": "Spanish",
"fr": "French"
}
},
"auth": {
"badge": "Admin Auth",
"titles": {
"signIn": "Sign in to CMS Admin",
"signUpOwner": "Welcome to CMS Admin",
"signUpUser": "Create an admin account",
"signUpDisabled": "Registration is disabled"
},
"descriptions": {
"signIn": "Better Auth is active on this app via /api/auth.",
"signUpOwner": "Create the first owner account to initialize this admin instance.",
"signUpUser": "Self-registration is enabled for admin users.",
"signUpDisabled": "Self-registration is currently turned off by an administrator."
},
"fields": {
"name": "Name",
"emailOrUsername": "Email or username",
"email": "Email",
"username": "Username (optional)",
"password": "Password"
},
"actions": {
"signInIdle": "Sign in",
"signInBusy": "Signing in...",
"signUpOwnerIdle": "Create owner account",
"signUpUserIdle": "Create account",
"signUpBusy": "Creating account..."
},
"links": {
"needAccount": "Need an account?",
"register": "Register",
"alreadyHaveAccount": "Already have an account?",
"goToSignIn": "Go to sign in"
},
"messages": {
"ownerCreated": "Owner account created. Registration is now disabled.",
"accountCreated": "Account created.",
"registrationDisabled": "Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration."
},
"errors": {
"nameRequired": "Name is required for account creation",
"signInFailed": "Sign in failed",
"signUpFailed": "Sign up failed",
"networkSignIn": "Network error while signing in",
"networkSignUp": "Network error while signing up"
}
},
"settings": {
"badge": "Admin Settings",
"title": "Settings",
"description": "Manage runtime policies for the admin authentication and onboarding flow.",
"actions": {
"backToDashboard": "Back to dashboard"
},
"registration": {
"title": "Admin self-registration",
"description": "When enabled, /register can create additional admin accounts after initial owner bootstrap.",
"currentStatusLabel": "Current status",
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"checkboxLabel": "Allow self-registration on /register for admin users",
"actions": {
"save": "Save registration policy"
},
"success": {
"updated": "Registration policy updated."
},
"errors": {
"updateFailed": "Saving settings failed. Ensure database migrations are applied."
}
}
},
"dashboard": {
"badge": "Admin App",
"title": "Content Dashboard",
"description": "Manage posts from a dedicated admin surface.",
"actions": {
"openRoadmap": "Open roadmap and progress"
},
"notices": {
"noCrudPermission": "You can read posts, but your role cannot create/update/delete posts.",
"crudSandboxTag": "MVP0 functional test"
},
"posts": {
"title": "Posts CRUD Sandbox",
"createTitle": "Create post",
"fields": {
"title": "Title",
"slug": "Slug",
"excerpt": "Excerpt",
"body": "Body",
"status": "Status"
},
"status": {
"draft": "Draft",
"published": "Published"
},
"actions": {
"create": "Create post",
"save": "Save changes",
"delete": "Delete"
},
"errors": {
"createFailed": "Create failed. Please check your input.",
"updateFailed": "Update failed. Please check your input.",
"updateMissingId": "Update failed. Missing post id.",
"deleteFailed": "Delete failed.",
"deleteMissingId": "Delete failed. Missing post id."
},
"success": {
"created": "Post created.",
"updated": "Post updated.",
"deleted": "Post deleted."
},
"fallback": {
"noExcerpt": "No excerpt"
}
}
}
}

View File

@@ -0,0 +1,132 @@
{
"common": {
"language": "Idioma",
"localeNames": {
"de": "Alemán",
"en": "Inglés",
"es": "Español",
"fr": "Francés"
}
},
"auth": {
"badge": "Autenticación de Admin",
"titles": {
"signIn": "Iniciar sesión en CMS Admin",
"signUpOwner": "Bienvenido a CMS Admin",
"signUpUser": "Crear una cuenta de admin",
"signUpDisabled": "El registro está deshabilitado"
},
"descriptions": {
"signIn": "Better Auth está activo en esta app mediante /api/auth.",
"signUpOwner": "Crea la primera cuenta owner para inicializar esta instancia de administración.",
"signUpUser": "El registro automático está habilitado para usuarios admin.",
"signUpDisabled": "El auto-registro está desactivado actualmente por un administrador."
},
"fields": {
"name": "Nombre",
"emailOrUsername": "Correo o nombre de usuario",
"email": "Correo",
"username": "Nombre de usuario (opcional)",
"password": "Contraseña"
},
"actions": {
"signInIdle": "Iniciar sesión",
"signInBusy": "Iniciando sesión...",
"signUpOwnerIdle": "Crear cuenta owner",
"signUpUserIdle": "Crear cuenta",
"signUpBusy": "Creando cuenta..."
},
"links": {
"needAccount": "¿Necesitas una cuenta?",
"register": "Registrarse",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"goToSignIn": "Ir a iniciar sesión"
},
"messages": {
"ownerCreated": "Cuenta owner creada. El registro ahora está deshabilitado.",
"accountCreated": "Cuenta creada.",
"registrationDisabled": "El registro está deshabilitado para esta instancia de administración. Pide a un administrador que cree una cuenta o habilite el auto-registro."
},
"errors": {
"nameRequired": "El nombre es obligatorio para crear la cuenta",
"signInFailed": "Error al iniciar sesión",
"signUpFailed": "Error al registrarse",
"networkSignIn": "Error de red al iniciar sesión",
"networkSignUp": "Error de red al registrarse"
}
},
"settings": {
"badge": "Ajustes de Admin",
"title": "Ajustes",
"description": "Gestiona políticas de ejecución para autenticación y onboarding del panel admin.",
"actions": {
"backToDashboard": "Volver al panel"
},
"registration": {
"title": "Auto-registro de admin",
"description": "Cuando está habilitado, /register puede crear cuentas admin adicionales después del bootstrap inicial del owner.",
"currentStatusLabel": "Estado actual",
"status": {
"enabled": "Habilitado",
"disabled": "Deshabilitado"
},
"checkboxLabel": "Permitir auto-registro en /register para usuarios admin",
"actions": {
"save": "Guardar política de registro"
},
"success": {
"updated": "Política de registro actualizada."
},
"errors": {
"updateFailed": "No se pudieron guardar los ajustes. Asegúrate de que las migraciones de base de datos estén aplicadas."
}
}
},
"dashboard": {
"badge": "App Admin",
"title": "Panel de Contenido",
"description": "Gestiona publicaciones desde una superficie de administración dedicada.",
"actions": {
"openRoadmap": "Abrir hoja de ruta y progreso"
},
"notices": {
"noCrudPermission": "Puedes leer publicaciones, pero tu rol no puede crear/editar/eliminar publicaciones.",
"crudSandboxTag": "Prueba funcional MVP0"
},
"posts": {
"title": "Sandbox CRUD de Publicaciones",
"createTitle": "Crear publicación",
"fields": {
"title": "Título",
"slug": "Slug",
"excerpt": "Extracto",
"body": "Contenido",
"status": "Estado"
},
"status": {
"draft": "Borrador",
"published": "Publicado"
},
"actions": {
"create": "Crear publicación",
"save": "Guardar cambios",
"delete": "Eliminar"
},
"errors": {
"createFailed": "Error al crear. Revisa tus datos.",
"updateFailed": "Error al actualizar. Revisa tus datos.",
"updateMissingId": "Error al actualizar. Falta el id de la publicación.",
"deleteFailed": "Error al eliminar.",
"deleteMissingId": "Error al eliminar. Falta el id de la publicación."
},
"success": {
"created": "Publicación creada.",
"updated": "Publicación actualizada.",
"deleted": "Publicación eliminada."
},
"fallback": {
"noExcerpt": "Sin extracto"
}
}
}
}

View File

@@ -0,0 +1,132 @@
{
"common": {
"language": "Langue",
"localeNames": {
"de": "Allemand",
"en": "Anglais",
"es": "Espagnol",
"fr": "Français"
}
},
"auth": {
"badge": "Authentification Admin",
"titles": {
"signIn": "Se connecter à CMS Admin",
"signUpOwner": "Bienvenue sur CMS Admin",
"signUpUser": "Créer un compte admin",
"signUpDisabled": "Linscription est désactivée"
},
"descriptions": {
"signIn": "Better Auth est actif sur cette application via /api/auth.",
"signUpOwner": "Créez le premier compte owner pour initialiser cette instance dadministration.",
"signUpUser": "Lauto-inscription est activée pour les utilisateurs admin.",
"signUpDisabled": "Lauto-inscription est actuellement désactivée par un administrateur."
},
"fields": {
"name": "Nom",
"emailOrUsername": "E-mail ou nom dutilisateur",
"email": "E-mail",
"username": "Nom dutilisateur (optionnel)",
"password": "Mot de passe"
},
"actions": {
"signInIdle": "Se connecter",
"signInBusy": "Connexion en cours...",
"signUpOwnerIdle": "Créer le compte owner",
"signUpUserIdle": "Créer un compte",
"signUpBusy": "Création du compte..."
},
"links": {
"needAccount": "Besoin dun compte ?",
"register": "Sinscrire",
"alreadyHaveAccount": "Vous avez déjà un compte ?",
"goToSignIn": "Aller à la connexion"
},
"messages": {
"ownerCreated": "Compte owner créé. Linscription est maintenant désactivée.",
"accountCreated": "Compte créé.",
"registrationDisabled": "Linscription est désactivée pour cette instance admin. Demandez à un administrateur de créer un compte ou de réactiver lauto-inscription."
},
"errors": {
"nameRequired": "Le nom est requis pour créer un compte",
"signInFailed": "Échec de la connexion",
"signUpFailed": "Échec de linscription",
"networkSignIn": "Erreur réseau lors de la connexion",
"networkSignUp": "Erreur réseau lors de linscription"
}
},
"settings": {
"badge": "Paramètres Admin",
"title": "Paramètres",
"description": "Gérez les politiques dexécution pour lauthentification et lonboarding de ladmin.",
"actions": {
"backToDashboard": "Retour au tableau de bord"
},
"registration": {
"title": "Auto-inscription admin",
"description": "Lorsquelle est activée, /register peut créer des comptes admin supplémentaires après linitialisation du premier owner.",
"currentStatusLabel": "Statut actuel",
"status": {
"enabled": "Activé",
"disabled": "Désactivé"
},
"checkboxLabel": "Autoriser lauto-inscription sur /register pour les utilisateurs admin",
"actions": {
"save": "Enregistrer la politique dinscription"
},
"success": {
"updated": "Politique dinscription mise à jour."
},
"errors": {
"updateFailed": "Échec de lenregistrement des paramètres. Vérifiez que les migrations de base de données sont appliquées."
}
}
},
"dashboard": {
"badge": "Application Admin",
"title": "Tableau de bord contenu",
"description": "Gérez les publications depuis une surface dadministration dédiée.",
"actions": {
"openRoadmap": "Ouvrir la feuille de route et la progression"
},
"notices": {
"noCrudPermission": "Vous pouvez lire les publications, mais votre rôle ne peut pas créer/modifier/supprimer des publications.",
"crudSandboxTag": "Test fonctionnel MVP0"
},
"posts": {
"title": "Sandbox CRUD des publications",
"createTitle": "Créer une publication",
"fields": {
"title": "Titre",
"slug": "Slug",
"excerpt": "Extrait",
"body": "Contenu",
"status": "Statut"
},
"status": {
"draft": "Brouillon",
"published": "Publié"
},
"actions": {
"create": "Créer une publication",
"save": "Enregistrer les modifications",
"delete": "Supprimer"
},
"errors": {
"createFailed": "Échec de la création. Vérifiez vos données.",
"updateFailed": "Échec de la mise à jour. Vérifiez vos données.",
"updateMissingId": "Échec de la mise à jour. ID de publication manquant.",
"deleteFailed": "Échec de la suppression.",
"deleteMissingId": "Échec de la suppression. ID de publication manquant."
},
"success": {
"created": "Publication créée.",
"updated": "Publication mise à jour.",
"deleted": "Publication supprimée."
},
"fallback": {
"noExcerpt": "Aucun extrait"
}
}
}
}

View File

@@ -0,0 +1,53 @@
"use client"
import type { AppLocale } from "@cms/i18n"
import { createContext, type ReactNode, useContext, useMemo } from "react"
import type { AdminMessages } from "@/i18n/messages"
import { translateMessage } from "@/i18n/messages"
type AdminI18nContextValue = {
locale: AppLocale
messages: AdminMessages
}
const AdminI18nContext = createContext<AdminI18nContextValue | null>(null)
export function AdminI18nProvider({
locale,
messages,
children,
}: {
locale: AppLocale
messages: AdminMessages
children: ReactNode
}) {
const value = useMemo(
() => ({
locale,
messages,
}),
[locale, messages],
)
return <AdminI18nContext.Provider value={value}>{children}</AdminI18nContext.Provider>
}
export function useAdminI18n(): AdminI18nContextValue {
const context = useContext(AdminI18nContext)
if (!context) {
throw new Error("useAdminI18n must be used inside AdminI18nProvider")
}
return context
}
export function useAdminT() {
const { messages } = useAdminI18n()
return useMemo(
() => (key: string, fallback?: string) => translateMessage(messages, key, fallback),
[messages],
)
}

View File

@@ -0,0 +1,13 @@
import { getTranslations } from "next-intl/server"
export default async function AboutPage() {
const t = await getTranslations("About")
return (
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
</section>
)
}

View File

@@ -0,0 +1,13 @@
import { getTranslations } from "next-intl/server"
export default async function ContactPage() {
const t = await getTranslations("Contact")
return (
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
</section>
)
}

View File

@@ -1,7 +1,12 @@
import { getPublicHeaderBanner } from "@cms/db"
import { notFound } from "next/navigation"
import { hasLocale, NextIntlClientProvider } from "next-intl"
import { getTranslations } from "next-intl/server"
import type { ReactNode } from "react"
import { PublicHeaderBanner } from "@/components/public-header-banner"
import { PublicSiteFooter } from "@/components/public-site-footer"
import { PublicSiteHeader } from "@/components/public-site-header"
import { routing } from "@/i18n/routing"
import { Providers } from "../providers"
@@ -12,6 +17,28 @@ type LocaleLayoutProps = {
}>
}
export async function generateMetadata({ params }: LocaleLayoutProps) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) {
return {}
}
const t = await getTranslations({
locale,
namespace: "Seo",
})
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("title"),
description: t("description"),
},
}
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params
@@ -19,9 +46,16 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
notFound()
}
const banner = await getPublicHeaderBanner()
return (
<NextIntlClientProvider locale={locale}>
<Providers>{children}</Providers>
<Providers>
<PublicHeaderBanner banner={banner} />
<PublicSiteHeader />
<main>{children}</main>
<PublicSiteFooter />
</Providers>
</NextIntlClientProvider>
)
}

View File

@@ -2,20 +2,15 @@ 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, 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">
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-16">
<header className="space-y-3">
<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>
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
@@ -36,6 +31,6 @@ export default async function HomePage() {
))}
</ul>
</section>
</main>
</section>
)
}

View File

@@ -3,9 +3,30 @@ import type { ReactNode } from "react"
import "./globals.css"
const metadataBase = new URL(process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000")
export const metadata: Metadata = {
title: "CMS Web",
metadataBase,
title: {
default: "CMS Web",
template: "%s | CMS Web",
},
description: "Public frontend for the CMS monorepo",
applicationName: "CMS Web",
openGraph: {
type: "website",
siteName: "CMS Web",
title: "CMS Web",
description: "Public frontend for the CMS monorepo",
url: metadataBase,
},
alternates: {
canonical: "/",
},
robots: {
index: true,
follow: true,
},
}
export default function RootLayout({ children }: { children: ReactNode }) {

View File

@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next"
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${baseUrl}/sitemap.xml`,
}
}

View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next"
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
const publicRoutes = ["/", "/about", "/contact"]
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date()
return publicRoutes.map((route) => ({
url: `${baseUrl}${route}`,
lastModified: now,
}))
}

View File

@@ -0,0 +1,25 @@
import type { PublicHeaderBanner as PublicHeaderBannerData } from "@cms/db"
import Link from "next/link"
type PublicHeaderBannerProps = {
banner: PublicHeaderBannerData | null
}
export function PublicHeaderBanner({ banner }: PublicHeaderBannerProps) {
if (!banner) {
return null
}
return (
<div className="border-b border-amber-200 bg-amber-50">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-6 py-2 text-sm text-amber-900">
<p>{banner.message}</p>
{banner.ctaLabel && banner.ctaHref ? (
<Link href={banner.ctaHref} className="font-medium underline underline-offset-2">
{banner.ctaLabel}
</Link>
) : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { useTranslations } from "next-intl"
import { getBuildInfo } from "@/lib/build-info"
export function PublicSiteFooter() {
const t = useTranslations("Layout")
const year = new Date().getFullYear()
const buildInfo = getBuildInfo()
return (
<footer className="border-t border-neutral-200 bg-neutral-50">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-2 px-6 py-4 text-sm text-neutral-600">
<p>
{t("footer.copyright", {
year,
})}
</p>
<p>{t("footer.tagline")}</p>
<p className="font-mono text-xs text-neutral-500">
Build v{buildInfo.version} +sha.{buildInfo.sha}
</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useTranslations } from "next-intl"
import { Link } from "@/i18n/navigation"
import { LanguageSwitcher } from "./language-switcher"
export function PublicSiteHeader() {
const t = useTranslations("Layout")
const navItems = [
{ href: "/", label: t("nav.home") },
{ href: "/about", label: t("nav.about") },
{ href: "/contact", label: t("nav.contact") },
]
return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
<Link
href="/"
className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700"
>
{t("brand")}
</Link>
<nav className="flex flex-wrap items-center gap-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
{item.label}
</Link>
))}
</nav>
<LanguageSwitcher />
</div>
</header>
)
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { resolveRequestLocale } from "./request"
describe("resolveRequestLocale", () => {
it("accepts supported locales", () => {
expect(resolveRequestLocale("de")).toBe("de")
expect(resolveRequestLocale("en")).toBe("en")
expect(resolveRequestLocale("es")).toBe("es")
expect(resolveRequestLocale("fr")).toBe("fr")
})
it("falls back to default locale for unsupported values", () => {
expect(resolveRequestLocale("it")).toBe("en")
expect(resolveRequestLocale(undefined)).toBe("en")
})
})

View File

@@ -1,11 +1,16 @@
import type { AppLocale } from "@cms/i18n"
import { hasLocale } from "next-intl"
import { getRequestConfig } from "next-intl/server"
import { routing } from "./routing"
export function resolveRequestLocale(requested: string | undefined): AppLocale {
return hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
}
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
const locale = resolveRequestLocale(requested)
return {
locale,

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { getBuildInfo } from "./build-info"
afterEach(() => {
vi.unstubAllEnvs()
})
describe("getBuildInfo (web)", () => {
it("returns fallback values when env is missing", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
expect(getBuildInfo()).toEqual({
version: "0.0.1-dev",
sha: "local",
})
})
it("uses env values and truncates git sha", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "123456789abc")
expect(getBuildInfo()).toEqual({
version: "0.2.0",
sha: "1234567",
})
})
})

View File

@@ -0,0 +1,21 @@
const FALLBACK_VERSION = "0.0.1-dev"
const FALLBACK_SHA = "local"
function shortenSha(input: string): string {
const value = input.trim()
if (!value) {
return FALLBACK_SHA
}
return value.slice(0, 7)
}
export function getBuildInfo() {
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
return {
version,
sha,
}
}

View File

@@ -15,5 +15,31 @@
"es": "Spanisch",
"fr": "Französisch"
}
},
"Layout": {
"brand": "CMS Web",
"nav": {
"home": "Start",
"about": "Über uns",
"contact": "Kontakt"
},
"footer": {
"copyright": "© {year} CMS Web",
"tagline": "Powered by Next.js, Bun, Prisma und TanStack."
}
},
"Seo": {
"title": "CMS Web",
"description": "Öffentliches Frontend für das CMS-Monorepo."
},
"About": {
"badge": "Über uns",
"title": "Über dieses Projekt",
"description": "Diese öffentliche App ist die Frontend-Oberfläche für CMS-gesteuerte Inhalte und kommende dynamische Seiten."
},
"Contact": {
"badge": "Kontakt",
"title": "Kontakt",
"description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt."
}
}

View File

@@ -15,5 +15,31 @@
"es": "Spanish",
"fr": "French"
}
},
"Layout": {
"brand": "CMS Web",
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"footer": {
"copyright": "© {year} CMS Web",
"tagline": "Powered by Next.js, Bun, Prisma, and TanStack."
}
},
"Seo": {
"title": "CMS Web",
"description": "Public frontend for the CMS monorepo."
},
"About": {
"badge": "About",
"title": "About this project",
"description": "This public app is the frontend surface for CMS-driven content and upcoming dynamic pages."
},
"Contact": {
"badge": "Contact",
"title": "Contact",
"description": "Contact and commission flows will be introduced in upcoming MVP steps."
}
}

View File

@@ -15,5 +15,31 @@
"es": "Español",
"fr": "Francés"
}
},
"Layout": {
"brand": "CMS Web",
"nav": {
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto"
},
"footer": {
"copyright": "© {year} CMS Web",
"tagline": "Impulsado por Next.js, Bun, Prisma y TanStack."
}
},
"Seo": {
"title": "CMS Web",
"description": "Frontend público para el monorepo CMS."
},
"About": {
"badge": "Acerca de",
"title": "Sobre este proyecto",
"description": "Esta app pública es la superficie frontend para contenido gestionado por CMS y próximas páginas dinámicas."
},
"Contact": {
"badge": "Contacto",
"title": "Contacto",
"description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP."
}
}

View File

@@ -15,5 +15,31 @@
"es": "Espagnol",
"fr": "Français"
}
},
"Layout": {
"brand": "CMS Web",
"nav": {
"home": "Accueil",
"about": "À propos",
"contact": "Contact"
},
"footer": {
"copyright": "© {year} CMS Web",
"tagline": "Propulsé par Next.js, Bun, Prisma et TanStack."
}
},
"Seo": {
"title": "CMS Web",
"description": "Frontend public pour le monorepo CMS."
},
"About": {
"badge": "À propos",
"title": "À propos de ce projet",
"description": "Cette application publique est la surface frontend pour le contenu piloté par CMS et les futures pages dynamiques."
},
"Contact": {
"badge": "Contact",
"title": "Contact",
"description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP."
}
}

View File

@@ -17,7 +17,7 @@
"conventional-changelog-cli": "5.0.0",
"jsdom": "28.0.0",
"msw": "2.12.9",
"turbo": "2.8.3",
"turbo": "^2.8.6",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.1.0",
"vitepress": "1.6.4",
@@ -30,6 +30,7 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "5.90.20",
@@ -1450,19 +1451,19 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="],
"turbo": ["turbo@2.8.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.6", "turbo-darwin-arm64": "2.8.6", "turbo-linux-64": "2.8.6", "turbo-linux-arm64": "2.8.6", "turbo-windows-64": "2.8.6", "turbo-windows-arm64": "2.8.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-QMj1SQwUYehc+xJ9SxXn56UO43hfKN64/NFetVW1BwzysRqn+q0FSgrmk+IbJ+djfd8j8zXGKGeqsnUcXwQSUQ=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-6QeZ/aLZizekiI6tKZN0IGP1a1WYZ9c/qDKPa0rSmj2X0O0Iw/ES4rKZV40S5n8SUJdiU01EFLygHJ2oWaYKXg=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RS4Z902vB93cQD3PJS/1IMmS0HefrB5ZXuw4ECOrxhOGz5jJVmYFJ6weDzedjoTDeYHHXGo1NoiCSHg69ngWKA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.6", "", { "os": "linux", "cpu": "x64" }, "sha512-hCWDnDepYbrSJdByuryKFoHAGFkvgBYXr6qdaGsYhX1Wgq8isqXCQBKOo99Y/9tXDwKGEeQ7xnkdFvSL7AQ4iQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oS15aCYEpynG/l69xs/ZnQ0dnz0pHhfHg70Zf5J+j5Cam0/RA0MpcryjneN/9G0PmP8a/6ZxnL5nZahX+wOBPA=="],
"turbo-windows-64": ["turbo-windows-64@2.8.3", "", { "os": "win32", "cpu": "x64" }, "sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw=="],
"turbo-windows-64": ["turbo-windows-64@2.8.6", "", { "os": "win32", "cpu": "x64" }, "sha512-eqBxqJD7H/uk9V0QO10VgwY9J2BUXejsGuzChln72Yl+o8GZwsvhOekndRxccR90J8ZO+LKO24+3VzHFh4Cu/g=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-I3VEQyxIlNZ6XTg4fLKAkuhcwzIs/GD7Vs1yhelH2aUTjf08wprjBWknDqP7mjAHMpsosRrq4DtfSZEQm83Hxg=="],
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],

View File

@@ -6,7 +6,7 @@ module.exports = {
"always",
["feat", "fix", "refactor", "perf", "test", "docs", "build", "ci", "chore", "revert"],
],
"scope-empty": [2, "never"],
"scope-empty": [0],
"subject-empty": [2, "never"],
},
}

View File

@@ -0,0 +1,13 @@
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: cms-gitea-runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: "${GITEA_INSTANCE_URL}"
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN}"
GITEA_RUNNER_NAME: "${GITEA_RUNNER_NAME:-cms-runner}"
GITEA_RUNNER_LABELS: "${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://node:20-bookworm}"
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -21,8 +21,16 @@ export default defineConfig({
{ text: "Architecture", link: "/architecture" },
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" },
{ text: "CRUD Baseline", link: "/product-engineering/crud-baseline" },
{ text: "CRUD Examples", link: "/product-engineering/crud-examples" },
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "i18n Conventions", link: "/product-engineering/i18n-conventions" },
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
{ text: "Domain Glossary", link: "/product-engineering/domain-glossary" },
{ text: "Environment Runbook", link: "/product-engineering/environment-runbook" },
{ text: "Delivery Pipeline", link: "/product-engineering/delivery-pipeline" },
{ text: "Git Flow Governance", link: "/product-engineering/git-flow-governance" },
{ text: "Testing Strategy", link: "/product-engineering/testing-strategy" },
{ text: "ADR Index", link: "/adr/" },
{ text: "Workflow", link: "/workflow" },
],
},
@@ -32,7 +40,17 @@ export default defineConfig({
},
{
text: "Public API",
items: [{ text: "Section Overview", link: "/public-api/" }],
items: [
{ text: "Section Overview", link: "/public-api/" },
{ text: "Glossary", link: "/public-api/glossary" },
],
},
{
text: "ADR",
items: [
{ text: "Index", link: "/adr/" },
{ text: "0001 Monorepo Foundation", link: "/adr/0001-monorepo-foundation" },
],
},
],
socialLinks: [{ icon: "github", link: "https://example.com/replace-with-repo" }],

View File

@@ -0,0 +1,37 @@
# ADR 0001: Monorepo Foundation
- Status: Accepted
- Date: 2026-02-10
## Context
The CMS platform requires:
- separate admin and public apps
- shared domain contracts and data access
- consistent tooling and CI quality gates
- incremental delivery through MVP stages
A fragmented multi-repo setup would increase coordination overhead and duplicate shared contracts.
## Decision
Adopt a Bun workspace monorepo with:
- `apps/admin` and `apps/web` for runtime surfaces
- shared packages (`@cms/content`, `@cms/db`, `@cms/crud`, `@cms/ui`, `@cms/i18n`)
- shared quality tooling (Biome, TypeScript, Vitest, Playwright, Turbo)
## Consequences
### Positive
- shared contract updates propagate in one change set
- easier cross-app refactors and testing
- single CI pipeline with consistent gates
### Negative
- stronger need for workspace discipline and clear boundaries
- larger repository clone/build surface
- potential for cross-package coupling if conventions are not enforced

17
docs/adr/README.md Normal file
View File

@@ -0,0 +1,17 @@
# ADR Index
Architecture Decision Records (ADRs) capture important technical decisions and context.
## Format
- Numbered files: `0001-<short-title>.md`
- Immutable once accepted (new ADRs supersede old decisions)
- Include:
- Status
- Context
- Decision
- Consequences
## Records
- [0001 - Monorepo Foundation](./0001-monorepo-foundation.md)

View File

@@ -7,6 +7,7 @@ Engineering documentation hub for this repository.
- [Product / Engineering](/product-engineering/)
- [Admin / User Guides](/admin-user-guides/)
- [Public API](/public-api/)
- [ADR Index](/adr/)
## Core Sources
@@ -14,6 +15,7 @@ Engineering documentation hub for this repository.
- Branching and promotion flow: `BRANCHING.md`
- Contribution and commit schema: `CONTRIBUTING.md`
- Release history: `CHANGELOG.md`
- Versioning and release policy: `VERSIONING.md`
## Documentation Scope

View File

@@ -0,0 +1,52 @@
# Artist CMS Inspiration Notes
## Scope
Inspiration-only notes for implementation direction.
These are not direct copy targets and do not override current CMS roadmap decisions.
## Useful Patterns Observed in `gaertan`
### Media and Delivery
- S3-backed storage with signed URL/object access patterns.
- Route-level image streaming/proxy from storage keys.
- Multiple artwork variants/renditions for different view contexts.
- Dedicated actions for generated gallery variants and missing-variant backfill.
### Portfolio Domain
- Artwork linked to galleries/albums/tags/categories.
- Filterable portfolio pages (album/year/tag/search).
- Gallery components designed for responsive/justified layouts.
### Commissions Domain
- Rich commission model:
- types
- options
- extras
- custom cards
- custom inputs
- Public request form + admin request management.
- Commission status/kanban-like mapping for intake/in-progress/completed.
### Color and Processing
- Artwork color extraction workflows (palette/tones) from stored image files.
- Potential pipeline point for future theming and discovery filters.
## How We Should Reuse These Ideas Here
- Keep the domain approach, but normalize to current CMS architecture (`@cms/*` packages, Next app router, shared CRUD services).
- Start with deterministic MVP1 primitives:
- media CRUD + rendition slots
- portfolio grouping entities
- commission/customer linking
- Defer heavy media automation (advanced transforms/watermark/palette orchestration) to MVP2 after baseline reliability is proven.
## Guardrails
- No direct schema/code lift from `gaertan`; re-model explicitly for this repository.
- Keep upload and processing abstraction pluggable (S3 now, alternative provider later).
- Favor explicit auditability for media/commission mutations.

View File

@@ -39,6 +39,7 @@ Optional:
- Support user bootstrap is available via `bun run auth:seed:support`.
- Root `bun run db:seed` runs DB seed and support-user seed.
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy.
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is now a fallback/default only.
- Runtime source of truth is admin settings (`/settings`) backed by `system_setting`.
- Owner/support checks for future admin user-management mutations remain tracked in `TODO.md`.
- Email verification and forgot/reset password pipelines are tracked for MVP2.

View File

@@ -0,0 +1,244 @@
# CMS Feature Topics (Domain-Centric)
## Purpose
Describe the CMS by feature domains/modules (not personas), so implementation and UI structure stay clear.
## 1) Pages
Scope:
- create/edit/publish/unpublish/schedule pages
- slug + SEO metadata
- draft and publish states
Core entities:
- `Page`
- `PageVersion` (later)
- `SeoMeta`
MVP fit:
- MVP1 core
## 2) Navigation
Scope:
- menus, nested items, ordering, visibility
- route linking to pages or external URLs
Core entities:
- `NavigationMenu`
- `NavigationItem`
MVP fit:
- MVP1 core
## 3) Media
Scope:
- upload/browse/replace/delete media
- media-type classification (artwork, banner, promo, generic, video/gif)
- metadata management
Core entities:
- `MediaAsset`
- `MediaMetadata`
- `MediaVariant` (renditions)
MVP fit:
- MVP1 core
## 4) Portfolio / Artworks
Scope:
- artworks with grouped structures
- grouping by galleries/albums/categories/tags
- ordering and visibility
Core entities:
- `Artwork`
- `Gallery`
- `Album`
- `Category`
- `Tag`
MVP fit:
- MVP1 core
## 5) Cards and Reusable Blocks
Scope:
- reusable content blocks for pages
- card-based sections (price cards, promo cards, feature cards)
Core entities:
- `BlockTemplate`
- `BlockInstance`
- `CardPreset`
MVP fit:
- MVP1 (baseline blocks), MVP2 (advanced builder UX)
## 6) Forms
Scope:
- embeddable forms on pages
- schema-driven field definitions
- submission handling and moderation
Core entities:
- `FormDefinition`
- `FormField`
- `FormSubmission`
MVP fit:
- MVP1 for commission request path
- MVP2 for generic form builder
## 7) Announcements and Banners
Scope:
- prominent notices on public pages
- schedule windows and priority
Core entities:
- `Announcement`
- `HeaderBanner`
MVP fit:
- MVP1 core
## 8) News / Blog
Scope:
- editorial posts and updates
- author metadata, status flow
Core entities:
- `NewsPost`
- `NewsCategory`
MVP fit:
- MVP1 secondary core
## 9) Commissions
Scope:
- commission request intake
- admin processing and kanban status transitions
Core entities:
- `CommissionRequest`
- `CommissionStatus`
- `CommissionType` (options/extras/custom fields)
MVP fit:
- MVP1 core
## 10) Customers (CRM-Lite)
Scope:
- recurring customer records
- customer-to-commission linking and reuse
Core entities:
- `Customer`
- `CustomerContact`
- `CustomerCommissionLink`
MVP fit:
- MVP1 core
## 11) Users, Roles, and Permissions
Scope:
- users, role assignment, status (active/banned)
- protected owner/support invariants
Core entities:
- `User`
- `Role`
- `Permission`
MVP fit:
- MVP0/MVP1 bridge; refinements in MVP2
## 12) Settings
Scope:
- system settings and feature flags
- registration policy and future locale toggles
Core entities:
- `SystemSetting`
MVP fit:
- MVP0 baseline, expanded in MVP1/MVP2
## 13) Processing Pipelines (Later)
Scope:
- watermarking
- color extraction
- advanced media transforms
- queue/retry visibility
Core entities:
- `MediaJob`
- `MediaJobRun`
- `ExtractedPalette`
MVP fit:
- MVP2
## Suggested Admin IA Alignment
- Dashboard
- Pages
- Navigation
- Media
- Portfolio
- Announcements
- News
- Commissions
- Customers
- Users
- Settings

View File

@@ -7,6 +7,8 @@ MVP0 now includes a shared CRUD foundation package: `@cms/crud`.
Current baseline:
- Shared service factory: `createCrudService`
- Repository contract: `list`, `findById`, `create`, `update`, `delete`
- Service surface for list/detail/editor flows: `list`, `getById`, `create`, `update`, `delete`
- Shared validation error type: `CrudValidationError`
- Shared not-found error type: `CrudNotFoundError`
- Shared mutation audit hook contract: `CrudAuditHook`
@@ -24,6 +26,11 @@ Current baseline:
- `registerPostCrudAuditHook`
Validation for create/update is enforced by `@cms/content` schemas.
Contract tests validate:
- repository list/detail behavior via CRUD service
- validation and not-found errors
- audit payload propagation (`actor`, `metadata`)
The admin dashboard currently includes a temporary posts CRUD sandbox to validate this flow through a real app UI.
@@ -31,3 +38,4 @@ The admin dashboard currently includes a temporary posts CRUD sandbox to validat
- This is the base layer for future entities (pages, navigation, media, users, commissions).
- Audit hook persistence/transport is intentionally left for later implementation work.
- Implementation examples are documented in `crud-examples.md`.

View File

@@ -0,0 +1,69 @@
# CRUD Examples
## Goal
Provide concrete implementation patterns for new entities adopting `@cms/crud`.
## Example: Service Factory Wiring
```ts
import { createCrudService } from "@cms/crud"
import { createPageInputSchema, updatePageInputSchema } from "@cms/content"
const pageCrudService = createCrudService({
resource: "page",
repository: pageRepository,
schemas: {
create: createPageInputSchema,
update: updatePageInputSchema,
},
auditHooks: pageAuditHooks,
})
```
## Example: Repository Contract
```ts
const pageRepository = {
list: () => db.page.findMany({ orderBy: { updatedAt: "desc" } }),
findById: (id: string) => db.page.findUnique({ where: { id } }),
create: (input: CreatePageInput) => db.page.create({ data: input }),
update: (id: string, input: UpdatePageInput) =>
db.page.update({
where: { id },
data: input,
}),
delete: (id: string) => db.page.delete({ where: { id } }),
}
```
## Example: Action Usage
```ts
export async function createPage(input: unknown, context?: CrudMutationContext) {
return pageCrudService.create(input, context)
}
export async function updatePage(id: string, input: unknown, context?: CrudMutationContext) {
return pageCrudService.update(id, input, context)
}
export async function deletePage(id: string, context?: CrudMutationContext) {
return pageCrudService.delete(id, context)
}
```
## Testing Expectations
- validation failure returns `CrudValidationError`
- missing IDs return `CrudNotFoundError`
- repository methods are called in expected order
- audit hooks receive `actor`, `metadata`, `before`, `after`
## Adoption Checklist
1. Add entity schemas in `@cms/content`
2. Add repository + service in `@cms/db`
3. Add unit tests for contract + validation
4. Wire route/action permission checks before mutations
5. Add docs links and TODO updates

View File

@@ -0,0 +1,83 @@
# Delivery Pipeline
## Scope
Operational pipeline baseline for image build/push, staging deploy, production promotion, and rollback.
## Registry Credentials Strategy
Use scoped Gitea secrets:
- `CMS_IMAGE_REGISTRY`
- `CMS_IMAGE_NAMESPACE`
- `CMS_IMAGE_REGISTRY_USER`
- `CMS_IMAGE_REGISTRY_PASSWORD`
Policy:
- credentials only in CI secrets
- no plaintext credentials in repo
- least privilege: push/pull for target namespace only
## Build and Push Flow
- Workflow: `.gitea/workflows/release.yml`
- Trigger:
- tag push `vX.Y.Z`
- manual `workflow_dispatch`
- Steps:
1. validate tag vs root `package.json` version
2. generate changelog
3. extract release notes from `CHANGELOG.md`
4. docker login
5. build and push `cms-web` and `cms-admin` images
6. publish/update Gitea release notes through API
Additional required secret:
- `GITEA_RELEASE_TOKEN`
## Staging Deployment Automation
- Workflow: `.gitea/workflows/deploy.yml`
- Manual input:
- `environment=staging`
- `image_tag=vX.Y.Z`
- Remote deployment uses SSH + compose file:
- `docker-compose.staging.yml`
Required secrets:
- `CMS_STAGING_HOST`
- `CMS_STAGING_USER`
- `CMS_DEPLOY_KEY`
- `CMS_REMOTE_DEPLOY_PATH`
## Production Promotion and Rollback
Promotion:
- run deploy workflow with:
- `environment=production`
- `image_tag=vX.Y.Z`
Rollback:
- release workflow supports manual production rollback by `rollback_image_tag`
- deploy workflow supports `rollback_tag` input for environment-specific rollback
- recovery action:
- rerun deploy/rollback with previous known-good tag
## Deployment Verification
After deploy:
1. app health checks (web/admin)
2. auth smoke flow
3. i18n smoke flow
4. critical route checks (`/`, `/login`, `/todo`)
## Notes
- Current workflows are production-oriented scaffolds and require secret provisioning in Gitea.
- Host hardening, network ACLs, and backup policy remain mandatory operational controls.

View File

@@ -0,0 +1,35 @@
# Domain Glossary
## Core Terms
### Owner
Highest-privilege admin role. Exactly one canonical owner must exist at all times.
### Support User
Hidden technical support account used for break-glass access and operational recovery.
### Admin Registration Policy
Runtime policy controlling whether `/register` can create additional admin users after owner bootstrap.
### Protected Account
Account that cannot be deleted/demoted through self-service flows (support + canonical owner).
### CRUD Service
Shared `@cms/crud` service abstraction combining schema validation, repository orchestration, and audit hooks.
### Permission Scope
RBAC access scope granularity: `own`, `team`, `global`.
### Roadmap Source Of Truth
`TODO.md` in repository root. Rendered in admin via `/todo`.
### Header Banner
Public-site announcement strip configured through `system_setting` key `public.header_banner`.

View File

@@ -0,0 +1,103 @@
# Environment and Deployment Runbook
## Scope
Operational baseline for `dev`, `staging`, and `production`.
## Environments
### Dev (local)
- Runtime: Bun + local Next dev servers
- Entry point: `bun run dev`
- Database: local/remote dev Postgres from `.env`
- Characteristics:
- fastest feedback
- non-production data acceptable
- migrations created here first
### Staging
- Runtime: Docker Compose (`docker-compose.staging.yml`)
- Purpose: integration validation and release candidate checks
- Characteristics:
- production-like environment
- controlled test data
- candidate for production promotion
### Production
- Runtime: Docker Compose (`docker-compose.production.yml`)
- Purpose: end-user traffic
- Characteristics:
- protected secrets and stricter access controls
- immutable release artifacts
- rollback procedures required
## Core Commands
### Local development
```bash
bun install
bun run db:generate
bun run db:migrate
bun run db:seed
bun run dev
```
### Staging compose
```bash
bun run docker:staging:up
bun run docker:staging:down
```
### Production compose
```bash
bun run docker:production:up
bun run docker:production:down
```
## Release Flow
1. Complete work on task branch.
2. Merge into `dev` and pass quality gates.
3. Promote `dev` -> `staging`.
4. Validate staging smoke/e2e + manual checks.
5. Promote `staging` -> `main` and tag release.
## Migration Policy
- Create migrations in development only.
- Apply migrations in deployment using `prisma migrate deploy`.
- Never hand-edit applied migration history.
## Rollback Baseline
Current baseline strategy:
- rollback app image/tag to previous known-good release
- restore database from backup when schema/data changes require recovery
## Secrets and Config
- Dev: `.env`
- Staging: `.env.staging` (from `.env.staging.example`)
- Production: `.env.production` (from `.env.production.example`)
Minimum sensitive values:
- `DATABASE_URL`
- `BETTER_AUTH_SECRET`
- `CMS_SUPPORT_*` credentials/keys
## Verification Checklist
- `bun run check`
- `bun run typecheck`
- `bun run test`
- `bun run test:e2e`
- app startup health for web/admin
- login flow and permissions smoke

View File

@@ -0,0 +1,93 @@
# Git Flow Governance
## Scope
Governance rules for branch protections, PR gates, branch naming, and merge discipline.
## Branch Protection
Protected branches:
- `main`
- `staging`
Apply protections using:
- Gitea UI settings
- or automation script: `.gitea/scripts/configure-branch-protection.sh`
Minimum policy:
- no direct pushes
- PR merge required
- required status checks
- at least one reviewer approval
## Branch Protection Verification Checklist
Use this checklist in Gitea repository settings after applying policy:
1. `main` protection exists and direct push is disabled.
2. `staging` protection exists and direct push is disabled.
3. Required checks include:
- `Governance Checks`
- `Lint Typecheck Unit E2E`
4. Pull request approval is required.
5. Branch must be up to date before merge (recommended in protected branches).
API automation example:
```bash
sh .gitea/scripts/configure-branch-protection.sh \
"$GITEA_URL" \
"$GITEA_OWNER" \
"$GITEA_REPO" \
"$GITEA_ADMIN_TOKEN"
```
Notes:
- The script applies baseline protection for `main` and `staging`.
- Final verification is still required in the Gitea UI to confirm repository-specific policies.
## PR Gates
Required checks are implemented in `.gitea/workflows/ci.yml`:
- Governance Checks
- Lint Typecheck Unit E2E
## Branch Naming and TODO Scope
Allowed branch prefixes:
- `todo/`
- `refactor/`
- `code/`
Validation script:
- `.gitea/scripts/check-branch-name.sh`
Rule:
- one primary TODO item per delivery branch
PR TODO reference enforcement:
- template: `.gitea/PULL_REQUEST_TEMPLATE.md`
- CI check: `.gitea/scripts/check-pr-todo-reference.sh`
## Branch Lifecycle
1. Create short-lived branch from latest integration tip.
2. Implement one primary scope.
3. Open PR and pass required checks.
4. Merge into `dev`.
5. Promote `dev -> staging -> main`.
## Commit and Tag Policy
- Conventional commits required (`CONTRIBUTING.md`)
- release tags: `vX.Y.Z`
- changelog generated from commit history

View File

@@ -2,19 +2,20 @@
## Scope
MVP0 introduces i18n runtime only for the public app (`@cms/web`) using `next-intl`.
MVP0 introduces i18n runtime baselines for both apps.
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
- Public app: path-stable routing (no locale in URL) via `apps/web/src/proxy.ts`
- Public app: message loading through `apps/web/src/i18n/request.ts`
- Public app: locale-aware navigation helpers in `apps/web/src/i18n/navigation.ts`
- Public app: language switcher component backed by Zustand store
- Admin app: cookie-based locale resolution and message loading in root layout
- Admin app: runtime i18n provider (`AdminI18nProvider`) and locale switcher UI
## 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`.
- Translation key and workflow standards are documented in `i18n-conventions.md`.

View File

@@ -0,0 +1,86 @@
# i18n Conventions
## Scope
This document defines translation conventions for both apps in MVP0+.
- Public app i18n: `next-intl` message namespaces and route-level usage
- Admin app i18n: JSON dictionaries + runtime resolver/provider
- Shared locale contract: `@cms/i18n` (`de`, `en`, `es`, `fr`; default `en`)
## Locale Policy
- Source of truth: `packages/i18n/src/index.ts`
- Current enabled locales are code-driven and shared across web/admin.
- Admin-managed locale toggles are planned for a later MVP.
## Key Naming Conventions
- Use `camelCase` for keys.
- Group by domain namespace (not by component filename).
- Keep keys stable; update values, not key names, during copy edits.
### Public app namespaces
- `Layout.*`
- `Home.*`
- `LanguageSwitcher.*`
- Page-specific namespaces, e.g. `About.*`, `Contact.*`
- Metadata namespace: `Seo.*`
### Admin app namespaces
- `common.*`
- `auth.*`
- `dashboard.*`
- `settings.*`
## Message Structure
- Keep messages as nested JSON objects.
- Avoid very deep nesting (prefer 2-3 levels).
- Keep punctuation in translation values, not code.
- Avoid embedding HTML in message strings.
## Fallback Rules
- Unknown/invalid locale values fallback to default locale `en`.
- Missing translation key behavior:
- Admin: `translateMessage` returns provided fallback, else key.
- Public: ensure required keys exist in locale JSON; avoid runtime missing-key states.
## Adding New Translation Keys
1. Add key/value in `apps/*/src/messages/en.json`.
2. Add equivalent key in `de/es/fr` JSON files.
3. Use key via translator:
- Web: `useTranslations("Namespace")` or `getTranslations("Namespace")`
- Admin: `useAdminT()` or server-side `translateMessage(...)`
4. Add/adjust tests for behavior where relevant.
## Translation Workflow
1. Author English source copy first.
2. Add keys in all supported locales in same change.
3. Keep semantic parity across locales.
4. Run checks:
- `bun run check`
- `bun run typecheck`
- `bun run test`
5. For route-level i18n behavior changes, run e2e smoke:
- `bunx playwright test --grep "i18n smoke"`
## QA Checklist
- Locale switch persists after refresh.
- Page headings and navigation labels translate correctly.
- Metadata (`Seo`) strings resolve per locale.
- No missing-key placeholders visible in UI.
## Related Files
- `apps/web/src/i18n/request.ts`
- `apps/web/src/i18n/routing.ts`
- `apps/admin/src/i18n/server.ts`
- `apps/admin/src/i18n/messages.ts`
- `packages/i18n/src/index.ts`

View File

@@ -8,6 +8,19 @@ This section covers platform and implementation documentation for engineers and
- [Architecture](/architecture)
- [Better Auth Baseline](/product-engineering/auth-baseline)
- [RBAC And Permissions](/product-engineering/rbac-permission-model)
- [i18n Conventions](/product-engineering/i18n-conventions)
- [CRUD Examples](/product-engineering/crud-examples)
- [Package Catalog And Decision Notes](/product-engineering/package-catalog)
- [User Personas And Use-Case Topics](/product-engineering/user-personas-and-use-cases)
- [CMS Feature Topics (Domain-Centric)](/product-engineering/cms-feature-topics)
- [Domain Glossary](/product-engineering/domain-glossary)
- [Artist CMS Inspiration Notes](/product-engineering/artist-cms-inspiration)
- [Environment Runbook](/product-engineering/environment-runbook)
- [Staging Deployment Checklist](/product-engineering/staging-deployment-checklist)
- [Delivery Pipeline](/product-engineering/delivery-pipeline)
- [Git Flow Governance](/product-engineering/git-flow-governance)
- [Testing Strategy Baseline](/product-engineering/testing-strategy)
- [ADR Index](/adr/)
- [Workflow](/workflow)
## Scope
@@ -19,6 +32,4 @@ This section covers platform and implementation documentation for engineers and
## Planned Expansions
- Domain model and glossary
- ADR (Architecture Decision Record) index
- Operational playbooks (incident, rollback, recovery)

View File

@@ -0,0 +1,153 @@
# Package Catalog And Decision Notes
## Purpose
Track package decisions in one place:
- what is already used
- why it is used
- when to keep/remove/replace
- which packages are candidates for later MVPs
This file is decision support, not a lockfile replacement.
## Current Core Stack (Used Now)
### Runtime and App Foundation
- `bun`:
workspace package manager + runtime for scripts and local dev.
- `next` + `react` + `react-dom`:
app framework for `admin` and `web`.
- `typescript`:
typed contracts across apps/packages.
### Data and Validation
- `prisma` + `@prisma/client` + `pg` + `@prisma/adapter-pg`:
DB schema/migrations + typed DB access on PostgreSQL.
- `zod`:
shared runtime validation for domain schemas and CRUD inputs.
### Auth, State, Data Fetching
- `better-auth`:
admin auth/session + role metadata baseline.
- `zustand`:
lightweight client state (e.g. locale/UI state).
- `@tanstack/react-query`:
async state/query cache patterns for admin/public app data fetching.
- `@tanstack/react-form` and `@tanstack/react-table`:
form/table primitives in admin workflows.
### UI and Styling
- `tailwindcss` + `@tailwindcss/postcss`:
utility-first styling baseline.
- `class-variance-authority`, `clsx`, `tailwind-merge`:
component variant + class composition in `@cms/ui`.
### Testing and Quality
- `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `msw`, `jsdom`:
unit/integration tests + UI interaction + API mocking.
- `@playwright/test`:
end-to-end tests.
- `@biomejs/biome`:
lint/format/check baseline.
- `turbo`:
monorepo task orchestration.
### Docs and Release Governance
- `vitepress`:
docs site.
- `conventional-changelog-cli`:
changelog generation from conventional commits.
- `@commitlint/cli` + `@commitlint/config-conventional`:
commit message schema enforcement.
## Media and Color Processing Notes
### Why `sharp` is typically the default choice
`sharp` is usually the best baseline for server-side image processing because:
- strong performance and memory behavior
- reliable resize/crop/format conversion pipeline
- robust support for production workloads
- good integration in Node/Bun server contexts
Use cases for this CMS:
- generate artwork renditions (thumb/card/full/custom)
- normalize uploads and output formats
- create banner/promo safe-size outputs
- optional watermark compositing pipeline
### Color extraction package options
1. `node-vibrant`
- Best for: quick dominant palette extraction for UI accents and tagging.
- Tradeoff: less control over advanced color science.
2. `colorthief`
- Best for: simple dominant-color extraction with minimal setup.
- Tradeoff: more limited output and tuning compared to richer libraries.
3. `culori` / `chroma-js` (supporting libs, often combined with extractor)
- Best for: color manipulation, conversion, contrast checks, palette normalization.
- Tradeoff: not a full image extractor by itself.
Recommended approach:
- MVP2 start with `sharp` + `node-vibrant` + `culori`
- keep extraction pipeline behind an internal adapter so replacement is easy
## Candidate Packages For Later (Not Installed Yet)
### File Upload and Storage Abstraction
- `@aws-sdk/client-s3` (+ presign utilities):
for S3/R2/object storage adapters and signed upload/download flows.
- `uploadthing` or custom presigned-upload implementation:
faster admin upload UX with secure direct-to-storage path.
### Rich Text / Page Builder
- `tiptap`:
rich editorial experience for pages/news.
- `@dnd-kit/core`:
drag-and-drop block ordering/page-builder interactions.
### Media Pipelines / Jobs
- `bullmq` + `ioredis`:
background job queue for heavy media transforms (watermark/video/etc).
### Commissions and CRM Extensions
- `@tanstack/react-virtual`:
large admin tables (requests/customers) without rendering bottlenecks.
### Observability / Reliability
- `@sentry/nextjs`:
app error monitoring.
- `pino`:
structured logs for services/workflows.
## Add/Remove Decision Rules
When adding a package, document:
1. problem it solves
2. why existing stack is insufficient
3. expected maintenance/runtime cost
4. fallback/exit plan
When removing/replacing:
1. list impacted modules
2. verify tests and migration path
3. update this catalog and related ADR/docs

View File

@@ -0,0 +1,100 @@
# Staging Deployment Checklist
## Purpose
Operational checklist for the first real staging deployment using `.gitea/workflows/deploy.yml`.
Use this once end-to-end, save the record, then mark MVP0 staging deployment as complete in `TODO.md`.
## Preconditions
- Docker host for staging is reachable via SSH.
- Gitea repo secrets are configured:
- `CMS_STAGING_HOST`
- `CMS_STAGING_USER`
- `CMS_DEPLOY_KEY`
- `CMS_REMOTE_DEPLOY_PATH`
- `CMS_IMAGE_REGISTRY`
- `CMS_IMAGE_NAMESPACE`
- `CMS_IMAGE_REGISTRY_USER`
- `CMS_IMAGE_REGISTRY_PASSWORD`
- Release image tag exists in registry (e.g. `v0.1.0`).
- Remote deploy path contains:
- `docker-compose.staging.yml`
- staging env file(s) needed by compose
## Step-by-Step Execution
1. Verify release images exist:
- `cms-web:<tag>`
- `cms-admin:<tag>`
2. In Gitea Actions, run `CMS Deploy` workflow.
3. Inputs:
- `environment=staging`
- `image_tag=<tag>`
- `rollback_tag=` (empty for normal deploy)
4. Confirm workflow success.
5. Validate staging endpoints:
- web base route
- admin login route
6. Run smoke checks on staging:
- auth login
- i18n route/switch baseline
- admin dashboard route access
7. If failure:
- rerun `CMS Deploy` with `rollback_tag=<previous-tag>`
- capture root cause and remediation notes
## Evidence To Capture
- Workflow run URL
- Deployed image tag
- Timestamp (UTC)
- Validation results (pass/fail)
- Rollback performed or not
## Deployment Record Template
Copy the block below into a new file under `docs/product-engineering/staging-deployments/`.
```md
# Staging Deployment Record - <YYYY-MM-DD>
- Date (UTC):
- Operator:
- Workflow run URL:
- Target environment: staging
- Image tag:
- Previous tag:
## Preconditions
- [ ] Secrets configured in Gitea
- [ ] Registry images available
- [ ] Remote compose path verified
## Execution
1. Triggered `CMS Deploy` with `environment=staging`, `image_tag=<tag>`
2. Workflow status: <!-- pass/fail -->
## Validation
- [ ] Web route check
- [ ] Admin login route check
- [ ] Auth smoke flow
- [ ] i18n smoke flow
- [ ] Admin dashboard access
## Rollback
- Performed: <!-- yes/no -->
- Rollback tag:
- Rollback workflow run URL:
## Outcome
- Result: <!-- success/failed -->
- Notes:
- Follow-up actions:
```

View File

@@ -0,0 +1,33 @@
# Testing Strategy Baseline
## Goals
- Keep lint, typecheck, unit/integration, and e2e as mandatory quality gates.
- Make e2e runs deterministic by preparing schema and seeded data before test execution.
- Keep test data isolated per environment (`dev` local, CI database service in workflow).
## Current Gate Stack
- `bun run check`
- `bun run typecheck`
- `bun run test`
- `bun run test:e2e`
## Data Preparation
- `bun run test:e2e:prepare` runs:
- Prisma client generation
- migration deploy
- seed data (including support user bootstrap)
- `bun run test:e2e` and related scripts call `test:e2e:prepare` automatically.
## Locale Integration Coverage
- `e2e/i18n.pw.ts` covers:
- web locale switch + persistence
- admin locale switch + persistence
## CI
- Real quality workflow: `.gitea/workflows/ci.yml`
- Uses a PostgreSQL service container and runs the full gate stack, including e2e.

View File

@@ -0,0 +1,116 @@
# User Personas And Use-Case Topics
## Purpose
Define who uses this CMS and which feature topics matter for each role.
This keeps roadmap decisions grounded in real workflows instead of isolated features.
## Primary Personas
### 1. Owner Artist (Primary Operator)
Main goals:
- publish and maintain portfolio website content
- manage artworks, grouped collections, and featured content
- open/close commissions and track incoming requests
Core topics:
- pages + navigation builder
- media library + artwork metadata + renditions
- announcement/banner management
- commissions + customer records
- news/blog updates
### 2. Studio Manager / Assistant
Main goals:
- handle operational content updates and commission administration
- manage customer communication and request statuses
Core topics:
- commission kanban and request triage
- customer profile maintenance
- media organization and moderation
- limited page edits under role constraints
### 3. Content Editor / Social Manager
Main goals:
- publish updates, news posts, and campaign visuals
- keep public-facing content fresh without deep admin privileges
Core topics:
- news/blog authoring
- announcements/promotions
- selected media uploads and metadata edits
- landing page block updates (where permitted)
### 4. Technical Support (Protected Role)
Main goals:
- break-glass access for incident support
- diagnostics and recovery support without owning business content
Core topics:
- support access route/key flow
- protected account safeguards
- operational diagnostics and rollback awareness
### 5. Returning Customer (Commission Client)
Main goals:
- submit repeat commission requests with reduced data re-entry
- track active request state
Core topics:
- customer-linked commission intake
- commission status visibility
- communication and requirement updates
### 6. Public Visitor / Collector / Fan
Main goals:
- discover artwork, updates, and commission availability
- navigate pages and portfolio smoothly
Core topics:
- portfolio browsing (gallery/album/tag/category)
- announcement visibility
- news/blog consumption
- commission request entry points
## Role-to-Feature Responsibility Map
- Owner Artist:
all core CMS domains
- Studio Manager:
commissions/customers/media operations
- Content Editor:
editorial/news/announcements + constrained page/media tasks
- Technical Support:
operational support only, no business ownership transfer
- Public personas:
consumption and request flows on public app
## Planning Guidance
When adding a roadmap item, always specify:
1. target persona(s)
2. primary user outcome
3. permissions required
4. public impact (if any)
If an item cannot be mapped to at least one clear persona outcome, it should not be prioritized.

View File

@@ -0,0 +1,43 @@
# Public API Glossary
## Scope
Baseline terms for future public API design and integration discussions.
## Terms
### Public API
Externally consumable endpoints intended for non-admin clients.
### Resource
Entity exposed by an API endpoint (for example: `page`, `media`, `news`).
### Contract
The stable request/response schema for an endpoint version.
### Version
Compatibility boundary for API contracts (for example: `v1`).
### Authentication
Identity verification mechanism for protected API routes.
### Authorization
Permission check determining whether an authenticated actor can access a resource/action.
### Pagination
Mechanism for splitting large result sets across requests.
### Idempotency
Property where repeating a request does not change final state beyond the first successful call.
### Rate Limit
Request threshold policy applied per consumer/time window.

View File

@@ -11,6 +11,11 @@ No stable public API surface is documented yet.
- Add API docs when real endpoints are implemented and versioned
- Use OpenAPI as source of truth for endpoint reference
- Keep integration guides and authentication examples here
- Use glossary terms consistently across API specs and guides
## Reference
- [Public API Glossary](/public-api/glossary)
## Notes

View File

@@ -15,10 +15,10 @@ Follow `BRANCHING.md`:
## Quality Gates
- `bun run lint`
- `bun run check`
- `bun run typecheck`
- `bun run test`
- `bun run test:e2e --list`
- `bun run test:e2e`
## Changelog
@@ -28,3 +28,9 @@ Follow `BRANCHING.md`:
```bash
bun run changelog:release
```
## Governance
- Branch and PR governance checks run in `.gitea/workflows/ci.yml`.
- PR template: `.gitea/PULL_REQUEST_TEMPLATE.md`
- Versioning policy: `VERSIONING.md`

35
e2e/i18n-smoke.pw.ts Normal file
View File

@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test"
test.describe("i18n smoke", () => {
test("web renders localized page headings on key routes", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "web-chromium")
await page.goto("/")
await page.locator("select").first().selectOption("de")
await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible()
await page.getByRole("link", { name: /über uns/i }).click()
await expect(page.getByRole("heading", { name: /über dieses projekt/i })).toBeVisible()
await page.locator("select").first().selectOption("es")
await expect(page.getByRole("heading", { name: /sobre este proyecto/i })).toBeVisible()
await page.getByRole("link", { name: /contacto/i }).click()
await expect(page.getByRole("heading", { name: /^contacto$/i })).toBeVisible()
})
test("admin login renders localized heading and labels", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
await page.goto("/login")
await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
await page.locator("select").first().selectOption("fr")
await expect(page.getByRole("heading", { name: /se connecter à cms admin/i })).toBeVisible()
await expect(page.getByLabel(/e-mail ou nom dutilisateur/i)).toBeVisible()
await page.locator("select").first().selectOption("es")
await expect(page.getByRole("heading", { name: /iniciar sesión en cms admin/i })).toBeVisible()
await expect(page.getByLabel(/correo o nombre de usuario/i)).toBeVisible()
})
})

29
e2e/i18n.pw.ts Normal file
View File

@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test"
test.describe("i18n integration", () => {
test("web language switcher updates and persists locale", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "web-chromium")
await page.goto("/")
await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible()
await page.locator("select").first().selectOption("de")
await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible()
})
test("admin language switcher updates and persists locale", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
await page.goto("/login")
await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
await page.locator("select").first().selectOption("de")
await expect(page.getByRole("heading", { name: /bei cms admin anmelden/i })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: /bei cms admin anmelden/i })).toBeVisible()
})
})

View File

@@ -1,10 +1,13 @@
import { expect, test } from "@playwright/test"
const BUILD_INFO_PATTERN = /Build v\S+ \+sha\.[a-z0-9]{5,7}/i
test("smoke", async ({ page }, testInfo) => {
await page.goto("/")
if (testInfo.project.name === "web-chromium") {
await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible()
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return
}
@@ -12,6 +15,7 @@ test("smoke", async ({ page }, testInfo) => {
if (await dashboardHeading.isVisible({ timeout: 2000 })) {
await expect(dashboardHeading).toBeVisible()
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return
}

20
e2e/support-auth.pw.ts Normal file
View File

@@ -0,0 +1,20 @@
import { expect, test } from "@playwright/test"
const SUPPORT_LOGIN_KEY = process.env.CMS_SUPPORT_LOGIN_KEY ?? "support-access"
test.describe("support fallback route", () => {
test("valid support key opens sign-in page", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
await page.goto(`/support/${SUPPORT_LOGIN_KEY}`)
await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
})
test("invalid support key returns not found", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
const response = await page.goto("/support/invalid-key")
expect(response?.status()).toBe(404)
})
})

View File

@@ -18,9 +18,10 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "bun run db:generate && playwright test",
"test:e2e:headed": "bun run db:generate && playwright test --headed",
"test:e2e:ui": "bun run db:generate && playwright test --ui",
"test:e2e:prepare": "bun run db:generate && bun run db:migrate:deploy && bun run db:seed",
"test:e2e": "bun run test:e2e:prepare && playwright test",
"test:e2e:headed": "bun run test:e2e:prepare && playwright test --headed",
"test:e2e:ui": "bun run test:e2e:prepare && playwright test --ui",
"commitlint": "commitlint --last",
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0",
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s",
@@ -43,22 +44,22 @@
"docker:production:down": "docker compose -f docker-compose.production.yml down"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@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",
"@biomejs/biome": "2.3.14",
"conventional-changelog-cli": "5.0.0",
"jsdom": "28.0.0",
"msw": "2.12.9",
"conventional-changelog-cli": "5.0.0",
"turbo": "2.8.3",
"turbo": "^2.8.6",
"typescript": "5.9.3",
"vitepress": "1.6.4",
"vite-tsconfig-paths": "6.1.0",
"vitepress": "1.6.4",
"vitest": "4.0.18"
}
}

Some files were not shown because too many files have changed in this diff Show More