27 Commits

Author SHA1 Message Date
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
07e5f53793 feat(admin): add posts CRUD sandbox and shared CRUD foundation 2026-02-10 19:35:41 +01:00
de26cb7647 feat(web-i18n): add es/fr locales and expand switcher locale set 2026-02-10 19:23:36 +01:00
0e2248b5c7 feat(auth): block protected account deletion in auth endpoints 2026-02-10 18:47:52 +01:00
29a6e38ff3 feat(auth): enforce single-owner invariant in bootstrap flow 2026-02-10 18:43:06 +01:00
b96cd6d800 feat(admin-auth): support username login and add dashboard logout 2026-02-10 18:35:19 +01:00
7b665ae633 feat(admin-auth): add first-start onboarding flow and dev db reset command 2026-02-10 18:14:47 +01:00
411861419f feat(auth): bootstrap protected support and first owner users 2026-02-10 17:50:16 +01:00
df1280af4a refactor(db): simplify to single prisma schema workflow 2026-02-10 17:42:48 +01:00
670f7d3fb2 chore(release): initialize versioning roadmap and changelog 2026-02-10 12:58:42 +01:00
2dcb8a80ba docs(todo): mark auth routing baseline complete 2026-02-10 12:53:44 +01:00
efb93f212b fix(next): migrate admin middleware to proxy convention 2026-02-10 12:52:38 +01:00
24eca3e740 refactor(auth): localize admin auth and replace latest ranges 2026-02-10 12:49:59 +01:00
ba8abb3b1b feat(auth): add better-auth core wiring for admin and db 2026-02-10 12:42:49 +01:00
3949fd2c11 docs(todo): add i18n milestones and test coverage tasks 2026-02-10 12:17:14 +01:00
947cb0a3d7 feat(rbac): enforce admin access checks and document permission model 2026-02-10 12:16:36 +01:00
4041a4ac4a Docs 2026-02-10 11:57:42 +01:00
7ba96f6a03 Add docs 2026-02-10 02:27:12 +01:00
107 changed files with 5905 additions and 240 deletions

View File

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

View File

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

View File

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

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

@@ -0,0 +1,70 @@
name: CMS CI
on:
pull_request:
push:
branches:
- dev
- staging
- main
workflow_dispatch:
env:
BUN_VERSION: "1.3.5"
NODE_ENV: "test"
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/cms?schema=public"
BETTER_AUTH_SECRET: "ci-test-secret-change-me"
BETTER_AUTH_URL: "http://localhost:3001"
CMS_ADMIN_ORIGIN: "http://127.0.0.1:3001"
CMS_WEB_ORIGIN: "http://127.0.0.1:3000"
CMS_ADMIN_SELF_REGISTRATION_ENABLED: "false"
CMS_SUPPORT_USERNAME: "support"
CMS_SUPPORT_EMAIL: "support@cms.local"
CMS_SUPPORT_PASSWORD: "support-ci-password"
CMS_SUPPORT_NAME: "Technical Support"
CMS_SUPPORT_LOGIN_KEY: "support-access"
jobs:
quality:
name: Lint Typecheck Unit E2E
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cms
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d cms"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Playwright browser deps
run: bunx playwright install --with-deps chromium
- name: Lint and format checks
run: bun run check
- name: Typecheck
run: bun run typecheck
- name: Unit and integration tests
run: bun run test
- name: E2E tests
run: bun run test:e2e

3
.gitignore vendored
View File

@@ -12,6 +12,8 @@ out
# build
dist
coverage
docs/.vitepress/dist
docs/.vitepress/cache
*.tsbuildinfo
playwright-report
test-results
@@ -25,6 +27,7 @@ test-results
# prisma
packages/db/prisma/dev.db*
packages/db/prisma/generated/
# misc
.DS_Store

View File

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

View File

@@ -38,6 +38,8 @@ bun install
cp .env.example .env
```
Set `BETTER_AUTH_SECRET` before production use.
3. Generate Prisma client and run migrations:
```bash
@@ -54,15 +56,20 @@ bun run dev
- Web: http://localhost:3000
- Admin: http://localhost:3001
- Admin login: http://localhost:3001/login
## Useful scripts
- `bun run dev`
- `bun run dev:web`
- `bun run dev:admin`
- `bun run docs:dev`
- `bun run docs:build`
- `bun run docs:preview`
- `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`
@@ -79,6 +86,7 @@ bun run dev
- Unit/integration/component: Vitest + Testing Library + MSW
- E2E: Playwright (separate projects for `web` and `admin`)
- Use `bun run test` and `bun run test:e2e` (not plain `bun test`, which uses Bun's runner)
- E2E data prep (migrations + seed): `bun run test:e2e:prepare`
One-time Playwright browser install:
@@ -91,6 +99,7 @@ bunx playwright install
The repo includes a theoretical CI/CD and deployment baseline:
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
- Real quality gate workflow: `.gitea/workflows/ci.yml`
- App images:
- `apps/web/Dockerfile`
- `apps/admin/Dockerfile`
@@ -131,6 +140,23 @@ bun run changelog:release
bun run changelog:preview
```
## Docs Tool
- Docs tool: VitePress
- Docs source directory: `docs/`
Run docs locally:
```bash
bun run docs:dev
```
Build static docs:
```bash
bun run docs:build
```
## Recommended next packages
- Auth: `better-auth` or `next-auth`

83
TODO.md
View File

@@ -18,28 +18,41 @@ This file is the single source of truth for roadmap and delivery progress.
### MVP1 Gate: Mandatory Before Feature Work
- [ ] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
- [ ] [P1] RBAC enforcement at route and action level in admin
- [ ] [P1] Permission matrix documented and tested
- [ ] [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] [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
- [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)
- [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
- [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`)
- [ ] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [ ] [P1] Protected admin routes and session handling
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
- [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
- [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)
@@ -48,11 +61,24 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Vitest + Testing Library + MSW baseline
- [x] [P1] Playwright baseline with web/admin projects
- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [ ] [P1] RBAC policy unit tests and permission regression suite
- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [~] [P1] RBAC policy unit tests and permission regression suite
- [ ] [P1] i18n unit tests (locale resolution, fallback, message key loading)
- [x] [P1] i18n integration tests (admin/public locale switch and persistence)
- [ ] [P1] i18n e2e smoke tests (localized headings/content per route)
- [ ] [P1] CRUD contract tests for shared service patterns
### 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
### Delivery Pipeline And Runtime
- [x] [P2] Theoretical Gitea Actions workflow scaffold (`.gitea/workflows/ci-cd-theoretical.yml`)
@@ -71,6 +97,12 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
- [x] [P2] Changelog scaffold and generation scripts (`CHANGELOG.md`, `bun run changelog:*`)
- [ ] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
- [ ] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
- [ ] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
- [ ] [P1] App footer implementation plan for version + commit hash (admin + web)
- [ ] [P2] Automated version injection in CI (stamping build from tag + commit hash)
- [ ] [P2] Validation tests for displayed version/hash consistency per deployment
- [ ] [P1] Release tagging and changelog publication policy in CI
## MVP 1: Core CMS Business Features
@@ -83,6 +115,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags)
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
- [ ] [P1] Users management (invite, roles, status)
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window)
@@ -92,6 +126,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Dynamic page rendering from CMS page entities
- [ ] [P1] Navigation rendering from managed menu structure
- [ ] [P1] Media entity rendering with enrichment data
- [ ] [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
@@ -107,6 +142,9 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Unit tests for content schemas and service logic
- [ ] [P1] Component tests for admin forms (pages/media/navigation)
- [ ] [P1] Integration tests for owner invariant and hidden support-user protection
- [ ] [P1] Integration tests for registration allow/deny behavior
- [ ] [P1] Integration tests for translated content CRUD and locale-specific validation
- [ ] [P1] E2E happy paths: create page, publish, see on public app
- [ ] [P1] E2E happy paths: media upload + artwork refinement display
- [ ] [P1] E2E happy paths: commissions kanban transitions
@@ -118,6 +156,12 @@ 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
- [ ] [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
@@ -141,12 +185,25 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Load/perf tests for key public routes
- [ ] [P2] Flake tracking and quarantine policy for e2e
- [ ] [P1] Coverage thresholds and enforcement policy
- [ ] [P1] Locale matrix regression suite for critical user journeys
## Discovery Log
- [2026-02-10] Prisma client must be generated before app/e2e startup to avoid runtime module errors.
- [2026-02-10] `bun test` conflicts with Playwright-style test files; keep e2e files on `*.pw.ts` and run e2e via Playwright.
- [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`.
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes.
- [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`).
## How We Use This File

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,318 @@
"use client"
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" | "signup-disabled"
}
type AuthResponse = {
user?: {
role?: string
}
message?: string
}
function persistRoleCookie(role: unknown) {
if (typeof role !== "string") {
return
}
// biome-ignore lint/suspicious/noDocumentCookie: Temporary fallback for middleware role resolution.
document.cookie = `cms_role=${encodeURIComponent(role)}; Path=/; SameSite=Lax`
}
export function LoginForm({ mode }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const t = useAdminT()
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
const [name, setName] = useState("Admin User")
const [username, setUsername] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [isBusy, setIsBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setIsBusy(true)
setError(null)
setSuccess(null)
try {
const response = await fetch("/api/auth/sign-in/email", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
identifier: email,
password,
callbackURL: nextPath,
}),
})
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? t("auth.errors.signInFailed", "Sign in failed"))
return
}
persistRoleCookie(payload?.user?.role)
router.push(nextPath)
router.refresh()
} catch {
setError(t("auth.errors.networkSignIn", "Network error while signing in"))
} finally {
setIsBusy(false)
}
}
async function handleSignUp(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) {
setError(t("auth.errors.nameRequired", "Name is required for account creation"))
return
}
setIsBusy(true)
setError(null)
setSuccess(null)
try {
const response = await fetch("/api/auth/sign-up/email", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name,
username,
email,
password,
callbackURL: nextPath,
}),
})
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? t("auth.errors.signUpFailed", "Sign up failed"))
return
}
persistRoleCookie(payload?.user?.role)
setSuccess(
mode === "signup-owner"
? t("auth.messages.ownerCreated", "Owner account created. Registration is now disabled.")
: t("auth.messages.accountCreated", "Account created."),
)
router.push(nextPath)
router.refresh()
} catch {
setError(t("auth.errors.networkSignUp", "Network error while signing up"))
} finally {
setIsBusy(false)
}
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
<div className="space-y-3">
<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"
? t("auth.titles.signIn", "Sign in to CMS Admin")
: mode === "signup-owner"
? 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"
? 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>
{mode === "signin" ? (
<form
onSubmit={handleSignIn}
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
{t("auth.fields.emailOrUsername", "Email or username")}
</label>
<input
id="email"
type="text"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
{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.signInBusy", "Signing in...")
: t("auth.actions.signInIdle", "Sign in")}
</button>
<p className="text-xs text-neutral-600">
{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">
{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,36 @@
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { hasOwnerUser } from "@/lib/auth/server"
import { LoginForm } from "./login-form"
export const dynamic = "force-dynamic"
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function getSingleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0]
}
return input
}
export default async function LoginPage({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams
const nextPath = getSingleValue(params.next) ?? "/"
const role = await resolveRoleFromServerContext()
if (role) {
redirect("/")
}
const hasOwner = await hasOwnerUser()
if (!hasOwner) {
redirect(`/welcome?next=${encodeURIComponent(nextPath)}`)
}
return <LoginForm mode="signin" />
}

View File

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

View File

@@ -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 MediaManagementPage() {
const role = await requirePermissionForRoute({
nextPath: "/media",
permission: "media:read",
scope: "team",
})
return (
<AdminShell
role={role}
activePath="/media"
badge="Admin App"
title="Media"
description="Prepare media library and enrichment workflows."
>
<AdminSectionPlaceholder
feature="Media Library"
summary="This route is ready for media browsing, upload, and metadata refinement features."
requiredPermission="media:read (team)"
nextSteps={[
"Add media upload and asset listing.",
"Add enrichment fields (alt text, source, tags).",
"Add artwork-specific refinement fields.",
]}
/>
</AdminShell>
)
}

View File

@@ -1,37 +1,389 @@
import { listPosts } from "@cms/db"
import { hasPermission } from "@cms/content/rbac"
import { createPost, deletePost, listPosts, updatePost } 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"
export const dynamic = "force-dynamic"
export default async function AdminHomePage() {
const posts = await listPosts()
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 readRequiredField(formData: FormData, field: string): string {
const value = formData.get(field)
if (typeof value !== "string") {
return ""
}
return value.trim()
}
function readOptionalField(formData: FormData, field: string): string | undefined {
const value = readRequiredField(formData, field)
return value.length > 0 ? value : undefined
}
async function requireNewsWritePermission() {
await requirePermissionForRoute({
nextPath: "/",
permission: "news:write",
scope: "team",
})
}
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 ? `/?${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")
try {
await createPost({
title: readRequiredField(formData, "title"),
slug: readRequiredField(formData, "slug"),
excerpt: readOptionalField(formData, "excerpt"),
body: readRequiredField(formData, "body"),
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({
error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."),
})
}
revalidatePath("/")
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: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."),
})
}
try {
await updatePost(id, {
title: readRequiredField(formData, "title"),
slug: readRequiredField(formData, "slug"),
excerpt: readOptionalField(formData, "excerpt"),
body: readRequiredField(formData, "body"),
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({
error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."),
})
}
revalidatePath("/")
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: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."),
})
}
try {
await deletePost(id)
} catch {
redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") })
}
revalidatePath("/")
redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") })
}
export default async function AdminHomePage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/",
permission: "news:read",
scope: "team",
})
const [resolvedSearchParams, locale, posts] = await Promise.all([
searchParams,
resolveAdminLocale(),
listPosts(),
])
const messages = await getAdminMessages(locale)
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const canCreatePost = hasPermission(role, "news:write", "team")
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="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>
</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}
</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="mb-4 flex items-center justify-between">
<h2 className="text-xl font-medium">Posts</h2>
<Button>Create post</Button>
<div className="space-y-4">
<div className="flex items-center justify-between">
<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 ? (
<form
action={createPostAction}
className="space-y-3 rounded-lg border border-neutral-200 p-4"
>
<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">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
minLength={3}
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">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
minLength={3}
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">
{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">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
minLength={1}
rows={4}
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">
{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">{t("dashboard.posts.status.draft", "Draft")}</option>
<option value="published">
{t("dashboard.posts.status.published", "Published")}
</option>
</select>
</label>
<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">
{t(
"dashboard.notices.noCrudPermission",
"You can read posts, but your role cannot create/update/delete posts.",
)}
</div>
)}
</div>
<div className="space-y-3">
{posts.map((post) => (
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
{canCreatePost ? (
<>
<form action={updatePostAction} className="space-y-3">
<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">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
minLength={3}
defaultValue={post.title}
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">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
minLength={3}
defaultValue={post.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">
{t("dashboard.posts.fields.excerpt", "Excerpt")}
</span>
<input
name="excerpt"
defaultValue={post.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">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
minLength={1}
rows={4}
defaultValue={post.body}
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">
{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">{t("dashboard.posts.status.draft", "Draft")}</option>
<option value="published">
{t("dashboard.posts.status.published", "Published")}
</option>
</select>
</label>
<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">
{t("dashboard.posts.actions.delete", "Delete")}
</Button>
</form>
</>
) : (
<>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-medium">{post.title}</h3>
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide">
@@ -39,10 +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 ?? 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

@@ -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

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

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

View File

@@ -2,6 +2,9 @@ import { readFile } from "node:fs/promises"
import path from "node:path"
import Link from "next/link"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type TodoState = "done" | "partial" | "planned"
@@ -401,6 +404,12 @@ function filterButtonClass(active: boolean): string {
export default async function AdminTodoPage(props: {
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/todo",
permission: "roadmap:read",
scope: "global",
})
const content = await getTodoMarkdown()
const sections = parseTodo(content)
const progress = getProgressCounts(sections)
@@ -420,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>
<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>
</div>
</header>
}
>
<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>
@@ -593,6 +597,6 @@ export default async function AdminTodoPage(props: {
{content}
</pre>
</details>
</main>
</AdminShell>
)
}

View File

@@ -0,0 +1,57 @@
import Link from "next/link"
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function getSingleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0]
}
return input
}
export default async function UnauthorizedPage({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams
const required = getSingleValue(params.required)
const scope = getSingleValue(params.scope)
const reason = getSingleValue(params.reason)
return (
<main className="mx-auto flex min-h-screen w-full max-w-xl flex-col gap-6 px-6 py-20">
<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">Access denied</h1>
<p className="text-neutral-600">
You do not have the required role/permission for this admin route.
</p>
</header>
<section className="rounded-xl border border-neutral-200 p-5">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="text-neutral-500">Reason</dt>
<dd className="font-medium text-neutral-800">{reason ?? "insufficient-permission"}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-neutral-500">Required permission</dt>
<dd className="font-medium text-neutral-800">{required ?? "n/a"}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-neutral-500">Required scope</dt>
<dd className="font-medium text-neutral-800">{scope ?? "n/a"}</dd>
</div>
</dl>
</section>
<Link
href="/"
className="inline-flex w-fit rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Back to dashboard
</Link>
</main>
)
}

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

View File

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

View File

@@ -0,0 +1,43 @@
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("/users")).toEqual({
permission: "users:read",
scope: "own",
})
expect(getRequiredPermission("/commissions")).toEqual({
permission: "commissions:read",
scope: "own",
})
})
})

View File

@@ -0,0 +1,149 @@
import { hasPermission, normalizeRole, type PermissionScope, type Role } from "@cms/content/rbac"
import type { NextRequest } from "next/server"
type RoutePermission = {
permission: Parameters<typeof hasPermission>[1]
scope: PermissionScope
}
type GuardRule = {
route: RegExp
requirement: RoutePermission | null
}
const guardRules: GuardRule[] = [
{
route: /^\/unauthorized(?:\/|$)/,
requirement: null,
},
{
route: /^\/api\/auth(?:\/|$)/,
requirement: null,
},
{
route: /^\/login(?:\/|$)/,
requirement: null,
},
{
route: /^\/register(?:\/|$)/,
requirement: null,
},
{
route: /^\/welcome(?:\/|$)/,
requirement: null,
},
{
route: /^\/support\/[^/]+(?:\/|$)/,
requirement: null,
},
{
route: /^\/todo(?:\/|$)/,
requirement: {
permission: "roadmap:read",
scope: "global",
},
},
{
route: /^\/pages(?:\/|$)/,
requirement: {
permission: "pages:read",
scope: "team",
},
},
{
route: /^\/media(?:\/|$)/,
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: {
permission: "dashboard:read",
scope: "global",
},
},
]
export function resolveDefaultRole(): Role | null {
if (process.env.NODE_ENV === "production") {
return null
}
return normalizeRole(process.env.CMS_DEV_ROLE)
}
export function resolveRoleFromRawValue(raw: string | null | undefined): Role | null {
return normalizeRole(raw)
}
export function resolveRoleFromRequest(request: NextRequest): Role | null {
const roleFromCookie = request.cookies.get("cms_role")?.value
const roleFromHeader = request.headers.get("x-cms-role")
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
if (resolved) {
return resolved
}
return resolveDefaultRole()
}
export function getRequiredPermission(pathname: string): RoutePermission {
for (const rule of guardRules) {
if (rule.route.test(pathname)) {
return (
rule.requirement ?? {
permission: "dashboard:read",
scope: "global",
}
)
}
}
return {
permission: "dashboard:read",
scope: "global",
}
}
export function canAccessRoute(role: Role, pathname: string): boolean {
const rule = guardRules.find((item) => item.route.test(pathname))
if (rule && rule.requirement === null) {
return true
}
const requirement = getRequiredPermission(pathname)
return hasPermission(role, requirement.permission, requirement.scope)
}
export function isPublicRoute(pathname: string): boolean {
const rule = guardRules.find((item) => item.route.test(pathname))
return rule?.requirement === null
}

View File

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

View File

@@ -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],
)
}

46
apps/admin/src/proxy.ts Normal file
View File

@@ -0,0 +1,46 @@
import { type NextRequest, NextResponse } from "next/server"
import {
canAccessRoute,
getRequiredPermission,
isPublicRoute,
resolveRoleFromRequest,
} from "@/lib/access"
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (isPublicRoute(pathname)) {
return NextResponse.next()
}
const role = resolveRoleFromRequest(request)
if (!role) {
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = "/login"
loginUrl.searchParams.set("next", pathname)
return NextResponse.redirect(loginUrl)
}
if (!canAccessRoute(role, pathname)) {
const unauthorizedUrl = request.nextUrl.clone()
unauthorizedUrl.pathname = "/unauthorized"
const required = getRequiredPermission(pathname)
unauthorizedUrl.searchParams.set("required", required.permission)
unauthorizedUrl.searchParams.set("scope", required.scope)
return NextResponse.redirect(unauthorizedUrl)
}
const response = NextResponse.next()
response.headers.set("x-cms-role", role)
return response
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

531
bun.lock
View File

@@ -5,22 +5,23 @@
"": {
"name": "cms-monorepo",
"devDependencies": {
"@biomejs/biome": "latest",
"@commitlint/cli": "latest",
"@commitlint/config-conventional": "latest",
"@playwright/test": "latest",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "latest",
"@testing-library/user-event": "latest",
"@vitejs/plugin-react": "latest",
"@vitest/coverage-istanbul": "latest",
"conventional-changelog-cli": "latest",
"jsdom": "latest",
"msw": "latest",
"turbo": "latest",
"typescript": "latest",
"vite-tsconfig-paths": "latest",
"vitest": "latest",
"@biomejs/biome": "2.3.14",
"@commitlint/cli": "20.4.1",
"@commitlint/config-conventional": "20.4.1",
"@playwright/test": "1.58.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "5.1.3",
"@vitest/coverage-istanbul": "4.0.18",
"conventional-changelog-cli": "5.0.0",
"jsdom": "28.0.0",
"msw": "2.12.9",
"turbo": "2.8.3",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.1.0",
"vitepress": "1.6.4",
"vitest": "4.0.18",
},
},
"apps/admin": {
@@ -29,25 +30,27 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-form": "latest",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"@tanstack/react-table": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest",
"@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-table": "8.21.3",
"better-auth": "1.4.18",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "latest",
"typescript": "latest",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.2.2",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.9.3",
},
},
"apps/web": {
@@ -56,23 +59,25 @@
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"next": "16.1.6",
"next-intl": "4.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "5.0.11",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "latest",
"typescript": "latest",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.2.2",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.9.3",
},
},
"packages/config": {
@@ -83,12 +88,24 @@
"name": "@cms/content",
"version": "0.0.1",
"dependencies": {
"zod": "latest",
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "latest",
"typescript": "5.9.3",
},
},
"packages/crud": {
"name": "@cms/crud",
"version": "0.0.1",
"dependencies": {
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "5.9.3",
},
},
"packages/db": {
@@ -96,38 +113,48 @@
"version": "0.0.1",
"dependencies": {
"@cms/content": "workspace:*",
"@prisma/adapter-pg": "latest",
"@prisma/client": "latest",
"pg": "latest",
"zod": "latest",
"@cms/crud": "workspace:*",
"@prisma/adapter-pg": "7.3.0",
"@prisma/client": "7.3.0",
"pg": "8.18.0",
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@types/node": "latest",
"@types/pg": "latest",
"prisma": "latest",
"typescript": "latest",
"@types/node": "25.2.2",
"@types/pg": "8.16.0",
"prisma": "7.3.0",
"typescript": "5.9.3",
},
},
"packages/i18n": {
"name": "@cms/i18n",
"version": "0.0.1",
"devDependencies": {
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "5.9.3",
},
},
"packages/ui": {
"name": "@cms/ui",
"version": "0.0.1",
"dependencies": {
"class-variance-authority": "latest",
"clsx": "latest",
"tailwind-merge": "latest",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"tailwind-merge": "3.4.0",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3",
},
"peerDependencies": {
"react": "latest",
"react-dom": "latest",
"react": "19.2.4",
"react-dom": "19.2.4",
},
},
},
@@ -136,6 +163,42 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@algolia/abtesting": ["@algolia/abtesting@1.14.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-cZfj+1Z1dgrk3YPtNQNt0H9Rr67P8b4M79JjUKGS0d7/EbFbGxGgSu6zby5f22KXo3LT0LZa4O2c6VVbupJuDg=="],
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-n17WSJ7vazmM6yDkWBAjY12J8ERkW9toOqNgQ1GEZu/Kc4dJDJod1iy+QP5T/UlR3WICgZDi/7a/VX5TY5LAPQ=="],
"@algolia/client-analytics": ["@algolia/client-analytics@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-v5bMZMEqW9U2l40/tTAaRyn4AKrYLio7KcRuHmLaJtxuJAhvZiE7Y62XIsF070juz4MN3eyvfQmI+y5+OVbZuA=="],
"@algolia/client-common": ["@algolia/client-common@5.48.0", "", {}, "sha512-7H3DgRyi7UByScc0wz7EMrhgNl7fKPDjKX9OcWixLwCj7yrRXDSIzwunykuYUUO7V7HD4s319e15FlJ9CQIIFQ=="],
"@algolia/client-insights": ["@algolia/client-insights@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-tXmkB6qrIGAXrtRYHQNpfW0ekru/qymV02bjT0w5QGaGw0W91yT+53WB6dTtRRsIrgS30Al6efBvyaEosjZ5uw=="],
"@algolia/client-personalization": ["@algolia/client-personalization@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-4tXEsrdtcBZbDF73u14Kb3otN+xUdTVGop1tBjict+Rc/FhsJQVIwJIcTrOJqmvhtBfc56Bu65FiVOnpAZCxcw=="],
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-unzSUwWFpsDrO8935RhMAlyK0Ttua/5XveVIwzfjs5w+GVBsHgIkbOe8VbBJccMU/z1LCwvu1AY3kffuSLAR5Q=="],
"@algolia/client-search": ["@algolia/client-search@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-RB9bKgYTVUiOcEb5bOcZ169jiiVW811dCsJoLT19DcbbFmU4QaK0ghSTssij35QBQ3SCOitXOUrHcGgNVwS7sQ=="],
"@algolia/ingestion": ["@algolia/ingestion@1.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-rhoSoPu+TDzDpvpk3cY/pYgbeWXr23DxnAIH/AkN0dUC+GCnVIeNSQkLaJ+CL4NZ51cjLIjksrzb4KC5Xu+ktw=="],
"@algolia/monitoring": ["@algolia/monitoring@1.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-aSe6jKvWt+8VdjOaq2ERtsXp9+qMXNJ3mTyTc1VMhNfgPl7ArOhRMRSQ8QBnY8ZL4yV5Xpezb7lAg8pdGrrulg=="],
"@algolia/recommend": ["@algolia/recommend@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-p9tfI1bimAaZrdiVExL/dDyGUZ8gyiSHsktP1ZWGzt5hXpM3nhv4tSjyHtXjEKtA0UvsaHKwSfFE8aAAm1eIQA=="],
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0" } }, "sha512-XshyfpsQB7BLnHseMinp3fVHOGlTv6uEHOzNK/3XrEF9mjxoZAcdVfY1OCXObfwRWX5qXZOq8FnrndFd44iVsQ=="],
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0" } }, "sha512-Q4XNSVQU89bKNAPuvzSYqTH9AcbOOiIo6AeYMQTxgSJ2+uvT78CLPMG89RIIloYuAtSfE07s40OLV50++l1Bbw=="],
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.48.0", "", { "dependencies": { "@algolia/client-common": "5.48.0" } }, "sha512-ZgxV2+5qt3NLeUYBTsi6PLyHcENQWC0iFppFZekHSEDA2wcLdTUjnaJzimTEULHIvJuLRCkUs4JABdhuJktEag=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="],
@@ -184,6 +247,14 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@biomejs/biome": ["@biomejs/biome@2.3.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.14", "@biomejs/cli-darwin-x64": "2.3.14", "@biomejs/cli-linux-arm64": "2.3.14", "@biomejs/cli-linux-arm64-musl": "2.3.14", "@biomejs/cli-linux-x64": "2.3.14", "@biomejs/cli-linux-x64-musl": "2.3.14", "@biomejs/cli-win32-arm64": "2.3.14", "@biomejs/cli-win32-x64": "2.3.14" }, "bin": { "biome": "bin/biome" } }, "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A=="],
@@ -216,8 +287,12 @@
"@cms/content": ["@cms/content@workspace:packages/content"],
"@cms/crud": ["@cms/crud@workspace:packages/crud"],
"@cms/db": ["@cms/db@workspace:packages/db"],
"@cms/i18n": ["@cms/i18n@workspace:packages/i18n"],
"@cms/ui": ["@cms/ui@workspace:packages/ui"],
"@cms/web": ["@cms/web@workspace:apps/web"],
@@ -270,6 +345,12 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="],
"@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="],
@@ -332,10 +413,24 @@
"@exodus/bytes": ["@exodus/bytes@1.12.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw=="],
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.1.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "@formatjs/intl-localematcher": "0.8.1", "decimal.js": "^10.6.0", "tslib": "^2.8.1" } }, "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q=="],
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.0", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg=="],
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/icu-skeleton-parser": "2.1.1", "tslib": "^2.8.1" } }, "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA=="],
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "tslib": "^2.8.1" } }, "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q=="],
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@hutson/parse-repository-url": ["@hutson/parse-repository-url@5.0.0", "", {}, "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg=="],
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.70", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-CYNRCgN6nBTjN4dNkrBCjHXNR2e4hQihdsZUs/afUNFOWLSYjfihca4EFN05rRvDk4Xoy2n8tym6IxBZmcn+Qg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -430,6 +525,10 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
"@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
@@ -516,6 +615,24 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -598,6 +715,16 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
@@ -612,8 +739,16 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@vitest/coverage-istanbul": ["@vitest/coverage-istanbul@4.0.18", "", { "dependencies": { "@istanbuljs/schema": "^0.1.3", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/trace-mapping": "0.3.31", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-instrument": "^6.0.3", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-0OhjP30owEDihYTZGWuq20rNtV1RjjJs1Mv4MaZIKcFBmiLUXX7HJLX4fU7wE+Mrc3lQxI2HKq6WrSXi5FGuCQ=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
@@ -630,12 +765,46 @@
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.28", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.28", "", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="],
"@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="],
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
"add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"algoliasearch": ["algoliasearch@5.48.0", "", { "dependencies": { "@algolia/abtesting": "1.14.0", "@algolia/client-abtesting": "5.48.0", "@algolia/client-analytics": "5.48.0", "@algolia/client-common": "5.48.0", "@algolia/client-insights": "5.48.0", "@algolia/client-personalization": "5.48.0", "@algolia/client-query-suggestions": "5.48.0", "@algolia/client-search": "5.48.0", "@algolia/ingestion": "1.48.0", "@algolia/monitoring": "1.48.0", "@algolia/recommend": "5.48.0", "@algolia/requester-browser-xhr": "5.48.0", "@algolia/requester-fetch": "5.48.0", "@algolia/requester-node-http": "5.48.0" } }, "sha512-aD8EQC6KEman6/S79FtPdQmB7D4af/etcRL/KwiKFKgAE62iU8c5PeEQvpvIcBPurC3O/4Lj78nOl7ZcoazqSw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
@@ -652,8 +821,14 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="],
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
@@ -662,8 +837,14 @@
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@@ -684,6 +865,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
@@ -726,6 +909,8 @@
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.2.0", "", { "dependencies": { "jiti": "^2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ=="],
@@ -760,6 +945,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
@@ -772,6 +959,8 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
@@ -804,6 +993,8 @@
"find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
@@ -838,16 +1029,24 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
@@ -856,6 +1055,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"icu-minify": ["icu-minify@4.8.2", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-LHBQV+skKkjZSPd590pZ7ZAHftUgda3eFjeuNwA8/15L8T8loCNBktKQyTlkodAU86KovFXeg/9WntlAo5wA5A=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -866,6 +1067,8 @@
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
"intl-messageformat": ["intl-messageformat@11.1.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/fast-memoize": "3.1.0", "@formatjs/icu-messageformat-parser": "3.5.1", "tslib": "^2.8.1" } }, "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -880,6 +1083,8 @@
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -892,6 +1097,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -906,6 +1113,8 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -962,14 +1171,32 @@
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.12.9", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-NYbi51C6M3dujGmcmuGemu68jy12KqQPoVWGeroKToLGsBgrwG5ErM8WctoIIg49/EV49SEvYM9WSqO4G7kNeQ=="],
@@ -982,10 +1209,16 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
"next-intl": ["next-intl@4.4.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^4.4.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
@@ -998,6 +1231,8 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -1052,12 +1287,16 @@
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prisma": ["prisma@7.3.0", "", { "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", "@prisma/engines": "7.3.0", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
@@ -1080,6 +1319,12 @@
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
@@ -1094,24 +1339,34 @@
"rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -1120,6 +1375,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="],
"spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
@@ -1128,6 +1385,8 @@
"spdx-license-ids": ["spdx-license-ids@3.0.22", "", {}, "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
@@ -1142,16 +1401,22 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
@@ -1180,6 +1445,8 @@
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1210,22 +1477,42 @@
"unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-intl": ["use-intl@4.8.2", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.8.2", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-3VNXZgDnPFqhIYosQ9W1Hc6K5q+ZelMfawNbexdwL/dY7BTHbceLUBX5Eeex9lgogxTp0pf1SjHuhYNAjr9H3g=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
"validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.0", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg=="],
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
"vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
@@ -1264,12 +1551,16 @@
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@commitlint/is-ignored/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@conventional-changelog/git-client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "tslib": "^2.8.1" } }, "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="],
@@ -1296,6 +1587,14 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vitejs/plugin-vue/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"conventional-changelog/conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@8.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-eOvlTO6OcySPyyyk8pKz2dP4jjElYunj9hn9/s0OB+gapTO8zwS9UQWrZ1pmF2hFs3vw1xhonOLGcGjy/zgsuA=="],
"conventional-changelog-core/git-raw-commits": ["git-raw-commits@5.0.0", "", { "dependencies": { "@conventional-changelog/git-client": "^1.0.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg=="],
@@ -1334,8 +1633,110 @@
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vitepress/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@vitejs/plugin-vue/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"@vitejs/plugin-vue/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitepress/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"vitepress/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vitepress/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vitepress/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vitepress/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vitepress/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vitepress/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vitepress/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vitepress/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vitepress/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vitepress/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vitepress/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vitepress/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vitepress/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vitepress/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vitepress/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vitepress/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vitepress/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vitepress/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vitepress/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vitepress/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vitepress/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vitepress/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vitepress/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
}
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from "vitepress"
export default defineConfig({
title: "CMS Docs",
description: "Documentation hub for product, engineering, API, and admin/user guidance.",
cleanUrls: true,
lastUpdated: true,
themeConfig: {
nav: [
{ text: "Home", link: "/" },
{ text: "Product / Engineering", link: "/product-engineering/" },
{ text: "Admin / User Guides", link: "/admin-user-guides/" },
{ text: "Public API", link: "/public-api/" },
],
sidebar: [
{
text: "Product / Engineering",
items: [
{ text: "Section Overview", link: "/product-engineering/" },
{ text: "Getting Started", link: "/getting-started" },
{ text: "Architecture", link: "/architecture" },
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" },
{ text: "CRUD Baseline", link: "/product-engineering/crud-baseline" },
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
{ text: "Testing Strategy", link: "/product-engineering/testing-strategy" },
{ text: "Workflow", link: "/workflow" },
],
},
{
text: "Admin / User Guides",
items: [{ text: "Section Overview", link: "/admin-user-guides/" }],
},
{
text: "Public API",
items: [{ text: "Section Overview", link: "/public-api/" }],
},
],
socialLinks: [{ icon: "github", link: "https://example.com/replace-with-repo" }],
footer: {
message: "Internal documentation",
copyright: "Copyright © CMS",
},
},
})

View File

@@ -0,0 +1,23 @@
# Admin / User Guides
This section will hold practical guides for non-engineering and operational usage of the admin app.
## Intended Audience
- Admins
- Editors
- Managers
- Internal operators
## Planned Guide Topics
- Navigating dashboard sections
- Role and permission behavior by task
- Managing pages and navigation
- Managing media and artwork refinement
- Handling commissions and kanban flow
- Publishing and preview workflows
## Status
This section is intentionally scaffolded early and will be filled as core features land.

23
docs/architecture.md Normal file
View File

@@ -0,0 +1,23 @@
# Architecture
## Monorepo Structure
- `apps/web`: public app
- `apps/admin`: admin app
- `packages/db`: prisma + data access
- `packages/content`: shared schemas and domain contracts
- `packages/crud`: shared CRUD service patterns (validation, errors, audit hooks)
- `packages/ui`: shared UI layer
- `packages/i18n`: shared locale definitions and i18n helpers
- `packages/config`: shared TS config
## Design Principles
- Shared contracts before feature implementation
- RBAC and CRUD base as prerequisites for MVP1 feature work
- Keep admin and public responsibilities clearly separated
- Public routing is path-stable; locale is resolved via `next-intl` middleware + cookie
## Pending Documentation
See documentation tasks in `TODO.md` under the Documentation track.

54
docs/getting-started.md Normal file
View File

@@ -0,0 +1,54 @@
# Getting Started
## Install
```bash
bun install
```
## Environment
```bash
cp .env.example .env
```
## Database
```bash
bun run db:generate
bun run db:migrate
bun run db:seed
```
Create a named migration:
```bash
bun run db:migrate:named -- --name your_migration_name
```
Reset local dev DB:
```bash
bun run db:reset:dev
```
## Run apps
```bash
bun run dev
```
- Web: `http://localhost:3000`
- Web locale switching: use the language switcher in the page header
- Admin: `http://localhost:3001`
- Admin welcome (first start): `http://localhost:3001/welcome`
- Admin login: `http://localhost:3001/login`
- Admin register (when enabled): `http://localhost:3001/register`
## Run docs
```bash
bun run docs:dev
```
- Docs: `http://localhost:4173`

26
docs/index.md Normal file
View File

@@ -0,0 +1,26 @@
# CMS Docs
Engineering documentation hub for this repository.
## Documentation Tracks
- [Product / Engineering](/product-engineering/)
- [Admin / User Guides](/admin-user-guides/)
- [Public API](/public-api/)
## Core Sources
- Roadmap and progress: `TODO.md`
- Branching and promotion flow: `BRANCHING.md`
- Contribution and commit schema: `CONTRIBUTING.md`
- Release history: `CHANGELOG.md`
## Documentation Scope
This docs site should track:
- Architecture and domain boundaries
- RBAC and permission model
- CRUD standards and implementation patterns
- Delivery pipeline and environment promotion flow
- Operational runbooks (deploy, rollback, incident response)

View File

@@ -0,0 +1,45 @@
# Better Auth Baseline
## Scope
This baseline activates Better Auth for the admin app with email/password login and Prisma-backed sessions.
Implemented in MVP0:
- Admin-local auth config: `apps/admin/src/lib/auth/server.ts`
- Admin auth API routes: `apps/admin/src/app/api/auth/[...all]/route.ts`
- Admin auth pages: `/welcome`, `/login`, `/register`
- Support fallback sign-in page: `/support/<CMS_SUPPORT_LOGIN_KEY>`
- Prisma auth models (`user`, `session`, `account`, `verification`)
- First registration creates owner; subsequent registrations are disabled
- Owner invariant reconciliation is enforced in auth bootstrap and owner promotion flow
- Protected accounts (support + canonical owner) are blocked from delete-account auth endpoints
## Environment
Required variables:
- `BETTER_AUTH_SECRET`
- `BETTER_AUTH_URL`
- `CMS_ADMIN_ORIGIN`
- `CMS_WEB_ORIGIN`
- `DATABASE_URL`
Optional:
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED`
- `CMS_SUPPORT_USERNAME`
- `CMS_SUPPORT_EMAIL`
- `CMS_SUPPORT_PASSWORD`
- `CMS_SUPPORT_NAME`
- `CMS_SUPPORT_LOGIN_KEY`
- `CMS_DEV_ROLE` (development-only middleware bypass)
## Notes
- Support user bootstrap is available via `bun run auth:seed:support`.
- Root `bun run db:seed` runs DB seed and support-user seed.
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is 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,40 @@
# CRUD Baseline
## Scope
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`
- Shared mutation context contract (`actor`, `metadata`)
## First Integration
`@cms/db` `posts` now uses the shared CRUD foundation:
- `listPosts`
- `getPostById`
- `createPost`
- `updatePost`
- `deletePost`
- `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.
## Notes
- This is the base layer for future entities (pages, navigation, media, users, commissions).
- Audit hook persistence/transport is intentionally left for later implementation work.

View File

@@ -0,0 +1,21 @@
# i18n Baseline
## Scope
MVP0 introduces i18n runtime baselines for both apps.
Current baseline:
- Shared locale contract in `@cms/i18n` (`de`, `en`, `es`, `fr`; default `en`)
- 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.
- Translation key conventions and workflow docs are tracked in `TODO.md`.

View File

@@ -0,0 +1,25 @@
# Product / Engineering Docs
This section covers platform and implementation documentation for engineers and product stakeholders.
## Start Here
- [Getting Started](/getting-started)
- [Architecture](/architecture)
- [Better Auth Baseline](/product-engineering/auth-baseline)
- [RBAC And Permissions](/product-engineering/rbac-permission-model)
- [Testing Strategy Baseline](/product-engineering/testing-strategy)
- [Workflow](/workflow)
## Scope
- System architecture and boundaries
- RBAC and CRUD base standards
- Delivery workflow and promotion flow
- Infrastructure and deployment runbooks
## Planned Expansions
- Domain model and glossary
- ADR (Architecture Decision Record) index
- Operational playbooks (incident, rollback, recovery)

View File

@@ -0,0 +1,62 @@
# RBAC And Permission Model
This document defines the current role model, permission matrix, and scope semantics used by the admin app.
## Roles
- `admin`: full system access
- `manager`: broad operational access with selective limitations
- `editor`: content-focused access with reduced user-management privileges
## Permission Scopes
- `own`: applies to records the user owns
- `team`: applies to records within the user's team/org unit
- `global`: applies across all records
Scope hierarchy (higher includes lower):
- `global` -> `team` -> `own`
## Permission Matrix Summary
### Admin
- All permissions at `global` scope
### Manager
- Dashboard and roadmap read: `global`
- Pages, navigation, media, commissions, banner, news: `global`
- Users: `read` at `global`, `write` at `team`
### Editor
- Dashboard: `read` at `global`
- Pages/navigation/media/news: mostly `team`
- Publish and workflow transitions: mostly `own`
- Users and commissions: mostly `own`
- Banner: `read` at `global`
## Enforcement Layers
- Route-level: `apps/admin/src/proxy.ts`
- Action-level: server component checks in admin pages (`/` and `/todo`)
- Shared model + checks: `packages/content/src/rbac.ts`
## Dev Role Fallback
For local development only:
- If no role cookie/header is present and environment is not production,
role falls back to `CMS_DEV_ROLE` or `admin`.
Use this only as bootstrap behavior until full auth/session integration is finished.
## Related Tasks
See `TODO.md` MVP0 gate items:
- RBAC domain model finalized
- RBAC route/action enforcement
- Permission matrix documented and tested

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.

17
docs/public-api/index.md Normal file
View File

@@ -0,0 +1,17 @@
# Public API Docs
This section is reserved for externally consumable API documentation.
## Current Status
No stable public API surface is documented yet.
## Planned Approach
- 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
## Notes
Internal endpoints should not be treated as public API until explicitly documented in this section.

30
docs/workflow.md Normal file
View File

@@ -0,0 +1,30 @@
# Workflow
## Branching Model
Follow `BRANCHING.md`:
- Long-lived: `main`, `staging`, `dev`
- Task branches: `todo/*`, `refactor/*`, `code/*`
## Promotion Path
1. Task branch -> `dev`
2. `dev` -> `staging`
3. `staging` -> `main`
## Quality Gates
- `bun run check`
- `bun run typecheck`
- `bun run test`
- `bun run test:e2e`
## Changelog
- Conventional commits required (see `CONTRIBUTING.md`)
- Generate changelog with:
```bash
bun run changelog:release
```

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

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

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

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

View File

@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./rbac": "./src/rbac.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
@@ -12,11 +13,11 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "latest"
"zod": "4.3.6"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"typescript": "latest"
"@biomejs/biome": "2.3.14",
"typescript": "5.9.3"
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { postSchema, upsertPostSchema } from "./index"
import { createPostInputSchema, postSchema, updatePostInputSchema, upsertPostSchema } from "./index"
describe("content schemas", () => {
it("accepts a valid post", () => {
@@ -17,7 +17,24 @@ describe("content schemas", () => {
expect(post.slug).toBe("hello-world")
})
it("rejects invalid upsert payload", () => {
it("rejects invalid create payload", () => {
const result = createPostInputSchema.safeParse({
title: "Hi",
slug: "x",
body: "",
status: "unknown",
})
expect(result.success).toBe(false)
})
it("rejects empty update payload", () => {
const result = updatePostInputSchema.safeParse({})
expect(result.success).toBe(false)
})
it("keeps upsert alias for backward compatibility", () => {
const result = upsertPostSchema.safeParse({
title: "Hi",
slug: "x",

View File

@@ -1,23 +1,35 @@
import { z } from "zod"
export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"])
export const postSchema = z.object({
id: z.string().uuid(),
const postMutableFieldsSchema = z.object({
title: z.string().min(3).max(180),
slug: z.string().min(3).max(180),
excerpt: z.string().max(320).optional(),
body: z.string().min(1),
status: postStatusSchema,
})
export const postSchema = z.object({
id: z.string().uuid(),
...postMutableFieldsSchema.shape,
createdAt: z.date(),
updatedAt: z.date(),
})
export const upsertPostSchema = postSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
export const createPostInputSchema = postMutableFieldsSchema
export const updatePostInputSchema = postMutableFieldsSchema
.partial()
.refine((value) => Object.keys(value).length > 0, {
message: "At least one field is required for an update.",
})
// Backward-compatible alias while migrating callers to create/update-specific schemas.
export const upsertPostSchema = createPostInputSchema
export type Post = z.infer<typeof postSchema>
export type CreatePostInput = z.infer<typeof createPostInputSchema>
export type UpdatePostInput = z.infer<typeof updatePostInputSchema>
export type UpsertPostInput = z.infer<typeof upsertPostSchema>

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest"
import { hasPermission, normalizeRole, permissionMatrix } from "./rbac"
describe("rbac model", () => {
it("normalizes valid roles", () => {
expect(normalizeRole("OWNER")).toBe("owner")
expect(normalizeRole("support")).toBe("support")
expect(normalizeRole("ADMIN")).toBe("admin")
expect(normalizeRole("manager")).toBe("manager")
expect(normalizeRole("unknown")).toBeNull()
})
it("grants admin full access", () => {
expect(hasPermission("owner", "users:manage_roles", "global")).toBe(true)
expect(hasPermission("support", "news:publish", "global")).toBe(true)
expect(hasPermission("admin", "users:manage_roles", "global")).toBe(true)
expect(hasPermission("admin", "news:publish", "global")).toBe(true)
})
it("enforces scope hierarchy", () => {
expect(hasPermission("editor", "news:write", "team")).toBe(true)
expect(hasPermission("editor", "news:write", "global")).toBe(false)
expect(hasPermission("editor", "news:publish", "own")).toBe(true)
})
it("keeps matrix explicit for non-admin roles", () => {
expect(permissionMatrix.editor.length).toBeGreaterThan(0)
expect(permissionMatrix.manager.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,126 @@
import { z } from "zod"
export const roleSchema = z.enum(["owner", "support", "admin", "editor", "manager"])
export const permissionScopeSchema = z.enum(["own", "team", "global"])
export const permissionSchema = z.enum([
"dashboard:read",
"roadmap:read",
"pages:read",
"pages:write",
"pages:publish",
"navigation:read",
"navigation:write",
"media:read",
"media:write",
"media:refine",
"users:read",
"users:write",
"users:manage_roles",
"commissions:read",
"commissions:write",
"commissions:transition",
"banner:read",
"banner:write",
"news:read",
"news:write",
"news:publish",
])
export type Role = z.infer<typeof roleSchema>
export type Permission = z.infer<typeof permissionSchema>
export type PermissionScope = z.infer<typeof permissionScopeSchema>
export type PermissionGrant = {
permission: Permission
scopes: PermissionScope[]
}
const allPermissions = permissionSchema.options
const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({
permission,
scopes: ["global"],
}))
export const permissionMatrix: Record<Role, PermissionGrant[]> = {
owner: allGlobalGrants,
support: allGlobalGrants,
admin: allGlobalGrants,
manager: [
{ permission: "dashboard:read", scopes: ["global"] },
{ permission: "roadmap:read", scopes: ["global"] },
{ permission: "pages:read", scopes: ["global"] },
{ permission: "pages:write", scopes: ["global"] },
{ permission: "pages:publish", scopes: ["global"] },
{ permission: "navigation:read", scopes: ["global"] },
{ permission: "navigation:write", scopes: ["global"] },
{ permission: "media:read", scopes: ["global"] },
{ permission: "media:write", scopes: ["global"] },
{ permission: "media:refine", scopes: ["global"] },
{ permission: "users:read", scopes: ["global"] },
{ permission: "users:write", scopes: ["team"] },
{ permission: "commissions:read", scopes: ["global"] },
{ permission: "commissions:write", scopes: ["global"] },
{ permission: "commissions:transition", scopes: ["global"] },
{ permission: "banner:read", scopes: ["global"] },
{ permission: "banner:write", scopes: ["global"] },
{ permission: "news:read", scopes: ["global"] },
{ permission: "news:write", scopes: ["global"] },
{ permission: "news:publish", scopes: ["global"] },
],
editor: [
{ permission: "dashboard:read", scopes: ["global"] },
{ permission: "pages:read", scopes: ["team"] },
{ permission: "pages:write", scopes: ["team"] },
{ permission: "pages:publish", scopes: ["own"] },
{ permission: "navigation:read", scopes: ["team"] },
{ permission: "navigation:write", scopes: ["team"] },
{ permission: "media:read", scopes: ["team"] },
{ permission: "media:write", scopes: ["team"] },
{ permission: "media:refine", scopes: ["team"] },
{ permission: "users:read", scopes: ["own"] },
{ permission: "commissions:read", scopes: ["own"] },
{ permission: "commissions:write", scopes: ["own"] },
{ permission: "commissions:transition", scopes: ["own"] },
{ permission: "banner:read", scopes: ["global"] },
{ permission: "news:read", scopes: ["team"] },
{ permission: "news:write", scopes: ["team"] },
{ permission: "news:publish", scopes: ["own"] },
],
}
const scopeWeight: Record<PermissionScope, number> = {
own: 1,
team: 2,
global: 3,
}
export function normalizeRole(input: string | null | undefined): Role | null {
if (!input) {
return null
}
const parsed = roleSchema.safeParse(input.toLowerCase())
if (!parsed.success) {
return null
}
return parsed.data
}
export function hasPermission(
role: Role,
permission: Permission,
scope: PermissionScope = "global",
): boolean {
const grants = permissionMatrix[role]
const grant = grants.find((item) => item.permission === permission)
if (!grant) {
return false
}
return grant.scopes.some((grantedScope) => scopeWeight[grantedScope] >= scopeWeight[scope])
}

View File

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

View File

@@ -0,0 +1,41 @@
import type { ZodIssue } from "zod"
export class CrudError extends Error {
public readonly code: string
constructor(message: string, code: string) {
super(message)
this.name = "CrudError"
this.code = code
}
}
export class CrudValidationError extends CrudError {
public readonly resource: string
public readonly operation: "create" | "update"
public readonly issues: ZodIssue[]
constructor(params: {
resource: string
operation: "create" | "update"
issues: ZodIssue[]
}) {
super(`Validation failed for ${params.resource} ${params.operation}`, "CRUD_VALIDATION")
this.name = "CrudValidationError"
this.resource = params.resource
this.operation = params.operation
this.issues = params.issues
}
}
export class CrudNotFoundError extends CrudError {
public readonly resource: string
public readonly id: string
constructor(params: { resource: string; id: string }) {
super(`${params.resource} ${params.id} was not found`, "CRUD_NOT_FOUND")
this.name = "CrudNotFoundError"
this.resource = params.resource
this.id = params.id
}
}

View File

@@ -0,0 +1,3 @@
export * from "./errors"
export * from "./service"
export * from "./types"

View File

@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest"
import { z } from "zod"
import { CrudNotFoundError, CrudValidationError } from "./errors"
import { createCrudService } from "./service"
type FakeEntity = {
id: string
title: string
}
type CreateFakeEntityInput = {
title: string
}
type UpdateFakeEntityInput = {
title?: string
}
function createMemoryRepository() {
const state = new Map<string, FakeEntity>()
let sequence = 0
return {
list: async () => Array.from(state.values()),
findById: async (id: string) => state.get(id) ?? null,
create: async (input: CreateFakeEntityInput) => {
sequence += 1
const created = {
id: `${sequence}`,
title: input.title,
}
state.set(created.id, created)
return created
},
update: async (id: string, input: UpdateFakeEntityInput) => {
const current = state.get(id)
if (!current) {
throw new Error("unexpected missing entity in test repository")
}
const updated = {
...current,
...input,
}
state.set(id, updated)
return updated
},
delete: async (id: string) => {
const current = state.get(id)
if (!current) {
throw new Error("unexpected missing entity in test repository")
}
state.delete(id)
return current
},
}
}
describe("createCrudService", () => {
it("supports list and detail lookups through the repository contract", async () => {
const service = createCrudService({
resource: "fake-entity",
repository: createMemoryRepository(),
schemas: {
create: z.object({
title: z.string().min(3),
}),
update: z.object({
title: z.string().min(3).optional(),
}),
},
})
const createdA = await service.create({ title: "First" })
const createdB = await service.create({ title: "Second" })
expect(await service.getById(createdA.id)).toEqual(createdA)
expect(await service.getById("missing")).toBeNull()
const listed = await service.list()
expect(listed).toHaveLength(2)
expect(listed).toContainEqual(createdA)
expect(listed).toContainEqual(createdB)
})
it("validates create and update payloads", async () => {
const service = createCrudService({
resource: "fake-entity",
repository: createMemoryRepository(),
schemas: {
create: z.object({
title: z.string().min(3),
}),
update: z
.object({
title: z.string().min(3).optional(),
})
.refine((value) => Object.keys(value).length > 0, {
message: "at least one field must be updated",
}),
},
})
await expect(service.create({ title: "ok" })).rejects.toBeInstanceOf(CrudValidationError)
await expect(service.update("1", {})).rejects.toBeInstanceOf(CrudValidationError)
})
it("throws not found for unknown update and delete", async () => {
const service = createCrudService({
resource: "fake-entity",
repository: createMemoryRepository(),
schemas: {
create: z.object({
title: z.string().min(3),
}),
update: z.object({
title: z.string().min(3).optional(),
}),
},
})
await expect(service.update("missing", { title: "Updated" })).rejects.toBeInstanceOf(
CrudNotFoundError,
)
await expect(service.delete("missing")).rejects.toBeInstanceOf(CrudNotFoundError)
})
it("emits audit events for create, update and delete", async () => {
const events: Array<{
action: string
beforeTitle: string | null
afterTitle: string | null
actorRole: string | null
requestId: string | null
}> = []
const service = createCrudService({
resource: "fake-entity",
repository: createMemoryRepository(),
schemas: {
create: z.object({
title: z.string().min(3),
}),
update: z.object({
title: z.string().min(3).optional(),
}),
},
auditHooks: [
(event) => {
events.push({
action: event.action,
beforeTitle: event.before?.title ?? null,
afterTitle: event.after?.title ?? null,
actorRole: event.actor?.role ?? null,
requestId:
typeof event.metadata?.requestId === "string" ? event.metadata.requestId : null,
})
},
],
})
const created = await service.create(
{ title: "Created" },
{
actor: { id: "u-1", role: "owner" },
metadata: {
requestId: "req-1",
},
},
)
await service.update(created.id, { title: "Updated" })
await service.delete(created.id)
expect(events).toEqual([
{
action: "create",
beforeTitle: null,
afterTitle: "Created",
actorRole: "owner",
requestId: "req-1",
},
{
action: "update",
beforeTitle: "Created",
afterTitle: "Updated",
actorRole: null,
requestId: null,
},
{
action: "delete",
beforeTitle: "Updated",
afterTitle: null,
actorRole: null,
requestId: null,
},
])
})
})

View File

@@ -0,0 +1,159 @@
import type { ZodIssue } from "zod"
import { CrudNotFoundError, CrudValidationError } from "./errors"
import type { CrudAction, CrudAuditHook, CrudMutationContext, CrudRepository } from "./types"
type SchemaSafeParseResult<TInput> =
| {
success: true
data: TInput
}
| {
success: false
error: {
issues: ZodIssue[]
}
}
type CrudSchema<TInput> = {
safeParse: (input: unknown) => SchemaSafeParseResult<TInput>
}
type CrudSchemas<TCreateInput, TUpdateInput> = {
create: CrudSchema<TCreateInput>
update: CrudSchema<TUpdateInput>
}
type CreateCrudServiceOptions<TRecord, TCreateInput, TUpdateInput, TId extends string = string> = {
resource: string
repository: CrudRepository<TRecord, TCreateInput, TUpdateInput, TId>
schemas: CrudSchemas<TCreateInput, TUpdateInput>
auditHooks?: Array<CrudAuditHook<TRecord>>
}
async function emitAuditHooks<TRecord>(
hooks: Array<CrudAuditHook<TRecord>>,
event: {
resource: string
action: CrudAction
actor: CrudMutationContext["actor"]
metadata: CrudMutationContext["metadata"]
before: TRecord | null
after: TRecord | null
},
): Promise<void> {
if (hooks.length === 0) {
return
}
const payload = {
...event,
actor: event.actor ?? null,
at: new Date(),
}
for (const hook of hooks) {
await hook(payload)
}
}
function parseOrThrow<TInput>(params: {
schema: CrudSchema<TInput>
input: unknown
resource: string
operation: "create" | "update"
}): TInput {
const parsed = params.schema.safeParse(params.input)
if (parsed.success) {
return parsed.data
}
throw new CrudValidationError({
resource: params.resource,
operation: params.operation,
issues: parsed.error.issues,
})
}
export function createCrudService<TRecord, TCreateInput, TUpdateInput, TId extends string = string>(
options: CreateCrudServiceOptions<TRecord, TCreateInput, TUpdateInput, TId>,
) {
const auditHooks = options.auditHooks ?? []
return {
list: () => options.repository.list(),
getById: (id: TId) => options.repository.findById(id),
create: async (input: unknown, context: CrudMutationContext = {}) => {
const payload = parseOrThrow({
schema: options.schemas.create,
input,
resource: options.resource,
operation: "create",
})
const created = await options.repository.create(payload)
await emitAuditHooks(auditHooks, {
resource: options.resource,
action: "create",
actor: context.actor,
metadata: context.metadata,
before: null,
after: created,
})
return created
},
update: async (id: TId, input: unknown, context: CrudMutationContext = {}) => {
const payload = parseOrThrow({
schema: options.schemas.update,
input,
resource: options.resource,
operation: "update",
})
const existing = await options.repository.findById(id)
if (!existing) {
throw new CrudNotFoundError({
resource: options.resource,
id,
})
}
const updated = await options.repository.update(id, payload)
await emitAuditHooks(auditHooks, {
resource: options.resource,
action: "update",
actor: context.actor,
metadata: context.metadata,
before: existing,
after: updated,
})
return updated
},
delete: async (id: TId, context: CrudMutationContext = {}) => {
const existing = await options.repository.findById(id)
if (!existing) {
throw new CrudNotFoundError({
resource: options.resource,
id,
})
}
const deleted = await options.repository.delete(id)
await emitAuditHooks(auditHooks, {
resource: options.resource,
action: "delete",
actor: context.actor,
metadata: context.metadata,
before: existing,
after: null,
})
return deleted
},
}
}

View File

@@ -0,0 +1,31 @@
export type CrudAction = "create" | "update" | "delete"
export type CrudActor = {
id?: string | null
role?: string | null
}
export type CrudMutationContext = {
actor?: CrudActor | null
metadata?: Record<string, unknown>
}
export type CrudAuditEvent<TRecord> = {
resource: string
action: CrudAction
at: Date
actor: CrudActor | null
metadata?: Record<string, unknown>
before: TRecord | null
after: TRecord | null
}
export type CrudAuditHook<TRecord> = (event: CrudAuditEvent<TRecord>) => Promise<void> | void
export type CrudRepository<TRecord, TCreateInput, TUpdateInput, TId extends string = string> = {
list: () => Promise<TRecord[]>
findById: (id: TId) => Promise<TRecord | null>
create: (input: TCreateInput) => Promise<TRecord>
update: (id: TId, input: TUpdateInput) => Promise<TRecord>
delete: (id: TId) => Promise<TRecord>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "system_setting" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "system_setting_pkey" PRIMARY KEY ("key")
);

View File

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

View File

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

View File

@@ -1,2 +1,10 @@
export { db } from "./client"
export { createPost, listPosts } from "./posts"
export {
createPost,
deletePost,
getPostById,
listPosts,
registerPostCrudAuditHook,
updatePost,
} from "./posts"
export { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "./settings"

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