From 9b068926a9e230278caaa412b146cff71ef05133 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 12:41:44 -0400 Subject: [PATCH 1/5] Containerize the API with a persistent volume (#183) --- .dockerignore | 27 +++++++++++++++++++++++++ .gitignore | 9 +++++++++ Dockerfile | 47 ++++++++++++++++++++++++++++++++++++++++++++ api/README.md | 32 ++++++++++++++++++++++++++++++ api/requirements.txt | 15 ++++++++++++++ deploy/.env.example | 17 ++++++++++++++++ deploy/compose.yaml | 41 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 188 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 api/README.md create mode 100644 api/requirements.txt create mode 100644 deploy/.env.example create mode 100644 deploy/compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c9792a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Keep the image build context minimal: only api/ and the bundled +# custom_components/nestquest/core/ are copied (see the Dockerfile); +# everything else — VCS state, virtualenvs, tests, the frontend, and +# any local secrets or database files — never enters the build. +.git +.gitignore +.venv +**/__pycache__ +**/*.py[cod] +.pytest_cache +**/*.db +**/*.db-wal +**/*.db-shm +# Local configuration — never baked into an image. +.env +**/.env +deploy +design +docs +frontend +orchestration_tests +scripts +templates +tests +uv.lock +hacs.json +*.md diff --git a/.gitignore b/.gitignore index cc771bf..ee069cc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ __pycache__/ .pytest_cache/ *.metadata.json frontend/node_modules/ + +# Feature 17 (API deployment): local secret config and runtime data +# are never committed — compose reads deploy/.env; SQLite/WAL sidecar +# files are container/volume artifacts. +.env +**/.env +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bec18f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Container image for the NestQuest API service (Feature 17). +# +# Build context is the REPOSITORY ROOT (compose sets it), because the +# API loads the bundled domain layer from +# custom_components/nestquest/core/ by file location +# (api/nestquest_core.py): the image must carry both api/ and the +# bundled core at their relative positions. Everything else in the +# repository is excluded via .dockerignore. +# +# Multi-stage so no pip/build toolchain survives into the runtime: +# the builder installs the deployable's own manifest +# (api/requirements.txt) into a staging prefix and the runtime copies +# only the installed packages. The service runs as a non-root user; +# the SQLite database lives on the mounted volume at +# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing +# there — configuration is environment-supplied, never baked in). +FROM python:3.12-slim AS builder + +COPY api/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt + +FROM python:3.12-slim + +# The staged dependency install from the builder (no pip, no wheels). +COPY --from=builder /install /usr/local + +# /app is the repository-root equivalent: api/ resolves imports as +# ``api.*`` and nestquest_core.py finds the bundled core one level up. +WORKDIR /app +COPY api/ ./api/ +COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/ + +# Non-root runtime user; owns the database volume mount point. +RUN useradd --system --create-home --home-dir /home/nestquest nestquest \ + && mkdir -p /var/lib/nestquest \ + && chown nestquest:nestquest /var/lib/nestquest +USER nestquest + +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 + +# The database path and the service token / OIDC settings are supplied +# by the environment (compose env_file), never committed into the image. +ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db +VOLUME /var/lib/nestquest + +EXPOSE 8080 +CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..376da9d --- /dev/null +++ b/api/README.md @@ -0,0 +1,32 @@ +# NestQuest API + +Standalone FastAPI service owning the NestQuest database (Feature 16). +Served with ``uvicorn --factory api.app:create_app``; configuration is +environment-supplied (``api/config.py``) and every variable is required. + +## Runtime dependencies + +``api/requirements.txt`` is the API deployable's OWN dependency +manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept +out of the integration's ``pyproject.toml`` dev group, which exists only +so the monorepo test venv can run the API tests. + +## Container deployment (scaffold) + +The image is built from the repository ROOT context (the API loads the +bundled domain core from ``custom_components/nestquest/core/`` by file +location, so both trees must be in the build context): + + docker build -t nestquest-api:latest . + +Run it with compose — the SQLite file lives on the named volume +``nestquest-db`` so recreating the container keeps household data: + + cp deploy/.env.example deploy/.env # then fill in real values + docker compose -f deploy/compose.yaml up -d --build + +``deploy/.env`` is git-ignored and supplies the secrets (panel service +token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to +``/var/lib/nestquest/nestquest.db`` by compose. The port is bound to +localhost only — TLS/reverse proxy, Authentik, network separation, +backup and restore are later Feature 17 tasks. \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..fed0749 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,15 @@ +# Runtime dependency manifest for the NestQuest API deployable. +# +# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in +# the INTEGRATION's dev group only (so the test venv can exercise the +# API); they are deliberately NOT integration runtime dependencies. +# Per that manifest's own comment, the API deployable declares its own +# runtime dependencies HERE — this file is what the container image +# installs (see the Dockerfile at the repository root). +# +# Compatible ranges mirroring the dev group's floors; the API is a +# server, so these are the only packages its runtime needs. +fastapi>=0.115 +uvicorn>=0.30 +pyjwt[crypto]>=2.9 +httpx>=0.28 diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..ee27088 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,17 @@ +# Example environment for the NestQuest API container deployment. +# +# Copy to ``deploy/.env`` (git-ignored) and fill in REAL values there; +# this example carries placeholder names only and is never a source of +# secrets. Compose loads ``deploy/.env`` into the api service; the +# API fails fast on startup when any of these is unset or empty +# (api/config.py — there are no silent defaults). + +# The static panel-plane service token the HA integration presents as +# a Bearer credential on /api/v1/panel/* routes. Generate one, e.g.: +# python -c "import secrets; print(secrets.token_urlsafe(32))" +NESTQUEST_PANEL_TOKEN= + +# The Authentik OIDC settings the admin plane verifies JWTs against. +NESTQUEST_OIDC_ISSUER= +NESTQUEST_OIDC_AUDIENCE= +NESTQUEST_OIDC_JWKS_URL= \ No newline at end of file diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..ea2be77 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,41 @@ +# Compose deployment scaffold for the NestQuest API service (Feature +# 17). The SQLite database lives on the NAMED VOLUME ``nestquest-db`` +# mounted at /var/lib/nestquest — recreating the container keeps the +# household data (``docker compose down`` leaves named volumes alone). +# +# Configuration is supplied by the environment: the fixed database +# path here, and the secret values (panel service token, Authentik +# OIDC settings) from ``deploy/.env`` — a git-ignored file you create +# from ``deploy/.env.example`` (placeholder names only; never commit +# real values). TLS/reverse proxy, Authentik wiring and network +# separation are later Feature 17 tasks; the port is bound to +# localhost only until those land. +services: + api: + build: + context: .. + dockerfile: Dockerfile + image: nestquest-api:latest + init: true + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + # The SQLite file inside the named volume; fixed, not a secret. + NESTQUEST_DB_PATH: /var/lib/nestquest/nestquest.db + env_file: + # Secret values (NESTQUEST_PANEL_TOKEN, NESTQUEST_OIDC_*); absent + # until you create deploy/.env from the example. + - path: .env + required: false + volumes: + - nestquest-db:/var/lib/nestquest + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + +volumes: + nestquest-db: \ No newline at end of file -- 2.55.0 From f6d222764c1f3319a8906962402e0df83b782073 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Thu, 24 Sep 2026 08:00:04 -0400 Subject: [PATCH 2/5] Task 9d121e76: Reverse proxy and TLS for the API (#214) --- RELEASE_NOTES.md | 150 +- admin/.env.example | 13 + admin/.gitignore | 2 + admin/index.html | 15 + admin/package-lock.json | 3034 +++++++++++++++++ admin/package.json | 28 + admin/public/icons/icon-192.png | Bin 0 -> 2580 bytes admin/public/icons/icon-512.png | Bin 0 -> 8815 bytes admin/public/icons/icon-maskable-512.png | Bin 0 -> 8371 bytes admin/public/manifest.webmanifest | 14 + admin/public/sw.js | 88 + admin/scripts/generate-icons.mjs | 80 + admin/src/App.css | 123 + admin/src/App.test.tsx | 47 + admin/src/App.tsx | 63 + admin/src/AppRoutes.test.tsx | 193 ++ admin/src/AppRoutes.tsx | 211 ++ admin/src/api/client.test.ts | 94 + admin/src/api/client.ts | 93 + admin/src/api/definitions.ts | 174 + admin/src/api/events.test.ts | 162 + admin/src/api/events.ts | 196 ++ admin/src/api/history.ts | 85 + admin/src/api/instances.ts | 23 + admin/src/api/presence.ts | 126 + admin/src/api/snapshot.ts | 55 + admin/src/api/testStream.ts | 39 + admin/src/api/useTransitions.tsx | 61 + admin/src/auth/oidc.test.ts | 173 + admin/src/auth/oidc.ts | 225 ++ admin/src/auth/pkce.test.ts | 41 + admin/src/auth/pkce.ts | 55 + admin/src/config.test.ts | 215 ++ admin/src/config.ts | 221 ++ admin/src/definitions/DefinitionsTab.css | 585 ++++ admin/src/definitions/DefinitionsTab.test.tsx | 1087 ++++++ admin/src/definitions/DefinitionsTab.tsx | 968 ++++++ admin/src/definitions/definitionIcons.tsx | 103 + admin/src/definitions/format.ts | 111 + admin/src/definitions/glyphs.tsx | 67 + admin/src/history/HistoryTab.css | 296 ++ admin/src/history/HistoryTab.test.tsx | 631 ++++ admin/src/history/HistoryTab.tsx | 300 ++ admin/src/history/format.ts | 94 + admin/src/history/glyphs.tsx | 41 + admin/src/index.css | 76 + admin/src/main.tsx | 13 + admin/src/pwa/pwa.test.ts | 396 +++ admin/src/pwa/register.ts | 13 + admin/src/schedule/OverrideEditor.css | 220 ++ admin/src/schedule/OverrideEditor.test.tsx | 533 +++ admin/src/schedule/OverrideEditor.tsx | 348 ++ admin/src/schedule/ScheduleTab.css | 416 +++ admin/src/schedule/ScheduleTab.test.tsx | 276 ++ admin/src/schedule/ScheduleTab.tsx | 557 +++ admin/src/schedule/cycle.test.ts | 115 + admin/src/schedule/cycle.ts | 142 + admin/src/schedule/format.ts | 37 + admin/src/schedule/glyphs.tsx | 85 + admin/src/today/TodayTab.css | 331 ++ admin/src/today/TodayTab.test.tsx | 408 +++ admin/src/today/TodayTab.tsx | 347 ++ admin/src/today/format.ts | 50 + admin/src/today/glyphs.tsx | 61 + admin/tsconfig.json | 16 + admin/vite-sw-plugin.ts | 93 + admin/vite.config.ts | 10 + api/README.md | 12 +- api/routes_admin.py | 545 ++- api/routes_panel.py | 37 +- api/sse.py | 50 + custom_components/nestquest/core/dao_meta.py | 36 +- .../nestquest/core/dao_presence.py | 51 + custom_components/nestquest/core/dao_rules.py | 131 +- .../nestquest/core/materialize.py | 219 +- .../nestquest/core/migrations.py | 31 +- .../nestquest/core/presence_management.py | 79 +- .../nestquest/core/quest_definitions.py | 69 +- custom_components/nestquest/core/schema.py | 3 +- custom_components/nestquest/core/sweep.py | 57 +- custom_components/nestquest/manifest.json | 2 +- deploy/.env.example | 8 +- deploy/compose.yaml | 13 +- docs/ARCHITECTURE.md | 203 ++ orchestration_tests/test_cycle_prompt.py | 30 + orchestration_tests/test_nq_orchestrate.py | 12 + pyproject.toml | 2 +- scripts/nq-orchestrate | 12 + templates/ORCHESTRATOR-PROMPT.template.md | 100 +- tests/test_api_admin_occurrences.py | 431 +++ tests/test_api_admin_presence_consequence.py | 448 +++ tests/test_api_admin_presence_reads.py | 345 ++ tests/test_api_admin_questdef.py | 130 +- tests/test_api_admin_questdef_assignees.py | 431 +++ tests/test_api_admin_questdef_list.py | 299 ++ tests/test_api_admin_snapshot.py | 328 ++ tests/test_api_admin_sse.py | 283 ++ tests/test_api_sse.py | 15 +- tests/test_core_sweep.py | 295 ++ tests/test_dao_rules.py | 67 + tests/test_materialize.py | 127 + tests/test_migrations.py | 95 +- tests/test_schema.py | 25 + uv.lock | 2 +- 104 files changed, 19763 insertions(+), 185 deletions(-) create mode 100644 admin/.env.example create mode 100644 admin/.gitignore create mode 100644 admin/index.html create mode 100644 admin/package-lock.json create mode 100644 admin/package.json create mode 100644 admin/public/icons/icon-192.png create mode 100644 admin/public/icons/icon-512.png create mode 100644 admin/public/icons/icon-maskable-512.png create mode 100644 admin/public/manifest.webmanifest create mode 100644 admin/public/sw.js create mode 100644 admin/scripts/generate-icons.mjs create mode 100644 admin/src/App.css create mode 100644 admin/src/App.test.tsx create mode 100644 admin/src/App.tsx create mode 100644 admin/src/AppRoutes.test.tsx create mode 100644 admin/src/AppRoutes.tsx create mode 100644 admin/src/api/client.test.ts create mode 100644 admin/src/api/client.ts create mode 100644 admin/src/api/definitions.ts create mode 100644 admin/src/api/events.test.ts create mode 100644 admin/src/api/events.ts create mode 100644 admin/src/api/history.ts create mode 100644 admin/src/api/instances.ts create mode 100644 admin/src/api/presence.ts create mode 100644 admin/src/api/snapshot.ts create mode 100644 admin/src/api/testStream.ts create mode 100644 admin/src/api/useTransitions.tsx create mode 100644 admin/src/auth/oidc.test.ts create mode 100644 admin/src/auth/oidc.ts create mode 100644 admin/src/auth/pkce.test.ts create mode 100644 admin/src/auth/pkce.ts create mode 100644 admin/src/config.test.ts create mode 100644 admin/src/config.ts create mode 100644 admin/src/definitions/DefinitionsTab.css create mode 100644 admin/src/definitions/DefinitionsTab.test.tsx create mode 100644 admin/src/definitions/DefinitionsTab.tsx create mode 100644 admin/src/definitions/definitionIcons.tsx create mode 100644 admin/src/definitions/format.ts create mode 100644 admin/src/definitions/glyphs.tsx create mode 100644 admin/src/history/HistoryTab.css create mode 100644 admin/src/history/HistoryTab.test.tsx create mode 100644 admin/src/history/HistoryTab.tsx create mode 100644 admin/src/history/format.ts create mode 100644 admin/src/history/glyphs.tsx create mode 100644 admin/src/index.css create mode 100644 admin/src/main.tsx create mode 100644 admin/src/pwa/pwa.test.ts create mode 100644 admin/src/pwa/register.ts create mode 100644 admin/src/schedule/OverrideEditor.css create mode 100644 admin/src/schedule/OverrideEditor.test.tsx create mode 100644 admin/src/schedule/OverrideEditor.tsx create mode 100644 admin/src/schedule/ScheduleTab.css create mode 100644 admin/src/schedule/ScheduleTab.test.tsx create mode 100644 admin/src/schedule/ScheduleTab.tsx create mode 100644 admin/src/schedule/cycle.test.ts create mode 100644 admin/src/schedule/cycle.ts create mode 100644 admin/src/schedule/format.ts create mode 100644 admin/src/schedule/glyphs.tsx create mode 100644 admin/src/today/TodayTab.css create mode 100644 admin/src/today/TodayTab.test.tsx create mode 100644 admin/src/today/TodayTab.tsx create mode 100644 admin/src/today/format.ts create mode 100644 admin/src/today/glyphs.tsx create mode 100644 admin/tsconfig.json create mode 100644 admin/vite-sw-plugin.ts create mode 100644 admin/vite.config.ts create mode 100644 api/sse.py create mode 100644 docs/ARCHITECTURE.md create mode 100644 tests/test_api_admin_occurrences.py create mode 100644 tests/test_api_admin_presence_consequence.py create mode 100644 tests/test_api_admin_presence_reads.py create mode 100644 tests/test_api_admin_questdef_assignees.py create mode 100644 tests/test_api_admin_questdef_list.py create mode 100644 tests/test_api_admin_snapshot.py create mode 100644 tests/test_api_admin_sse.py create mode 100644 tests/test_core_sweep.py diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5bd20da..6504e5f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,27 +1,135 @@ # NestQuest Release Notes -## Unreleased +## Version 0.6.0 — 2026-09-23 -- **Storage layer extraction (task d0b691d8):** the SQLite storage layer - (schema, migrations, db, store, const, recurrence, DAOs) is bundled - under `custom_components/nestquest/core/`, with the integration's - original module names preserved as re-export shims. -- **Logger namespace change (breaking for logging configuration):** the - migrations runner now logs under - `custom_components.nestquest.core.migrations` instead of - `custom_components.nestquest.migrations`. Users who tuned the old - logger name (e.g. in `logger.yaml` or `configuration.yaml`) must - update the entry to keep that level, as the old name no longer - receives migration log output. -- **Additional logger namespace moves:** the children registry and the - admin allowlist business layers now log under - `custom_components.nestquest.core.children` and - `custom_components.nestquest.core.admin_allowlist` respectively - (moved with the engines/business-layer extraction, task 510c1f78). - The parent `custom_components.nestquest` logger still catches both, - so users who tune the parent rather than the leaf names are - unaffected; only users who pinned a leaf logger name by its old - spelling need to update. +### Scope + +Sixth release of NestQuest, promoting the re-imagined architecture +(Features 15, 16, 18 and 20) and the foreground development-orchestration +tooling from `dev` onto 0.5.0. This is a major architectural change: the +Home Assistant integration becomes a thin client of a standalone NestQuest +API service that owns the database. + +- **Feature 15 — Domain Core Extraction:** all domain code (schema, + migrations, database access, the DAOs, the pure recurrence/presence + engines, materialization, completion, the SSE-free event payload + builders, the panel snapshot builder, the children/definitions/allowlist + business layers, and an explicit settings object) now lives in a + Home-Assistant-free package bundled inside the integration at + `custom_components/nestquest/core/`. The SQLite schema and migration + versions are unchanged, so an existing `nestquest.db` opens without + migration. Covered by a no-Home-Assistant end-to-end smoke test. + (Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.) +- **Feature 16 — NestQuest API Service:** a standalone FastAPI service + (`api/`) that imports `core/` and is the only writer of the database. + Panel plane (`/api/v1/panel/*`) is authenticated by a static service + token: household snapshot and an idempotent complete route. Admin plane + (`/api/v1/admin/*`): children, quest definitions, presence + schedule/override, uncomplete, regenerate, history query + CSV export, + and settings — authenticated by an Authentik OIDC JWT (issuer, audience, + JWKS signature and expiry) plus membership in `nestquest-admins`, with + the panel service token rejected outright. A server-sent-events stream + publishes the quest-completed, quest-uncompleted, quest-missed and + child-day-complete transitions; the missed sweep runs in the API with a + cross-process-safe watermark. `/health` and the OpenAPI document are + served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd, + e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53, + 2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.) +- **Feature 18 — HA Integration as API Client:** the integration no longer + owns a database. It polls `GET /api/v1/panel/snapshot` and builds the + existing sensors (entity ids and per-child instance payload shape + unchanged), subscribes to the SSE stream and re-fires the four HA bus + events, proxies `nestquest.complete_quest` to the panel complete route, + and caches the last-good snapshot so a brief API outage does not blank + the panel. The HA admin services and the service permission gate are + removed; only the panel completion path remains. (Tasks 37d5e806, + 6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.) +- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no + hand-written card YAML and no manual dashboard creation. The cards + auto-discover children from a household entity attribute (ordered by the + API's `sort_order`), a Lovelace dashboard strategy generates the + party-board view and one quest-log view per child at render time with + computed navigation paths, and the integration registers the dashboard + on first setup. Navigation and the panel transitions match the design + mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f, + fc69c940, 67775be9.) +- **Development orchestration tooling:** durable OpenCode controller and + role launchers, the foreground terminal orchestrator, the independent + review/approval gates, and the automatic release-preparation policy. + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. +- A running NestQuest API service, reachable at the base URL and panel + service token configured in the integration options. The API service + source ships in `api/`; there is no container image, compose file or + deployment runbook in this release, and Feature 17 — the deployment + (TLS reverse proxy, Authentik, network separation, backups) — is **not** + part of this release and must be completed before the integration is + functional. See `docs/ARCHITECTURE.md` for the target design. + +### Breaking changes + +- **The HA integration no longer owns a database.** It reads exclusively + from the NestQuest API. The local `nestquest.db` is no longer opened, + migrated or written by the integration (the file is left untouched on + disk). Without a reachable API the entities become unavailable after the + configured staleness threshold; the panel serves the last-good snapshot + in the meantime. +- **HA admin services are removed** (`uncomplete`, quest-definition + create/update/set-active, presence pattern/override, CSV export, + `manage_child`, `regenerate`) and the HA-side permission gate is gone. + Admin operations move to the API, and — once Feature 19 lands — the + admin PWA. Any automation that called an admin service must be + repointed at the API. +- **Logger namespaces moved** under `custom_components.nestquest.core.*`: + the migrations runner now logs under + `custom_components.nestquest.core.migrations` (previously + `custom_components.nestquest.migrations`), and the children registry and + admin allowlist log under `custom_components.nestquest.core.children` and + `custom_components.nestquest.core.admin_allowlist`. The parent + `custom_components.nestquest` logger still catches all three; only users + who pinned a leaf logger name by its old spelling must update. +- **The panel installs with no configuration.** `child_order`, + `quest_log_path` and `board_path` are no longer required (an explicit + value still wins). The integration registers a `custom:nestquest-party` + dashboard on setup; point TouchHub at it. +- **Schema is unchanged at version 7** — no migration runs on upgrade. + +### Known Limitations + +- **Feature 17 (API deployment and identity) is not in this release.** The + API service source ships in `api/`, but no container image, compose + file, TLS reverse proxy, Authentik application/provider/`nestquest-admins` + group, proven network separation of the panel routes, or backup/restore + is included. Feature 17's containerization groundwork was completed on + its own feature branch, which is **not** merged into `dev` and therefore + is not part of this release. Until that deployment is completed the + integration has nothing to talk to. +- **Feature 19 (admin PWA) is not in this release.** There is no admin UI + in 0.6.0; admin operations are reachable only through the API directly. +- Moving the household database from Home Assistant to the API box is a + manual file copy (the schema is unchanged). +- Single household; single API service. + +### Rollback + +Schema version is unchanged (7), so no migration is involved in either +direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`) +before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0 +resumes using exactly that file. + +**HACS installs:** + +1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0). +2. Restart Home Assistant. + +**Manual installs:** + +1. Replace `custom_components/nestquest/` with the 0.5.0 tree. +2. Restart Home Assistant. + +**Repository operators:** git-revert the 0.6.0 release merge on `main`. ## Version 0.5.0 — 2026-09-18 diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..706fd43 --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,13 @@ +# NestQuest Admin PWA — runtime configuration (Vite env). +# Copy to .env (or .env.local) and fill in real values. NEVER commit .env. + +# Authentik OIDC provider. Defaults to the issuer below when unset. +VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/ + +# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application. +# No default: the app shows a configuration error screen when this is missing. +# Never set a client_secret — this is a browser SPA using PKCE only. +VITE_AUTHENTIK_CLIENT_ID= + +# NestQuest API base URL. +VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..04c01ba --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ \ No newline at end of file diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..8f95fb8 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,15 @@ + + + + + + + + + NestQuest Admin + + +
+ + + \ No newline at end of file diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..7da0990 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,3034 @@ +{ + "name": "nestquest-admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nestquest-admin", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.47.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^30.1.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "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.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..cb86399 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,28 @@ +{ + "name": "nestquest-admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "lucide-react": "^1.47.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^30.1.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } +} diff --git a/admin/public/icons/icon-192.png b/admin/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4435870c8891af50b9eff2d5fd05a1aed156abe4 GIT binary patch literal 2580 zcmaJ@X;@QN6TV4+u*i;n%5sf}1u3>gS;Z??K@2{UfQk4KzzrpU0u@l;qBT-hMG_Ss z1@(e}1Q0=FX%TQ&Hf@4zhRrG>iEKrZAXwl#;i*69kMBovo;!19=AC!WoH?nUwC$Se zhUyRmX}VKgcY*bh;-^Xk|9ua>dP0y|kGty@W=!{J-_(iS+YD6xn%vdo5nn%5T9~lc z;KISj@%y3jsU-i{C9bzz(^Bf%j-8Jj&s;I5T2s+7;y83ohCHfa&w3MU;vV#s_&U^w zy^|jID&7ikfJu57SjNOx&allg0p$}~VVzFqP0M-wZV+!fNm5zTdQW`Ix6)pHu zQ*2%@ty`>K>^QcS6c3A=+6ETl41Y8Eu*l=TOhk)J+!dBPZ<|93Kf*}iBBi6l{Wt$$ zNh;y5xqfCi_BqKg3-=yb;=v`^=pc7yR>b2_VHw6E@f)xgoEbjfHg<+T)Y1C9fQ_*x z`E<+_l??~q-Z(4VKw#G3)!mF*yvsNzOIBI$Ao~OeX?Rk^q!SBGkx)AQI7Xz1%LBki zSsYp=4kky2w1U*QuNaBAl8*GvhC}{z=#V$v=}Ul*V3oEiHez(IsDTiVTc&#;$!@1@ zg^U*KLXZny9on@yll78kjfBUbzji#eU0OE%vOwy9lG;~C^S8F~lo5hZM;g@Mcwx@U zO=>LXVDnv1c1_hJ5H5EOKr&x$Vh`tFQuDAoB-_cYs?FESF8>{eR561vlKKkE!l4v$ zJAWGfI`t;jIot=)(vgbax(3=achHHdm`%9p2$caczh9ew>XX(0F~39s{Jo}?RoONl!9_ANKx6AXrjZF1Ezm^! zs^8Mm+FoJ93N9WVmqfEIS5uUc08|)I?3DJLmyYM#=q}Q$wW%daU`_S3c%Gp#Es<*%w7XBprE4}JmwX+wz696!) z_qnKAsE*7vRyns4)FkeZ+US~FC%>GPMkSfe#HDaPNR`kC-_Sb}U1hVbxr|wAE=G$% z?V?Th@XhU>^}lr?o84?67`D5OdJj@7oxSkwG(oE9p^+mJg+8BWQK4tObFQ(lBN85m zZpBhKmK__cf#?}A#UDiHCld=_^gsDRjY`%fxQpGP@?jCGl0Ut!NQd%b4pyhELI}JB z*fa-|MJ2`bWZ%nnr3sv19uKJG)9@*Dr0+l!Is$6yh@4}h$8E++wNlER(p0Hi=^%o( zrq1Ni1G;G4t4IS7ok&qb&P5JcaAM3EGD7*(vOwq5>v%K*q8Ol6m8Z&-b@oIr*W{(Z zWGYSIriJ=3CT>C2b^=|<9WYZqe3Nvrj^U-G6CS=wna9H+>Q-%C-mC}d_DXqY%ZT}f zwn?09mY+;ancci+3+h*&=!)tsV7ul3S@)}yu)ua6I2^U`MoNEe{tPF^iBYlAjYfye zZUQDkeFuDLrD}~S_;Q_Ed`f}KienY-=+zc^l>}%|pbA`1kuOs+LG?o4x`LE8QPv{Y zf|C+9+Jv-6UX^PD9#+2G8{#Uv3$3qRz1>4wnO6wxn1W^9;np|j-~(t9=);Sc9smZ4 z+nsIsIx>N@kPB6tOeq?>%aRHSO_FIL>0;Y}IU_i!WW4c0|J_4Apjvm>!@aT1-+PMJ zRhA^HQdx8*>Q3$dP>%T0+N*2JA-&fd2xdiWBTb%tz9_SJeq9j{*8<0vYfL$S^fV@= z*^Dg{g>YlJaws=Uh~=&yYK4@g|Ar4?<87CgvD`vzCihM=p}BP2dP2V zCFpgpRg*1Q71=}B)u|6j!mAE%P(lW!CqEJ{cc;FTjpgt&U~=J~ZlV388pZKqpO zGtSwOfT2@dVIH_jEOXhvrK}RSwoTTMnVwwFQ2M@yYy6cxbrCI3A0(oKF3ig@Cop$# zgT6Af12xux;nDo*LfGlzD9io__>Y??8khm7&3!?Y8~R-4fVxS=mChbxt9|Pl_U7SG zV?6BPGWTEcco9o*1?HI`afuo^-p(6u6rFlK0QrEz*|4q~N~1VOB@_GZx}=>#VBR*w zZ0Hq+%xhO0k4_ zXXwMr!GaS=-%0xUsE{swsH2pBZM-OjrY%h)$lU1mZv!r+2Top3$HN1Eu%B(yrzeYk zuE_1oECx{8oso&Cs}GEBc#saul+fAE$d02ojSUlL{R*TfhY6ZH=-Z5{Mu(4aR7!`k zG_B^@hl494qAmt_sg&};ohrPP*RU{3+b8>8KGc+=^lF%_ishL5Dh5Ojn}rZ`fG`Ok zM=0Vm_$I{!0ecj;HY%tD`2*n+#5o2Pl8{)%yf}j&{bsWA|C+=uGBHtD8Y8g6djX!! zF0}y;Mc*8%DYU^LJs@0x{W76=aeM~Nxv=LS3}C`2Akbd<1ur~au4mPJUb)VpS?#Gf S(*eBpAop!F*V3)~j||)2Btx;W$71>c_RGKaxYI9OJk_W2Z+)&3pIO!`nMP zeY5tp?f1?+pI`M{?SZdv&N%(d%*;N!M|b@6K#!#to_g`Ak5l?Y&kX&}?@!*mg%z)r zRcw^szvA;2TZ+#Qb-yst8!>U_sKV3-dUES#dU`|H%S|5}el6Uql)Fl{Ox+NaK_Z{cKGgwjQE!HL&_C*>c$P?*s~_ft%PfhmMJaQ9ZID;HF<+L z>a6RH2jb?Yx3tQ{(TP4_)j>v@ zwzR31n*!V1v4q4W1mMAFCEw`M@H1N&hxdemO8EW=xr{%o4y7VKuONf&IxOn$da)Cd z(zQ<<@=^$C!(}niRE`9@^M`%D7W0ZU6 zXLiDlDQ^BLC@+M|>$fyz@=EX#L7)=*`*IrB&HycV4+}=RaLgAkjhIOna>**@sVYzK z{ThSf5i>O%$5mMV*7;M|Vwy`<&@H>Ak~N?Af%Z-(%Ryn55>chXidA(?O)*Vfy88>A zTt>d~P)y{@7e_}0V52je@|3A>qktv-~R?EHJ`yX(TPamq!HNaNwH9u5)F8XX<1%F;=}M=A5V z&Z|$Y%b6&bR0zKV3dHq&$C}l>l7YtDTfH57UQ}B2k>p!n$?jES^iui}7962=6I9Q1 zlb6HTt_cJ1z}V1r=HeK$dd$O6^U1&cc0Fu< zkWHd{AkLh_8@u~;=8Ce%a1@1!cHILcDqE+{j>Nz>WmjtCH|0>hsGaM=PQ<%gnG}8#|v#`oNRbqBcrgj;#M^yIeC&rjfI?c z)W?EsxumR=O%MA#yWkJdB2!C)xej0|AKp&SBejf=jZ=AL#oZr&+^Uw8v%8kO*U+Oj zA$8flRM}OXlaGvuDP#7LM5#;&Qf%|T(n)-g`VnU*?d73wCdHCc^ipRliE6#B5X)T(`WuCO1HS1CEjvu;WcK=u<~se_Xfjdhs}kYY+5RjBGtht%*jWw<6#Xg&1R8)IQ?Z7gpcJNU69uN5kbOv za~1Ii${9v-MXp`RDPfF4oDO~sWzvn3aBtlsDD+NSVM9WW4_gP(pHdisShvySbj>40 zkT27uCe?CFVX<30sO~lS(f1zXy{76!oMg^((1+?o5J6Myfmu55O3(n zZiOVp`wf!K>7Y=;74;@nW{j^?>K;kznHA%FrA@!0V6@rD{X5gUa&nSH=1>NJxx!r1 zJQ?vF=c9C(>eG7x`24gaPSd<-uo(pNw+GY^3p@1&gR(Izw9DilDbi{*6bBWj}|hAe=4XFZ9* zET7SCzQVB7Pm?3Kxt>m{Y*ORBfplq+on)O%Y6t{lh<}~Uxec@$CmnpE;qOer+&tk@ z3F>WfN_M3;!mYcweHHv->gV24a8J!B>WXnUcyJTn8|RgDgwId{IP|fx#J0gNb}{Kn z&#EftXMn!4$PAow|B*#q)d#W)fPVAJ-1H2V05J0_EimP%Ea1;0iPbMwdC3TODLq7K`Cxb`m zV`q_%V&fK*@wy?$NZbTnZXSv8Q`H^n)1k5RNmuxN26v^-FdS0Wec}u533JfRkRiGu zS!C*m`FX+~m)|FqLO1H4&A;UcTLXGJ$R{V0P>&TpD$OcFdiQklv7ia7E=vmTZ|(@Z zShuRP0fKgMvq+I{$YHX8`_S!r7w;U?eIg0Ib@%*YV<@+ZxZB0a`V)0z<*5ww9Nd1^ zG-h9~&^-&t0$px4Zig8jviGqUom0*qBG)K-rFX7XHPyF+C)VoklEy3DaO95mk+YOI zKg2ElTOe@#;%-V+aO6UYC>j==G1B8w4f=f*uw=g+vjvuX5b#Wys03iCH;mp9WENfG zWNuC!dHn)6&rV+8dim0OfydsCoUdH;x9&uy_pE@Wzg6sSqD= zn>U~{^a!4SC<{BeXUa_6>GqK$0o#FDC>L`xg%;iA9FbccYTiPPnGa(iwnp$fFozg} zE(q1<$ zOXMCr%XL69Gst1z^}vCjV{L15aTwgL}pMZK!(r-H3z*bkb2$^_U_bO zK8D(*LJAIrok16t_;r097r`_eN=kp9FnTG#XVXdPz1jkne)_h#051;LO|uGCUN@XF z#k&84!hb;XS*oe|N1Ey^#iD@FR%o#ZuEf2GB&86Yp(FrWU{%mxEJ*;9$B=LJdtM_| zp|P*wx7fMl6SxI6z~TCEuD;zs3#bGcy@EUHF^q;&#cF+JTp3wELceDbI>C;0WWfah z$i%(u2Pr!>lg9Xjf`s;$VqQXS(z7#BV-(wVXMJT)rdzc5wCV%T;3M+Un zyhIs9G29>hz>uRfU67~8hZn2w`Nhs43BUh^FT7IcN`(h(x{Z|7nMrBt?m+OVpFPC< zJ~ci{AxC-uuRz+PfS>Fpz1ua7TUiJ6t(1Ax`86Xu7LR_JB!P7Ar+D$+GX zGYjnP7dwq?>71J<6i^N^f%)3-sd>@t)S!8DNr)dhy{A*j+{I)es}gX> z?R#&KAfl9f8gzV*n|Fhch7VH%UI4MLqW*XD{mh%?9q*!Ws%UuQ`17*I^7A89a4EVW zdEnuF6=X@@K7U5(J(^yUySaH4zn^DSH|oMMwK-fyn< zFH1quO@p_(kNFqG@xp63G@tnT-%63uQjFsOC)9>JP=kc|#Pb=$3q7UH>dgsFf%Y6? zKi_o}0!EvKDd;;Fx%pEqnVBS87M{NA6<(;P&F}K!cC-|C&4m4Q44{={$JBeUau;z)Ugs$%tPx2&tIbDdzh1-52L%`7L4gY^+HXSz)B?R3H8e*ep<~W{c409J8z%K z%4uJ^dNAcAE$i4IWVEs^(0YnZ2+{(m9<&Ccg@je-Mkk8L&B(`#xQb62-x$v7u;q1z zW)xGwcxj2k_j-uBt@X9dT4*6ArjlRHxy2>0$&#(9POk5UMTt5$x<`*`F_O|MpQM%vuh7v3x`#`s zQc-loLA+KzN0ZB)X}4h{QFOpSj;P)Ai8t|*n9G`g#pG4omNwxBx={$015=>L5!DI% zMkqDsX>=+iv8c!j&zNxY_K^9FYGv7ozUYZ@&|rvsT@LJg0p+NX?32?`=H<7z7SXX! zP7fB`k7Z%R?HXR0APf2Me0n{RnyWgn0AUn}5?t#D!hDwS4J?XfWDzPXv@taStdo|E z1q7uUe+2n2nM|aB8HDx;xdBL&Mt6x!pr^a#CUg-svJpCr^9j$zF=@_n0ox2W_|4q$ zP$(T{CvRb28l3`=ooWPKD-%z!M(v0?U3)yrI?nxaSbNQ|op@Jk)avWWwdN|z_dhT{ z_map*@E^2@@JlR4s3|tl)yE;&!z3emYWQfQM7x_s^_Dz_g?cvyeo{HQ)a~@NLXfVP z4KQ49kZNX|Wex?ckfMz8zq>iW?R7P-9K}ia^Osm1tXPT{pCH#L;I%=bYchGD?VCYV zys=7o?0@&a0^9~f#59$*_2`q)f)8bw7P=_p1OnkbE;KOE8#8H-AciZJi9*YnBb|#7 zZQ-gMuT(mKOqveppvH5>^avwrOZ2|*sOy~S>6n&&w1gU*v$ZjdvN3lp3mB?xHr zdC?9w<&%dqm`dcHWP3d9FzF_F(Le?t621!Pw1WE8RCjd>sd1AypawGy*CZivw%0A5 zQ~kxU5}!uX8$%L{_wY{+VS=(Mb&*>bO>1mOvQ?X#9r!>nRG8uxUZI^2aYz_DgnX5* zV$cx8dgsBo+2Sy{#4u$tYZLVzo6c!Fp-xJXEwMBv(0^5=sl!pX_hdFUQCnhg$88^; zuTEMhk7E@n&h)mwu>IMcxnk+sOze4^wag{uBrRZd_id4K%U|il&((m8C~=zfr_WVN zO}p%9PpSTSs3MB1NBEk`vN3F=%D>}tnlIiSF1L6b7)+w?7yT9Q0lp}o4vzUxX)wh8 ziua4_-?ooIw;DzB98-`n7cg=yagfc(`VZC6yU3ftZUIp(%n}2}qxH1QrHFWM_{+af z;u$yrdE2traOW@}AzGR6KdHiPgrIM^Nso~%#>o`!Z~2@Y4)VT1naEY7iB{?;)~C41 z-)?S8W`|8gy~rtgk6@&G#W3d30H08llvop*LucAN8s@?RvZZ=07_X8!^C2Ptt1uS> zy!j8+@Jao)FPX1upr?031kH{=zv*~oK> z_o}(-K&qVMWK)qm=0wUk#saZZ(MI^Mn$Ft!#SpNH#t#dK!u_}=8p0Te@RMk*%?aZnhCRxyJ}FZfo&5o_;AX(b&^(3Z&lo?W1G2QGo;> zq2?G#iieziYV`+`fUa02kPgob(}=V5PD;(aoqU6~%*L|7^{SSyF`LfauEq`cW5OiZ zF;5nnK=DRWQ_s8NZNt9&)d^rCQ`gYN-jaA`=Z`}3gJRpYrpw8o>}}eNFkvn!?OgDD zeFFWIvY34;APX=mZGNx+Vf?b8Kpe=BpAnX4IaQ>K9EE@t)UOv4356cD_1?_c9Y^7_GB|TP6NUub)(6}HcPfl z=6`mX&!Kas*5zzA{ASBr8R@cO$?_UyChdS8Z8KG3j##q|2wQY(h$nn}&8Rqs&jHNWU zFQOc!2`sC3J&B{gLh_zG{IfxIJ<;oZ6%40I&v&DBV`m!4WLBGL7R@AoY;*g4iPSbJ z?tgrN#8sks-sxZ;Ay~|wr}tiKK+66%hFN-2pp+hEX~{yl%V%iQ1{}z^h582k*E;Am zV*X8LK6x0_I)SIoj$v8F#AHeA!xM`EtK>AG_%nWE&gS!Jsi5ig|9>z=Wuv(R{961k h1DF)UO+@U)|e`|4713pZaR_@yScK{tuRSZ{q*} literal 0 HcmV?d00001 diff --git a/admin/public/icons/icon-maskable-512.png b/admin/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ae98c76043bc1c19dde093220bf2e8289683a0be GIT binary patch literal 8371 zcmc&)dt6l2_CF&CCemp5el(OBCfVepuuRYRL{mUb1tpz)T}Ul56H>H8X<>2&6N}7j zc#4WRmKq4*>7fzVYr{0)h)Sd3V;;#|<&2YOI%ogZ+K2J>&+mS&-p}p+GtBI>&wA~( zzU#4he@1L{Xy?A28HNd+I%VQ4hUoxbJ20K_PkP@8-y*YDTf9u-X zMHhAio^aH7KdY>pJMght#RnFj+VSbA*i~CzfKTtT;>5s{-D|x^)&^C7y*TLb;m6Oe zKEC?6H(icDwDFp>=<9ci+`s?8#@$+wUTH|U5cfoD>8-1M%Xi|JvBxTV zP4>O?McmL%_}33l)>KvY%c?$BnP}O)@aGj-)isr)BTA!%QdV-r zkxioQnFoV2Wct%Ek<4W5ahzx|$ZG{#kbn7k660TP5;Prjbb}DLmwd+58|bU)jof2Q zJIQR1tq+4Fgi_8Xn*79VHS!2mJ~HLRqL)o>&~)^3iYBl4m-h*_K>uA`&$$CGPW)~ag7bZw@$NKlj6_UYzlFx}L0e+oI$fN${ zm7>~DF`|LA-uP}{yHextnr+OzvgP9Ziqtsy?tVpTMRym!4N|0Qe&O8<$xi=)jIfjM zAMtDQh?wISz*Iz@_Ak#CQ|>J9Azl5{$4g$n;caE|G`|hf4v-)H)O}~Tv3@G89)tft z*p0Gbr~lnQ)0s~HFPzEt$vvz3tlxNVi2R!0FLhJ=nJxHttj+nB`+cI1V7L>be`4tF zzGrKj?gYAyMEV`;T0Pg_?|XeE?VT-CNt@r_avh}U4g#$I6TNxYJsLr$9wayNm-v_65%`2}8&bduXB6Im=8FOPKLY-#mTf7u38g2h!#?%~6UYoyIVQ>mUDj+}j|U@PN4W zQn&t>@D&S@71Z-3!*f2K<}*^;psD<31X9SB&!b#9MeNcRFa!Cx9Rk@20r0vn7ayj3 zqL&Bv0X}Y4$%bAtxF?ar&@+RRHPM`C;|g)-gDd*GRNa1($*oZZH8NBwAh0ko(>EqGh&tSbu>iunL35v&G$F#~fw#5TL_jUc-%SJr=wN*Tv<T6^)EoQAT=Y6W3;0V4sw1#U$6sxh}s^^P4G8zV1XF zQpS^<_rL^(N#xPKI+E%!pgp)4!9wPFxa*W=jJ>_)FbgSVDj{VOp{b(Wu6c$L*iG^` z{dAPB)Edih(1=h7p4s zNKu5Xn3a+|dTm2qFaQO$J|KpLF8~M4miaM|-Iq8uLl_}vA9n=Sj>|>p{6flnauHl& zq*MX?6Ql{if1F3%529@-Q?!`9Z7&T8UZ7+Y2A} zWKw+tsErc2RX`}%OqR@~=dSL-h?zg3$JCnH@eDdkQhIMlDLH#cF9VBb7)KU*^hU8) z52?t(gPao_nkV7B7mznWy$uSy3Q%HI*Q(tem&0>AAHfJ(BjI>Uj*}j?yrH&cd?^^uqE!&A!kjD^&3I#e1K&E0kybd+dRBVgoD`ZIL!w;ZV?8{2 zXh|9cquizu^H%B-B-;Tin6QCNGZAk$NOpJ-+NO+)Vsr>2h1fArdRh3z%r*9mCcw6KEyVogK^VCh?eBWjy%}PPt79fw z@Vp?sLbszBh^q?Vi%*kn(l#jh-Sk#GCUPl4pE5xoJ z!z}XfA3V&A^Kh=;zUu@G`(Roj!$oVTZmAR&7=BRh2EP}Jh8X`#N-;K`OG@_0^LDP!m)#feb;@ea38RQutrWCG26D(sCV zC7@$I=%^bhjR+3UmgfL^6w(m%S-1x>GQ`qQ-C)UfP>53@dvIPbNxWJ&U0NL!{)yZO z+d)UU5wy#Ikm)*??7EHWRMAr{rG|+N3jacG>d^HCvITxG!gS6Jlk|JLgUgh5Mu*x5 zk(y`4$jobC|HABd86ubu@bpE^6neY~5UHE7QlrGY+#e%5k0m&G(abVa+~*&^228SAgHV(+ePvXJ!Deq>wt2aII4=OkN=_5UZ?lNToa#9Hq|8Z=tE{EBRA5)&Z~s z5JfIwHAset@u9k8$#=i*HK_=kIk*u^La>FaV|bN3AQ)V!0JcqVA*gC*<5}+|<+&E? zIvQ|bn-DwZWcCR7CJ3`qwYiAfMuQXT3NS@k6i6>Y{t?uBsjAaSoHazZUdjy&-$DI( zyU4ADM3L73PWeeaUy9g+czy_T9DJ$*x;{_F_5}b!!vOZs&*%`HPD&32r-=k(&!K~g zIfAiAMia*CttPwebGfW>3q&WXIbW<@d*9{*Ty<#jG-8?q28(oZPzf0GxlPOoE58f8 zpM41wPnNW>{aJL!FtOrn##JGwCtylFHvr(t!nbMyMiz64q4{J4a9t*ln{8zEM6yNX z6ST}}2j{yi#D;_>&!osPjE3)acxF@$b#m9<>@67=kWnlt0B|C&q0UzJ+%&NKKqx;< zN+$8=u21UOmx4a=^)smMC=E*7r%M!TU)H%Of|S`YM@Bj|#gzPSg6HtNg;*3!!cmR! zAXsr^IaeMe6i|B>%IToma528i*UOJOb>!PV$yX|>C!Rso0K5?^_mF8b1L{ALI}We^ zo7=Nty5s^a8|f@5uYr{854BGx)p@B4H0FM_Y8%vxGpY5VRRIV)<~%P3dKF!J8inIcK15HOK>8Y%e(1Msg)L7!w;fDHJL3R)Rp7NSvw!I2eIVX6%}A zWEFhsNO&l%m}+G1D+svn?3%}c_&72Oi1(LFz;%kqufKWm!@xY`ze0c(Jm_cSJZ>Bb7mo8N5-#$u-@MqkO(FL-O_qZpx);Jt7Wp{}c+%WvSV6xr>J}Oa+~broGo zY)5p5*G~q$xHSB*e0t1qF|7#4r0xmC`s0_&|M9xh_DgOd7l-pw_^dde(bPrQ&lRaR z%ex&s3ru23YQU3qx;!yTO2)z%!Jwzdov(7arjq*~43;1{0|o%wSNlG>>+LTEcgth- z{4|=&%3b7Ko#Xn#zONk%EVtV%5m=~W%=_v|YFcQ5eyQa19dQ8UVnr4wde3E)4cohy ztJP@1&cp<5H&`*vmT@Y6GI>Pvs8nsmLfX5Z+aEN&waH_;easAlORP0hq!h!aKi8dO zpv5;i5#UDH5jvY;;DSbIvl~^}FmAX2ds*qJuVGT+18XnzSn&-lc=ZMs-GSo41XgT1 zd(Cc`OFbz^?t9U0m`7Lx_)U|LrxGk|Id%DRxi7Ri5e?|<5mwsN;6l<*w2LCGw||=g zkIl3l=hCV&^QTW@rPWUCK1}<_x)ad8fj@!yOb2+3f@T+OwnPWNi+lM8XNYH^e zNpJaJhb*|`ly1qJ+GSs;8JyGINVIe~;U1b@4!$0x4A`_T^8M}bemmcS;m0r!y$#@| zOUuHCOAuErE;NsW6pK*5JD`&X{J{AOS7Z7?7b9xGG;!t1{Xn{h@131x0Y+@DG8fS3 zxjutqA@s-rr8|P5&newwBJ^M@Xn#~n4%^Aq+uJtu#x<9jHl^gKT^o=Iy}Q6aE%DDH zd#*-|{KnGktIhe@Kp(^^vlVqINxZhv*HRBmA%B8laU_*@>mgTCGu(S*(FV@}MQK*e z?&`oWFEH7zO#2}n*xcvu$owZ>`H@%u7cR{s$}30pCtl2G?)g}`r(h&Qqo}(WcgKYa z6~x0QVG7Dohj>D=-tiL)(HMNU{&|6QcTTK~0Kq4{bZVhE^Z+G)QE84V^HqG{A9SOq z!}yE6JJ9&URW7}Epg~e|*LSL0@7aa9mSZV|Em`%~_bM#AV&I< { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key)) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + if (new URL(request.url).origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => + caches.match("/index.html", { cacheName: SHELL_CACHE }).then( + (cached) => cached || Response.error(), + ), + ), + ); + return; + } + + if (STATIC_DESTINATIONS.includes(request.destination)) { + event.respondWith(cacheFirst(request)); + } +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok && response.type === "basic") { + const cache = await caches.open(RUNTIME_CACHE); + await cache.put(request, response.clone()); + } + return response; +} diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs new file mode 100644 index 0000000..89cd01f --- /dev/null +++ b/admin/scripts/generate-icons.mjs @@ -0,0 +1,80 @@ +// Generates the committed PWA icons under public/icons/ as genuine PNGs. +// Pure Node (zlib only) — no network, no image dependencies. +// Run: node scripts/generate-icons.mjs +import { writeFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; + +// Brand tokens (design/tokens/nestquest-admin-tokens.css). +const BLUE = [0x3b, 0x3a, 0xb8]; +const PURPLE = [0xa0, 0x35, 0xcc]; +const WHITE = [0xff, 0xff, 0xff]; + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +function encodePng(size, pixel) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: RGB + const raw = Buffer.alloc(size * (size * 3 + 1)); + for (let y = 0; y < size; y++) { + const row = y * (size * 3 + 1); + raw[row] = 0; // filter: none + for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest") +// around a white dot ("quest"). `scale` shrinks the mark for the maskable +// safe zone (inner 80% circle). +function brandMark(size, scale) { + const c = size / 2; + const outer = size * 0.34 * scale; + const inner = size * 0.24 * scale; + const dot = size * 0.1 * scale; + return (x, y) => { + const d = Math.hypot(x + 0.5 - c, y + 0.5 - c); + if ((d <= outer && d >= inner) || d <= dot) return WHITE; + const t = (x + y) / (2 * (size - 1)); + return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t)); + }; +} + +const outDir = new URL("../public/icons/", import.meta.url); +const icons = [ + ["icon-192.png", 192, 1], + ["icon-512.png", 512, 1], + ["icon-maskable-512.png", 512, 0.75], +]; +for (const [name, size, scale] of icons) { + const path = fileURLToPath(new URL(name, outDir)); + writeFileSync(path, encodePng(size, brandMark(size, scale))); + console.log(`wrote ${path}`); +} diff --git a/admin/src/App.css b/admin/src/App.css new file mode 100644 index 0000000..4f8fbcc --- /dev/null +++ b/admin/src/App.css @@ -0,0 +1,123 @@ +/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */ + +.admin-shell { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.admin-header { + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.admin-title { + margin: 0; + font-size: 24px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.admin-subline { + margin: 2px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.admin-panel { + flex: 1; + padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */ +} + +/* A built screen supplies its own header and 92px-padded scroll column. */ +.admin-panel--screen { + padding: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.admin-tabbar { + position: fixed; + inset: auto 0 0; + display: flex; + min-height: calc(var(--nq-a-tabbar-height) + 12px); + padding: 8px 4px calc(12px + env(safe-area-inset-bottom)); + background: var(--nq-a-surface); + border-top: 1px solid var(--nq-a-border); +} + +.admin-tab { + flex: 1; + border: 0; + background: none; + font-family: inherit; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); + cursor: pointer; + padding: 6px 4px; +} + +.admin-tab.active { + font-weight: 700; + color: var(--nq-brand-blue); +} +/* Auth screens (login, errors, refusal). */ + +.admin-login, +.admin-error, +.admin-refusal, +.admin-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 100%; + padding: 32px 16px; + text-align: center; +} + +.admin-login-button { + margin-top: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + background: var(--nq-a-accent, #2563eb); + color: #fff; + font-size: 15px; + font-weight: 700; + cursor: pointer; +} + +.admin-error, +.admin-refusal { + color: var(--nq-a-ink, inherit); +} + +.admin-refusal h1, +.admin-error h1 { + margin: 0; + font-size: 20px; +} + +.admin-refusal p, +.admin-error p { + margin: 0; + max-width: 34em; + font-size: 14px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx new file mode 100644 index 0000000..7a38e6a --- /dev/null +++ b/admin/src/App.test.tsx @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import App from "./App"; + +describe("NestQuest Admin shell", () => { + beforeEach(() => { + // The Today tab fetches the admin snapshot; keep the shell tests offline. + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the title and all four tab labels", () => { + render(); + + expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy(); + for (const label of ["Today", "Tasks", "Schedule", "History"]) { + expect(screen.getByRole("tab", { name: label })).toBeTruthy(); + } + }); + + it("starts on the Today screen and switches on tab click", () => { + render(); + + expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "History" })); + + expect(screen.getByRole("heading", { name: "History" })).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); + expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe( + "true", + ); + }); + + it("the Tasks tab renders the definitions screen", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); + + expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); + }); +}); \ No newline at end of file diff --git a/admin/src/App.tsx b/admin/src/App.tsx new file mode 100644 index 0000000..5f44fad --- /dev/null +++ b/admin/src/App.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import "./App.css"; +import { TransitionsProvider } from "./api/useTransitions"; +import DefinitionsTab from "./definitions/DefinitionsTab"; +import HistoryTab from "./history/HistoryTab"; +import ScheduleTab from "./schedule/ScheduleTab"; +import TodayTab from "./today/TodayTab"; + +const TABS = [ + { id: "today", label: "Today" }, + { id: "tasks", label: "Tasks" }, + { id: "schedule", label: "Schedule" }, + { id: "history", label: "History" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +export default function App() { + const [active, setActive] = useState("today"); + + return ( + +
+ {/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the + generic shell header stays only as a visually hidden page heading. */} +
+

NestQuest Admin

+

Admin console

+
+
+ {active === "today" ? ( + + ) : active === "tasks" ? ( + + ) : active === "schedule" ? ( + + ) : ( + + )} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/admin/src/AppRoutes.test.tsx b/admin/src/AppRoutes.test.tsx new file mode 100644 index 0000000..5ba0287 --- /dev/null +++ b/admin/src/AppRoutes.test.tsx @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import AppRoutes from "./AppRoutes"; +import { storeTokens } from "./auth/oidc"; +import { config } from "./config"; + +// The bundled config reads real env at import time; seed the shared module's +// clientId directly so AppRoutes sees a configured app in these tests. +(config as { clientId: string | undefined }).clientId = "test-client-id"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("AppRoutes auth gating", () => { + afterEach(() => { + cleanup(); + sessionStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + window.history.replaceState({}, "", "/"); + }); + + it("renders the login screen when unauthenticated at /", () => { + render(); + expect(screen.getByTestId("login-screen")).toBeTruthy(); + expect( + screen.getByRole("button", { name: /sign in with authentik/i }), + ).toBeTruthy(); + }); + + function probeCalls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes("/api/")); + } + + it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + const calls = probeCalls(fetchMock); + expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]); + const [, init] = fetchMock.mock.calls[0]; + expect(init.method ?? "GET").toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + for (const url of calls) { + expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true); + } + }); + + it("renders the four-tab shell after a successful authenticated probe", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy(); + }); + for (const label of ["Today", "Tasks", "Schedule", "History"]) { + expect(screen.getByRole("tab", { name: label })).toBeTruthy(); + } + expect(String(fetchMock.mock.calls[0][0])).toBe( + `${config.apiBaseUrl}/api/v1/admin/ping`, + ); + }); + + it("renders the explicit refusal when the API answers 403", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("refusal-screen")).toBeTruthy(); + }); + expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + }); + + it("keeps the login flow when the probe answers 401", async () => { + storeTokens({ access_token: "stale", expires_in: 600 }); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const fetchMock = vi.fn().mockImplementation(async (url: string) => + String(url).includes("/api/v1/admin/ping") + ? new Response("", { status: 401 }) + : jsonResponse({ + issuer: config.issuer, + authorization_endpoint: "https://auth.example.com/authorize/", + token_endpoint: "https://auth.example.com/token/", + userinfo_endpoint: "https://auth.example.com/userinfo/", + end_session_endpoint: "https://auth.example.com/end-session/", + jwks_uri: "https://auth.example.com/jwks/", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("login-screen")).toBeTruthy(); + }); + expect(assign).toHaveBeenCalledTimes(1); + const redirect = new URL(assign.mock.calls[0][0] as string); + expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/"); + expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + expect(screen.queryByTestId("probe-error-screen")).toBeNull(); + }); + + for (const status of [404, 500]) { + it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + expect(screen.queryByTestId("refusal-screen")).toBeNull(); + }); + } + + it("shows the error screen, not the shell, when the probe request fails", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + expect(screen.getByText(/could not be reached/i)).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + }); + + it("re-probes on retry and enters the shell once the probe succeeds", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + // After the retry the ping succeeds; the shell's own tab loads stay pending. + let pings = 0; + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {}); + pings += 1; + return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 })); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy(); + }); + expect(String(fetchMock.mock.calls[1][0])).toBe( + `${config.apiBaseUrl}/api/v1/admin/ping`, + ); + }); + + it("shows the configuration error screen when the client id is missing", () => { + const original = config.clientId; + (config as { clientId: string | undefined }).clientId = undefined; + try { + render(); + expect(screen.getByTestId("config-error-screen")).toBeTruthy(); + } finally { + (config as { clientId: string | undefined }).clientId = original; + } + }); +}); diff --git a/admin/src/AppRoutes.tsx b/admin/src/AppRoutes.tsx new file mode 100644 index 0000000..9959c19 --- /dev/null +++ b/admin/src/AppRoutes.tsx @@ -0,0 +1,211 @@ +/** + * Minimal path-based routing without react-router-dom: the app only needs two + * paths (`/` shell and `/auth/callback`), so a tiny window.location switch is + * enough and keeps the bundle small. Change screens listen to popstate. + */ +import { useEffect, useState } from "react"; +import { ConfigError, config } from "./config"; +import { + AuthError, + buildAuthorizeUrl, + clearAuth, + handleCallback, + isAuthenticated, +} from "./auth/oidc"; +import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client"; +import AdminShell from "./App"; + +type Screen = + | { kind: "loading" } + | { kind: "login" } + | { kind: "config-error"; message: string } + | { kind: "auth-error"; message: string } + | { kind: "refusal"; message: string } + | { kind: "probe-error"; message: string } + | { kind: "app" }; + +function currentPath(): string { + return window.location.pathname.replace(/\/+$/, "") || "/"; +} + +const ADMIN_PROBE_PATH = "/api/v1/admin/ping"; + +async function probeApi(setScreen: (screen: Screen) => void) { + // The admin plane's authorization probe: only a 2xx enters the shell, so a + // non-admin (403) sees the explicit refusal and any other failure an error. + try { + const response = await apiFetch(ADMIN_PROBE_PATH, {}, config); + if (response.ok) { + setScreen({ kind: "app" }); + return; + } + setScreen({ + kind: "probe-error", + message: `The admin API answered ${response.status}. The admin console cannot load.`, + }); + } catch (error) { + if (error instanceof ConfigError) { + setScreen({ kind: "config-error", message: error.message }); + return; + } + if (error instanceof ApiForbiddenError) { + setScreen({ kind: "refusal", message: error.message }); + return; + } + if (error instanceof ApiUnauthorizedError) { + // apiFetch has already started the login redirect. + setScreen({ kind: "login" }); + return; + } + setScreen({ + kind: "probe-error", + message: "The admin API could not be reached. Check your connection and try again.", + }); + } +} + +function LoginScreen({ onLogin }: { onLogin: () => void }) { + return ( +
+

NestQuest Admin

+

Sign in to continue

+ +
+ ); +} + +export default function AppRoutes() { + const [screen, setScreen] = useState({ kind: "loading" }); + + useEffect(() => { + // Re-evaluate on navigation (popstate) — routing is path based. + const onPop = () => setScreen({ kind: "loading" }); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + useEffect(() => { + let cancelled = false; + const path = currentPath(); + + if (!config.clientId) { + setScreen({ + kind: "config-error", + message: + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + }); + return; + } + + if (path === "/auth/callback") { + (async () => { + try { + await handleCallback(window.location.search, config); + if (cancelled) return; + window.history.replaceState({}, "", "/"); + await probeApi((next) => !cancelled && setScreen(next)); + } catch (error) { + if (cancelled) return; + const message = + error instanceof AuthError || error instanceof Error + ? error.message + : "Authentication failed."; + setScreen({ kind: "auth-error", message }); + } + })(); + return () => { + cancelled = true; + }; + } + + if (isAuthenticated()) { + void probeApi((next) => !cancelled && setScreen(next)); + } else { + setScreen({ kind: "login" }); + } + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startLogin = async () => { + try { + const url = await buildAuthorizeUrl(config); + window.location.assign(url); + } catch (error) { + setScreen({ + kind: "config-error", + message: error instanceof Error ? error.message : "Login failed.", + }); + } + }; + + switch (screen.kind) { + case "loading": + return ( +
+ Loading… +
+ ); + case "login": + return ; + case "config-error": + return ( +
+

Configuration error

+

{screen.message}

+
+ ); + case "auth-error": + return ( +
+

Sign-in failed

+

{screen.message}

+ +
+ ); + case "refusal": + return ( +
+

Access refused

+

{screen.message}

+ +
+ ); + case "probe-error": + return ( +
+

Admin console unavailable

+

{screen.message}

+ + +
+ ); + case "app": + return ; + } +} diff --git a/admin/src/api/client.test.ts b/admin/src/api/client.test.ts new file mode 100644 index 0000000..9cd828a --- /dev/null +++ b/admin/src/api/client.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ApiForbiddenError, + ApiUnauthorizedError, + apiFetch, +} from "./client"; +import { storeTokens } from "../auth/oidc"; +import type { AppConfig } from "../config"; + +const app: AppConfig = { + issuer: "https://auth.example.com/application/o/nestquest", + clientId: "test-client-id", + apiBaseUrl: "https://api.example.com", + authorizeEndpoint: "https://auth.example.com/application/o/authorize/", + tokenEndpoint: "https://auth.example.com/application/o/token/", + userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", + endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", + redirectUri: "http://localhost:5173/auth/callback", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("API client", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("attaches the Bearer token to API calls", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + + await apiFetch("/api/v1/children", {}, app, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.example.com/api/v1/children"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + }); + + it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 })); + + await expect( + apiFetch("/api/v1/children", {}, app, fetchMock as any), + ).rejects.toBeInstanceOf(ApiForbiddenError); + }); + + it("triggers a login redirect on 401 with no refresh token", async () => { + storeTokens({ access_token: "stale", expires_in: 600 }); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 })); + + await expect( + apiFetch("/api/v1/children", {}, app, fetchMock as any), + ).rejects.toBeInstanceOf(ApiUnauthorizedError); + expect(assign).toHaveBeenCalledTimes(1); + const url = new URL(assign.mock.calls[0][0] as string); + expect(url.origin + url.pathname).toBe( + "https://auth.example.com/application/o/authorize/", + ); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => { + storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call + .mockResolvedValueOnce( + jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant + ) + .mockResolvedValueOnce(new Response("", { status: 500 })); // retry + + const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint); + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh"); + // A server failure must surface as the real status, not ApiUnauthorizedError. + expect(response.status).toBe(500); + }); +}); diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..4ad468b --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,93 @@ +/** + * Minimal fetch wrapper for the NestQuest API. + * + * - Attaches `Authorization: Bearer ` when a token is stored. + * - 401: not authenticated (or token expired) -> trigger a new login. + * - 403: authenticated but refused -> throws ApiForbiddenError so the UI can + * render an explicit refusal message. + */ +import { config, type AppConfig } from "../config"; +import { + buildAuthorizeUrl, + getToken, + isExpired, + refreshAccessToken, +} from "../auth/oidc"; + +export class ApiForbiddenError extends Error { + constructor(message = "Your account is not in the nestquest-admins group.") { + super(message); + this.name = "ApiForbiddenError"; + } +} + +export class ApiUnauthorizedError extends Error { + constructor(message = "Not authenticated.") { + super(message); + this.name = "ApiUnauthorizedError"; + } +} + +export interface ApiRequestOptions extends Omit { + headers?: Record; +} + +/** + * Redirect the browser to Authentik to start a new login. + * Exported for testing injection. + */ +export async function triggerLogin(app: AppConfig = config): Promise { + const url = await buildAuthorizeUrl(app); + window.location.assign(url); +} + +export async function apiFetch( + path: string, + options: ApiRequestOptions = {}, + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + if (isExpired() && (await refreshAccessToken(app, fetchImpl))) { + // token renewed; fall through and use the fresh one + } + + const token = getToken(); + const headers = { + ...(options.headers ?? {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const response = await fetchImpl(`${app.apiBaseUrl}${path}`, { + ...options, + headers, + }); + + if (response.status === 401) { + // Not authenticated: either no token or an unusable one. + if (await refreshAccessToken(app, fetchImpl)) { + const retryToken = getToken(); + const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, { + ...options, + headers: { + ...(options.headers ?? {}), + ...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}), + }, + }); + if (retryResponse.status === 403) throw new ApiForbiddenError(); + if (retryResponse.ok) return retryResponse; + // Only 401 means "unauthorized"; preserve every other status (e.g. a + // 500 from the API) so callers can react to the real response. + if (retryResponse.status === 401) throw new ApiUnauthorizedError(); + return retryResponse; + } + await triggerLogin(app); + throw new ApiUnauthorizedError(); + } + + if (response.status === 403) { + // Authenticated, but Authentik's token does not belong to nestquest-admins. + throw new ApiForbiddenError(); + } + + return response; +} diff --git a/admin/src/api/definitions.ts b/admin/src/api/definitions.ts new file mode 100644 index 0000000..b38f83b --- /dev/null +++ b/admin/src/api/definitions.ts @@ -0,0 +1,174 @@ +/** + * Typed client for the admin quest-definition routes and the children list + * the assignee picker reads (/api/v1/admin/quest-definitions, /children). + */ +import { apiFetch } from "./client"; + +export type WindowName = "morning" | "afternoon" | "evening"; + +/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */ +export type RuleType = + | "daily" + | "weekly" + | "monthly_day" + | "monthly_weekday" + | "yearly" + | "custom_days"; + +export interface DefinitionRule { + rule_type: RuleType; + interval: number; + /** 0 = Monday .. 6 = Sunday. */ + weekday_set: number[] | null; + day_of_month: number | null; + nth_weekday: number | null; + nth_weekday_weekday: number | null; + month: number | null; + start_date: string; + end_date: string | null; +} + +export interface DefinitionAssignee { + id: number; + display_name: string; +} + +export interface DefinitionWindow { + window: WindowName; + /** Local wall-clock "HH:MM", or null. */ + due_time: string | null; +} + +export interface QuestDefinition { + id: number; + title: string; + description: string | null; + icon: string | null; + is_active: boolean; + skip_on_away: boolean; + rule: DefinitionRule; + assignees: DefinitionAssignee[]; + windows: DefinitionWindow[]; +} + +export interface AdminChild { + id: number; + display_name: string; + colour: string | null; + avatar_ref: string | null; + sort_order: number; + is_active: boolean; +} + +/** A window declaration as the create/edit bodies accept it. */ +export type WindowEntry = WindowName | [WindowName, string | null]; + +export interface DefinitionCreateBody { + title: string; + rule: DefinitionRule; + assignee_child_ids: number[]; + windows: WindowEntry[]; + description?: string | null; + icon?: string | null; + skip_on_away?: boolean; +} + +export type DefinitionEditBody = Partial; + +export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions"; +export const CHILDREN_PATH = "/api/v1/admin/children"; + +/** A non-2xx save response; `detail` is the API's message when it sent one. */ +export class ApiRequestError extends Error { + constructor( + readonly status: number, + readonly detail: string | null, + ) { + super(detail ?? `Request failed (HTTP ${status}).`); + this.name = "ApiRequestError"; + } +} + +/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */ +function readDetail(body: unknown): string | null { + if (!body || typeof body !== "object" || !("detail" in body)) return null; + const { detail } = body as { detail: unknown }; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + const messages = detail + .map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null)) + .filter((msg): msg is string => typeof msg === "string"); + return messages.length > 0 ? messages.join("; ") : null; + } + return null; +} + +export async function readJson(response: Response): Promise { + if (!response.ok) { + let body: unknown = null; + try { + body = await response.json(); + } catch { + // Non-JSON error body: report the status alone. + } + throw new ApiRequestError(response.status, readDetail(body)); + } + return (await response.json()) as T; +} + +export async function fetchDefinitions(): Promise { + const body = await readJson<{ definitions: QuestDefinition[] }>( + await apiFetch(DEFINITIONS_PATH), + ); + return body.definitions; +} + +export async function fetchChildren(): Promise { + const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH)); + return body.children; +} + +function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise { + return apiFetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export async function createDefinition(body: DefinitionCreateBody): Promise { + return readJson(await sendJson(DEFINITIONS_PATH, "POST", body)); +} + +export async function updateDefinition( + id: number, + body: DefinitionEditBody, +): Promise { + return readJson(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body)); +} + +export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`; + +export interface OccurrencesPreviewOptions { + /** "YYYY-MM-DD"; the API defaults to its host's today. */ + startDate?: string; + /** 1..50; the API defaults to 10. */ + count?: number; +} + +/** + * The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by + * the same backend recurrence engine the materializer uses. + */ +export async function previewOccurrences( + rule: DefinitionRule, + options: OccurrencesPreviewOptions = {}, +): Promise { + const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule }; + if (options.startDate !== undefined) body.start_date = options.startDate; + if (options.count !== undefined) body.count = options.count; + const response = await readJson<{ dates: string[] }>( + await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body), + ); + return response.dates; +} diff --git a/admin/src/api/events.test.ts b/admin/src/api/events.test.ts new file mode 100644 index 0000000..33a24fd --- /dev/null +++ b/admin/src/api/events.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events"; +import { frame, testStream, type TestStream } from "./testStream"; +import { storeTokens } from "../auth/oidc"; + +const COMPLETED = { + child_id: 1, + child_name: "Declan", + instance_id: 12, + quest_title: "Make bed", + window: "morning", + due_date: "2026-09-23", + due_time: null, + occurred_at: "2026-09-23T11:04:00Z", +}; +const DAY_COMPLETE = { + child_id: 2, + child_name: "Maeve", + quests_due: 4, + quests_completed: 4, + occurred_at: "2026-09-23T12:00:00Z", +}; + +describe("SseParser", () => { + it("yields every frame from one chunk", () => { + const parser = new SseParser(); + const events = parser.push( + frame("nestquest_quest_completed", COMPLETED) + + frame("nestquest_child_day_complete", DAY_COMPLETE), + ); + expect(events).toEqual([ + { type: "nestquest_quest_completed", payload: COMPLETED }, + { type: "nestquest_child_day_complete", payload: DAY_COMPLETE }, + ]); + }); + + it("buffers a frame split across two chunks", () => { + const parser = new SseParser(); + const text = frame("nestquest_quest_uncompleted", COMPLETED); + const cut = text.indexOf("Make"); + expect(parser.push(text.slice(0, cut))).toEqual([]); + expect(parser.push(text.slice(cut))).toEqual([ + { type: "nestquest_quest_uncompleted", payload: COMPLETED }, + ]); + }); + + it("buffers a split inside the event name and the blank-line terminator", () => { + const parser = new SseParser(); + const text = frame("nestquest_quest_missed", COMPLETED); + expect(parser.push(text.slice(0, 12))).toEqual([]); + expect(parser.push(text.slice(12, -1))).toEqual([]); + expect(parser.push(text.slice(-1))).toEqual([ + { type: "nestquest_quest_missed", payload: COMPLETED }, + ]); + }); + + it("treats a CRLF split across chunks as one line break", () => { + const parser = new SseParser(); + const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`; + const cut = text.indexOf("\n"); // chunk ends on "\r" + expect(parser.push(text.slice(0, cut))).toEqual([]); + expect(parser.push(text.slice(cut))).toEqual([ + { type: "nestquest_quest_completed", payload: COMPLETED }, + ]); + }); + + it("drops malformed, unknown and comment frames without throwing", () => { + const parser = new SseParser(); + const events = parser.push( + "event: nestquest_quest_completed\ndata: not-json\n\n" + + "event: nestquest_quest_completed\ndata: [1,2]\n\n" + + "event: something_else\ndata: {}\n\n" + + ": keep-alive\n\n" + + "data: {}\n\n" + + frame("nestquest_quest_completed", COMPLETED), + ); + expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]); + }); +}); + +describe("openTransitionStream", () => { + let fetchMock: ReturnType; + let streams: TestStream[]; + let stop: (() => void) | null; + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + streams = []; + stop = null; + fetchMock = vi.fn(() => { + const stream = testStream(); + streams.push(stream); + return Promise.resolve(stream.response); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + stop?.(); + vi.unstubAllGlobals(); + }); + + it("sends the token in the Authorization header, never in the URL", async () => { + stop = openTransitionStream({ onTransition: () => {} }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`)); + expect(String(url)).not.toContain("at-123"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + expect(init.headers.Accept).toBe("text/event-stream"); + }); + + it("delivers transitions from frames split across chunks", async () => { + const seen: TransitionEvent[] = []; + stop = openTransitionStream({ onTransition: (event) => seen.push(event) }); + await waitFor(() => expect(streams).toHaveLength(1)); + const text = frame("nestquest_quest_completed", COMPLETED); + streams[0].push(text.slice(0, 20)); + streams[0].push(text.slice(20)); + await waitFor(() => + expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]), + ); + }); + + it("stop aborts the request and cancels the body", async () => { + stop = openTransitionStream({ onTransition: () => {} }); + await waitFor(() => expect(streams).toHaveLength(1)); + const signal = fetchMock.mock.calls[0][1].signal as AbortSignal; + stop(); + expect(signal.aborted).toBe(true); + await waitFor(() => expect(streams[0].cancelled()).toBe(true)); + }); + + it("reconnects after the stream drops", async () => { + const seen: TransitionEvent[] = []; + stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 }); + await waitFor(() => expect(streams).toHaveLength(1)); + streams[0].close(); + await waitFor(() => expect(streams).toHaveLength(2)); + streams[1].push(frame("nestquest_quest_missed", COMPLETED)); + await waitFor(() => expect(seen).toHaveLength(1)); + }); + + it("does not reconnect after stop, even with a reconnect pending", async () => { + stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 }); + await waitFor(() => expect(streams).toHaveLength(1)); + streams[0].close(); + stop(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("gives up on a 403 refusal instead of retrying", async () => { + fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 }))); + stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/admin/src/api/events.ts b/admin/src/api/events.ts new file mode 100644 index 0000000..bf6b572 --- /dev/null +++ b/admin/src/api/events.ts @@ -0,0 +1,196 @@ +/** + * Live transition stream: GET /api/v1/admin/events (text/event-stream). + * + * The admin JWT travels in the Authorization header (via apiFetch), so the + * stream is read with fetch + a ReadableStream reader — EventSource cannot + * set headers, and the token must never go into a URL. + */ +import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client"; + +export const EVENTS_PATH = "/api/v1/admin/events"; + +/** The four documented transitions, as the API names them on the wire. */ +export const TRANSITION_TYPES = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", +] as const; + +export type TransitionType = (typeof TRANSITION_TYPES)[number]; + +/** Quest transitions carry the instance; a day-complete carries the child's counts. */ +export interface QuestTransitionPayload { + child_id: number; + child_name: string | null; + instance_id: number; + quest_title: string | null; + window: string; + due_date: string; + due_time: string | null; + occurred_at: string; +} + +export interface ChildDayCompletePayload { + child_id: number; + child_name: string | null; + quests_due: number; + quests_completed: number; + occurred_at: string; +} + +export type TransitionEvent = + | { + type: + | "nestquest_quest_completed" + | "nestquest_quest_uncompleted" + | "nestquest_quest_missed"; + payload: QuestTransitionPayload; + } + | { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload }; + +function isTransitionType(value: string): value is TransitionType { + return (TRANSITION_TYPES as readonly string[]).includes(value); +} + +/** + * Incremental SSE frame parser. Feed it decoded text in whatever chunks the + * network delivers; it buffers a partial line or frame until it completes. + * Comments (keep-alives), unknown event types and frames whose data is not a + * JSON object are dropped, never thrown. + */ +export class SseParser { + private buffer = ""; + private eventType = ""; + private data: string[] = []; + /** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */ + private pendingCr = false; + + push(text: string): TransitionEvent[] { + if (this.pendingCr && text.startsWith("\n")) text = text.slice(1); + this.pendingCr = text.endsWith("\r"); + this.buffer += text; + const lines = this.buffer.split(/\r\n|\r|\n/); + this.buffer = lines.pop() ?? ""; + const events: TransitionEvent[] = []; + for (const line of lines) { + const event = this.line(line); + if (event) events.push(event); + } + return events; + } + + private line(line: string): TransitionEvent | null { + if (line === "") return this.dispatch(); + if (line.startsWith(":")) return null; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "event") this.eventType = value; + else if (field === "data") this.data.push(value); + return null; + } + + private dispatch(): TransitionEvent | null { + const type = this.eventType; + const data = this.data.join("\n"); + this.eventType = ""; + this.data = []; + if (!isTransitionType(type)) return null; + let payload: unknown; + try { + payload = JSON.parse(data); + } catch { + return null; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + return { type, payload } as TransitionEvent; + } +} + +export interface TransitionStreamOptions { + onTransition: (event: TransitionEvent) => void; + /** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */ + minDelayMs?: number; + maxDelayMs?: number; +} + +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + }); +} + +/** Read one connection until the body ends (or the signal aborts). */ +async function readOnce( + signal: AbortSignal, + onTransition: (event: TransitionEvent) => void, +): Promise { + const response = await apiFetch(EVENTS_PATH, { + signal, + headers: { Accept: "text/event-stream" }, + }); + if (!response.ok || !response.body) { + throw new Error(`Event stream request failed (HTTP ${response.status}).`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + const cancel = () => void reader.cancel().catch(() => {}); + signal.addEventListener("abort", cancel, { once: true }); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done || signal.aborted) break; + for (const event of parser.push(decoder.decode(value, { stream: true }))) { + if (signal.aborted) break; + onTransition(event); + } + } + } finally { + signal.removeEventListener("abort", cancel); + reader.releaseLock(); + } +} + +/** + * Subscribe to the admin transition stream, reconnecting with a bounded + * backoff after a drop. Returns a stop function that aborts the open request + * and any pending reconnect. A 401/403 stops the stream for good: apiFetch + * has already started a login (401) or the account is refused (403). + */ +export function openTransitionStream({ + onTransition, + minDelayMs = 1_000, + maxDelayMs = 30_000, +}: TransitionStreamOptions): () => void { + const controller = new AbortController(); + const { signal } = controller; + + void (async () => { + let delay = minDelayMs; + while (!signal.aborted) { + try { + await readOnce(signal, onTransition); + // A connection that was accepted and then ended starts the backoff over. + delay = minDelayMs; + } catch (error: unknown) { + if (signal.aborted) return; + if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return; + // Dropped or refused connection: fall through to the backoff. + } + if (signal.aborted) return; + await wait(delay, signal); + delay = Math.min(delay * 2, maxDelayMs); + } + })(); + + return () => controller.abort(); +} diff --git a/admin/src/api/history.ts b/admin/src/api/history.ts new file mode 100644 index 0000000..eeecda8 --- /dev/null +++ b/admin/src/api/history.ts @@ -0,0 +1,85 @@ +/** + * Typed client for the admin history route (/api/v1/admin/history): the + * append-only event log over a closed due-date range for one filter. + */ +import { apiFetch } from "./client"; +import { ApiRequestError, readJson } from "./definitions"; + +export type HistoryFilter = "all" | "reversals" | "missed"; + +export type HistoryEventType = "completed" | "uncompleted" | "missed"; + +export interface HistoryRow { + /** UTC ISO stamp; null for a derived missed row (there is no event). */ + occurred_at: string | null; + event_type: HistoryEventType; + child_name: string; + child_id: number; + quest_title: string; + instance_id: number; + window: string; + /** "YYYY-MM-DD", the day the task was owed. */ + due_date: string; + /** Local wall-clock "HH:MM", or null. */ + due_time: string | null; + /** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */ + actor: string; + was_on_time: boolean | null; +} + +export interface HistoryResponse { + filter: HistoryFilter; + start: string; + end: string; + count: number; + rows: HistoryRow[]; +} + +export const HISTORY_PATH = "/api/v1/admin/history"; + +/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */ +export async function fetchHistory( + filter: HistoryFilter, + start: string, + end: string, +): Promise { + const params = new URLSearchParams({ start, end, filter }); + return readJson(await apiFetch(`${HISTORY_PATH}?${params.toString()}`)); +} + +export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv"; + +export interface HistoryCsv { + blob: Blob; + filename: string; +} + +/** The `filename` from a Content-Disposition header, or null. */ +export function csvFilename(disposition: string | null): string | null { + const match = disposition?.match(/filename="?([^";]+)"?/i); + return match ? match[1].trim() : null; +} + +/** + * The CSV export of the same range and filter as `fetchHistory`. Goes through + * apiFetch so the admin token travels in the Authorization header. + */ +export async function exportHistoryCsv( + filter: HistoryFilter, + start: string, + end: string, +): Promise { + const params = new URLSearchParams({ start, end, filter }); + const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`); + if (!response.ok) { + // readJson raises ApiRequestError with the API's `detail` for a non-OK response. + await readJson(response); + throw new ApiRequestError(response.status, null); + } + return { + blob: await response.blob(), + filename: + csvFilename(response.headers.get("Content-Disposition")) ?? + `nestquest-history-${start}-to-${end}.csv`, + }; +} diff --git a/admin/src/api/instances.ts b/admin/src/api/instances.ts new file mode 100644 index 0000000..bcc3c33 --- /dev/null +++ b/admin/src/api/instances.ts @@ -0,0 +1,23 @@ +/** + * Typed client for the admin instance mutation route + * (POST /api/v1/admin/instances/{id}/uncomplete). + */ +import { apiFetch } from "./client"; +import { readJson } from "./definitions"; + +export interface UncompleteResult { + instance_id: number; + /** The derived state after the reversal: "open", or "missed" when past due. */ + state: string; + /** False when the instance was already open (a no-op). */ + appended: boolean; +} + +export function uncompletePath(instanceId: number): string { + return `/api/v1/admin/instances/${instanceId}/uncomplete`; +} + +/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */ +export async function uncompleteInstance(instanceId: number): Promise { + return readJson(await apiFetch(uncompletePath(instanceId), { method: "POST" })); +} diff --git a/admin/src/api/presence.ts b/admin/src/api/presence.ts new file mode 100644 index 0000000..a04461e --- /dev/null +++ b/admin/src/api/presence.ts @@ -0,0 +1,126 @@ +/** + * Typed client for the admin presence routes: a child's repeating schedule + * (/api/v1/admin/children/{id}/presence-schedule) and the household's + * date-range overrides (/api/v1/admin/presence-overrides). + */ +import { apiFetch } from "./client"; +import { CHILDREN_PATH, readJson } from "./definitions"; + +/** + * Cycle week index ("0".."n-1", JSON object keys) -> present weekdays + * (0 = Monday .. 6 = Sunday). An empty list is away that whole week. + */ +export type PresencePattern = Record; + +export interface PresenceScheduleBody { + cycle_length_weeks: number; + /** "YYYY-MM-DD"; pins cycle week 0. */ + anchor_date: string; + pattern: PresencePattern; +} + +export interface PresenceSchedule extends PresenceScheduleBody { + child_id: number; +} + +export interface PresenceOverride { + id: number; + child_id: number; + start_date: string; + end_date: string; + is_present: boolean; + note: string | null; +} + +export interface PresenceOverrideFilter { + childId?: number; + /** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */ + start?: string; + end?: string; +} + +export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides"; + +export function presenceSchedulePath(childId: number): string { + return `${CHILDREN_PATH}/${childId}/presence-schedule`; +} + +/** The child's schedule, or null when it has none (present every day). */ +export async function fetchPresenceSchedule(childId: number): Promise { + const body = await readJson<{ schedule: PresenceSchedule | null }>( + await apiFetch(presenceSchedulePath(childId)), + ); + return body.schedule; +} + +/** Set (upsert) the child's schedule; resolves to the schedule as stored. */ +export async function savePresenceSchedule( + childId: number, + body: PresenceScheduleBody, +): Promise { + return readJson( + await apiFetch(presenceSchedulePath(childId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export async function fetchPresenceOverrides( + filter: PresenceOverrideFilter = {}, +): Promise { + const params = new URLSearchParams(); + if (filter.childId !== undefined) params.set("child_id", String(filter.childId)); + if (filter.start !== undefined) params.set("start", filter.start); + if (filter.end !== undefined) params.set("end", filter.end); + const query = params.toString(); + const body = await readJson<{ overrides: PresenceOverride[] }>( + await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH), + ); + return body.overrides; +} + +export interface PresenceOverrideRange { + child_id: number; + /** "YYYY-MM-DD", inclusive. */ + start_date: string; + /** "YYYY-MM-DD", inclusive; never before start_date. */ + end_date: string; + is_present: boolean; +} + +export interface PresenceOverrideBody extends PresenceOverrideRange { + note?: string; +} + +export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`; + +function postJson(path: string, body: unknown): Promise { + return apiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * How many open upcoming tasks saving this override would remove. Computed + * by the API before anything is saved; completed instances never count. + */ +export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise { + const body = await readJson<{ removed: number }>( + await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range), + ); + return body.removed; +} + +export async function createPresenceOverride(body: PresenceOverrideBody): Promise { + return readJson(await postJson(PRESENCE_OVERRIDES_PATH, body)); +} + +export async function deletePresenceOverride(overrideId: number): Promise { + await readJson<{ status: string }>( + await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }), + ); +} diff --git a/admin/src/api/snapshot.ts b/admin/src/api/snapshot.ts new file mode 100644 index 0000000..9fa0186 --- /dev/null +++ b/admin/src/api/snapshot.ts @@ -0,0 +1,55 @@ +/** + * Typed client for GET /api/v1/admin/snapshot — today's household snapshot + * that drives the admin Today tab. + */ +import { apiFetch } from "./client"; + +export type InstanceWindow = "morning" | "afternoon" | "evening"; +export type InstanceState = "open" | "completed" | "missed"; + +export interface SnapshotInstance { + id: number; + definition_id: number; + child_id: number; + title: string; + icon: string | null; + window: InstanceWindow; + /** Local wall-clock "HH:MM", or null when the definition has no due time. */ + due_time: string | null; + state: InstanceState; + overdue: boolean; + /** UTC ISO timestamp of the completion event, when completed. */ + completed_at: string | null; + on_time: boolean | null; +} + +export interface SnapshotChild { + child_id: number; + child_name: string; + present: boolean; + /** "YYYY-MM-DD" of the next present day, or null. */ + next_present: string | null; + due_today: number; + completed_today: number; + remaining_today: number; + completion_pct: number; + instances: SnapshotInstance[]; +} + +export interface AdminSnapshot { + /** "YYYY-MM-DD" in the household's local zone. */ + today_iso: string; + /** 1-based day in the custody cycle. */ + cycle_day: number; + children: SnapshotChild[]; +} + +export const SNAPSHOT_PATH = "/api/v1/admin/snapshot"; + +export async function fetchSnapshot(): Promise { + const response = await apiFetch(SNAPSHOT_PATH); + if (!response.ok) { + throw new Error(`Snapshot request failed (HTTP ${response.status}).`); + } + return (await response.json()) as AdminSnapshot; +} diff --git a/admin/src/api/testStream.ts b/admin/src/api/testStream.ts new file mode 100644 index 0000000..18c469e --- /dev/null +++ b/admin/src/api/testStream.ts @@ -0,0 +1,39 @@ +/** + * Test helper: a controllable text/event-stream Response whose body the test + * feeds chunk by chunk (so a frame can be split across chunk boundaries). + */ +export interface TestStream { + response: Response; + push(text: string): void; + close(): void; + /** True once the reader cancelled the body (the stream was torn down). */ + cancelled(): boolean; +} + +export function testStream(): TestStream { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + let cancelled = false; + const body = new ReadableStream({ + start(c) { + controller = c; + }, + cancel() { + cancelled = true; + }, + }); + return { + response: new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + push: (text) => controller.enqueue(encoder.encode(text)), + close: () => controller.close(), + cancelled: () => cancelled, + }; +} + +/** One SSE frame exactly as api/sse.py writes it. */ +export function frame(type: string, payload: unknown): string { + return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; +} diff --git a/admin/src/api/useTransitions.tsx b/admin/src/api/useTransitions.tsx new file mode 100644 index 0000000..e021d79 --- /dev/null +++ b/admin/src/api/useTransitions.tsx @@ -0,0 +1,61 @@ +/** + * One live transition stream per signed-in shell, fanned out to the screens. + * + * opens the stream on mount and aborts it on unmount; + * useTransitions(handler) registers a screen's handler for as long as the + * screen is mounted. Without a provider the hook does nothing, so a screen + * rendered on its own (e.g. in a test) never opens a stream. + */ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { openTransitionStream, type TransitionEvent } from "./events"; + +type Listener = (event: TransitionEvent) => void; + +interface TransitionHub { + subscribe(listener: Listener): () => void; +} + +const TransitionsContext = createContext(null); + +export function TransitionsProvider({ children }: { children: ReactNode }) { + const [hub] = useState(() => { + const listeners = new Set(); + return { + listeners, + subscribe(listener: Listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + }); + + useEffect( + () => + openTransitionStream({ + onTransition: (event) => { + for (const listener of [...hub.listeners]) { + try { + listener(event); + } catch { + // One screen's handler must not tear down the shared stream. + } + } + }, + }), + [hub], + ); + + return {children}; +} + +/** Call `handler` for every transition while the calling component is mounted. */ +export function useTransitions(handler: Listener): void { + const hub = useContext(TransitionsContext); + const handlerRef = useRef(handler); + useEffect(() => { + handlerRef.current = handler; + }); + useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]); +} diff --git a/admin/src/auth/oidc.test.ts b/admin/src/auth/oidc.test.ts new file mode 100644 index 0000000..cd63b02 --- /dev/null +++ b/admin/src/auth/oidc.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildAuthorizeUrl, + clearAuth, + getToken, + handleCallback, + logout, + storeTokens, +} from "./oidc"; +import type { AppConfig } from "../config"; + +function testApp(): AppConfig { + return { + issuer: "https://auth.example.com/application/o/nestquest", + clientId: "test-client-id", + apiBaseUrl: "https://api.example.com", + authorizeEndpoint: "https://auth.example.com/application/o/authorize/", + tokenEndpoint: "https://auth.example.com/application/o/token/", + userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", + endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", + redirectUri: "http://localhost:5173/auth/callback", + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("OIDC flow", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => { + const app = testApp(); + const url = new URL(await buildAuthorizeUrl(app)); + + expect(url.origin + url.pathname).toBe(app.authorizeEndpoint); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe("test-client-id"); + expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri); + expect(url.searchParams.get("scope")).toBe("openid email profile"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/); + + expect(sessionStorage.getItem("nestquest.oidc_state")).toBe( + url.searchParams.get("state"), + ); + expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/); + }); + + it("handleCallback rejects a mismatched state", async () => { + await buildAuthorizeUrl(testApp()); + sessionStorage.setItem("nestquest.oidc_state", "different-state"); + + const fetchMock = vi.fn(); + await expect( + handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any), + ).rejects.toThrow(/state mismatch/i); + expect(fetchMock).not.toHaveBeenCalled(); + expect(getToken()).toBeUndefined(); + }); + + it("handleCallback exchanges the code with the correct form body and stores the token", async () => { + await buildAuthorizeUrl(testApp()); + const verifier = sessionStorage.getItem("nestquest.code_verifier")!; + const state = sessionStorage.getItem("nestquest.oidc_state")!; + + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + access_token: "at-123", + refresh_token: "rt-456", + id_token: "id-789", + expires_in: 300, + }), + ); + await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://auth.example.com/application/o/token/"); + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("abc"); + expect(body.get("redirect_uri")).toBe(testApp().redirectUri); + expect(body.get("client_id")).toBe("test-client-id"); + expect(body.get("code_verifier")).toBe(verifier); + expect(body.has("client_secret")).toBe(false); + + expect(getToken()).toBe("at-123"); + // one-time values consumed + expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull(); + expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull(); + }); + + it("handleCallback surfaces provider errors", async () => { + await expect( + handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any), + ).rejects.toThrow(/nope|access_denied/); + }); + + it("clearAuth removes all stored auth data", () => { + storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 }); + clearAuth(); + expect(getToken()).toBeUndefined(); + expect(sessionStorage.length).toBe(0); + }); + + it("logout clears auth and redirects to the end-session endpoint", async () => { + storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 }); + const assign = vi.fn(); + const original = window.location; + vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" }); + + await logout(testApp()); + + expect(assign).toHaveBeenCalledTimes(1); + const url = new URL(assign.mock.calls[0][0] as string); + expect(url.toString()).toBe( + "https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173", + ); + expect(getToken()).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => { + const issuer = "https://sso.discovery.example/application/o/discovered"; + const discoveryDoc = { + issuer, + authorization_endpoint: "https://sso.discovery.example/application/o/authorize/", + token_endpoint: "https://sso.discovery.example/application/o/token/", + userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/", + end_session_endpoint: `${issuer}/end-session/`, + jwks_uri: `${issuer}/jwks/`, + }; + const fetchMock = vi.fn().mockImplementation((url: string) => + Promise.resolve( + String(url).endsWith("/.well-known/openid-configuration") + ? jsonResponse(discoveryDoc) + : jsonResponse({}), + ), + ); + const app: AppConfig = { + issuer, + clientId: "test-client-id", + apiBaseUrl: "https://api.example.com", + redirectUri: "http://localhost:5173/auth/callback", + }; + + const url = new URL(await buildAuthorizeUrl(app, fetchMock as any)); + + // The authorize endpoint comes from the discovery document — Authentik + // serves it outside the app-specific issuer path, so it is NOT + // `${issuer}/authorize/` and must never be a hardcoded production host. + expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint); + expect(url.searchParams.get("client_id")).toBe("test-client-id"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe( + `${issuer}/.well-known/openid-configuration`, + ); + }); +}); diff --git a/admin/src/auth/oidc.ts b/admin/src/auth/oidc.ts new file mode 100644 index 0000000..f2fe723 --- /dev/null +++ b/admin/src/auth/oidc.ts @@ -0,0 +1,225 @@ +/** + * OIDC authorization-code + PKCE flow for a PUBLIC client. + * + * Never sends a client_secret anywhere. Tokens are stored in sessionStorage + * (per-tab, cleared on tab close) — never localStorage. + */ +import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config"; +import { + computeCodeChallenge, + generateCodeVerifier, + generateState, +} from "./pkce"; + +const STORAGE_KEYS = { + accessToken: "nestquest.access_token", + refreshToken: "nestquest.refresh_token", + expiresAt: "nestquest.expires_at", + idToken: "nestquest.id_token", + state: "nestquest.oidc_state", + codeVerifier: "nestquest.code_verifier", +} as const; + +export class AuthError extends Error {} + +export interface TokenResponse { + access_token?: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + token_type?: string; +} + +function makeScopes(): string { + return "openid email profile"; +} + +/** + * Build the Authentik authorization redirect URL, storing state + verifier in + * sessionStorage so the callback can validate them. Endpoints are resolved + * from the issuer's OIDC discovery document when not set on the config. + */ +export async function buildAuthorizeUrl( + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!app.clientId) { + throw new AuthError( + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + ); + } + const resolved = await resolveEndpoints(app, fetchImpl); + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await computeCodeChallenge(codeVerifier); + + sessionStorage.setItem(STORAGE_KEYS.state, state); + sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier); + + const url = new URL(resolved.authorizeEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", app.clientId); + url.searchParams.set("redirect_uri", app.redirectUri); + url.searchParams.set("scope", makeScopes()); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +/** + * Handle the OAuth callback: validate state, exchange the code at the token + * endpoint (PKCE, form-urlencoded, no client_secret), store tokens. + */ +export async function handleCallback( + query: URLSearchParams | string = window.location.search, + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + const params = + typeof query === "string" ? new URLSearchParams(query) : query; + + const providerError = params.get("error"); + if (providerError) { + throw new AuthError( + params.get("error_description") || `Provider returned error: ${providerError}`, + ); + } + + const code = params.get("code"); + const state = params.get("state"); + if (!code || !state) { + throw new AuthError("Callback is missing the code or state parameter."); + } + + const storedState = sessionStorage.getItem(STORAGE_KEYS.state); + const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier); + if (!storedState || storedState !== state || !codeVerifier) { + throw new AuthError("State mismatch — possible CSRF; refusing to log in."); + } + + if (!app.clientId) { + throw new AuthError( + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + ); + } + + const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: app.redirectUri, + client_id: app.clientId, + code_verifier: codeVerifier, + }); + + const response = await fetchImpl(resolved.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!response.ok) { + throw new AuthError(`Token exchange failed with HTTP ${response.status}`); + } + const tokens = (await response.json()) as TokenResponse; + if (!tokens.access_token) { + throw new AuthError("Token response did not include an access token."); + } + + storeTokens(tokens); + // One-time use values are consumed on success. + sessionStorage.removeItem(STORAGE_KEYS.state); + sessionStorage.removeItem(STORAGE_KEYS.codeVerifier); +} + +export function storeTokens(tokens: TokenResponse): void { + sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!); + if (tokens.refresh_token) { + sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token); + } + if (tokens.id_token) { + sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token); + } + if (typeof tokens.expires_in === "number") { + sessionStorage.setItem( + STORAGE_KEYS.expiresAt, + String(Date.now() + tokens.expires_in * 1000), + ); + } +} + +export function getToken(): string | undefined { + return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined; +} + +export function getRefreshToken(): string | undefined { + return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined; +} + +export function getIdToken(): string | undefined { + return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined; +} + +export function isAuthenticated(): boolean { + return Boolean(getToken()); +} + +/** True once the stored access token has passed its (approximate) expiry. */ +export function isExpired(): boolean { + const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt); + if (!expiresAt) return false; + return Date.now() >= Number(expiresAt); +} + +/** + * Refresh the access token with the refresh token grant. Returns false when + * no refresh token is stored or the grant fails (caller should re-login). + */ +export async function refreshAccessToken( + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken || !app.clientId) return false; + + const resolved = await resolveEndpoints(app, fetchImpl); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: app.clientId, + }); + + const response = await fetchImpl(resolved.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!response.ok) return false; + const tokens = (await response.json()) as TokenResponse; + if (!tokens.access_token) return false; + storeTokens(tokens); + return true; +} + +export function clearAuth(): void { + for (const key of Object.values(STORAGE_KEYS)) { + sessionStorage.removeItem(key); + } +} + +/** Sign out: clear local auth, then send the browser to the end-session endpoint. */ +export async function logout( + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + const resolved = await resolveEndpoints(app, fetchImpl); + const idTokenHint = getIdToken(); + clearAuth(); + const params = new URLSearchParams(); + if (idTokenHint) params.set("id_token_hint", idTokenHint); + if (app.clientId) params.set("client_id", app.clientId); + params.set("post_logout_redirect_uri", window.location.origin); + window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`); +} diff --git a/admin/src/auth/pkce.test.ts b/admin/src/auth/pkce.test.ts new file mode 100644 index 0000000..7b7f686 --- /dev/null +++ b/admin/src/auth/pkce.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + base64UrlEncode, + computeCodeChallenge, + generateCodeVerifier, + generateState, + isLikelyValidVerifier, +} from "./pkce"; + +describe("PKCE helpers", () => { + it("base64UrlEncode has no padding and url-safe characters", () => { + // bytes chosen to force "+" and "/" and padding in plain base64 + const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]); + const encoded = base64UrlEncode(bytes); + expect(encoded).not.toMatch(/[+/=]/); + }); + + it("computes the RFC 7636 appendix-B S256 vector", async () => { + const verifier = + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = await computeCodeChallenge(verifier); + expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); + + it("generates verifiers with valid charset and length", () => { + for (let i = 0; i < 20; i++) { + const verifier = generateCodeVerifier(); + expect(isLikelyValidVerifier(verifier)).toBe(true); + } + }); + + it("rejects verifiers outside 43-128 chars or with reserved chars", () => { + expect(isLikelyValidVerifier("short")).toBe(false); + expect(isLikelyValidVerifier("a".repeat(129))).toBe(false); + expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false); + }); + + it("generates distinct random states", () => { + expect(generateState()).not.toBe(generateState()); + }); +}); diff --git a/admin/src/auth/pkce.ts b/admin/src/auth/pkce.ts new file mode 100644 index 0000000..de04c5f --- /dev/null +++ b/admin/src/auth/pkce.ts @@ -0,0 +1,55 @@ +/** + * PKCE (RFC 7636) helpers for a public SPA client. + * + * Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be + * served over https or http://localhost, otherwise crypto.subtle is + * unavailable and login cannot work. + */ + +const VERIFIER_CHARSET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +/** base64url-encode bytes with NO padding. */ +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function randomBase64Url(byteLength: number): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +/** + * Random code_verifier: 43-128 chars, unreserved charset only. + * 32 random bytes -> 43 base64url chars, which is the minimum allowed length. + */ +export function generateCodeVerifier(): string { + return randomBase64Url(32); +} + +/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */ +export async function computeCodeChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Random opaque state value for CSRF protection. */ +export function generateState(): string { + return randomBase64Url(16); +} + +export function isLikelyValidVerifier(verifier: string): boolean { + return ( + verifier.length >= 43 && + verifier.length <= 128 && + [...verifier].every((char) => VERIFIER_CHARSET.includes(char)) + ); +} diff --git a/admin/src/config.test.ts b/admin/src/config.test.ts new file mode 100644 index 0000000..9fcf9bf --- /dev/null +++ b/admin/src/config.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ConfigError, + discoveryUrlForIssuer, + readConfig, + resolveEndpoints, + resolveEndpointsFromDiscovery, + type AppConfig, +} from "./config"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the +// application-specific issuer path, end-session/jwks under it. +function discoveryDocument(issuer: string) { + const normalized = issuer.replace(/\/+$/, ""); + const origin = new URL(normalized).origin; + return { + issuer, + authorization_endpoint: `${origin}/application/o/authorize/`, + token_endpoint: `${origin}/application/o/token/`, + userinfo_endpoint: `${origin}/application/o/userinfo/`, + end_session_endpoint: `${normalized}/end-session/`, + jwks_uri: `${normalized}/jwks/`, + }; +} + +describe("config", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("readConfig keeps the production issuer as the default", () => { + const cfg = readConfig({}); + expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest"); + expect(cfg.clientId).toBeUndefined(); + }); + + it("readConfig normalizes a trailing slash off a custom issuer", () => { + expect( + readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" }) + .issuer, + ).toBe("https://sso.custom.example/application/o/custom"); + }); + + it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => { + const cfg = readConfig({}); + expect(cfg.authorizeEndpoint).toBeUndefined(); + expect(cfg.tokenEndpoint).toBeUndefined(); + expect(cfg.userinfoEndpoint).toBeUndefined(); + expect(cfg.endSessionEndpoint).toBeUndefined(); + expect(cfg.jwksUri).toBeUndefined(); + }); + + it("builds the discovery URL for issuers with and without a trailing slash", () => { + expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe( + "https://sso.example.com/application/o/app/.well-known/openid-configuration", + ); + expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe( + "https://sso.example.com/application/o/app/.well-known/openid-configuration", + ); + }); + + it("resolves every endpoint from the discovery document of a non-default issuer", async () => { + const issuer = "https://sso.other-host.example/application/o/other-app"; + const doc = discoveryDocument(issuer); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + const app: AppConfig = { + issuer, + clientId: "client-id", + apiBaseUrl: "https://api.example.com", + redirectUri: "http://localhost:5173/auth/callback", + }; + + const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration", + ); + // Every endpoint comes verbatim from the (non-default) discovery document. + expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint); + expect(resolved.tokenEndpoint).toBe(doc.token_endpoint); + expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint); + expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint); + expect(resolved.jwksUri).toBe(doc.jwks_uri); + // Proves no hardcoded production host leaked into the resolved endpoints. + for (const endpoint of [ + resolved.authorizeEndpoint, + resolved.tokenEndpoint, + resolved.userinfoEndpoint, + resolved.endSessionEndpoint, + resolved.jwksUri, + ]) { + expect(endpoint).not.toMatch(/cubecraftlabs/); + } + }); + + it("resolves discovery for an issuer configured with a trailing slash", async () => { + const issuer = "https://sso.slashy.example/application/o/slashy/"; + const doc = discoveryDocument(issuer); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + + const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledWith( + "https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration", + ); + expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint); + expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint); + }); + + it("caches discovery per issuer for the session", async () => { + const issuer = "https://sso.cached.example/application/o/cached"; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer))); + + const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any); + const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + }); + + it("surfaces a clear configuration error when discovery returns an HTTP error", async () => { + const issuer = "https://sso.http-error.example/application/o/broken"; + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 })); + + const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then( + () => null, + (error: unknown) => error, + ); + + expect(error).toBeInstanceOf(ConfigError); + expect((error as Error).message).toContain(issuer); + expect((error as Error).message).toContain("HTTP 500"); + }); + + it("surfaces a clear configuration error when discovery is unreachable", async () => { + const issuer = "https://sso.unreachable.example/application/o/unreachable"; + const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down")); + + await expect( + resolveEndpointsFromDiscovery(issuer, fetchMock as any), + ).rejects.toBeInstanceOf(ConfigError); + }); + + it("surfaces a clear configuration error when the discovery document is incomplete", async () => { + const issuer = "https://sso.incomplete.example/application/o/incomplete"; + const doc = discoveryDocument(issuer) as Record; + delete doc.end_session_endpoint; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + + await expect( + resolveEndpointsFromDiscovery(issuer, fetchMock as any), + ).rejects.toThrow(/end_session_endpoint/); + }); + + it("rejects a discovery document advertising a different issuer", async () => { + const issuer = "https://sso.mismatch.example/application/o/mismatch"; + const doc = { + ...discoveryDocument(issuer), + issuer: "https://sso.elsewhere.example/application/o/elsewhere", + }; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + + await expect( + resolveEndpointsFromDiscovery(issuer, fetchMock as any), + ).rejects.toThrow(/issuer mismatch/i); + }); + + it("the bundled config resolves its endpoints from discovery at runtime", async () => { + const issuer = "https://sso.bundled.example/application/o/bundled"; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer))); + const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" }); + expect(bundled.authorizeEndpoint).toBeUndefined(); + + const resolved = await resolveEndpoints(bundled, fetchMock as any); + + expect(resolved.authorizeEndpoint).toBe( + "https://sso.bundled.example/application/o/authorize/", + ); + expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/"); + expect(resolved.userinfoEndpoint).toBe( + "https://sso.bundled.example/application/o/userinfo/", + ); + expect(resolved.endSessionEndpoint).toBe( + "https://sso.bundled.example/application/o/bundled/end-session/", + ); + expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/"); + }); + + it("does not re-fetch discovery when the config already carries endpoints", async () => { + const app: AppConfig = { + issuer: "https://auth.example.com/application/o/nestquest", + clientId: "client-id", + apiBaseUrl: "https://api.example.com", + authorizeEndpoint: "https://auth.example.com/application/o/authorize/", + tokenEndpoint: "https://auth.example.com/application/o/token/", + userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", + endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", + redirectUri: "http://localhost:5173/auth/callback", + }; + const fetchMock = vi.fn(); + + const resolved = await resolveEndpoints(app, fetchMock as any); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(resolved).toBe(app); + }); +}); diff --git a/admin/src/config.ts b/admin/src/config.ts new file mode 100644 index 0000000..9880be9 --- /dev/null +++ b/admin/src/config.ts @@ -0,0 +1,221 @@ +/** + * Runtime configuration, read from Vite env (import.meta.env). + * See .env.example for the variables and their defaults. + * + * OIDC endpoints are NOT hardcoded here. They are resolved lazily from the + * issuer's OIDC discovery document (see resolveEndpoints), because Authentik + * serves authorize/token/userinfo OUTSIDE the application-specific issuer + * path (issuer is /application/o//, authorize is /application/o/authorize/). + * Both hardcoding the production host and naively concatenating onto the + * issuer would break non-default issuers; discovery is the only correct way. + */ + +export interface AppConfig { + issuer: string; + clientId: string | undefined; + apiBaseUrl: string; + /** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */ + authorizeEndpoint?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + endSessionEndpoint?: string; + jwksUri?: string; + redirectUri: string; +} + +/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */ +export type ResolvedAppConfig = AppConfig & + Required< + Pick< + AppConfig, + "authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint" + > + >; + +export interface DiscoveredEndpoints { + issuer: string; + authorizeEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + endSessionEndpoint: string; + jwksUri: string; +} + +/** Raised when OIDC discovery fails or the configuration is unusable. */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +const REQUIRED_ENDPOINT_NAMES = [ + "authorizeEndpoint", + "tokenEndpoint", + "userinfoEndpoint", + "endSessionEndpoint", +] as const; + +export function readConfig(env: Record = import.meta.env as any): AppConfig { + const issuer = ( + env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/" + ).replace(/\/$/, ""); + const clientId = env.VITE_AUTHENTIK_CLIENT_ID; + const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com"; + + return { + issuer, + // No default: a missing client id must surface as a configuration error + // screen, not a broken redirect to Authentik. + clientId: clientId || undefined, + apiBaseUrl, + // authorize/token/userinfo/end-session endpoints are resolved from the + // issuer's discovery document at runtime — never hardcoded here. + redirectUri: `${window.location.origin}/auth/callback`, + }; +} + +export const config: AppConfig = readConfig(); + +interface DiscoveryDocument { + issuer?: unknown; + authorization_endpoint?: unknown; + token_endpoint?: unknown; + userinfo_endpoint?: unknown; + end_session_endpoint?: unknown; + jwks_uri?: unknown; +} + +function normalizeIssuer(issuer: string): string { + return issuer.replace(/\/+$/, ""); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Discovery document location for an issuer, tolerant of a trailing slash. */ +export function discoveryUrlForIssuer(issuer: string): string { + return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`; +} + +async function fetchDiscoveredEndpoints( + issuer: string, + fetchImpl: typeof fetch, +): Promise { + const normalizedIssuer = normalizeIssuer(issuer); + const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; + + let response: Response; + try { + response = await fetchImpl(discoveryUrl); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new ConfigError( + `OIDC discovery request to ${discoveryUrl} failed (${reason}). ` + + "Check VITE_AUTHENTIK_ISSUER and network reachability.", + ); + } + if (!response.ok) { + throw new ConfigError( + `OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` + + "Check VITE_AUTHENTIK_ISSUER.", + ); + } + + let doc: DiscoveryDocument; + try { + doc = (await response.json()) as DiscoveryDocument; + } catch { + throw new ConfigError( + `OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`, + ); + } + + const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint); + const tokenEndpoint = nonEmptyString(doc.token_endpoint); + const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint); + const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint); + const jwksUri = nonEmptyString(doc.jwks_uri); + const discoveredIssuer = nonEmptyString(doc.issuer); + + const endpointFields: Array<[string, string | undefined]> = [ + ["authorization_endpoint", authorizationEndpoint], + ["token_endpoint", tokenEndpoint], + ["userinfo_endpoint", userinfoEndpoint], + ["end_session_endpoint", endSessionEndpoint], + ["jwks_uri", jwksUri], + ]; + const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name); + if (missing.length > 0) { + throw new ConfigError( + `OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` + + "The issuer must point at an OIDC provider that publishes these endpoints.", + ); + } + + if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) { + throw new ConfigError( + `OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` + + `but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`, + ); + } + + return { + issuer: normalizedIssuer, + authorizeEndpoint: authorizationEndpoint!, + tokenEndpoint: tokenEndpoint!, + userinfoEndpoint: userinfoEndpoint!, + endSessionEndpoint: endSessionEndpoint!, + jwksUri: jwksUri!, + }; +} + +/** + * Session cache: one discovery fetch per issuer per SPA session. Failed + * lookups are evicted so a transient network error can be retried instead of + * poisoning the whole session. + */ +const discoveryCache = new Map>(); + +export function resolveEndpointsFromDiscovery( + issuer: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const normalizedIssuer = normalizeIssuer(issuer); + const cached = discoveryCache.get(normalizedIssuer); + if (cached) return cached; + + const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl); + discoveryCache.set(normalizedIssuer, pending); + pending.catch(() => { + discoveryCache.delete(normalizedIssuer); + }); + return pending; +} + +/** + * Return `app` with all OIDC endpoints filled in. When the config already + * carries them (pre-resolved config, tests) this is a no-op; otherwise the + * issuer's discovery document is fetched (cached for the session) and its + * authorization/token/userinfo/end-session/jwks endpoints are used. + */ +export async function resolveEndpoints( + app: AppConfig, + fetchImpl: typeof fetch = fetch, +): Promise { + const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]); + if (missing.length === 0) { + return app as ResolvedAppConfig; + } + + const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl); + return { + ...app, + authorizeEndpoint: discovered.authorizeEndpoint, + tokenEndpoint: discovered.tokenEndpoint, + userinfoEndpoint: discovered.userinfoEndpoint, + endSessionEndpoint: discovered.endSessionEndpoint, + jwksUri: discovered.jwksUri, + }; +} diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css new file mode 100644 index 0000000..a857716 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.css @@ -0,0 +1,585 @@ +/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). + Mobile-first at the 390x844 reference; the column is capped and centred wider. */ + +.defs-screen { + width: 100%; + max-width: 640px; + margin: 0 auto; +} + +/* §3.1 header */ +.defs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.defs-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.defs-sub { + margin: 2px 0 0; + min-height: 1.4em; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-new { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 0 14px; + border: 0; + border-radius: var(--nq-a-density-radius); + background: var(--nq-brand-gradient); + color: #fff; + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.defs-new:disabled { + opacity: 0.5; + cursor: default; +} + +/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ +.defs-scroll { + display: flex; + flex-direction: column; + gap: var(--nq-a-density-gap); + padding: 14px 16px; +} + +/* Filter chips */ +.defs-chips { + display: flex; + gap: 6px; + overflow-x: auto; +} + +.defs-chip { + flex: none; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.defs-chip--active { + border-color: #18181d; + background: #18181d; + color: #fff; +} + +/* Rows */ +.defs-card { + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + overflow: hidden; +} + +.defs-list { + margin: 0; + padding: 0; + list-style: none; +} + +.defs-list > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.defs-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: var(--nq-a-tap-min); + padding: var(--nq-a-density-row-pad); + border: 0; + background: none; + color: var(--nq-a-ink); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.defs-row-glyph { + flex: none; + display: flex; + color: var(--nq-a-ink-secondary); +} + +.defs-row-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.defs-row-title { + font-size: var(--nq-a-size-row); + font-weight: 700; +} + +.defs-row-meta { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-row-chevron { + flex: none; + display: flex; + color: var(--nq-a-ink-tertiary); +} + +/* An inactive definition renders the whole row tertiary (never hard-deleted). */ +.defs-row--inactive, +.defs-row--inactive .defs-row-glyph, +.defs-row--inactive .defs-row-meta { + color: var(--nq-a-ink-tertiary); +} + +.defs-row--editing { + background: var(--nq-a-selected); +} + +.defs-row--editing .defs-row-glyph { + color: var(--nq-brand-blue); +} + +.defs-empty { + padding: var(--nq-a-density-card-pad); + font-size: 13px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Loading / error */ +.defs-status { + padding: 24px 13px; + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + text-align: center; + font-size: 13.5px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-status--error { + color: var(--nq-a-danger-strong); +} + +.defs-status--error p { + margin: 0 0 10px; +} + +.defs-retry { + padding: 8px 16px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +/* §3.2 edit sheet */ +.defs-sheet-layer { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.defs-scrim { + position: absolute; + inset: 0; + background: rgba(9, 9, 11, 0.4); + animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out); +} + +.defs-sheet { + position: relative; + width: 100%; + max-width: 640px; + max-height: 92%; + margin: 0 auto; + overflow-y: auto; + padding: 14px 18px 24px; + border-radius: var(--nq-a-radius-sheet); + background: var(--nq-a-surface); + box-shadow: var(--nq-a-sheet-shadow); + animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out); +} + +@keyframes defs-fade { + from { + opacity: 0; + } +} + +@keyframes defs-rise { + from { + transform: translateY(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .defs-scrim, + .defs-sheet { + animation: none; + } +} + +.defs-grabber { + width: 38px; + height: 4px; + margin: 0 auto 12px; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-border); +} + +.defs-sheet-title { + margin: 0 0 12px; + font-size: 19px; + font-weight: 800; +} + +.defs-fields { + display: flex; + flex-direction: column; + gap: 11px; +} + +.defs-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.defs-label { + font-size: var(--nq-a-size-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: var(--nq-a-track-label); + color: var(--nq-a-ink-secondary); +} + +.defs-input { + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 14px; + font-weight: 600; +} + +.defs-select-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.defs-picker { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); +} + +.defs-picker > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.defs-picker-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 0; + background: none; + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; +} + +.defs-picker-check { + display: flex; + color: var(--nq-brand-blue); +} + +.defs-picker-option--locked { + color: var(--nq-a-ink-secondary); + cursor: default; +} + +.defs-picker-note { + font-size: 12px; + font-weight: 600; +} + +.defs-repeats { + display: flex; + gap: 8px; +} + +.defs-interval { + flex: none; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: var(--nq-a-ink-secondary); +} + +.defs-interval .defs-input { + width: 64px; +} + +.defs-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.defs-day, +.defs-segment { + min-height: var(--nq-a-tap-min); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.defs-day--on, +.defs-segment--on { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-segmented { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.defs-icon-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr)); + gap: 6px; +} + +.defs-icon { + display: flex; + align-items: center; + justify-content: center; + min-height: var(--nq-a-tap-min); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + cursor: pointer; +} + +.defs-icon--on { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-due-times { + display: flex; + flex-direction: column; + gap: 6px; +} + +.defs-due { + display: grid; + grid-template-columns: 84px 1fr; + align-items: center; + gap: 8px; + font-size: 12.5px; + font-weight: 700; +} + +.defs-hint { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.defs-toggle-title { + font-size: 13.5px; + font-weight: 700; +} + +.defs-toggle-sub { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-switch { + flex: none; + position: relative; + width: 44px; + height: 26px; + padding: 0; + border: 0; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-border); + cursor: pointer; + transition: background var(--nq-dur-base) var(--nq-ease-out); +} + +.defs-switch--on { + background: var(--nq-brand-gradient); +} + +.defs-switch-knob { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25); + transition: transform var(--nq-dur-base) var(--nq-ease-out); +} + +.defs-switch--on .defs-switch-knob { + transform: translateX(18px); +} + +.defs-sheet-error { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +.defs-sheet-footer { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.defs-confirm { + margin-top: 12px; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +.defs-confirm p { + margin: 0; +} + +.defs-confirm .defs-sheet-footer { + margin-top: 10px; +} + +.defs-cancel, +.defs-save { + padding: 13px 0; + border-radius: var(--nq-a-density-radius); + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.defs-cancel { + flex: 1; + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.defs-save { + flex: 2; + border: 0; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-cancel:disabled, +.defs-save:disabled { + opacity: 0.6; + cursor: default; +} + +.defs-preview { + margin-top: 8px; +} + +.defs-preview--error { + color: var(--nq-a-ink-secondary); + font-style: italic; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx new file mode 100644 index 0000000..038429b --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -0,0 +1,1087 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import DefinitionsTab from "./DefinitionsTab"; +import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; +import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; +import { storeTokens } from "../auth/oidc"; + +function child(id: number, name: string, overrides: Partial = {}): AdminChild { + return { + id, + display_name: name, + colour: null, + avatar_ref: null, + sort_order: id, + is_active: true, + ...overrides, + }; +} + +const CHILDREN: AdminChild[] = [ + child(1, "Declan"), + child(2, "Maeve"), + child(3, "Rory"), + child(4, "Old", { is_active: false }), +]; + +function rule(overrides: Partial = {}): DefinitionRule { + return { + rule_type: "daily", + interval: 1, + weekday_set: null, + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: "2026-01-01", + end_date: null, + ...overrides, + }; +} + +const BRUSH: QuestDefinition = { + id: 10, + title: "Brush teeth", + description: null, + icon: "smile", + is_active: true, + skip_on_away: true, + rule: rule(), + assignees: [ + { id: 1, display_name: "Declan" }, + { id: 2, display_name: "Maeve" }, + { id: 3, display_name: "Rory" }, + ], + windows: [ + { window: "morning", due_time: "07:30" }, + { window: "evening", due_time: null }, + ], +}; + +const BINS: QuestDefinition = { + id: 11, + title: "Take out bins", + description: "Green bin", + icon: null, + is_active: true, + skip_on_away: false, + rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }), + assignees: [ + { id: 1, display_name: "Declan" }, + { id: 4, display_name: "Old" }, + ], + windows: [{ window: "afternoon", due_time: "16:00" }], +}; + +const PIANO: QuestDefinition = { + id: 12, + title: "Piano practice", + description: null, + icon: null, + is_active: false, + skip_on_away: true, + rule: rule({ rule_type: "monthly_day", day_of_month: 15 }), + assignees: [{ id: 2, display_name: "Maeve" }], + windows: [{ window: "afternoon", due_time: null }], +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +interface Call { + method: string; + path: string; + body: unknown; +} + +const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview"; + +/** Answers an occurrence-preview request; the default serves no dates. */ +type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise; + +/** + * A stub of the admin API: serves the list and children, records writes, and + * lets a test queue a response for the next write. Occurrence previews are + * reads (recorded, never counted as writes) answered by `setPreview`. + */ +function fakeApi(initial: QuestDefinition[]) { + let definitions = [...initial]; + const calls: Call[] = []; + let nextWrite: Response | null = null; + let preview: PreviewHandler = async () => jsonResponse({ dates: [] }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const path = new URL(String(input), "http://localhost").pathname; + const method = init.method ?? "GET"; + const body = typeof init.body === "string" ? JSON.parse(init.body) : null; + calls.push({ method, path, body }); + if (method === "POST" && path === PREVIEW_PATH) return preview(body); + if (method === "GET" && path === "/api/v1/admin/quest-definitions") { + return jsonResponse({ definitions }); + } + if (method === "GET" && path === "/api/v1/admin/children") { + return jsonResponse({ children: CHILDREN }); + } + if (nextWrite) { + const response = nextWrite; + nextWrite = null; + return response; + } + if (method === "POST" && path === "/api/v1/admin/quest-definitions") { + const created: QuestDefinition = { + id: 99, + title: body.title, + description: null, + icon: body.icon ?? null, + is_active: true, + skip_on_away: body.skip_on_away, + rule: body.rule, + assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({ + id: c.id, + display_name: c.display_name, + })), + windows: body.windows.map(([window, due_time]: [string, string | null]) => ({ + window, + due_time, + })), + }; + definitions = [...definitions, created]; + return jsonResponse(created, 201); + } + const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path); + if (method === "PATCH" && match) { + const id = Number(match[1]); + definitions = definitions.map((d) => + d.id === id + ? { + ...d, + title: body.title ?? d.title, + icon: "icon" in body ? body.icon : d.icon, + skip_on_away: body.skip_on_away ?? d.skip_on_away, + rule: body.rule ?? d.rule, + // Like the API: a sent list replaces the whole set; an absent one keeps it. + assignees: + "assignee_child_ids" in body + ? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({ + id: c.id, + display_name: c.display_name, + })) + : d.assignees, + } + : d, + ); + return jsonResponse(definitions.find((d) => d.id === id)); + } + return jsonResponse({ detail: "unexpected" }, 500); + }); + return { + fetchMock, + calls, + writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH), + previews: () => calls.filter((c) => c.path === PREVIEW_PATH), + setPreview: (handler: PreviewHandler) => { + preview = handler; + }, + listFetches: () => + calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions") + .length, + failNextWrite: (response: Response) => { + nextWrite = response; + }, + }; +} + +let api: ReturnType; + +async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) { + api = fakeApi(initial); + vi.stubGlobal("fetch", api.fetchMock); + render(); + await screen.findByText("Brush teeth"); +} + +function meta(row: HTMLElement): string { + return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? ""; +} + +function sheet(): HTMLElement { + return screen.getByRole("dialog"); +} + +describe("DefinitionsTab", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("header counts definitions and active children", async () => { + await renderReady(); + expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(screen.getByText("3 definitions · 3 children")).toBeTruthy(); + const [url, init] = api.fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/); + expect((init as RequestInit & { headers: Record }).headers.Authorization).toBe( + "Bearer at-123", + ); + }); + + it("rows show ` · · ` meta", async () => { + await renderReady(); + const brush = screen.getByTestId("definition-10"); + expect(within(brush).getByText("Brush teeth")).toBeTruthy(); + expect(meta(brush)).toBe("All three · Daily · Morning, Evening"); + expect(meta(screen.getByTestId("definition-11"))).toBe( + "Declan, Old · Every 2 weeks on Mon, Thu · Afternoon", + ); + expect(brush.className).not.toContain("defs-row--inactive"); + }); + + it("an inactive definition is styled inactive with `· inactive`", async () => { + await renderReady(); + const piano = screen.getByTestId("definition-12"); + expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive"); + expect(piano.className).toContain("defs-row--inactive"); + }); + + it("filter chips filter by child", async () => { + await renderReady(); + const chips = screen.getByRole("group", { name: "Filter by child" }); + expect( + within(chips) + .getAllByRole("button") + .map((b) => b.textContent), + ).toEqual(["All", "Declan", "Maeve", "Rory"]); + expect(within(chips).getByRole("button", { name: "All" }).className).toContain( + "defs-chip--active", + ); + + fireEvent.click(within(chips).getByRole("button", { name: "Maeve" })); + expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe( + "true", + ); + expect(screen.queryByTestId("definition-10")).toBeTruthy(); + expect(screen.queryByTestId("definition-11")).toBeNull(); + expect(screen.queryByTestId("definition-12")).toBeTruthy(); + + fireEvent.click(within(chips).getByRole("button", { name: "Rory" })); + expect(screen.queryByTestId("definition-10")).toBeTruthy(); + expect(screen.queryByTestId("definition-12")).toBeNull(); + + fireEvent.click(within(chips).getByRole("button", { name: "All" })); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + + it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local + await renderReady(); + expect(api.listFetches()).toBe(1); + + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy(); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: " Feed the cat " }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain( + "Declan, Rory", + ); + + fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), { + target: { value: "weekly" }, + }); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "2" }, + }); + // Today (Wednesday) is preselected; add Saturday. + expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe( + "true", + ); + fireEvent.click(within(s).getByRole("button", { name: "Saturday" })); + + fireEvent.click(within(s).getByRole("button", { name: "Evening" })); + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.change(within(s).getByLabelText("Morning due time"), { + target: { value: "07:15" }, + }); + + const toggle = within(s).getByRole("switch", { name: "Skip on away days" }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(api.writes()).toEqual([ + { + method: "POST", + path: "/api/v1/admin/quest-definitions", + body: { + title: "Feed the cat", + icon: null, + rule: { + rule_type: "weekly", + interval: 2, + weekday_set: [2, 5], + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: "2026-09-23", + end_date: null, + }, + assignee_child_ids: [1, 3], + windows: [ + ["morning", "07:15"], + ["evening", null], + ], + skip_on_away: false, + }, + }, + ]); + + const created = await screen.findByTestId("definition-99"); + expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening"); + expect(api.listFetches()).toBe(2); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("4 definitions · 3 children")).toBeTruthy(); + }); + + it("tapping a row opens the sheet in editing state and saving PATCHes", async () => { + await renderReady(); + const row = screen.getByTestId("definition-11"); + fireEvent.click(row); + + // §3.1 editing state on the row. + expect(row.className).toContain("defs-row--editing"); + expect(meta(row)).toBe("Editing"); + expect(row.getAttribute("aria-expanded")).toBe("true"); + + const s = sheet(); + expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy(); + expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe( + "Take out bins", + ); + expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe( + "weekly", + ); + expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe( + "16:00", + ); + // The inactive child "Old" is shown locked, not offered as a checkbox. + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull(); + const locked = within(s).getByTestId("inactive-assignee-4"); + expect(locked.textContent).toContain("Old"); + expect(locked.textContent).toContain("Inactive"); + expect(locked.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" })); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Take out the bins" }, + }); + fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), { + target: { value: "monthly" }, + }); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), { + target: { value: "3" }, + }); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "1" }, + }); + fireEvent.change(within(s).getByLabelText("Afternoon due time"), { + target: { value: "" }, + }); + fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" })); + + // Changing the selection would drop "Old": confirm before sending. + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + expect(api.writes()).toHaveLength(0); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" })); + }); + + expect(api.writes()).toEqual([ + { + method: "PATCH", + path: "/api/v1/admin/quest-definitions/11", + body: { + title: "Take out the bins", + icon: null, + rule: { + rule_type: "monthly_day", + interval: 1, + weekday_set: null, + day_of_month: 3, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: "2026-01-01", + end_date: null, + }, + assignee_child_ids: [1, 2], + windows: [["afternoon", null]], + skip_on_away: true, + }, + }, + ]); + await screen.findByText("Take out the bins"); + expect(api.listFetches()).toBe(2); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy(); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Bins out" }, + }); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + const body = api.writes()[0].body as Record; + expect(api.writes()[0].method).toBe("PATCH"); + expect(body.title).toBe("Bins out"); + expect(body).not.toHaveProperty("assignee_child_ids"); + + await screen.findByText("Bins out"); + expect(meta(screen.getByTestId("definition-11"))).toBe( + "Declan, Old · Every 2 weeks on Mon, Thu · Afternoon", + ); + }); + + it("changing the active selection with an inactive assignee asks first", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + const confirm = within(s).getByTestId("unassign-confirm"); + expect(confirm.textContent).toContain("Saving will unassign Old."); + expect(document.activeElement).toBe(confirm); + expect(api.writes()).toHaveLength(0); + + // Going back sends nothing and restores the normal footer. + fireEvent.click(within(confirm).getByRole("button", { name: "Go back" })); + expect(within(s).queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(0); + + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" })); + }); + expect(api.writes()).toHaveLength(1); + expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([ + 1, 3, + ]); + }); + + it("reverting the selection to the original omits assignee_child_ids without asking", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids"); + }); + + it("focus moves into the sheet on open and returns to the row on Escape", async () => { + await renderReady(); + const row = screen.getByTestId("definition-10"); + row.focus(); + fireEvent.click(row); + const s = sheet(); + expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" })); + + fireEvent.keyDown(s, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(row); + expect(api.writes()).toHaveLength(0); + }); + + it("Tab and Shift+Tab wrap within the sheet", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + const title = within(s).getByRole("textbox", { name: "Title" }); + const save = within(s).getByRole("button", { name: "Save changes" }); + + save.focus(); + fireEvent.keyDown(s, { key: "Tab" }); + expect(document.activeElement).toBe(title); + + fireEvent.keyDown(s, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(save); + }); + + it("Escape does not close the sheet while saving", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + let release: (response: Response) => void = () => {}; + api.fetchMock.mockImplementationOnce( + () => new Promise((resolve) => (release = resolve)), + ); + const s = sheet(); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + fireEvent.keyDown(s, { key: "Escape" }); + expect(screen.getByRole("dialog")).toBeTruthy(); + + await act(async () => { + release(jsonResponse(BRUSH)); + }); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("the skip-on-away toggle reflects and persists a false value", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + const toggle = within(s).getByRole("switch", { name: "Skip on away days" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.className).not.toContain("defs-switch--on"); + expect(within(s).getByText("No instance when the child is absent")).toBeTruthy(); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect(api.writes()).toHaveLength(1); + expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false); + }); + + it("a 422 from save shows the API's detail and keeps the sheet open", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422)); + + await act(async () => { + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0"); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(api.listFetches()).toBe(1); + // Save is usable again after the failure. + expect( + (within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + + it("a 403 from save shows the admin-group refusal", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403)); + + await act(async () => { + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + }); + expect(screen.getByTestId("sheet-error").textContent).toBe( + "Your account is not in the nestquest-admins group.", + ); + }); + + it("guards against a double submit", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const save = within(sheet()).getByRole("button", { name: "Save changes" }); + await act(async () => { + fireEvent.click(save); + fireEvent.click(save); + }); + expect(api.writes()).toHaveLength(1); + }); + + it("an incomplete new task is not submitted", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title."); + expect(api.writes()).toHaveLength(0); + }); + + it("Cancel closes the sheet without writing", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening"); + expect(api.writes()).toHaveLength(0); + }); + + it("a 403 on load renders the refusal", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)), + ); + render(); + expect((await screen.findByTestId("definitions-error")).textContent).toContain( + "Your account is not in the nestquest-admins group.", + ); + }); + + it("scroll column carries the 92px bottom padding", async () => { + await renderReady(); + expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px"); + }); +}); + +/* ── Occurrence preview ─────────────────────────────────────────────── */ + +/** Past the sheet's 300 ms preview debounce. */ +function settleDebounce(): Promise { + return act(() => new Promise((resolve) => setTimeout(resolve, 400))); +} + +function previewText(): string | null { + return screen.queryByTestId("occurrence-preview")?.textContent ?? null; +} + +/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */ +const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"]; +/** The same rule on Tuesdays only. */ +const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"]; + +function deferred() { + let resolve!: (response: Response) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("DefinitionsTab occurrence preview", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("previews a weekly rule's next dates in plain language from the API", async () => { + await renderReady(); + api.setPreview(async () => jsonResponse({ dates: BINS_DATES })); + fireEvent.click(screen.getByTestId("definition-11")); + + await waitFor(() => + expect(previewText()).toBe( + "Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …", + ), + ); + expect(api.previews()).toEqual([ + { + method: "POST", + path: "/api/v1/admin/quest-definitions/occurrences-preview", + body: { rule: BINS.rule, count: 5 }, + }, + ]); + }); + + it("re-requests once per rule change, debounced, and not for non-rule edits", async () => { + await renderReady(); + api.setPreview(async ({ rule }) => + jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }), + ); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + + // A title edit leaves the rule alone: no new request. + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Bins" }, + }); + // Three rule edits in quick succession collapse into one request. + const days = within(s).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + fireEvent.click(within(days).getByRole("button", { name: "Thursday" })); + fireEvent.click(within(days).getByRole("button", { name: "Tuesday" })); + + await waitFor(() => + expect(previewText()).toBe( + "Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …", + ), + ); + await settleDebounce(); + expect(api.previews()).toHaveLength(2); + expect(api.previews()[1].body).toEqual({ + rule: { ...BINS.rule, weekday_set: [1] }, + count: 5, + }); + + // An interval change is a rule change too. + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "1" }, + }); + await waitFor(() => expect(api.previews()).toHaveLength(3)); + expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1); + }); + + it("window and assignee edits never request, and a rule edit waits out the debounce", async () => { + await renderReady(); + api.setPreview(async () => jsonResponse({ dates: BINS_DATES })); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + expect(api.previews()).toHaveLength(1); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + // Windows and assignees are not part of the rule. + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" })); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(api.previews()).toHaveLength(1); + expect(previewText()).toContain("Mon 5 Oct"); + + // A rule edit is not requested until the 300 ms debounce elapses. + const days = within(s).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Tuesday" })); + await act(async () => { + vi.advanceTimersByTime(299); + }); + expect(api.previews()).toHaveLength(1); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(api.previews()).toHaveLength(2); + expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([ + 0, 1, 3, + ]); + }); + + it("a rule edit hides the previous rule's dates until its own response arrives", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0].resolve(jsonResponse({ dates: BINS_DATES })); + }); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + // Within the debounce: the old dates are already gone. + expect(previewText()).toBeNull(); + // Request in flight: still nothing shown for the new rule. + await waitFor(() => expect(pending).toHaveLength(2)); + expect(previewText()).toBeNull(); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] })); + }); + await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct")); + }); + + it("a rule edit hides the previous rule's error until its own response arrives", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422)); + }); + await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected")); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + expect(previewText()).toBeNull(); + await waitFor(() => expect(pending).toHaveLength(2)); + expect(previewText()).toBeNull(); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] })); + }); + await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct")); + }); + + it("a stale response resolving after a newer one does not overwrite it", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + fireEvent.click(within(days).getByRole("button", { name: "Thursday" })); + fireEvent.click(within(days).getByRole("button", { name: "Tuesday" })); + await waitFor(() => expect(pending).toHaveLength(2)); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES })); + }); + await waitFor(() => expect(previewText()).toContain("Tue 6 Oct")); + await act(async () => { + pending[0].resolve(jsonResponse({ dates: BINS_DATES })); + }); + await settleDebounce(); + expect(previewText()).toBe( + "Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …", + ); + }); + + it("a failed preview shows a non-blocking message and the sheet still saves", async () => { + await renderReady(); + api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422)); + fireEvent.click(screen.getByTestId("definition-10")); + + await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected")); + expect(screen.queryByTestId("sheet-error")).toBeNull(); + + await act(async () => { + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + }); + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].method).toBe("PATCH"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("an incomplete draft does not call the preview endpoint", async () => { + await renderReady(); + api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES })); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), { + target: { value: "weekly" }, + }); + // Clear the default weekday: a weekly rule with no days is incomplete. + const days = within(s).getByRole("group", { name: "Days" }); + for (const day of within(days).getAllByRole("button")) { + if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day); + } + await settleDebounce(); + expect(api.previews()).toHaveLength(0); + expect(previewText()).toBeNull(); + + // An unparseable interval is incomplete too. + fireEvent.click(within(days).getByRole("button", { name: "Tuesday" })); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "" }, + }); + await settleDebounce(); + expect(api.previews()).toHaveLength(0); + + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "2" }, + }); + await waitFor(() => expect(previewText()).toContain("Tue 6 Oct")); + expect(api.previews()).toHaveLength(1); + }); +}); + +describe("DefinitionsTab icon picker", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + /** The Lucide name a row renders, or null for the generic fallback glyph. */ + function rowIcon(row: HTMLElement): string | null { + const svg = row.querySelector(".defs-row-glyph svg"); + expect(svg).not.toBeNull(); + return svg!.getAttribute("data-icon"); + } + + function iconRadio(s: HTMLElement, label: string): HTMLElement { + return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", { + name: label, + }); + } + + it("ships the curated subset, and every entry renders an SVG", () => { + expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([ + "bed", + "bath", + "shower-head", + "toilet", + "brush", + "smile", + "shirt", + "washing-machine", + "utensils", + "cooking-pot", + "apple", + "brush-cleaning", + "spray-can", + "trash-2", + "recycle", + "sofa", + "house", + "backpack", + "book-open", + "pencil", + "music", + "dog", + "cat", + "fish", + "sprout", + "droplets", + "bike", + "volleyball", + "gamepad-2", + "star", + "heart", + "sun", + "moon", + "clock", + "calendar", + "list-checks", + ]); + expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24); + expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40); + for (const { name } of DEFINITION_ICONS) { + const { container, unmount } = render(); + const svg = container.querySelector("svg"); + expect(svg, name).not.toBeNull(); + expect(svg!.getAttribute("data-icon")).toBe(name); + expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0); + unmount(); + } + }); + + it("a chosen icon is POSTed for a new task and renders on its row", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Make the bed" }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.click(iconRadio(s, "Bed")); + expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true"); + expect(iconRadio(s, "Bed").className).toContain("defs-icon--on"); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].method).toBe("POST"); + expect((api.writes()[0].body as { icon: string }).icon).toBe("bed"); + expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed"); + }); + + it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => { + await renderReady(); + expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + fireEvent.click(iconRadio(s, "Bins")); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + const { method, path, body } = api.writes()[0]; + expect(method).toBe("PATCH"); + expect(path).toBe("/api/v1/admin/quest-definitions/11"); + expect((body as { icon: string }).icon).toBe("trash-2"); + // Inactive "Old" stays assigned: the unchanged selection is still omitted. + expect(body).not.toHaveProperty("assignee_child_ids"); + await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2")); + }); + + it("an unchanged icon is still sent on edit and shows as selected", async () => { + await renderReady(); + expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile"); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true"); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Brush teeth well" }, + }); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect((api.writes()[0].body as { icon: string }).icon).toBe("smile"); + }); + + it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => { + const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" }; + await renderReady([legacy]); + const row = screen.getByTestId("definition-20"); + expect(rowIcon(row)).toBeNull(); + + fireEvent.click(row); + const s = sheet(); + const group = within(s).getByRole("radiogroup", { name: "Icon" }); + for (const radio of within(group).getAllByRole("radio")) { + expect(radio.getAttribute("aria-checked")).toBe("false"); + } + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull(); + }); +}); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx new file mode 100644 index 0000000..b4803b5 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -0,0 +1,968 @@ +/** + * Definitions ("Tasks") tab — list + edit sheet with icon picker + * (design/ADMIN-SPEC.md §3.1–3.3). + */ +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { ApiForbiddenError } from "../api/client"; +import { + ApiRequestError, + createDefinition, + fetchChildren, + fetchDefinitions, + previewOccurrences, + updateDefinition, + type AdminChild, + type DefinitionCreateBody, + type DefinitionEditBody, + type DefinitionRule, + type QuestDefinition, + type WindowEntry, + type WindowName, +} from "../api/definitions"; +import { + MONTH_SHORT, + WEEKDAY_SHORT, + WINDOW_LABELS, + WINDOW_ORDER, + assigneeSummary, + occurrenceLabel, + recurrenceSummary, + windowSummary, +} from "./format"; +import { + CheckGlyph, + ChevronDownGlyph, + ChevronRightGlyph, + PlusGlyph, + TaskGlyph, +} from "./glyphs"; +import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; +import "./DefinitionsTab.css"; + +/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ +export const SCROLL_BOTTOM_PADDING = "92px"; + +type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom"; + +const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "yearly", label: "Yearly" }, + { value: "custom", label: "Custom" }, +]; + +const REPEATS_BY_RULE: Record = { + daily: "daily", + weekly: "weekly", + monthly_day: "monthly", + monthly_weekday: "monthly", + yearly: "yearly", + custom_days: "custom", +}; + +/** How many upcoming dates the edit sheet previews. */ +const PREVIEW_COUNT = 5; + +/** Quiet period after the last rule edit before the preview is requested. */ +const PREVIEW_DEBOUNCE_MS = 300; + +const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +interface Draft { + title: string; + /** A Lucide name; a legacy value outside the subset is kept until replaced. */ + icon: string | null; + assigneeIds: number[]; + repeats: Repeats; + interval: string; + weekdays: number[]; + dayOfMonth: string; + month: string; + /** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */ + monthlyWeekday: { nth: number; weekday: number } | null; + windows: WindowName[]; + dueTimes: Record; + skipOnAway: boolean; + startDate: string; + endDate: string | null; +} + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] }; + +/** `null` = closed, `"new"` = creating, a definition = editing it. */ +type SheetTarget = null | "new" | QuestDefinition; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +function localIsoDate(date: Date): string { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function emptyDueTimes(): Record { + return { morning: "", afternoon: "", evening: "" }; +} + +function newDraft(activeChildren: AdminChild[]): Draft { + const today = new Date(); + return { + title: "", + icon: null, + assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], + repeats: "daily", + interval: "1", + weekdays: [(today.getDay() + 6) % 7], + dayOfMonth: String(today.getDate()), + month: String(today.getMonth() + 1), + monthlyWeekday: null, + windows: [], + dueTimes: emptyDueTimes(), + // Mirrors the API's create default: no instance on an away day. + skipOnAway: true, + startDate: localIsoDate(today), + endDate: null, + }; +} + +function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft { + const { rule } = definition; + const today = new Date(); + const active = new Set(activeChildren.map((child) => child.id)); + const dueTimes = emptyDueTimes(); + for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; + return { + title: definition.title, + icon: definition.icon, + // Inactive children cannot be submitted (the API rejects them with 422). + assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), + repeats: REPEATS_BY_RULE[rule.rule_type], + interval: String(rule.interval), + weekdays: + rule.rule_type === "weekly" || rule.rule_type === "custom_days" + ? [...(rule.weekday_set ?? [])] + : [(today.getDay() + 6) % 7], + dayOfMonth: String(rule.day_of_month ?? today.getDate()), + month: String(rule.month ?? today.getMonth() + 1), + monthlyWeekday: + rule.rule_type === "monthly_weekday" + ? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 } + : null, + windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)), + dueTimes, + skipOnAway: definition.skip_on_away, + startDate: rule.start_date, + endDate: rule.end_date, + }; +} + +function parseWhole(value: string, min: number, max: number): number | null { + if (!/^\d+$/.test(value.trim())) return null; + const n = Number(value); + return n >= min && n <= max ? n : null; +} + +/** Build the request rule from the draft, or return a message naming what to fix. */ +function buildRule(draft: Draft): DefinitionRule | string { + const interval = parseWhole(draft.interval, 1, 999); + if (interval === null) return "Interval must be a whole number of at least 1."; + const rule: DefinitionRule = { + rule_type: "daily", + interval, + weekday_set: null, + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: draft.startDate, + end_date: draft.endDate, + }; + switch (draft.repeats) { + case "daily": + return rule; + case "weekly": + case "custom": + if (draft.weekdays.length === 0) return "Pick at least one day."; + return { + ...rule, + rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days", + weekday_set: [...draft.weekdays].sort((a, b) => a - b), + }; + case "monthly": { + if (draft.monthlyWeekday) { + return { + ...rule, + rule_type: "monthly_weekday", + nth_weekday: draft.monthlyWeekday.nth, + nth_weekday_weekday: draft.monthlyWeekday.weekday, + }; + } + const day = parseWhole(draft.dayOfMonth, 1, 31); + if (day === null) return "Day of month must be 1 to 31."; + return { ...rule, rule_type: "monthly_day", day_of_month: day }; + } + case "yearly": { + const month = parseWhole(draft.month, 1, 12); + const day = parseWhole(draft.dayOfMonth, 1, 31); + if (month === null || day === null) return "Pick a month and a day (1 to 31)."; + return { ...rule, rule_type: "yearly", month, day_of_month: day }; + } + } +} + +/** + * `keepsAssignees`: an edit whose active selection is unchanged sends no + * assignee list, so an empty selection is fine when inactive children remain. + */ +function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string { + const title = draft.title.trim(); + if (!title) return "Give the task a title."; + if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child."; + if (draft.windows.length === 0) return "Pick at least one window."; + const rule = buildRule(draft); + if (typeof rule === "string") return rule; + const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( + (w) => [w, draft.dueTimes[w] || null], + ); + return { + title, + icon: draft.icon, + rule, + assignee_child_ids: [...draft.assigneeIds], + windows, + skip_on_away: draft.skipOnAway, + }; +} + +function saveErrorMessage(error: unknown): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return error.detail; + return "The task could not be saved. Check your connection and try again."; +} + +function toggle(list: T[], value: T): T[] { + return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; +} + +function sameIds(a: number[], b: number[]): boolean { + return a.length === b.length && a.every((id) => b.includes(id)); +} + +const FOCUSABLE = + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; + +/* ── List ───────────────────────────────────────────────────────────── */ + +function FilterChips({ + options, + filter, + onChange, +}: { + options: AdminChild[]; + filter: number | "all"; + onChange: (filter: number | "all") => void; +}) { + const chips: { key: number | "all"; label: string }[] = [ + { key: "all", label: "All" }, + ...options.map((child) => ({ key: child.id, label: child.display_name })), + ]; + return ( +
+ {chips.map((chip) => ( + + ))} +
+ ); +} + +function DefinitionRow({ + definition, + activeChildren, + editing, + onOpen, +}: { + definition: QuestDefinition; + activeChildren: AdminChild[]; + editing: boolean; + onOpen: () => void; +}) { + let meta = [ + assigneeSummary(definition.assignees, activeChildren), + recurrenceSummary(definition.rule), + windowSummary(definition.windows), + ].join(" · "); + if (!definition.is_active) meta += " · inactive"; + let className = "defs-row"; + if (!definition.is_active) className += " defs-row--inactive"; + if (editing) className += " defs-row--editing"; + return ( +
  • + +
  • + ); +} + +/* ── Edit sheet ─────────────────────────────────────────────────────── */ + +type PreviewState = + | { kind: "idle" } + | { kind: "ready"; ruleKey: string; dates: string[] } + | { kind: "error"; ruleKey: string; message: string }; + +/** + * The next few dates the backend would materialize for the draft's rule. + * `ruleKey` is the built rule serialized (null while the draft is invalid), so + * the request only goes out when the rule itself changes — debounced, and a + * superseded request's late response is dropped by its effect's cleanup. + * A result is tagged with the rule it answers and only shown while that rule + * is still the draft's, so a changed rule never shows the previous dates. + */ +function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) { + const [preview, setPreview] = useState({ kind: "idle" }); + + useEffect(() => { + if (ruleKey === null) { + setPreview({ kind: "idle" }); + return; + } + let cancelled = false; + const timer = setTimeout(() => { + previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT }) + .then((dates) => { + if (!cancelled) setPreview({ kind: "ready", ruleKey, dates }); + }) + .catch((error: unknown) => { + if (cancelled) return; + const detail = + error instanceof ApiForbiddenError || + (error instanceof ApiRequestError && error.detail) + ? `: ${error.message}` + : "."; + setPreview({ + kind: "error", + ruleKey, + message: `Upcoming dates unavailable${detail}`, + }); + }); + }, PREVIEW_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [ruleKey]); + + if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null; + if (preview.kind === "error") { + return ( +

    + {preview.message} +

    + ); + } + let text: string; + if (preview.dates.length === 0) { + text = "No upcoming dates in the next year."; + } else { + const labels = preview.dates.map(occurrenceLabel); + // A full page means the rule keeps going past the last shown date. + if (preview.dates.length >= PREVIEW_COUNT) labels.push("…"); + text = `Next: ${labels.join(" · ")}`; + } + return ( +

    + {text} +

    + ); +} + +function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { + return ( +
    + + {label} + + {children} +
    + ); +} + +function EditSheet({ + target, + activeChildren, + onCancel, + onSaved, +}: { + target: "new" | QuestDefinition; + activeChildren: AdminChild[]; + onCancel: () => void; + onSaved: () => void; +}) { + const [draft, setDraft] = useState(() => + target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren), + ); + // The active selection as loaded: while it is unchanged, the PATCH omits + // assignee_child_ids, because the API replaces the whole set and inactive + // children cannot be sent back. + const [originalActiveIds] = useState(() => draft.assigneeIds); + const inactiveAssignees = + target === "new" + ? [] + : target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id)); + const [pickerOpen, setPickerOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); + // A ref, not state: two taps in one frame must not both see `saving === false`. + const inFlight = useRef(false); + const sheetRef = useRef(null); + const titleRef = useRef(null); + const confirmRef = useRef(null); + + // Modal focus: move in on open, hand back to the trigger on close. + useEffect(() => { + const trigger = document.activeElement as HTMLElement | null; + titleRef.current?.focus(); + return () => { + if (trigger && trigger.isConnected) trigger.focus(); + }; + }, []); + + useEffect(() => { + if (confirming) confirmRef.current?.focus(); + }, [confirming]); + + function onKeyDown(event: ReactKeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + if (!inFlight.current) onCancel(); + return; + } + if (event.key !== "Tab" || !sheetRef.current) return; + const focusable = Array.from(sheetRef.current.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const current = document.activeElement; + const outside = !focusable.includes(current as HTMLElement); + if (event.shiftKey && (current === first || outside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (current === last || outside)) { + event.preventDefault(); + first.focus(); + } + } + + const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + + const selectedAssignees = activeChildren + .filter((child) => draft.assigneeIds.includes(child.id)) + .map((child) => ({ id: child.id, display_name: child.display_name })); + const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w)); + const previewRule = buildRule(draft); + const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule); + + async function save(confirmed = false) { + if (inFlight.current) return; + const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds); + const body = buildBody(draft, keepsAssignees); + if (typeof body === "string") { + setError(body); + setConfirming(false); + return; + } + if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) { + setError(null); + setConfirming(true); + return; + } + setConfirming(false); + inFlight.current = true; + setSaving(true); + setError(null); + try { + if (target === "new") { + await createDefinition(body); + } else { + const edit: DefinitionEditBody = { ...body }; + if (keepsAssignees) delete edit.assignee_child_ids; + await updateDefinition(target.id, edit); + } + onSaved(); + } catch (err) { + setError(saveErrorMessage(err)); + inFlight.current = false; + setSaving(false); + } + } + + return ( +
    +
    { + if (!inFlight.current) onCancel(); + }} + /> +
    + +
    + ); +} + +/* ── Screen ─────────────────────────────────────────────────────────── */ + +export default function DefinitionsTab() { + const [state, setState] = useState({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + const [filter, setFilter] = useState("all"); + const [sheet, setSheet] = useState(null); + + useEffect(() => { + let cancelled = false; + // Keep the list on screen during a post-save re-fetch; spin only on first load. + setState((current) => (current.kind === "ready" ? current : { kind: "loading" })); + Promise.all([fetchDefinitions(), fetchChildren()]) + .then(([definitions, children]) => { + if (!cancelled) setState({ kind: "ready", definitions, children }); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof ApiForbiddenError + ? error.message + : "Tasks could not be loaded. Check your connection and try again."; + setState({ kind: "error", message }); + }); + return () => { + cancelled = true; + }; + }, [attempt]); + + const activeChildren = + state.kind === "ready" + ? [...state.children] + .filter((child) => child.is_active) + .sort((a, b) => a.sort_order - b.sort_order) + : []; + + let sub = ""; + let body; + if (state.kind === "loading") { + body = ( +
    + Loading tasks… +
    + ); + } else if (state.kind === "error") { + body = ( +
    +

    {state.message}

    + +
    + ); + } else { + const { definitions } = state; + sub = `${definitions.length} definitions · ${activeChildren.length} children`; + const shown = + filter === "all" + ? definitions + : definitions.filter((d) => d.assignees.some((a) => a.id === filter)); + const editingId = sheet !== null && sheet !== "new" ? sheet.id : null; + body = ( + <> + + {shown.length > 0 ? ( +
      + {shown.map((definition) => ( + setSheet(definition)} + /> + ))} +
    + ) : ( +
    No tasks yet.
    + )} + + ); + } + + return ( +
    +
    +
    +

    Tasks

    +

    {sub}

    +
    + +
    +
    + {body} +
    + {sheet !== null && state.kind === "ready" ? ( + setSheet(null)} + onSaved={() => { + setSheet(null); + setAttempt((n) => n + 1); + }} + /> + ) : null} +
    + ); +} diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx new file mode 100644 index 0000000..dbf0a3d --- /dev/null +++ b/admin/src/definitions/definitionIcons.tsx @@ -0,0 +1,103 @@ +/** + * The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3). + * Only these named imports are bundled — no CDN, no runtime fetch. The stored + * value is the Lucide kebab-case name, so it stays meaningful to any surface. + */ +import { + Apple, + Backpack, + Bath, + Bed, + Bike, + BookOpen, + Brush, + BrushCleaning, + Calendar, + Cat, + Clock, + CookingPot, + Dog, + Droplets, + Fish, + Gamepad2, + Heart, + House, + ListChecks, + Moon, + Music, + Pencil, + Recycle, + Shirt, + ShowerHead, + Smile, + Sofa, + SprayCan, + Sprout, + Star, + Sun, + Toilet, + Trash2, + Utensils, + Volleyball, + WashingMachine, + type LucideIcon, +} from "lucide-react"; +import { TaskGlyph } from "./glyphs"; + +export interface DefinitionIconEntry { + name: string; + label: string; + Icon: LucideIcon; +} + +export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [ + { name: "bed", label: "Bed", Icon: Bed }, + { name: "bath", label: "Bath", Icon: Bath }, + { name: "shower-head", label: "Shower", Icon: ShowerHead }, + { name: "toilet", label: "Toilet", Icon: Toilet }, + { name: "brush", label: "Brush", Icon: Brush }, + { name: "smile", label: "Smile", Icon: Smile }, + { name: "shirt", label: "Clothes", Icon: Shirt }, + { name: "washing-machine", label: "Laundry", Icon: WashingMachine }, + { name: "utensils", label: "Meal", Icon: Utensils }, + { name: "cooking-pot", label: "Cooking", Icon: CookingPot }, + { name: "apple", label: "Snack", Icon: Apple }, + { name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning }, + { name: "spray-can", label: "Clean", Icon: SprayCan }, + { name: "trash-2", label: "Bins", Icon: Trash2 }, + { name: "recycle", label: "Recycling", Icon: Recycle }, + { name: "sofa", label: "Tidy", Icon: Sofa }, + { name: "house", label: "Home", Icon: House }, + { name: "backpack", label: "School bag", Icon: Backpack }, + { name: "book-open", label: "Reading", Icon: BookOpen }, + { name: "pencil", label: "Homework", Icon: Pencil }, + { name: "music", label: "Music", Icon: Music }, + { name: "dog", label: "Dog", Icon: Dog }, + { name: "cat", label: "Cat", Icon: Cat }, + { name: "fish", label: "Fish", Icon: Fish }, + { name: "sprout", label: "Plants", Icon: Sprout }, + { name: "droplets", label: "Water", Icon: Droplets }, + { name: "bike", label: "Bike", Icon: Bike }, + { name: "volleyball", label: "Sport", Icon: Volleyball }, + { name: "gamepad-2", label: "Games", Icon: Gamepad2 }, + { name: "star", label: "Star", Icon: Star }, + { name: "heart", label: "Heart", Icon: Heart }, + { name: "sun", label: "Morning", Icon: Sun }, + { name: "moon", label: "Bedtime", Icon: Moon }, + { name: "clock", label: "Clock", Icon: Clock }, + { name: "calendar", label: "Calendar", Icon: Calendar }, + { name: "list-checks", label: "Checklist", Icon: ListChecks }, +]; + +const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry])); + +export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null { + return name === null ? null : (BY_NAME.get(name) ?? null); +} + +/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */ +export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) { + const entry = findDefinitionIcon(name); + if (!entry) return ; + return