Compare commits
28 Commits
main
...
todo/mvp0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
8390689c8d
|
|||
|
bf1a92d129
|
|||
|
36b09cd9d7
|
|||
| 70fc154f97 | |||
| c4d0499d12 | |||
| d16fb6e121 | |||
| a508e3203a | |||
|
4d4b583cf4
|
|||
|
4ac7410148
|
|||
|
d0f731743c
|
|||
|
b618c8cb51
|
|||
|
07e5f53793
|
|||
|
de26cb7647
|
|||
|
0e2248b5c7
|
|||
|
29a6e38ff3
|
|||
|
b96cd6d800
|
|||
|
7b665ae633
|
|||
|
411861419f
|
|||
|
df1280af4a
|
|||
|
670f7d3fb2
|
|||
|
2dcb8a80ba
|
|||
|
efb93f212b
|
|||
|
24eca3e740
|
|||
|
ba8abb3b1b
|
|||
|
3949fd2c11
|
|||
|
947cb0a3d7
|
|||
|
4041a4ac4a
|
|||
|
7ba96f6a03
|
13
.env.example
13
.env.example
@@ -1 +1,14 @@
|
|||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cms?schema=public"
|
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"
|
||||||
|
|||||||
@@ -1 +1,11 @@
|
|||||||
DATABASE_URL="postgresql://cms:cms_production_password@localhost:65432/cms_production?schema=public"
|
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"
|
||||||
|
|||||||
@@ -1 +1,11 @@
|
|||||||
DATABASE_URL="postgresql://cms:cms_staging_password@localhost:55432/cms_staging?schema=public"
|
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
70
.gitea/workflows/ci.yml
Normal 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
3
.gitignore
vendored
@@ -12,6 +12,8 @@ out
|
|||||||
# build
|
# build
|
||||||
dist
|
dist
|
||||||
coverage
|
coverage
|
||||||
|
docs/.vitepress/dist
|
||||||
|
docs/.vitepress/cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
@@ -25,6 +27,7 @@ test-results
|
|||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
packages/db/prisma/dev.db*
|
packages/db/prisma/dev.db*
|
||||||
|
packages/db/prisma/generated/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -38,6 +38,8 @@ bun install
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Set `BETTER_AUTH_SECRET` before production use.
|
||||||
|
|
||||||
3. Generate Prisma client and run migrations:
|
3. Generate Prisma client and run migrations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -54,15 +56,20 @@ bun run dev
|
|||||||
|
|
||||||
- Web: http://localhost:3000
|
- Web: http://localhost:3000
|
||||||
- Admin: http://localhost:3001
|
- Admin: http://localhost:3001
|
||||||
|
- Admin login: http://localhost:3001/login
|
||||||
|
|
||||||
## Useful scripts
|
## Useful scripts
|
||||||
|
|
||||||
- `bun run dev`
|
- `bun run dev`
|
||||||
- `bun run dev:web`
|
- `bun run dev:web`
|
||||||
- `bun run dev:admin`
|
- `bun run dev:admin`
|
||||||
|
- `bun run docs:dev`
|
||||||
|
- `bun run docs:build`
|
||||||
|
- `bun run docs:preview`
|
||||||
- `bun run test`
|
- `bun run test`
|
||||||
- `bun run test:watch`
|
- `bun run test:watch`
|
||||||
- `bun run test:coverage`
|
- `bun run test:coverage`
|
||||||
|
- `bun run test:e2e:prepare`
|
||||||
- `bun run test:e2e`
|
- `bun run test:e2e`
|
||||||
- `bun run lint`
|
- `bun run lint`
|
||||||
- `bun run typecheck`
|
- `bun run typecheck`
|
||||||
@@ -79,6 +86,7 @@ bun run dev
|
|||||||
- Unit/integration/component: Vitest + Testing Library + MSW
|
- Unit/integration/component: Vitest + Testing Library + MSW
|
||||||
- E2E: Playwright (separate projects for `web` and `admin`)
|
- 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)
|
- 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:
|
One-time Playwright browser install:
|
||||||
|
|
||||||
@@ -91,6 +99,7 @@ bunx playwright install
|
|||||||
The repo includes a theoretical CI/CD and deployment baseline:
|
The repo includes a theoretical CI/CD and deployment baseline:
|
||||||
|
|
||||||
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
|
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
|
||||||
|
- Real quality gate workflow: `.gitea/workflows/ci.yml`
|
||||||
- App images:
|
- App images:
|
||||||
- `apps/web/Dockerfile`
|
- `apps/web/Dockerfile`
|
||||||
- `apps/admin/Dockerfile`
|
- `apps/admin/Dockerfile`
|
||||||
@@ -131,6 +140,23 @@ bun run changelog:release
|
|||||||
bun run changelog:preview
|
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
|
## Recommended next packages
|
||||||
|
|
||||||
- Auth: `better-auth` or `next-auth`
|
- Auth: `better-auth` or `next-auth`
|
||||||
|
|||||||
92
TODO.md
92
TODO.md
@@ -18,41 +18,67 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
|
|
||||||
### MVP1 Gate: Mandatory Before Feature Work
|
### MVP1 Gate: Mandatory Before Feature Work
|
||||||
|
|
||||||
- [ ] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
|
- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
|
||||||
- [ ] [P1] RBAC enforcement at route and action level in admin
|
- [x] [P1] RBAC enforcement at route and action level in admin
|
||||||
- [ ] [P1] Permission matrix documented and tested
|
- [x] [P1] Permission matrix documented and tested
|
||||||
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
- [x] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
|
||||||
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
- [x] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
|
||||||
- [ ] [P1] Shared error and audit hooks for CRUD mutations
|
- [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
|
### Admin App
|
||||||
|
|
||||||
- [x] [P1] Separate Next.js admin app in monorepo
|
- [x] [P1] Separate Next.js admin app in monorepo
|
||||||
- [x] [P1] App Router + TypeScript + `src/` structure
|
- [x] [P1] App Router + TypeScript + `src/` structure
|
||||||
- [x] [P1] Shared DB access via `@cms/db`
|
- [x] [P1] Shared DB access via `@cms/db`
|
||||||
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
- [x] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
||||||
- [ ] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
||||||
- [ ] [P1] Protected admin routes and session handling
|
- [x] [P1] Protected admin routes and session handling
|
||||||
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
|
- [x] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
|
||||||
|
- [x] [P1] Core admin IA (pages/media/users/commissions/settings)
|
||||||
|
|
||||||
### Public App
|
### Public App
|
||||||
|
|
||||||
- [x] [P1] Separate Next.js public app in monorepo
|
- [x] [P1] Separate Next.js public app in monorepo
|
||||||
- [x] [P1] App Router + TypeScript + `src/` structure
|
- [x] [P1] App Router + TypeScript + `src/` structure
|
||||||
- [~] [P1] Public app connected to shared data layer
|
- [x] [P1] Public app connected to shared data layer
|
||||||
- [ ] [P2] Public layout system (header/footer/navigation)
|
- [x] [P1] Localized route structure and middleware rules
|
||||||
- [ ] [P1] Header banner rendering from CMS-managed content
|
- [x] [P2] Public layout system (header/footer/navigation)
|
||||||
- [ ] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
|
- [x] [P1] Header banner rendering from CMS-managed content
|
||||||
|
- [x] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- [x] [P1] Vitest + Testing Library + MSW baseline
|
- [x] [P1] Vitest + Testing Library + MSW baseline
|
||||||
- [x] [P1] Playwright baseline with web/admin projects
|
- [x] [P1] Playwright baseline with web/admin projects
|
||||||
- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
|
- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
|
||||||
- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
|
- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
|
||||||
- [ ] [P1] RBAC policy unit tests and permission regression suite
|
- [~] [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
|
- [ ] [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
|
### Delivery Pipeline And Runtime
|
||||||
|
|
||||||
- [x] [P2] Theoretical Gitea Actions workflow scaffold (`.gitea/workflows/ci-cd-theoretical.yml`)
|
- [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/*`
|
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
|
||||||
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
|
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
|
||||||
- [x] [P2] Changelog scaffold and generation scripts (`CHANGELOG.md`, `bun run changelog:*`)
|
- [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
|
- [ ] [P1] Release tagging and changelog publication policy in CI
|
||||||
|
|
||||||
## MVP 1: Core CMS Business Features
|
## 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 enrichment metadata (alt text, copyright, author, source, tags)
|
||||||
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
||||||
- [ ] [P1] Users management (invite, roles, status)
|
- [ ] [P1] Users management (invite, roles, status)
|
||||||
|
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
|
||||||
|
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
|
||||||
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
||||||
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
||||||
- [ ] [P1] Header banner management (message, CTA, active window)
|
- [ ] [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] Dynamic page rendering from CMS page entities
|
||||||
- [ ] [P1] Navigation rendering from managed menu structure
|
- [ ] [P1] Navigation rendering from managed menu structure
|
||||||
- [ ] [P1] Media entity rendering with enrichment data
|
- [ ] [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
|
- [ ] [P2] Artwork views and listing filters
|
||||||
- [ ] [P1] Commission request submission flow
|
- [ ] [P1] Commission request submission flow
|
||||||
- [ ] [P1] Header banner render logic and fallbacks
|
- [ ] [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] Unit tests for content schemas and service logic
|
||||||
- [ ] [P1] Component tests for admin forms (pages/media/navigation)
|
- [ ] [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: create page, publish, see on public app
|
||||||
- [ ] [P1] E2E happy paths: media upload + artwork refinement display
|
- [ ] [P1] E2E happy paths: media upload + artwork refinement display
|
||||||
- [ ] [P1] E2E happy paths: commissions kanban transitions
|
- [ ] [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
|
- [ ] [P1] Audit log for key content operations
|
||||||
- [ ] [P2] Revision history for pages/navigation/media metadata
|
- [ ] [P2] Revision history for pages/navigation/media metadata
|
||||||
- [ ] [P1] Permission matrix refinement with granular scopes
|
- [ ] [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
|
- [ ] [P2] Error boundaries and UX fallback states
|
||||||
|
|
||||||
### Public App
|
### Public App
|
||||||
@@ -141,12 +185,26 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [ ] [P2] Load/perf tests for key public routes
|
- [ ] [P2] Load/perf tests for key public routes
|
||||||
- [ ] [P2] Flake tracking and quarantine policy for e2e
|
- [ ] [P2] Flake tracking and quarantine policy for e2e
|
||||||
- [ ] [P1] Coverage thresholds and enforcement policy
|
- [ ] [P1] Coverage thresholds and enforcement policy
|
||||||
|
- [ ] [P1] Locale matrix regression suite for critical user journeys
|
||||||
|
|
||||||
## Discovery Log
|
## Discovery Log
|
||||||
|
|
||||||
- [2026-02-10] Prisma client must be generated before app/e2e startup to avoid runtime module errors.
|
- [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] `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] 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`).
|
||||||
|
- [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`).
|
||||||
|
|
||||||
## How We Use This File
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
@@ -7,30 +7,33 @@
|
|||||||
"dev": "bun --env-file=../../.env next dev --port 3001",
|
"dev": "bun --env-file=../../.env next dev --port 3001",
|
||||||
"build": "bun --env-file=../../.env next build",
|
"build": "bun --env-file=../../.env next build",
|
||||||
"start": "bun --env-file=../../.env next start --port 3001",
|
"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",
|
"lint": "biome check src",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cms/content": "workspace:*",
|
"@cms/content": "workspace:*",
|
||||||
"@cms/db": "workspace:*",
|
"@cms/db": "workspace:*",
|
||||||
|
"@cms/i18n": "workspace:*",
|
||||||
"@cms/ui": "workspace:*",
|
"@cms/ui": "workspace:*",
|
||||||
"@tanstack/react-form": "latest",
|
"@tanstack/react-form": "1.28.0",
|
||||||
"@tanstack/react-query": "latest",
|
"@tanstack/react-query": "5.90.20",
|
||||||
"@tanstack/react-query-devtools": "latest",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@tanstack/react-table": "latest",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"next": "latest",
|
"better-auth": "1.4.18",
|
||||||
"react": "latest",
|
"next": "16.1.6",
|
||||||
"react-dom": "latest",
|
"react": "19.2.4",
|
||||||
"zustand": "latest"
|
"react-dom": "19.2.4",
|
||||||
|
"zustand": "5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@tailwindcss/postcss": "latest",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@types/node": "latest",
|
"@types/node": "25.2.2",
|
||||||
"@types/react": "latest",
|
"@types/react": "19.2.13",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "19.2.3",
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "4.1.18",
|
||||||
"typescript": "latest"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/admin/scripts/seed-support-user.ts
Normal file
11
apps/admin/scripts/seed-support-user.ts
Normal 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)
|
||||||
|
})
|
||||||
252
apps/admin/src/app/api/auth/[...all]/route.ts
Normal file
252
apps/admin/src/app/api/auth/[...all]/route.ts
Normal 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)
|
||||||
|
}
|
||||||
34
apps/admin/src/app/commissions/page.tsx
Normal file
34
apps/admin/src/app/commissions/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import { Providers } from "./providers"
|
import { Providers } from "./providers"
|
||||||
|
|
||||||
@@ -9,11 +10,16 @@ export const metadata: Metadata = {
|
|||||||
description: "Admin dashboard for the CMS monorepo",
|
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 (
|
return (
|
||||||
<html lang="en">
|
<html lang={locale}>
|
||||||
<body>
|
<body>
|
||||||
<Providers>{children}</Providers>
|
<Providers locale={locale} messages={messages}>
|
||||||
|
{children}
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
318
apps/admin/src/app/login/login-form.tsx
Normal file
318
apps/admin/src/app/login/login-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
apps/admin/src/app/login/page.tsx
Normal file
36
apps/admin/src/app/login/page.tsx
Normal 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" />
|
||||||
|
}
|
||||||
36
apps/admin/src/app/logout-button.tsx
Normal file
36
apps/admin/src/app/logout-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
apps/admin/src/app/media/page.tsx
Normal file
34
apps/admin/src/app/media/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { Button } from "@cms/ui/button"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
import Link from "next/link"
|
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 const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function AdminHomePage() {
|
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||||
const posts = await listPosts()
|
|
||||||
|
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 (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-6 py-16">
|
<AdminShell
|
||||||
<header className="space-y-3">
|
role={role}
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
activePath="/"
|
||||||
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
|
badge={t("dashboard.badge", "Admin App")}
|
||||||
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
|
title={t("dashboard.title", "Content Dashboard")}
|
||||||
<div className="pt-2">
|
description={t("dashboard.description", "Manage posts from a dedicated admin surface.")}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/todo"
|
href="/todo"
|
||||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
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>
|
</Link>
|
||||||
</div>
|
<Link
|
||||||
</header>
|
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">
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-medium">Posts</h2>
|
<div className="flex items-center justify-between">
|
||||||
<Button>Create post</Button>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-lg font-medium">{post.title}</h3>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
|
<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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
34
apps/admin/src/app/pages/page.tsx
Normal file
34
apps/admin/src/app/pages/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { AppLocale } from "@cms/i18n"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import type { AdminMessages } from "@/i18n/messages"
|
||||||
|
import { AdminI18nProvider } from "@/providers/admin-i18n-provider"
|
||||||
import { QueryProvider } from "@/providers/query-provider"
|
import { QueryProvider } from "@/providers/query-provider"
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({
|
||||||
return <QueryProvider>{children}</QueryProvider>
|
children,
|
||||||
|
locale,
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
locale: AppLocale
|
||||||
|
messages: AdminMessages
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdminI18nProvider locale={locale} messages={messages}>
|
||||||
|
<QueryProvider>{children}</QueryProvider>
|
||||||
|
</AdminI18nProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
40
apps/admin/src/app/register/page.tsx
Normal file
40
apps/admin/src/app/register/page.tsx
Normal 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" />
|
||||||
|
}
|
||||||
180
apps/admin/src/app/settings/page.tsx
Normal file
180
apps/admin/src/app/settings/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
apps/admin/src/app/support/[key]/page.tsx
Normal file
23
apps/admin/src/app/support/[key]/page.tsx
Normal 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" />
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { readFile } from "node:fs/promises"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import { AdminShell } from "@/components/admin-shell"
|
||||||
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type TodoState = "done" | "partial" | "planned"
|
type TodoState = "done" | "partial" | "planned"
|
||||||
@@ -401,6 +404,12 @@ function filterButtonClass(active: boolean): string {
|
|||||||
export default async function AdminTodoPage(props: {
|
export default async function AdminTodoPage(props: {
|
||||||
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
||||||
}) {
|
}) {
|
||||||
|
const role = await requirePermissionForRoute({
|
||||||
|
nextPath: "/todo",
|
||||||
|
permission: "roadmap:read",
|
||||||
|
scope: "global",
|
||||||
|
})
|
||||||
|
|
||||||
const content = await getTodoMarkdown()
|
const content = await getTodoMarkdown()
|
||||||
const sections = parseTodo(content)
|
const sections = parseTodo(content)
|
||||||
const progress = getProgressCounts(sections)
|
const progress = getProgressCounts(sections)
|
||||||
@@ -420,26 +429,21 @@ export default async function AdminTodoPage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-6 py-12">
|
<AdminShell
|
||||||
<header className="space-y-4">
|
role={role}
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
activePath="/todo"
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
badge="Admin App"
|
||||||
<div className="space-y-2">
|
title="Roadmap and Progress"
|
||||||
<h1 className="text-4xl font-semibold tracking-tight">Roadmap and Progress</h1>
|
description="Structured view from root TODO.md (single source of truth)."
|
||||||
<p className="text-neutral-600">
|
actions={
|
||||||
Structured view from root `TODO.md` (single source of truth).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
Back to dashboard
|
Back to dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
</header>
|
>
|
||||||
|
|
||||||
<section className="rounded-xl border border-neutral-200 bg-neutral-50 p-5">
|
<section className="rounded-xl border border-neutral-200 bg-neutral-50 p-5">
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
<p className="text-sm font-medium text-neutral-600">Weighted completion</p>
|
<p className="text-sm font-medium text-neutral-600">Weighted completion</p>
|
||||||
@@ -593,6 +597,6 @@ export default async function AdminTodoPage(props: {
|
|||||||
{content}
|
{content}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</main>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
57
apps/admin/src/app/unauthorized/page.tsx
Normal file
57
apps/admin/src/app/unauthorized/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
apps/admin/src/app/users/page.tsx
Normal file
34
apps/admin/src/app/users/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
apps/admin/src/app/welcome/page.tsx
Normal file
34
apps/admin/src/app/welcome/page.tsx
Normal 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" />
|
||||||
|
}
|
||||||
41
apps/admin/src/components/admin-locale-switcher.tsx
Normal file
41
apps/admin/src/components/admin-locale-switcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
apps/admin/src/components/admin-section-placeholder.tsx
Normal file
40
apps/admin/src/components/admin-section-placeholder.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
apps/admin/src/components/admin-shell.tsx
Normal file
117
apps/admin/src/components/admin-shell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
apps/admin/src/i18n/messages.test.ts
Normal file
147
apps/admin/src/i18n/messages.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
27
apps/admin/src/i18n/messages.ts
Normal file
27
apps/admin/src/i18n/messages.ts
Normal 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
|
||||||
|
}
|
||||||
20
apps/admin/src/i18n/server.ts
Normal file
20
apps/admin/src/i18n/server.ts
Normal 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
|
||||||
|
}
|
||||||
1
apps/admin/src/i18n/shared.ts
Normal file
1
apps/admin/src/i18n/shared.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const ADMIN_LOCALE_COOKIE = "cms_admin_locale"
|
||||||
42
apps/admin/src/lib/access-server.ts
Normal file
42
apps/admin/src/lib/access-server.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/admin/src/lib/access.test.ts
Normal file
43
apps/admin/src/lib/access.test.ts
Normal 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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
149
apps/admin/src/lib/access.ts
Normal file
149
apps/admin/src/lib/access.ts
Normal 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
|
||||||
|
}
|
||||||
522
apps/admin/src/lib/auth/server.ts
Normal file
522
apps/admin/src/lib/auth/server.ts
Normal 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)
|
||||||
|
}
|
||||||
30
apps/admin/src/lib/route-guards.ts
Normal file
30
apps/admin/src/lib/route-guards.ts
Normal 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
|
||||||
|
}
|
||||||
132
apps/admin/src/messages/de.json
Normal file
132
apps/admin/src/messages/de.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/admin/src/messages/en.json
Normal file
132
apps/admin/src/messages/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/admin/src/messages/es.json
Normal file
132
apps/admin/src/messages/es.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/admin/src/messages/fr.json
Normal file
132
apps/admin/src/messages/fr.json
Normal 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": "L’inscription 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 d’administration.",
|
||||||
|
"signUpUser": "L’auto-inscription est activée pour les utilisateurs admin.",
|
||||||
|
"signUpDisabled": "L’auto-inscription est actuellement désactivée par un administrateur."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Nom",
|
||||||
|
"emailOrUsername": "E-mail ou nom d’utilisateur",
|
||||||
|
"email": "E-mail",
|
||||||
|
"username": "Nom d’utilisateur (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 d’un compte ?",
|
||||||
|
"register": "S’inscrire",
|
||||||
|
"alreadyHaveAccount": "Vous avez déjà un compte ?",
|
||||||
|
"goToSignIn": "Aller à la connexion"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"ownerCreated": "Compte owner créé. L’inscription est maintenant désactivée.",
|
||||||
|
"accountCreated": "Compte créé.",
|
||||||
|
"registrationDisabled": "L’inscription est désactivée pour cette instance admin. Demandez à un administrateur de créer un compte ou de réactiver l’auto-inscription."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Le nom est requis pour créer un compte",
|
||||||
|
"signInFailed": "Échec de la connexion",
|
||||||
|
"signUpFailed": "Échec de l’inscription",
|
||||||
|
"networkSignIn": "Erreur réseau lors de la connexion",
|
||||||
|
"networkSignUp": "Erreur réseau lors de l’inscription"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"badge": "Paramètres Admin",
|
||||||
|
"title": "Paramètres",
|
||||||
|
"description": "Gérez les politiques d’exécution pour l’authentification et l’onboarding de l’admin.",
|
||||||
|
"actions": {
|
||||||
|
"backToDashboard": "Retour au tableau de bord"
|
||||||
|
},
|
||||||
|
"registration": {
|
||||||
|
"title": "Auto-inscription admin",
|
||||||
|
"description": "Lorsqu’elle est activée, /register peut créer des comptes admin supplémentaires après l’initialisation du premier owner.",
|
||||||
|
"currentStatusLabel": "Statut actuel",
|
||||||
|
"status": {
|
||||||
|
"enabled": "Activé",
|
||||||
|
"disabled": "Désactivé"
|
||||||
|
},
|
||||||
|
"checkboxLabel": "Autoriser l’auto-inscription sur /register pour les utilisateurs admin",
|
||||||
|
"actions": {
|
||||||
|
"save": "Enregistrer la politique d’inscription"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"updated": "Politique d’inscription mise à jour."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"updateFailed": "Échec de l’enregistrement 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 d’administration 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/admin/src/providers/admin-i18n-provider.tsx
Normal file
53
apps/admin/src/providers/admin-i18n-provider.tsx
Normal 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
46
apps/admin/src/proxy.ts
Normal 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).*)"],
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin"
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts")
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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)
|
||||||
|
|||||||
@@ -13,22 +13,24 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cms/content": "workspace:*",
|
"@cms/content": "workspace:*",
|
||||||
"@cms/db": "workspace:*",
|
"@cms/db": "workspace:*",
|
||||||
|
"@cms/i18n": "workspace:*",
|
||||||
"@cms/ui": "workspace:*",
|
"@cms/ui": "workspace:*",
|
||||||
"@tanstack/react-query": "latest",
|
"@tanstack/react-query": "5.90.20",
|
||||||
"@tanstack/react-query-devtools": "latest",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"next": "latest",
|
"next": "16.1.6",
|
||||||
"react": "latest",
|
"next-intl": "4.4.0",
|
||||||
"react-dom": "latest",
|
"react": "19.2.4",
|
||||||
"zustand": "latest"
|
"react-dom": "19.2.4",
|
||||||
|
"zustand": "5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@tailwindcss/postcss": "latest",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@types/node": "latest",
|
"@types/node": "25.2.2",
|
||||||
"@types/react": "latest",
|
"@types/react": "19.2.13",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "19.2.3",
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "4.1.18",
|
||||||
"typescript": "latest"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/web/src/app/[locale]/about/page.tsx
Normal file
13
apps/web/src/app/[locale]/about/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
|
export default async function AboutPage() {
|
||||||
|
const t = await getTranslations("About")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||||
|
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/contact/page.tsx
Normal file
13
apps/web/src/app/[locale]/contact/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
|
export default async function ContactPage() {
|
||||||
|
const t = await getTranslations("Contact")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||||
|
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
apps/web/src/app/[locale]/layout.tsx
Normal file
61
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { getPublicHeaderBanner } from "@cms/db"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { PublicHeaderBanner } from "@/components/public-header-banner"
|
||||||
|
import { PublicSiteFooter } from "@/components/public-site-footer"
|
||||||
|
import { PublicSiteHeader } from "@/components/public-site-header"
|
||||||
|
import { routing } from "@/i18n/routing"
|
||||||
|
import { Providers } from "../providers"
|
||||||
|
|
||||||
|
type LocaleLayoutProps = {
|
||||||
|
children: ReactNode
|
||||||
|
params: Promise<{
|
||||||
|
locale: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: LocaleLayoutProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
|
||||||
|
if (!hasLocale(routing.locales, locale)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations({
|
||||||
|
locale,
|
||||||
|
namespace: "Seo",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("description"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("description"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
|
||||||
|
if (!hasLocale(routing.locales, locale)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = await getPublicHeaderBanner()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale}>
|
||||||
|
<Providers>
|
||||||
|
<PublicHeaderBanner banner={banner} />
|
||||||
|
<PublicSiteHeader />
|
||||||
|
<main>{children}</main>
|
||||||
|
<PublicSiteFooter />
|
||||||
|
</Providers>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
import { listPosts } from "@cms/db"
|
import { listPosts } from "@cms/db"
|
||||||
import { Button } from "@cms/ui/button"
|
import { Button } from "@cms/ui/button"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const posts = await listPosts()
|
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16">
|
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-16">
|
||||||
<header className="space-y-3">
|
<header className="space-y-3">
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Web App</p>
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||||
<h1 className="text-4xl font-semibold tracking-tight">Your Next.js CMS Frontend</h1>
|
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||||
<p className="text-neutral-600">
|
<p className="text-neutral-600">{t("description")}</p>
|
||||||
This page reads posts through the shared database package.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
|
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-medium">Latest posts</h2>
|
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
|
||||||
<Button variant="secondary">Explore</Button>
|
<Button variant="secondary">{t("explore")}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
@@ -27,11 +26,11 @@ export default async function HomePage() {
|
|||||||
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
<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>
|
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
|
||||||
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2,19 +2,37 @@ import type { Metadata } from "next"
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import { Providers } from "./providers"
|
|
||||||
|
const metadataBase = new URL(process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000")
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase,
|
||||||
|
title: {
|
||||||
|
default: "CMS Web",
|
||||||
|
template: "%s | CMS Web",
|
||||||
|
},
|
||||||
|
description: "Public frontend for the CMS monorepo",
|
||||||
|
applicationName: "CMS Web",
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
siteName: "CMS Web",
|
||||||
title: "CMS Web",
|
title: "CMS Web",
|
||||||
description: "Public frontend for the CMS monorepo",
|
description: "Public frontend for the CMS monorepo",
|
||||||
|
url: metadataBase,
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>{children}</body>
|
||||||
<Providers>{children}</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/web/src/app/robots.ts
Normal file
13
apps/web/src/app/robots.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MetadataRoute } from "next"
|
||||||
|
|
||||||
|
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/web/src/app/sitemap.ts
Normal file
14
apps/web/src/app/sitemap.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { MetadataRoute } from "next"
|
||||||
|
|
||||||
|
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
|
||||||
|
|
||||||
|
const publicRoutes = ["/", "/about", "/contact"]
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return publicRoutes.map((route) => ({
|
||||||
|
url: `${baseUrl}${route}`,
|
||||||
|
lastModified: now,
|
||||||
|
}))
|
||||||
|
}
|
||||||
50
apps/web/src/components/language-switcher.tsx
Normal file
50
apps/web/src/components/language-switcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
apps/web/src/components/public-header-banner.tsx
Normal file
25
apps/web/src/components/public-header-banner.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PublicHeaderBanner as PublicHeaderBannerData } from "@cms/db"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
type PublicHeaderBannerProps = {
|
||||||
|
banner: PublicHeaderBannerData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicHeaderBanner({ banner }: PublicHeaderBannerProps) {
|
||||||
|
if (!banner) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-amber-200 bg-amber-50">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-6 py-2 text-sm text-amber-900">
|
||||||
|
<p>{banner.message}</p>
|
||||||
|
{banner.ctaLabel && banner.ctaHref ? (
|
||||||
|
<Link href={banner.ctaHref} className="font-medium underline underline-offset-2">
|
||||||
|
{banner.ctaLabel}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
apps/web/src/components/public-site-footer.tsx
Normal file
21
apps/web/src/components/public-site-footer.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
export function PublicSiteFooter() {
|
||||||
|
const t = useTranslations("Layout")
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-neutral-200 bg-neutral-50">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-2 px-6 py-4 text-sm text-neutral-600">
|
||||||
|
<p>
|
||||||
|
{t("footer.copyright", {
|
||||||
|
year,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p>{t("footer.tagline")}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
apps/web/src/components/public-site-header.tsx
Normal file
44
apps/web/src/components/public-site-header.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Link } from "@/i18n/navigation"
|
||||||
|
|
||||||
|
import { LanguageSwitcher } from "./language-switcher"
|
||||||
|
|
||||||
|
export function PublicSiteHeader() {
|
||||||
|
const t = useTranslations("Layout")
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: t("nav.home") },
|
||||||
|
{ href: "/about", label: t("nav.about") },
|
||||||
|
{ href: "/contact", label: t("nav.contact") },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700"
|
||||||
|
>
|
||||||
|
{t("brand")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap items-center gap-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/web/src/i18n/navigation.ts
Normal file
5
apps/web/src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createNavigation } from "next-intl/navigation"
|
||||||
|
|
||||||
|
import { routing } from "./routing"
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
|
||||||
14
apps/web/src/i18n/request.ts
Normal file
14
apps/web/src/i18n/request.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
8
apps/web/src/i18n/routing.ts
Normal file
8
apps/web/src/i18n/routing.ts
Normal 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",
|
||||||
|
})
|
||||||
45
apps/web/src/messages/de.json
Normal file
45
apps/web/src/messages/de.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Layout": {
|
||||||
|
"brand": "CMS Web",
|
||||||
|
"nav": {
|
||||||
|
"home": "Start",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {year} CMS Web",
|
||||||
|
"tagline": "Powered by Next.js, Bun, Prisma und TanStack."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Seo": {
|
||||||
|
"title": "CMS Web",
|
||||||
|
"description": "Öffentliches Frontend für das CMS-Monorepo."
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"badge": "Über uns",
|
||||||
|
"title": "Über dieses Projekt",
|
||||||
|
"description": "Diese öffentliche App ist die Frontend-Oberfläche für CMS-gesteuerte Inhalte und kommende dynamische Seiten."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"badge": "Kontakt",
|
||||||
|
"title": "Kontakt",
|
||||||
|
"description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt."
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/web/src/messages/en.json
Normal file
45
apps/web/src/messages/en.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Layout": {
|
||||||
|
"brand": "CMS Web",
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {year} CMS Web",
|
||||||
|
"tagline": "Powered by Next.js, Bun, Prisma, and TanStack."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Seo": {
|
||||||
|
"title": "CMS Web",
|
||||||
|
"description": "Public frontend for the CMS monorepo."
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"badge": "About",
|
||||||
|
"title": "About this project",
|
||||||
|
"description": "This public app is the frontend surface for CMS-driven content and upcoming dynamic pages."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"badge": "Contact",
|
||||||
|
"title": "Contact",
|
||||||
|
"description": "Contact and commission flows will be introduced in upcoming MVP steps."
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/web/src/messages/es.json
Normal file
45
apps/web/src/messages/es.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Layout": {
|
||||||
|
"brand": "CMS Web",
|
||||||
|
"nav": {
|
||||||
|
"home": "Inicio",
|
||||||
|
"about": "Acerca de",
|
||||||
|
"contact": "Contacto"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {year} CMS Web",
|
||||||
|
"tagline": "Impulsado por Next.js, Bun, Prisma y TanStack."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Seo": {
|
||||||
|
"title": "CMS Web",
|
||||||
|
"description": "Frontend público para el monorepo CMS."
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"badge": "Acerca de",
|
||||||
|
"title": "Sobre este proyecto",
|
||||||
|
"description": "Esta app pública es la superficie frontend para contenido gestionado por CMS y próximas páginas dinámicas."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"badge": "Contacto",
|
||||||
|
"title": "Contacto",
|
||||||
|
"description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP."
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/web/src/messages/fr.json
Normal file
45
apps/web/src/messages/fr.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Layout": {
|
||||||
|
"brand": "CMS Web",
|
||||||
|
"nav": {
|
||||||
|
"home": "Accueil",
|
||||||
|
"about": "À propos",
|
||||||
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {year} CMS Web",
|
||||||
|
"tagline": "Propulsé par Next.js, Bun, Prisma et TanStack."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Seo": {
|
||||||
|
"title": "CMS Web",
|
||||||
|
"description": "Frontend public pour le monorepo CMS."
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"badge": "À propos",
|
||||||
|
"title": "À propos de ce projet",
|
||||||
|
"description": "Cette application publique est la surface frontend pour le contenu piloté par CMS et les futures pages dynamiques."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"badge": "Contact",
|
||||||
|
"title": "Contact",
|
||||||
|
"description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP."
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/web/src/proxy.ts
Normal file
14
apps/web/src/proxy.ts
Normal 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|.*\\..*).*)"],
|
||||||
|
}
|
||||||
12
apps/web/src/store/locale.test.ts
Normal file
12
apps/web/src/store/locale.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
12
apps/web/src/store/locale.ts
Normal file
12
apps/web/src/store/locale.ts
Normal 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 }),
|
||||||
|
}))
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
"!**/coverage",
|
"!**/coverage",
|
||||||
"!**/playwright-report",
|
"!**/playwright-report",
|
||||||
"!**/test-results",
|
"!**/test-results",
|
||||||
"!**/next-env.d.ts"
|
"!**/prisma/generated",
|
||||||
|
"!**/next-env.d.ts",
|
||||||
|
"!**/.vitepress/cache",
|
||||||
|
"!**/.vitepress/dist"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
531
bun.lock
531
bun.lock
@@ -5,22 +5,23 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "cms-monorepo",
|
"name": "cms-monorepo",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@commitlint/cli": "latest",
|
"@commitlint/cli": "20.4.1",
|
||||||
"@commitlint/config-conventional": "latest",
|
"@commitlint/config-conventional": "20.4.1",
|
||||||
"@playwright/test": "latest",
|
"@playwright/test": "1.58.2",
|
||||||
"@testing-library/jest-dom": "latest",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/react": "latest",
|
"@testing-library/react": "16.3.2",
|
||||||
"@testing-library/user-event": "latest",
|
"@testing-library/user-event": "14.6.1",
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "5.1.3",
|
||||||
"@vitest/coverage-istanbul": "latest",
|
"@vitest/coverage-istanbul": "4.0.18",
|
||||||
"conventional-changelog-cli": "latest",
|
"conventional-changelog-cli": "5.0.0",
|
||||||
"jsdom": "latest",
|
"jsdom": "28.0.0",
|
||||||
"msw": "latest",
|
"msw": "2.12.9",
|
||||||
"turbo": "latest",
|
"turbo": "2.8.3",
|
||||||
"typescript": "latest",
|
"typescript": "5.9.3",
|
||||||
"vite-tsconfig-paths": "latest",
|
"vite-tsconfig-paths": "6.1.0",
|
||||||
"vitest": "latest",
|
"vitepress": "1.6.4",
|
||||||
|
"vitest": "4.0.18",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/admin": {
|
"apps/admin": {
|
||||||
@@ -29,25 +30,27 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cms/content": "workspace:*",
|
"@cms/content": "workspace:*",
|
||||||
"@cms/db": "workspace:*",
|
"@cms/db": "workspace:*",
|
||||||
|
"@cms/i18n": "workspace:*",
|
||||||
"@cms/ui": "workspace:*",
|
"@cms/ui": "workspace:*",
|
||||||
"@tanstack/react-form": "latest",
|
"@tanstack/react-form": "1.28.0",
|
||||||
"@tanstack/react-query": "latest",
|
"@tanstack/react-query": "5.90.20",
|
||||||
"@tanstack/react-query-devtools": "latest",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@tanstack/react-table": "latest",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"next": "latest",
|
"better-auth": "1.4.18",
|
||||||
"react": "latest",
|
"next": "16.1.6",
|
||||||
"react-dom": "latest",
|
"react": "19.2.4",
|
||||||
"zustand": "latest",
|
"react-dom": "19.2.4",
|
||||||
|
"zustand": "5.0.11",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "latest",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@types/node": "latest",
|
"@types/node": "25.2.2",
|
||||||
"@types/react": "latest",
|
"@types/react": "19.2.13",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "19.2.3",
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "4.1.18",
|
||||||
"typescript": "latest",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
@@ -56,23 +59,25 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cms/content": "workspace:*",
|
"@cms/content": "workspace:*",
|
||||||
"@cms/db": "workspace:*",
|
"@cms/db": "workspace:*",
|
||||||
|
"@cms/i18n": "workspace:*",
|
||||||
"@cms/ui": "workspace:*",
|
"@cms/ui": "workspace:*",
|
||||||
"@tanstack/react-query": "latest",
|
"@tanstack/react-query": "5.90.20",
|
||||||
"@tanstack/react-query-devtools": "latest",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"next": "latest",
|
"next": "16.1.6",
|
||||||
"react": "latest",
|
"next-intl": "4.4.0",
|
||||||
"react-dom": "latest",
|
"react": "19.2.4",
|
||||||
"zustand": "latest",
|
"react-dom": "19.2.4",
|
||||||
|
"zustand": "5.0.11",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "latest",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@types/node": "latest",
|
"@types/node": "25.2.2",
|
||||||
"@types/react": "latest",
|
"@types/react": "19.2.13",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "19.2.3",
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "4.1.18",
|
||||||
"typescript": "latest",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/config": {
|
"packages/config": {
|
||||||
@@ -83,12 +88,24 @@
|
|||||||
"name": "@cms/content",
|
"name": "@cms/content",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "latest",
|
"zod": "4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@cms/config": "workspace:*",
|
"@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": {
|
"packages/db": {
|
||||||
@@ -96,38 +113,48 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cms/content": "workspace:*",
|
"@cms/content": "workspace:*",
|
||||||
"@prisma/adapter-pg": "latest",
|
"@cms/crud": "workspace:*",
|
||||||
"@prisma/client": "latest",
|
"@prisma/adapter-pg": "7.3.0",
|
||||||
"pg": "latest",
|
"@prisma/client": "7.3.0",
|
||||||
"zod": "latest",
|
"pg": "8.18.0",
|
||||||
|
"zod": "4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@types/node": "latest",
|
"@types/node": "25.2.2",
|
||||||
"@types/pg": "latest",
|
"@types/pg": "8.16.0",
|
||||||
"prisma": "latest",
|
"prisma": "7.3.0",
|
||||||
"typescript": "latest",
|
"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": {
|
"packages/ui": {
|
||||||
"name": "@cms/ui",
|
"name": "@cms/ui",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "latest",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "latest",
|
"clsx": "2.1.1",
|
||||||
"tailwind-merge": "latest",
|
"tailwind-merge": "3.4.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@types/react": "latest",
|
"@types/react": "19.2.13",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "19.2.3",
|
||||||
"typescript": "latest",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "latest",
|
"react": "19.2.4",
|
||||||
"react-dom": "latest",
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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/content": ["@cms/content@workspace:packages/content"],
|
||||||
|
|
||||||
|
"@cms/crud": ["@cms/crud@workspace:packages/crud"],
|
||||||
|
|
||||||
"@cms/db": ["@cms/db@workspace:packages/db"],
|
"@cms/db": ["@cms/db@workspace:packages/db"],
|
||||||
|
|
||||||
|
"@cms/i18n": ["@cms/i18n@workspace:packages/i18n"],
|
||||||
|
|
||||||
"@cms/ui": ["@cms/ui@workspace:packages/ui"],
|
"@cms/ui": ["@cms/ui@workspace:packages/ui"],
|
||||||
|
|
||||||
"@cms/web": ["@cms/web@workspace:apps/web"],
|
"@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=="],
|
"@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": ["@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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/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=="],
|
"@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/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-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/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=="],
|
"@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=="],
|
"@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=="],
|
"add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"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=="],
|
"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-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||||
@@ -726,6 +909,8 @@
|
|||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
|
"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=="],
|
"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-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-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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"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-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=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
||||||
|
|
||||||
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
"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=="],
|
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
"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=="],
|
"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=="],
|
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
|
||||||
|
|
||||||
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
docs/.vitepress/config.mts
Normal file
45
docs/.vitepress/config.mts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
23
docs/admin-user-guides/index.md
Normal file
23
docs/admin-user-guides/index.md
Normal 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
23
docs/architecture.md
Normal 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
54
docs/getting-started.md
Normal 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
26
docs/index.md
Normal 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)
|
||||||
45
docs/product-engineering/auth-baseline.md
Normal file
45
docs/product-engineering/auth-baseline.md
Normal 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.
|
||||||
40
docs/product-engineering/crud-baseline.md
Normal file
40
docs/product-engineering/crud-baseline.md
Normal 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.
|
||||||
21
docs/product-engineering/i18n-baseline.md
Normal file
21
docs/product-engineering/i18n-baseline.md
Normal 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`.
|
||||||
25
docs/product-engineering/index.md
Normal file
25
docs/product-engineering/index.md
Normal 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)
|
||||||
62
docs/product-engineering/rbac-permission-model.md
Normal file
62
docs/product-engineering/rbac-permission-model.md
Normal 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
|
||||||
33
docs/product-engineering/testing-strategy.md
Normal file
33
docs/product-engineering/testing-strategy.md
Normal 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
17
docs/public-api/index.md
Normal 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
30
docs/workflow.md
Normal 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
29
e2e/i18n.pw.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,5 +8,12 @@ test("smoke", async ({ page }, testInfo) => {
|
|||||||
return
|
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
20
e2e/support-auth.pw.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
51
package.json
51
package.json
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cms-monorepo",
|
"name": "cms-monorepo",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -10,13 +11,17 @@
|
|||||||
"dev": "bun run db:generate && turbo dev --parallel",
|
"dev": "bun run db:generate && turbo dev --parallel",
|
||||||
"dev:web": "bun run db:generate && bun --filter @cms/web dev",
|
"dev:web": "bun run db:generate && bun --filter @cms/web dev",
|
||||||
"dev:admin": "bun run db:generate && bun --filter @cms/admin 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",
|
"build": "turbo build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "bun run db:generate && playwright test",
|
"test:e2e:prepare": "bun run db:generate && bun run db:migrate:deploy && bun run db:seed",
|
||||||
"test:e2e:headed": "bun run db:generate && playwright test --headed",
|
"test:e2e": "bun run test:e2e:prepare && playwright test",
|
||||||
"test:e2e:ui": "bun run db:generate && playwright test --ui",
|
"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",
|
"commitlint": "commitlint --last",
|
||||||
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0",
|
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0",
|
||||||
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s",
|
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s",
|
||||||
@@ -26,31 +31,35 @@
|
|||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"db:generate": "bun --filter @cms/db db:generate",
|
"db:generate": "bun --filter @cms/db db:generate",
|
||||||
"db:migrate": "bun --filter @cms/db db:migrate",
|
"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:push": "bun --filter @cms/db db:push",
|
||||||
"db:studio": "bun --filter @cms/db db:studio",
|
"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:up": "docker compose -f docker-compose.staging.yml up -d --build",
|
||||||
"docker:staging:down": "docker compose -f docker-compose.staging.yml down",
|
"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:up": "docker compose -f docker-compose.production.yml up -d --build",
|
||||||
"docker:production:down": "docker compose -f docker-compose.production.yml down"
|
"docker:production:down": "docker compose -f docker-compose.production.yml down"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "latest",
|
"@playwright/test": "1.58.2",
|
||||||
"@commitlint/cli": "latest",
|
"@commitlint/cli": "20.4.1",
|
||||||
"@commitlint/config-conventional": "latest",
|
"@commitlint/config-conventional": "20.4.1",
|
||||||
"@testing-library/jest-dom": "latest",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/react": "latest",
|
"@testing-library/react": "16.3.2",
|
||||||
"@testing-library/user-event": "latest",
|
"@testing-library/user-event": "14.6.1",
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "5.1.3",
|
||||||
"@vitest/coverage-istanbul": "latest",
|
"@vitest/coverage-istanbul": "4.0.18",
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"jsdom": "latest",
|
"jsdom": "28.0.0",
|
||||||
"msw": "latest",
|
"msw": "2.12.9",
|
||||||
"conventional-changelog-cli": "latest",
|
"conventional-changelog-cli": "5.0.0",
|
||||||
"turbo": "latest",
|
"turbo": "2.8.3",
|
||||||
"typescript": "latest",
|
"typescript": "5.9.3",
|
||||||
"vite-tsconfig-paths": "latest",
|
"vitepress": "1.6.4",
|
||||||
"vitest": "latest"
|
"vite-tsconfig-paths": "6.1.0",
|
||||||
|
"vitest": "4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./rbac": "./src/rbac.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
@@ -12,11 +13,11 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "latest"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"typescript": "latest"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import { postSchema, upsertPostSchema } from "./index"
|
import { createPostInputSchema, postSchema, updatePostInputSchema, upsertPostSchema } from "./index"
|
||||||
|
|
||||||
describe("content schemas", () => {
|
describe("content schemas", () => {
|
||||||
it("accepts a valid post", () => {
|
it("accepts a valid post", () => {
|
||||||
@@ -17,7 +17,24 @@ describe("content schemas", () => {
|
|||||||
expect(post.slug).toBe("hello-world")
|
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({
|
const result = upsertPostSchema.safeParse({
|
||||||
title: "Hi",
|
title: "Hi",
|
||||||
slug: "x",
|
slug: "x",
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export * from "./rbac"
|
||||||
|
|
||||||
export const postStatusSchema = z.enum(["draft", "published"])
|
export const postStatusSchema = z.enum(["draft", "published"])
|
||||||
|
|
||||||
export const postSchema = z.object({
|
const postMutableFieldsSchema = z.object({
|
||||||
id: z.string().uuid(),
|
|
||||||
title: z.string().min(3).max(180),
|
title: z.string().min(3).max(180),
|
||||||
slug: z.string().min(3).max(180),
|
slug: z.string().min(3).max(180),
|
||||||
excerpt: z.string().max(320).optional(),
|
excerpt: z.string().max(320).optional(),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
status: postStatusSchema,
|
status: postStatusSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const postSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
...postMutableFieldsSchema.shape,
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const upsertPostSchema = postSchema.omit({
|
export const createPostInputSchema = postMutableFieldsSchema
|
||||||
id: true,
|
export const updatePostInputSchema = postMutableFieldsSchema
|
||||||
createdAt: true,
|
.partial()
|
||||||
updatedAt: true,
|
.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 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>
|
export type UpsertPostInput = z.infer<typeof upsertPostSchema>
|
||||||
|
|||||||
31
packages/content/src/rbac.test.ts
Normal file
31
packages/content/src/rbac.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
126
packages/content/src/rbac.ts
Normal file
126
packages/content/src/rbac.ts
Normal 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])
|
||||||
|
}
|
||||||
22
packages/crud/package.json
Normal file
22
packages/crud/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/crud/src/errors.ts
Normal file
41
packages/crud/src/errors.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/crud/src/index.ts
Normal file
3
packages/crud/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./errors"
|
||||||
|
export * from "./service"
|
||||||
|
export * from "./types"
|
||||||
204
packages/crud/src/service.test.ts
Normal file
204
packages/crud/src/service.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
159
packages/crud/src/service.ts
Normal file
159
packages/crud/src/service.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/crud/src/types.ts
Normal file
31
packages/crud/src/types.ts
Normal 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>
|
||||||
|
}
|
||||||
9
packages/crud/tsconfig.json
Normal file
9
packages/crud/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@cms/config/tsconfig/base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -13,24 +13,27 @@
|
|||||||
"db:generate": "bun --env-file=../../.env prisma generate",
|
"db:generate": "bun --env-file=../../.env prisma generate",
|
||||||
"db:migrate": "bun --env-file=../../.env prisma migrate dev --name init",
|
"db:migrate": "bun --env-file=../../.env prisma migrate dev --name init",
|
||||||
"db:migrate:named": "bun --env-file=../../.env prisma migrate dev",
|
"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:push": "bun --env-file=../../.env prisma db push",
|
||||||
"db:studio": "bun --env-file=../../.env prisma studio",
|
"db:studio": "bun --env-file=../../.env prisma studio",
|
||||||
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
|
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cms/crud": "workspace:*",
|
||||||
"@cms/content": "workspace:*",
|
"@cms/content": "workspace:*",
|
||||||
"@prisma/adapter-pg": "latest",
|
"@prisma/adapter-pg": "7.3.0",
|
||||||
"@prisma/client": "latest",
|
"@prisma/client": "7.3.0",
|
||||||
"pg": "latest",
|
"pg": "8.18.0",
|
||||||
"zod": "latest"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cms/config": "workspace:*",
|
"@cms/config": "workspace:*",
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "2.3.14",
|
||||||
"@types/node": "latest",
|
"@types/node": "25.2.2",
|
||||||
"@types/pg": "latest",
|
"@types/pg": "8.16.0",
|
||||||
"prisma": "latest",
|
"prisma": "7.3.0",
|
||||||
"typescript": "latest"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun --env-file=../../.env prisma/seed.ts"
|
"seed": "bun --env-file=../../.env prisma/seed.ts"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user