From 233a325285718df4731bb5b45adc5d33fe3610e7 Mon Sep 17 00:00:00 2001 From: Efthimios Fousekis Date: Wed, 29 Jul 2026 20:19:27 +0300 Subject: [PATCH] feat(frontend): optional Google sign-in, my-cases library, delete-all and async render --- .env.example | 15 + firebase.json | 2 + frontend/.env.example | 21 + frontend/e2e/_mocks.ts | 39 + frontend/e2e/a11y.spec.ts | 7 +- frontend/package-lock.json | 907 +++++++++++++++++- frontend/package.json | 1 + frontend/src/App.library.test.tsx | 77 ++ frontend/src/App.tsx | 21 +- frontend/src/components/AuthMenu.test.tsx | 138 +++ frontend/src/components/AuthMenu.tsx | 118 +++ .../src/components/ConfirmDialog.test.tsx | 173 ++++ frontend/src/components/ConfirmDialog.tsx | 130 +++ frontend/src/components/Header.tsx | 10 +- frontend/src/components/MyCases.test.tsx | 231 +++++ frontend/src/components/MyCases.tsx | 178 ++++ .../src/components/steps/RenderStep.test.tsx | 129 ++- frontend/src/components/steps/RenderStep.tsx | 54 +- frontend/src/lib/api.auth.test.ts | 223 +++++ frontend/src/lib/api.ts | 167 +++- frontend/src/lib/auth.test.ts | 241 +++++ frontend/src/lib/auth.ts | 151 +++ frontend/src/lib/queries.auth.test.tsx | 78 ++ frontend/src/lib/queries.ts | 168 +++- frontend/vite.config.ts | 2 +- 25 files changed, 3219 insertions(+), 62 deletions(-) create mode 100644 frontend/src/App.library.test.tsx create mode 100644 frontend/src/components/AuthMenu.test.tsx create mode 100644 frontend/src/components/AuthMenu.tsx create mode 100644 frontend/src/components/ConfirmDialog.test.tsx create mode 100644 frontend/src/components/ConfirmDialog.tsx create mode 100644 frontend/src/components/MyCases.test.tsx create mode 100644 frontend/src/components/MyCases.tsx create mode 100644 frontend/src/lib/api.auth.test.ts create mode 100644 frontend/src/lib/auth.test.ts create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/queries.auth.test.tsx diff --git a/.env.example b/.env.example index 463223f..c85e9f9 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,21 @@ GMI_API_KEY= NEBIUS_INFERENCE_BASE_URL= NEBIUS_INFERENCE_API_KEY= +# ── Optional per-user multitenancy (Firebase ID-token auth) ───────────────── +# Set to turn on tenant isolation: every case route then accepts an optional +# `Authorization: Bearer ` header, and a verified caller's +# data is scoped to their own `tenants//` storage prefix (see +# src/claimscene/auth.py + api.py::get_tenant). Unset (the default) — the +# header is not even inspected, so behaviour is byte-identical to before this +# feature existed: every request is GUEST. +# +# This is a PUBLIC value — the same Firebase/GCP project id already baked +# into client-side Firebase config and visible in any browser's network tab +# (see frontend/.env.example's VITE_FIREBASE_PROJECT_ID, which must match). +# Not a secret; no service-account key or Firebase Admin SDK is needed — +# tokens are verified against Google's public signing certificates. +FIREBASE_PROJECT_ID= + # ── Web app / container (all optional — sane defaults) ─────────────────────── # The FastAPI app can serve the compiled React client from the same origin. # CLAIMSCENE_WEB_DIR points at the folder holding the built index.html (the diff --git a/firebase.json b/firebase.json index b8bebd7..c9e5876 100644 --- a/firebase.json +++ b/firebase.json @@ -10,6 +10,8 @@ { "source": "/scenarios/**", "run": { "serviceId": "claimscene", "region": "europe-west1" } }, { "source": "/cases", "run": { "serviceId": "claimscene", "region": "europe-west1" } }, { "source": "/cases/**", "run": { "serviceId": "claimscene", "region": "europe-west1" } }, + { "source": "/me", "run": { "serviceId": "claimscene", "region": "europe-west1" } }, + { "source": "/me/**", "run": { "serviceId": "claimscene", "region": "europe-west1" } }, { "source": "**", "destination": "/index.html" } ], "headers": [ diff --git a/frontend/.env.example b/frontend/.env.example index 529a7a6..0c311ff 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -13,3 +13,24 @@ VITE_API_BASE= # local backend at http://localhost:8000). Point it at a running FastAPI dev # server (`uvicorn claimscene.api:app --reload`) or a deployed Cloud Run URL. VITE_DEV_PROXY_TARGET=http://localhost:8000 + +# ── Optional per-user multitenancy (Google sign-in) ────────────────────────── +# All four are PUBLIC Firebase web-config values (the same ones baked into +# any Firebase client bundle and visible in a browser's network tab) — never +# secrets, safe to set as plain build-time env vars. +# +# Leave all four unset (the default) and the app builds and runs exactly as +# it does today: no sign-in button, no "My cases" library, no delete-my-data +# control — guest-only, byte-identical to a build with no auth code at all. +# +# Set all four to turn sign-in on: a "Sign in with Google" button appears in +# the header, and a signed-in visitor gets a "My cases" library plus a +# delete-all-my-data control. The backend also needs its own +# FIREBASE_PROJECT_ID set (see the repo-root .env.example) for the API to +# actually honour the resulting Authorization header — without that, a +# signed-in visitor's calls to the multitenancy routes degrade gracefully +# (the UI shows "Case library is not available on this deployment"). +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_APP_ID= diff --git a/frontend/e2e/_mocks.ts b/frontend/e2e/_mocks.ts index cade998..62cfd13 100644 --- a/frontend/e2e/_mocks.ts +++ b/frontend/e2e/_mocks.ts @@ -184,6 +184,37 @@ export async function installApiMocks(page: Page): Promise { if (pathname.endsWith("/cases/extract")) return route.fulfill(json(EXTRACT_RESPONSE)); if (pathname.endsWith("/cases/preview-schematic")) return route.fulfill(json(PREVIEW_RESPONSE)); + + // Async submit + poll (POST /cases/render/jobs, GET /cases/render/jobs/{id}) + // — the studio wizard's RenderStep uses this pair, never the synchronous + // POST /cases/render below. Both checks are matched by SUBSTRING (not + // endsWith) and placed ABOVE the generic `method === "GET"` fallback + // further down: a poll's URL ends in a job id, e.g. + // ".../cases/render/jobs/job-1", which does NOT satisfy + // `pathname.endsWith("/cases/render/jobs")` — falling through to that + // fallback would return the golden MANIFEST body instead of a job-status + // body, fail RenderJobStatusSchema parsing, and burn the poll loop's + // consecutive-error budget (a real bug caught before it shipped). + if (method === "POST" && pathname.endsWith("/cases/render/jobs")) { + // A brief, deterministic delay so the "Sealing your case" step is + // observable before it auto-advances to the sealed result — mirrors + // the synchronous /cases/render delay below. + return new Promise((resolve) => { + setTimeout(() => resolve(route.fulfill({ status: 202, ...json({ job_id: "job-1", status: "queued" }) })), 700); + }); + } + if (method === "GET" && pathname.includes("/cases/render/jobs/")) { + // Already "done" on the very first poll — offline/demo generation can + // finish near-instantly (see usePollRenderJob's first-tick-immediate + // behaviour), so the journey/a11y/responsive specs never need to wait + // out multiple poll intervals for the default path. + return route.fulfill(json({ + job_id: "job-1", status: "done", + created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:05Z", + result: RENDER_RESPONSE, + })); + } + if (pathname.endsWith("/cases/render")) { // A brief, deterministic delay so the "Sealing your case" step is // observable before it auto-advances to the sealed result. @@ -201,4 +232,12 @@ export async function installApiMocks(page: Page): Promise { } return route.fulfill({ status: 404, contentType: JSON_CT, body: "{}" }); }); + + // Optional per-user multitenancy (GET /me/library, DELETE /me/data). No + // spec signs in today (isAuthEnabled() is false with no VITE_FIREBASE_* + // build vars — see lib/auth.ts), so these are never reached by the current + // suite; registered for completeness/forward-compat should a future spec + // mock lib/auth as enabled. + await page.route("**/me/library", (route) => route.fulfill(json({ cases: [] }))); + await page.route("**/me/data", (route) => route.fulfill(json({ deleted: 0 }))); } diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts index e454476..62461be 100644 --- a/frontend/e2e/a11y.spec.ts +++ b/frontend/e2e/a11y.spec.ts @@ -77,7 +77,12 @@ test("review step with a vehicle selected (rotate handle visible) has no serious test("render step has no serious/critical a11y violations", async ({ page }) => { // Hold the render step open so it can be audited (it normally auto-advances). - await page.route("**/cases/render", async (route) => { + // The studio wizard submits an async render job (POST /cases/render/jobs), + // not the synchronous POST /cases/render — hold THAT route instead, then + // fall back to the default mock (installApiMocks, registered in + // beforeEach) so the submit still eventually resolves normally. + await page.route("**/cases/render/jobs", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); await new Promise((r) => setTimeout(r, 4000)); await route.fallback(); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 169cafa..b9bd031 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.51.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "firebase": "^12.16.0", "framer-motion": "^11.3.2", "lucide-react": "^0.407.0", "react": "^18.3.1", @@ -858,6 +859,574 @@ "node": ">=12" } }, + "node_modules/@firebase/ai": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.13.1.tgz", + "integrity": "sha512-RhT/VViTPBSplhQSuEp62HhLvfsV+LowMh8ZUo5MMRDzG7oFtSget4Kmg5oHP50hDVyWQuQj6to9iPFEZk08Tw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.22", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.22.tgz", + "integrity": "sha512-8BSaq/QRGU1+xyi8L2PTLTJU7MH9aMA72RQdIxrbhWFauOZY9OXo8f2YDN/972xA8d588tlnNVEQ2Mo69pT9Ow==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.28.tgz", + "integrity": "sha512-lIAlqUUbBu93FJMlQfslryQtBwwzdzvp23ePC6FNgymXk6Ook5v4Uvc0vdutvoIeqmyA3LfP0ZeRFK8+11kOOQ==", + "dependencies": { + "@firebase/analytics": "0.10.22", + "@firebase/analytics-types": "0.8.4", + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.4.tgz", + "integrity": "sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==" + }, + "node_modules/@firebase/app": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.15.1.tgz", + "integrity": "sha512-iD9+Z5HcPo0Uop5f72/VYMeXwKucBhW7iFrISkJFvQ+lSZikTNgTz0FgAtaaTkAG0pEZSnCymA2Fu49n0rcufQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.12.0.tgz", + "integrity": "sha512-wMeT6HLWRAuW7Cp/5UjWBGKgjPNxWNOoNf4PRIv0weljoGMZVeqbUY7wNBWTI2/31cX1NlXx8gQruDLsUShB3Q==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.5.tgz", + "integrity": "sha512-JI17mVcZs34zO6ZeSCrw4U2iohqy+n6GIzkbmsA+TbVjmvFLkUKt3bs5M+qRBteQm/0IWzqSHYFzEQLzDTQebg==", + "dependencies": { + "@firebase/app-check": "0.12.0", + "@firebase/app-check-types": "0.5.4", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", + "integrity": "sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.4.tgz", + "integrity": "sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.15.tgz", + "integrity": "sha512-HaiSM9TwbGIR4b7F6+UncHWlqdH89eeY7VUskaOGOlI2PxHS5Z+6hHsYGvNLy0SHDE6zyXO+3QSA6a4aqQxsqA==", + "dependencies": { + "@firebase/app": "0.15.1", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", + "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", + "dependencies": { + "@firebase/logger": "0.5.1" + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.8.tgz", + "integrity": "sha512-llcBREUC4iSNKZ6rvwud7Oz9Q7aAWU6KuQLa6pdu7Q+QAQsy4JLw6yFgxwtmzabsgznHmmcsX2UjHLLzqUxi3Q==", + "dependencies": { + "@firebase/auth": "1.13.3", + "@firebase/auth-types": "0.13.1", + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-compat/node_modules/@firebase/auth": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.3.tgz", + "integrity": "sha512-bqiq4uubDN2YyQkdvSWPQeJyXAv2O76ImF41En9b6UhV5JuBVYDoHYrrrE3NzIuGkpFMKagfhMRP4Vz6t+yQSQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz", + "integrity": "sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.1.tgz", + "integrity": "sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.3.tgz", + "integrity": "sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==", + "dependencies": { + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.1.tgz", + "integrity": "sha512-2LbUU8mmSA63HknxQMmWHjpzuNLBKflvVwQc2tpoVKg0biWleNEJX031ELks0vzFs+dDjOUkCJR72RP6mQHFOg==", + "dependencies": { + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.3.tgz", + "integrity": "sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.4.tgz", + "integrity": "sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/database": "1.1.3", + "@firebase/database-types": "1.0.20", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.20.tgz", + "integrity": "sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==", + "dependencies": { + "@firebase/app-types": "0.9.5", + "@firebase/util": "1.15.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.16.0.tgz", + "integrity": "sha512-qdHMHMvMr0nRMuZyWNR/ArWa0YlPE3C4eAbmxTASJMYXAesKPL0Y54p70moggrNPzaK7MSIIq5RDJJyntQyIYA==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "@firebase/webchannel-wrapper": "1.0.6", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^0.4.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.11.tgz", + "integrity": "sha512-W7o1WdwWq5aABK5Up2ncSvTQs/QGLR/fy7cVpFBNqhsXtxoMtflHf2xBIG6+aoptcuGAobddq4g2Sq27wqHaYw==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/firestore": "4.16.0", + "@firebase/firestore-types": "3.0.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.4.tgz", + "integrity": "sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.5.tgz", + "integrity": "sha512-bWCx713f4kE/uFV7gdFOLBS7lDoiZj48MRkbAqe35gkXcCeWF4QjRNO07Jhmve7EJIoQOBczL29y2r8VRuN1kw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/messaging-interop-types": "0.2.5", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.5.tgz", + "integrity": "sha512-10qlUXGY25G5/1g9UihqksPp2po+ZqSE7LEizsrdUP7vrTmkysXxGSZCDyojSEp6mQe/ecRDdDDI+z4XRdb4wQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/functions": "0.13.5", + "@firebase/functions-types": "0.6.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.4.tgz", + "integrity": "sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==" + }, + "node_modules/@firebase/installations": { + "version": "0.6.22", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.22.tgz", + "integrity": "sha512-ef6nn3GGQTdReCfotRMG77PJZu8CqEbiK5pEoBnM0gTu/Z9v0i/az2p3HABsa/1beQmmyh1OsOjf7P5+pgwdZw==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.22.tgz", + "integrity": "sha512-C/zpAuTP5S9OgKSPvXRupw3hoY/JZSlA1wFjD/Sb7LIQE0FNbcMdO8Y4KXVEkjVzma/DDDDIAzxEXqKMAzc88w==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/installations-types": "0.5.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.4.tgz", + "integrity": "sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.1.tgz", + "integrity": "sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.0.tgz", + "integrity": "sha512-GZoo0uGRvEbszo83xcgbjJp4FpkmBEr4l8Z4hi8gl+P1Spn/MTK3HapanMzSX4yUHuTEiF5hasWRxOaz+o5sxQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/messaging-interop-types": "0.2.5", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.27.tgz", + "integrity": "sha512-JNOiu1PPgdHzEPEtoFiNxQuu0x9bm4bfETSQCpGfcTlgWkhlSK7uh7nlsjC10TQLUNgYetLmuutaYTh8aeYLVA==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/messaging": "0.13.0", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.5.tgz", + "integrity": "sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw==" + }, + "node_modules/@firebase/performance": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.12.tgz", + "integrity": "sha512-fe7nV8teUU3OBHlMUZ9Lw4gLhCW2k4m5Uc3pfWGV+fl8uwJQBGp9Q3lqsJ+HSrFu3Q2pJyLAgrClPGSKyDeYgQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.25.tgz", + "integrity": "sha512-q6NjTXpIPoFuUmCmMN/maCdTgzT6aExs9xZo+PxfVLj6uLVGvpyAD6XWjmcrb7jChsFBYbq7E5dyNDF7Zhy9kA==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/performance": "0.7.12", + "@firebase/performance-types": "0.2.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.4.tgz", + "integrity": "sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==" + }, + "node_modules/@firebase/remote-config": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.9.0.tgz", + "integrity": "sha512-aNn6/eJhsSC+gXSToiXiYPv3ypLP9lFtzl+/q9kSOBPB7D6rae0Rt2uENZZLXGYbEgHYKQblOhijJAXGbbJjtQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.27.tgz", + "integrity": "sha512-FYwYWwSbUdza/pRX4NpSBm/Pimntum3jEIBpnDn5Ey1jHNWgjxrE8Z5SB4mCHd5wGCoYd3koJzxARl/VWIEx0Q==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/remote-config": "0.9.0", + "@firebase/remote-config-types": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.1.tgz", + "integrity": "sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==" + }, + "node_modules/@firebase/storage": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.3.tgz", + "integrity": "sha512-YX4/YL6P6/fufSSeGnVhjWddcIXbFq2cWIhMKFTZo1E/Rtcl2mJj/BYUQTwJfcE1Tl8un1FOya4L05jcSLN/Eg==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.3.tgz", + "integrity": "sha512-gruVqjtUGX8tEoeNbaWXZm0Zfcfcb7fvmDmBxV8yPAbWvExRnZYLO2+qw9idxNE7BvPXt5csyjSYHy//dAizxw==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/storage": "0.14.3", + "@firebase/storage-types": "0.8.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.4.tgz", + "integrity": "sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.1.tgz", + "integrity": "sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==", + "hasInstallScript": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.6.tgz", + "integrity": "sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==" + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -909,6 +1478,140 @@ "tslib": "^2.8.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/@grpc/proto-loader/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1089,6 +1792,54 @@ "node": ">=18" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==" + }, "node_modules/@puppeteer/browsers": { "version": "2.13.2", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", @@ -1850,7 +2601,6 @@ "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2101,7 +2851,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2847,7 +3596,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2858,8 +3606,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -3425,7 +4172,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "engines": { "node": ">=6" } @@ -3695,6 +4441,17 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3774,6 +4531,64 @@ "node": ">=8" } }, + "node_modules/firebase": { + "version": "12.16.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.16.0.tgz", + "integrity": "sha512-CNw6hFBdONkzF8UGLDx/RDRY9gVa5VmJNHd7qi4gdmA3ZuLkuOrhmWefB2l+FN+OxFpN77Itq7aO6zlTi780ag==", + "dependencies": { + "@firebase/ai": "2.13.1", + "@firebase/analytics": "0.10.22", + "@firebase/analytics-compat": "0.2.28", + "@firebase/app": "0.15.1", + "@firebase/app-check": "0.12.0", + "@firebase/app-check-compat": "0.4.5", + "@firebase/app-compat": "0.5.15", + "@firebase/app-types": "0.9.5", + "@firebase/auth": "1.13.3", + "@firebase/auth-compat": "0.6.8", + "@firebase/data-connect": "0.7.1", + "@firebase/database": "1.1.3", + "@firebase/database-compat": "2.1.4", + "@firebase/firestore": "4.16.0", + "@firebase/firestore-compat": "0.4.11", + "@firebase/functions": "0.13.5", + "@firebase/functions-compat": "0.4.5", + "@firebase/installations": "0.6.22", + "@firebase/installations-compat": "0.2.22", + "@firebase/messaging": "0.13.0", + "@firebase/messaging-compat": "0.2.27", + "@firebase/performance": "0.7.12", + "@firebase/performance-compat": "0.2.25", + "@firebase/remote-config": "0.9.0", + "@firebase/remote-config-compat": "0.2.27", + "@firebase/storage": "0.14.3", + "@firebase/storage-compat": "0.4.3", + "@firebase/util": "1.15.1" + } + }, + "node_modules/firebase/node_modules/@firebase/auth": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.3.tgz", + "integrity": "sha512-bqiq4uubDN2YyQkdvSWPQeJyXAv2O76ImF41En9b6UhV5JuBVYDoHYrrrE3NzIuGkpFMKagfhMRP4Vz6t+yQSQ==", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3905,7 +4720,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -4152,6 +4966,11 @@ "node": ">=6.0.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4190,6 +5009,11 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "node_modules/image-ssim": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/image-ssim/-/image-ssim-0.2.0.tgz", @@ -4410,7 +5234,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -4998,6 +5821,16 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "dev": true }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, "node_modules/lookup-closest-locale": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz", @@ -5945,6 +6778,28 @@ "node": ">=0.4.0" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6125,6 +6980,11 @@ "node": ">=0.10.0" } }, + "node_modules/re2js": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-0.4.3.tgz", + "integrity": "sha512-EuNmh7jurhHEE8Ge/lBo9JuMLb3qf866Xjjfyovw3wPc7+hlqDkZq4LwhrCQMEI+ARWfrKrHozEndzlpNT0WDg==" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6202,7 +7062,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6447,7 +7306,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -7388,8 +8246,7 @@ "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" }, "node_modules/unique-string": { "version": "2.0.0", @@ -7661,6 +8518,11 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", @@ -7676,6 +8538,27 @@ "node": ">=12" } }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e88efb..cd0fe12 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@tanstack/react-query": "^5.51.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "firebase": "^12.16.0", "framer-motion": "^11.3.2", "lucide-react": "^0.407.0", "react": "^18.3.1", diff --git a/frontend/src/App.library.test.tsx b/frontend/src/App.library.test.tsx new file mode 100644 index 0000000..20e7763 --- /dev/null +++ b/frontend/src/App.library.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import { claimsceneApi } from "@/lib/api"; + +// A true end-to-end path through App -> Header -> AuthMenu -> MyCases, with +// auth mocked as enabled + signed in. src/App.test.tsx covers the guest path +// (no auth mocking at all — isAuthEnabled() reads the real, unset env vars +// there) and is left completely untouched; this file only adds the +// signed-in library-navigation path App.tsx's new libraryOpen state exists +// for. +const mocks = vi.hoisted(() => ({ + isAuthEnabled: vi.fn(), + useAuthUser: vi.fn(), + signInWithGoogle: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + isAuthEnabled: mocks.isAuthEnabled, + useAuthUser: mocks.useAuthUser, + signInWithGoogle: mocks.signInWithGoogle, + signOut: mocks.signOut, +})); + +function renderApp() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(() => { + vi.spyOn(claimsceneApi, "health").mockResolvedValue({ + status: "ok", service: "claimscene-api", mode: "offline", + }); + vi.spyOn(claimsceneApi, "scenarios").mockResolvedValue([]); + vi.spyOn(claimsceneApi, "myLibrary").mockResolvedValue([]); + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + mocks.isAuthEnabled.mockReturnValue(true); + mocks.useAuthUser.mockReturnValue({ + user: { uid: "u1", displayName: "Ada", email: "ada@example.com", photoURL: null }, + loading: false, + }); + mocks.signInWithGoogle.mockResolvedValue(undefined); + mocks.signOut.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + window.location.hash = ""; +}); + +describe(" — signed-in My cases navigation", () => { + it("opens My cases from the header menu and returns to the landing on Back", async () => { + renderApp(); + await userEvent.click(screen.getByRole("button", { name: "Ada" })); + await userEvent.click(screen.getByRole("button", { name: /my cases/i })); + + expect(await screen.findByRole("heading", { name: /my cases/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Start a case/i })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /^back$/i })); + expect(screen.getByRole("button", { name: /^Start a case/i })).toBeInTheDocument(); + }); + + it("still enters the normal studio wizard when the visitor does not open the library", async () => { + renderApp(); + await userEvent.click(screen.getByRole("button", { name: /^Start a case/i })); + expect(screen.getByText(/Step 1 of 4/i)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: /my cases/i })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05d3d06..f5e0351 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,10 +4,16 @@ import { Footer } from "./components/Footer"; import { DisclosureBanner } from "./components/DisclosureBanner"; import { Hero } from "./components/Hero"; import { Studio } from "./components/Studio"; +import { MyCases } from "./components/MyCases"; import { useCaseStore } from "./store/useCaseStore"; export default function App() { const [started, setStarted] = useState(false); + // Only ever set true from the header's signed-in "My cases" menu item, + // which itself renders nothing without Firebase config — so a guest build + // (every build today) never has any control that could flip this, and this + // branch of the render below is dead code for guest, not just untriggered. + const [libraryOpen, setLibraryOpen] = useState(false); const reset = useCaseStore((s) => s.reset); const start = () => { @@ -35,9 +41,20 @@ export default function App() { Skip to main content -
+
{ + setLibraryOpen(true); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + />
- {started ? : } + {libraryOpen ? ( + setLibraryOpen(false)} /> + ) : started ? ( + + ) : ( + + )}
diff --git a/frontend/src/components/AuthMenu.test.tsx b/frontend/src/components/AuthMenu.test.tsx new file mode 100644 index 0000000..b3e799d --- /dev/null +++ b/frontend/src/components/AuthMenu.test.tsx @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// AuthMenu reads isAuthEnabled/useAuthUser/signInWithGoogle/signOut directly +// from lib/auth — mock the whole module so no real (or even lazily-imported +// stub) Firebase code ever runs in a component test. +const mocks = vi.hoisted(() => ({ + isAuthEnabled: vi.fn(), + useAuthUser: vi.fn(), + signInWithGoogle: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + isAuthEnabled: mocks.isAuthEnabled, + useAuthUser: mocks.useAuthUser, + signInWithGoogle: mocks.signInWithGoogle, + signOut: mocks.signOut, +})); + +import { AuthMenu } from "./AuthMenu"; + +afterEach(() => { + vi.restoreAllMocks(); + mocks.isAuthEnabled.mockReset(); + mocks.useAuthUser.mockReset(); + mocks.signInWithGoogle.mockReset().mockResolvedValue(undefined); + mocks.signOut.mockReset().mockResolvedValue(undefined); +}); + +describe(" — auth disabled (guest, unchanged)", () => { + it("renders nothing at all", () => { + mocks.isAuthEnabled.mockReturnValue(false); + mocks.useAuthUser.mockReturnValue({ user: null, loading: false }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe(" — auth enabled, signed out", () => { + beforeEach(() => { + mocks.isAuthEnabled.mockReturnValue(true); + mocks.useAuthUser.mockReturnValue({ user: null, loading: false }); + }); + + it("shows a Sign in with Google button", () => { + render(); + expect(screen.getByRole("button", { name: /sign in with google/i })).toBeInTheDocument(); + }); + + it("calls signInWithGoogle on click", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /sign in with google/i })); + expect(mocks.signInWithGoogle).toHaveBeenCalledTimes(1); + }); +}); + +describe(" — auth enabled, signed in", () => { + const user = { uid: "u1", displayName: "Ada Lovelace", email: "ada@example.com", photoURL: null }; + + beforeEach(() => { + mocks.isAuthEnabled.mockReturnValue(true); + mocks.useAuthUser.mockReturnValue({ user, loading: false }); + }); + + it("shows the user's display name", () => { + render(); + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + }); + + it("falls back to email when displayName is null", () => { + mocks.useAuthUser.mockReturnValue({ user: { ...user, displayName: null }, loading: false }); + render(); + expect(screen.getByText("ada@example.com")).toBeInTheDocument(); + }); + + it("opens the panel on click, showing My cases and Sign out", async () => { + render(); + const trigger = screen.getByRole("button", { name: "Ada Lovelace" }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /my cases/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sign out/i })).toBeInTheDocument(); + }); + + it("calls onOpenLibrary and closes the panel when My cases is clicked", async () => { + const onOpenLibrary = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Ada Lovelace" })); + await userEvent.click(screen.getByRole("button", { name: /my cases/i })); + expect(onOpenLibrary).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: /my cases/i })).not.toBeInTheDocument(); + }); + + it("calls signOut when Sign out is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Ada Lovelace" })); + await userEvent.click(screen.getByRole("button", { name: /sign out/i })); + expect(mocks.signOut).toHaveBeenCalledTimes(1); + }); + + it("closes the panel on Escape", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Ada Lovelace" })); + expect(screen.getByRole("button", { name: /my cases/i })).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("button", { name: /my cases/i })).not.toBeInTheDocument(); + }); + + it("closes the panel on an outside click", async () => { + render( +
+ + +
, + ); + await userEvent.click(screen.getByRole("button", { name: "Ada Lovelace" })); + expect(screen.getByRole("button", { name: /my cases/i })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "outside" })); + expect(screen.queryByRole("button", { name: /my cases/i })).not.toBeInTheDocument(); + }); + + it("shows an avatar image when photoURL is present", () => { + mocks.useAuthUser.mockReturnValue({ + user: { ...user, photoURL: "https://x.example/y.png" }, + loading: false, + }); + // A decorative avatar (alt="") is intentionally NOT exposed with role + // "img" to assistive tech, so it's queried directly rather than via + // getByRole — the point of alt="" is that it should NOT be announced. + const { container } = render(); + const img = container.querySelector("img"); + expect(img).toHaveAttribute("src", "https://x.example/y.png"); + expect(img).toHaveAttribute("alt", ""); + }); +}); diff --git a/frontend/src/components/AuthMenu.tsx b/frontend/src/components/AuthMenu.tsx new file mode 100644 index 0000000..65e3f67 --- /dev/null +++ b/frontend/src/components/AuthMenu.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "react"; +import { FolderOpen, LogOut, User as UserIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { isAuthEnabled, signInWithGoogle, signOut, useAuthUser } from "@/lib/auth"; + +/** Header sign-in control. Renders NOTHING unless isAuthEnabled() — the + * guard is checked here too (not only by the caller) so this component is + * safe to mount unconditionally. Signed out: a "Sign in with Google" + * button. Signed in: the user's name/avatar with a small disclosure panel + * (a plain trigger + `
` of buttons, not `role="menu"` — that ARIA + * pattern carries strict keyboard/structure rules this doesn't need). + * + * Ported from our other MIT entry, Cinemory, which pioneered this pattern + * for its own optional sign-in. */ +export function AuthMenu({ onOpenLibrary }: { onOpenLibrary: () => void }) { + const { user } = useAuthUser(); + const [open, setOpen] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocPointerDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onDocPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + if (!isAuthEnabled()) return null; + + const handleSignIn = async () => { + setSigningIn(true); + try { + await signInWithGoogle(); + } finally { + setSigningIn(false); + } + }; + + const handleSignOut = async () => { + setOpen(false); + await signOut(); + }; + + if (!user) { + return ( + + ); + } + + const label = user.displayName || user.email || "Signed in"; + + return ( +
+ + + {open && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/ConfirmDialog.test.tsx b/frontend/src/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..e32d020 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.test.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConfirmDialog } from "./ConfirmDialog"; + +const baseProps = { + title: "Delete all your data?", + description: "This cannot be undone.", + confirmLabel: "Delete everything", +}; + +function Harness({ + onConfirm = vi.fn(), + onCancel = vi.fn(), + ...rest +}: Partial<{ + onConfirm: () => void; + onCancel: () => void; + busy: boolean; + error: string | null; + destructive: boolean; +}> = {}) { + return ( +
+ + +
+ ); +} + +/** Mirrors real usage (MyCases): a trigger button flips `open`, and both + * onConfirm/onCancel close it — used specifically for the focus-return + * assertion, which needs a REAL open/close transition, not a static prop. */ +function StatefulHarness() { + const [open, setOpen] = useState(false); + return ( +
+ + setOpen(false)} + onCancel={() => setOpen(false)} + /> +
+ ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe(" — closed", () => { + it("renders nothing", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe(" — open", () => { + it("has an accessible dialog role labelled by the title", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAccessibleName("Delete all your data?"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("moves focus to Cancel on open", () => { + render(); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + }); + + it("calls onConfirm when the confirm button is clicked", async () => { + const onConfirm = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Delete everything" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel when Cancel is clicked", async () => { + const onCancel = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel on Escape", async () => { + const onCancel = vi.fn(); + render(); + await userEvent.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel when the overlay backdrop is clicked", async () => { + const onCancel = vi.fn(); + render(); + const overlay = screen.getByRole("dialog").parentElement as HTMLElement; + await userEvent.click(overlay); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("does not call onCancel when clicking inside the dialog panel", async () => { + const onCancel = vi.fn(); + render(); + await userEvent.click(screen.getByText("This cannot be undone.")); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("traps Tab from the last focusable back to the first", async () => { + render(); + const cancel = screen.getByRole("button", { name: "Cancel" }); + const confirm = screen.getByRole("button", { name: "Delete everything" }); + confirm.focus(); + expect(confirm).toHaveFocus(); + await userEvent.tab(); + expect(cancel).toHaveFocus(); + }); + + it("traps Shift+Tab from the first focusable back to the last", async () => { + render(); + const confirm = screen.getByRole("button", { name: "Delete everything" }); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await userEvent.tab({ shift: true }); + expect(confirm).toHaveFocus(); + }); + + it("returns focus to whatever triggered it, on close", async () => { + render(); + const trigger = screen.getByRole("button", { name: "trigger" }); + await userEvent.click(trigger); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await userEvent.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); + }); + + it("shows the error message when provided, without closing", () => { + const onCancel = vi.fn(); + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Could not delete your data right now."); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("renders no alert when error is absent", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("disables both actions and shows Working… while busy", () => { + render(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(); + }); + + it("uses the primary variant by default and danger (red) when destructive", () => { + const { rerender } = render(); + const primaryConfirm = screen.getByRole("button", { name: "Delete everything" }); + expect(primaryConfirm.className).not.toMatch(/border-red-400/); + + rerender(); + const destructiveConfirm = screen.getByRole("button", { name: "Delete everything" }); + expect(destructiveConfirm.className).toMatch(/border-red-400/); + }); + + it("uses the custom cancel label when provided", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Never mind" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..929d65e --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef } from "react"; +import { Button } from "./ui/button"; + +export interface ConfirmDialogProps { + open: boolean; + title: string; + description: string; + confirmLabel: string; + cancelLabel?: string; + /** Styles the confirm action as destructive (red) rather than primary. */ + destructive?: boolean; + /** Disables both actions while the confirmed operation is in flight. */ + busy?: boolean; + /** Shown inside the dialog (never closes it) when the confirmed action failed. */ + error?: string | null; + onConfirm: () => void; + onCancel: () => void; +} + +/** A small, hand-rolled accessible confirmation dialog: `role="dialog"` + + * `aria-modal`, focus moves to Cancel on open and returns to the trigger on + * close, Tab/Shift+Tab is trapped inside, and Escape cancels. No portal — + * a fixed, high-z-index overlay is enough here since this is always mounted + * near the root of whichever view opens it. + * + * Ported from our other MIT entry, Cinemory, which pioneered this pattern + * for its own delete-my-data confirmation. */ +export function ConfirmDialog({ + open, + title, + description, + confirmLabel, + cancelLabel = "Cancel", + destructive = false, + busy = false, + error = null, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const dialogRef = useRef(null); + const cancelRef = useRef(null); + const previouslyFocused = useRef(null); + + // Move focus in on open (defaulting to Cancel, not the destructive action), + // and restore it to whatever triggered the dialog on close. + useEffect(() => { + if (!open) return; + previouslyFocused.current = document.activeElement as HTMLElement | null; + cancelRef.current?.focus(); + return () => { + previouslyFocused.current?.focus(); + }; + }, [open]); + + // Escape-to-cancel and a Tab focus trap confined to the dialog's own + // focusable elements. + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (e.key !== "Tab") return; + const focusables = dialogRef.current?.querySelectorAll( + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (!focusables || focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (!first || !last) return; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, onCancel]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + > +
+

+ {title} +

+

+ {description} +

+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7337c73..6dc31b1 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,5 +1,6 @@ import { Wordmark } from "./Wordmark"; import { Badge } from "./ui/badge"; +import { AuthMenu } from "./AuthMenu"; import { useHealth } from "@/lib/queries"; /** Plain-English tooltip for the health badge. */ @@ -10,7 +11,11 @@ export function modeTooltip(mode: string): string { return `Backend mode "${mode}"`; } -export function Header() { +/** `onOpenLibrary` is only ever invoked from AuthMenu's signed-in "My cases" + * item, which itself renders nothing when Firebase config is absent (see + * lib/auth.ts::isAuthEnabled) — a build with no VITE_FIREBASE_* vars (every + * build today) never shows the control that could call it. */ +export function Header({ onOpenLibrary }: { onOpenLibrary?: () => void }) { const health = useHealth(); return ( @@ -25,7 +30,7 @@ export function Header() { > -
+
{health.isSuccess && ( @@ -40,6 +45,7 @@ export function Header() { API offline )} + {})} />
diff --git a/frontend/src/components/MyCases.test.tsx b/frontend/src/components/MyCases.test.tsx new file mode 100644 index 0000000..7bae726 --- /dev/null +++ b/frontend/src/components/MyCases.test.tsx @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MyCases } from "./MyCases"; +import { ApiError, claimsceneApi, type LibraryCase } from "@/lib/api"; + +// MyCases reads the current user via useAuthUser (lib/auth) but talks to the +// backend through claimsceneApi (lib/api) via the real queries.ts hooks, so +// react-query's real loading/success/error machinery is exercised. +const mocks = vi.hoisted(() => ({ useAuthUser: vi.fn() })); +vi.mock("@/lib/auth", () => ({ useAuthUser: mocks.useAuthUser })); + +const SIGNED_IN = { + user: { uid: "u1", displayName: "Ada", email: "ada@example.com", photoURL: null }, + loading: false, +}; + +function renderMyCases(onBack: () => void = vi.fn()) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe(" — loading and empty", () => { + it("shows a loading skeleton while the library is being fetched, then the empty state", async () => { + mocks.useAuthUser.mockReturnValue(SIGNED_IN); + let resolveLibrary!: (value: LibraryCase[]) => void; + const pending = new Promise((resolve) => { + resolveLibrary = resolve; + }); + vi.spyOn(claimsceneApi, "myLibrary").mockReturnValue(pending); + + const { container } = renderMyCases(); + expect(container.querySelector(".animate-pulse")).not.toBeNull(); + + resolveLibrary([]); + await waitFor(() => + expect(screen.getByText(/you have not saved any cases yet/i)).toBeInTheDocument(), + ); + expect(screen.queryByRole("button", { name: /delete all my data/i })).not.toBeInTheDocument(); + }); +}); + +describe(" — list", () => { + const CASES: LibraryCase[] = [ + { case_id: "rear-end-2026", manifest_hash: "a".repeat(16), created_at: "2026-01-15T10:00:00Z" }, + { case_id: "left-cross", manifest_hash: null, created_at: null }, + ]; + + beforeEach(() => { + mocks.useAuthUser.mockReturnValue(SIGNED_IN); + vi.spyOn(claimsceneApi, "myLibrary").mockResolvedValue(CASES); + }); + + it("renders every saved case by id", async () => { + renderMyCases(); + await waitFor(() => expect(screen.getByText("rear-end-2026")).toBeInTheDocument()); + expect(screen.getByText("left-cross")).toBeInTheDocument(); + }); + + it("shows the delete-all control once cases exist", async () => { + renderMyCases(); + await waitFor(() => + expect(screen.getByRole("button", { name: /delete all my data/i })).toBeInTheDocument(), + ); + }); + + it("shows 'Date unknown' for a case with no created_at", async () => { + renderMyCases(); + await waitFor(() => expect(screen.getByText("left-cross")).toBeInTheDocument()); + const row = screen.getByText("left-cross").closest("li") as HTMLElement; + expect(within(row).getByText("Date unknown")).toBeInTheDocument(); + }); + + it("links each row to its case, schematic and illustration", async () => { + renderMyCases(); + await waitFor(() => expect(screen.getByText("rear-end-2026")).toBeInTheDocument()); + const row = screen.getByText("rear-end-2026").closest("li") as HTMLElement; + expect(within(row).getByRole("link", { name: /case/i })).toHaveAttribute( + "href", + "/cases/rear-end-2026", + ); + expect(within(row).getByRole("link", { name: /schematic/i })).toHaveAttribute( + "href", + "/cases/rear-end-2026/schematic", + ); + expect(within(row).getByRole("link", { name: /illustration/i })).toHaveAttribute( + "href", + "/cases/rear-end-2026/illustration", + ); + }); +}); + +describe(" — 401 graceful degrade vs generic error", () => { + beforeEach(() => mocks.useAuthUser.mockReturnValue(SIGNED_IN)); + + it("shows the honest degrade note on a 401 (backend multitenancy not enabled here)", async () => { + vi.spyOn(claimsceneApi, "myLibrary").mockRejectedValue(new ApiError("nope", 401)); + renderMyCases(); + await waitFor(() => + expect(screen.getByText(/case library is not available on this deployment/i)).toBeInTheDocument(), + ); + }); + + it("shows a generic retry message on a non-401 ApiError", async () => { + vi.spyOn(claimsceneApi, "myLibrary").mockRejectedValue(new ApiError("boom", 500)); + renderMyCases(); + await waitFor(() => + expect(screen.getByText(/could not load your cases right now/i)).toBeInTheDocument(), + ); + }); + + it("shows the same generic message for a non-ApiError failure (e.g. a network error)", async () => { + vi.spyOn(claimsceneApi, "myLibrary").mockRejectedValue(new Error("network down")); + renderMyCases(); + await waitFor(() => + expect(screen.getByText(/could not load your cases right now/i)).toBeInTheDocument(), + ); + expect( + screen.queryByText(/case library is not available on this deployment/i), + ).not.toBeInTheDocument(); + }); +}); + +describe(" — delete all my data", () => { + const CASES: LibraryCase[] = [{ case_id: "c1", manifest_hash: null, created_at: null }]; + + beforeEach(() => mocks.useAuthUser.mockReturnValue(SIGNED_IN)); + + it("opens the confirm dialog, calls DELETE /me/data on confirm, and refreshes to the empty state", async () => { + const myLibrary = vi + .spyOn(claimsceneApi, "myLibrary") + .mockResolvedValueOnce(CASES) + .mockResolvedValueOnce([]); + const deleteMyData = vi.spyOn(claimsceneApi, "deleteMyData").mockResolvedValue({ deleted: 1 }); + renderMyCases(); + + await waitFor(() => expect(screen.getByText("c1")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: /delete all my data/i })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /delete everything/i })); + expect(deleteMyData).toHaveBeenCalledTimes(1); + + await waitFor(() => + expect(screen.getByText(/all your data has been deleted/i)).toBeInTheDocument(), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText(/you have not saved any cases yet/i)).toBeInTheDocument(), + ); + expect(myLibrary).toHaveBeenCalledTimes(2); // initial fetch + refetch after invalidation + }); + + it("does not call DELETE /me/data when the dialog is cancelled", async () => { + vi.spyOn(claimsceneApi, "myLibrary").mockResolvedValue(CASES); + const deleteMyData = vi.spyOn(claimsceneApi, "deleteMyData"); + renderMyCases(); + + await waitFor(() => expect(screen.getByText("c1")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: /delete all my data/i })); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(deleteMyData).not.toHaveBeenCalled(); + }); + + it("shows an error inside the dialog and keeps it open when the delete fails", async () => { + vi.spyOn(claimsceneApi, "myLibrary").mockResolvedValue(CASES); + vi.spyOn(claimsceneApi, "deleteMyData").mockRejectedValue(new Error("network down")); + renderMyCases(); + + await waitFor(() => expect(screen.getByText("c1")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: /delete all my data/i })); + await userEvent.click(screen.getByRole("button", { name: /delete everything/i })); + + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent(/could not delete your data/i), + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.queryByText(/all your data has been deleted/i)).not.toBeInTheDocument(); + }); + + it("clears the pending auto-dismiss timer on unmount without throwing", async () => { + vi.spyOn(claimsceneApi, "myLibrary").mockResolvedValueOnce(CASES).mockResolvedValueOnce([]); + vi.spyOn(claimsceneApi, "deleteMyData").mockResolvedValue({ deleted: 1 }); + const { unmount } = renderMyCases(); + + await waitFor(() => expect(screen.getByText("c1")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: /delete all my data/i })); + await userEvent.click(screen.getByRole("button", { name: /delete everything/i })); + await waitFor(() => + expect(screen.getByText(/all your data has been deleted/i)).toBeInTheDocument(), + ); + + expect(() => unmount()).not.toThrow(); + }); +}); + +describe(" — back navigation and the sign-out guard", () => { + it("calls onBack when the Back control is clicked", async () => { + mocks.useAuthUser.mockReturnValue(SIGNED_IN); + vi.spyOn(claimsceneApi, "myLibrary").mockResolvedValue([]); + const onBack = vi.fn(); + renderMyCases(onBack); + await userEvent.click(screen.getByRole("button", { name: /back/i })); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it("calls onBack automatically once auth resolves to signed-out", async () => { + mocks.useAuthUser.mockReturnValue({ user: null, loading: false }); + const onBack = vi.fn(); + renderMyCases(onBack); + await waitFor(() => expect(onBack).toHaveBeenCalledTimes(1)); + }); + + it("does not call onBack while the initial auth state is still loading", () => { + mocks.useAuthUser.mockReturnValue({ user: null, loading: true }); + const onBack = vi.fn(); + renderMyCases(onBack); + expect(onBack).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/MyCases.tsx b/frontend/src/components/MyCases.tsx new file mode 100644 index 0000000..72eafdb --- /dev/null +++ b/frontend/src/components/MyCases.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from "react"; +import { ArrowLeft, Download, FolderOpen, Film, Ruler, Trash2 } from "lucide-react"; +import { Button } from "./ui/button"; +import { HashChip } from "./HashChip"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { useAuthUser } from "@/lib/auth"; +import { ApiError, API_BASE, type LibraryCase } from "@/lib/api"; +import { useDeleteMyData, useMyLibrary } from "@/lib/queries"; + +function formatDate(iso: string | null): string { + if (!iso) return "Date unknown"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "Date unknown"; + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +/** The signed-in tenant's own case library — only ever mounted from the + * header's account menu, which itself only renders "My cases" when a user + * is signed in. Still defends against a client-side sign-out happening + * WHILE this view is open (the header stays mounted and reachable): once + * the auth state has definitively resolved to signed-out (not just still + * loading), it hands control back via `onBack` rather than keep showing a + * stale library. + * + * Structurally ported from our other MIT entry, Cinemory's MyReels — this + * is the ClaimScene-flavoured equivalent (cases, not reels). */ +export function MyCases({ onBack }: { onBack: () => void }) { + const { user, loading } = useAuthUser(); + const library = useMyLibrary(user !== null); + const deleteAll = useDeleteMyData(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [justDeleted, setJustDeleted] = useState(false); + const dismissTimer = useRef(null); + + useEffect(() => { + if (!loading && user === null) onBack(); + }, [loading, user, onBack]); + + useEffect( + () => () => { + if (dismissTimer.current !== null) window.clearTimeout(dismissTimer.current); + }, + [], + ); + + const unavailable = + library.isError && library.error instanceof ApiError && library.error.status === 401; + + const handleDelete = () => { + deleteAll.mutate(undefined, { + onSuccess: () => { + setConfirmOpen(false); + setJustDeleted(true); + if (dismissTimer.current !== null) window.clearTimeout(dismissTimer.current); + dismissTimer.current = window.setTimeout(() => setJustDeleted(false), 4000); + }, + }); + }; + + return ( +
+ + +
+
+

My cases

+

Every case sealed to your account.

+
+ {!!library.data?.length && ( + + )} +
+ + {justDeleted && ( +

+ All your data has been deleted. +

+ )} + + {library.isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ )} + + {unavailable && ( +

+ Case library is not available on this deployment. +

+ )} + + {library.isError && !unavailable && ( +

+ Could not load your cases right now. Try again in a moment. +

+ )} + + {library.data?.length === 0 && ( +
+ +

You have not saved any cases yet.

+
+ )} + + {!!library.data?.length && ( +
    + {library.data.map((c) => ( + + ))} +
+ )} + + setConfirmOpen(false)} + /> +
+ ); +} + +function CaseRow({ item }: { item: LibraryCase }) { + return ( +
  • +
    +

    {item.case_id}

    +

    {formatDate(item.created_at)}

    +
    +
    + {item.manifest_hash && } + + + +
    +
  • + ); +} diff --git a/frontend/src/components/steps/RenderStep.test.tsx b/frontend/src/components/steps/RenderStep.test.tsx index a97da26..d592673 100644 --- a/frontend/src/components/steps/RenderStep.test.tsx +++ b/frontend/src/components/steps/RenderStep.test.tsx @@ -1,16 +1,17 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RenderStep } from "./RenderStep"; import { claimsceneApi } from "@/lib/api"; import { useCaseStore } from "@/store/useCaseStore"; import { emptyScene } from "@/lib/scene"; +import { RENDER_JOB_POLL_INTERVAL_MS } from "@/lib/queries"; const SCENARIO = { id: "s02_left_cross", title: "Left Cross", context: "c", summary: "s", images: [], thumbnail: null }; function renderStep() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); return render( @@ -18,6 +19,17 @@ function renderStep() { ); } +function renderBody(overrides: Record = {}) { + return { + case_id: "sealed-1", manifest_hash: "a".repeat(64), manifest_url: "/cases/sealed-1", + provider: "fake-media", degraded: true, provider_degraded: false, + has_schematic_animation: false, schematic_kind: "static", + schematic_url: "/cases/sealed-1/schematic", illustration_url: "/cases/sealed-1/illustration", + report_markdown: "# report", scene: emptyScene(), warnings: [], artifacts: {}, + ...overrides, + }; +} + describe("RenderStep", () => { beforeEach(() => { useCaseStore.getState().reset(); @@ -25,21 +37,38 @@ describe("RenderStep", () => { useCaseStore.setState({ scene: emptyScene(), caseId: "case", scenario: SCENARIO, photos: [] }); }); - it("auto-fires the render and seals the result on success", async () => { - const spy = vi.spyOn(claimsceneApi, "render").mockResolvedValue({ case_id: "sealed-1" } as never); + afterEach(() => { + vi.useRealTimers(); + }); + + it("submits a background job, polls it, and seals the result when the first poll is already done (offline/demo)", async () => { + const submitSpy = vi + .spyOn(claimsceneApi, "submitRenderJob") + .mockResolvedValue({ job_id: "job-1", status: "queued" }); + const getJobSpy = vi + .spyOn(claimsceneApi, "getRenderJob") + .mockResolvedValue({ job_id: "job-1", status: "done", result: renderBody() as never }); + renderStep(); expect(screen.getByRole("heading", { name: /Sealing your case/i })).toBeInTheDocument(); + await waitFor(() => expect(useCaseStore.getState().result).not.toBeNull()); - expect(spy).toHaveBeenCalledWith( + expect(submitSpy).toHaveBeenCalledWith( expect.objectContaining({ scenarioId: "s02_left_cross", caseId: "case" }), ); + expect(getJobSpy).toHaveBeenCalledWith("job-1"); expect(useCaseStore.getState().step).toBe("result"); }); it("sends the frozen AI proposal + honest classification for the approval receipt", async () => { const proposed = emptyScene(); useCaseStore.setState({ proposedScene: proposed }); - const spy = vi.spyOn(claimsceneApi, "render").mockResolvedValue({ case_id: "s" } as never); + const spy = vi + .spyOn(claimsceneApi, "submitRenderJob") + .mockResolvedValue({ job_id: "job-1", status: "queued" }); + vi.spyOn(claimsceneApi, "getRenderJob").mockResolvedValue({ + job_id: "job-1", status: "running", + }); renderStep(); await waitFor(() => expect(spy).toHaveBeenCalled()); expect(spy).toHaveBeenCalledWith( @@ -51,22 +80,92 @@ describe("RenderStep", () => { ); }); - it("shows an error with retry + back on failure", async () => { - vi.spyOn(claimsceneApi, "render").mockRejectedValue(new Error("render blew up")); + it("sends the uploaded photos + their roles when no scenario is selected", async () => { + const file = new File([new Uint8Array([1, 2, 3])], "damage_a.png", { type: "image/png" }); + useCaseStore.setState({ + scenario: null, + photos: [{ id: "p1", file, url: "blob:x", name: "damage_a.png", role: "damage_photo" }], + }); + const spy = vi + .spyOn(claimsceneApi, "submitRenderJob") + .mockResolvedValue({ job_id: "job-1", status: "queued" }); + vi.spyOn(claimsceneApi, "getRenderJob").mockResolvedValue({ job_id: "job-1", status: "running" }); + renderStep(); + await waitFor(() => expect(spy).toHaveBeenCalled()); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ files: [file], roles: ["damage_photo"] }), + ); + expect(spy.mock.calls[0]?.[0].scenarioId).toBeUndefined(); + }); + + it("keeps polling across queued -> running -> done before sealing the result", async () => { + vi.useFakeTimers(); + vi.spyOn(claimsceneApi, "submitRenderJob").mockResolvedValue({ job_id: "job-1", status: "queued" }); + const getJobSpy = vi + .spyOn(claimsceneApi, "getRenderJob") + .mockResolvedValueOnce({ job_id: "job-1", status: "queued" }) + .mockResolvedValueOnce({ job_id: "job-1", status: "running" }) + .mockResolvedValueOnce({ job_id: "job-1", status: "done", result: renderBody() as never }); + + renderStep(); + + // Flush the submit and the poll's immediate first tick (queued). + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByRole("heading", { name: /Sealing your case/i })).toBeInTheDocument(); + expect(getJobSpy).toHaveBeenCalledTimes(1); + expect(useCaseStore.getState().result).toBeNull(); + + // Second tick (running) — one interval later. + await act(async () => { + await vi.advanceTimersByTimeAsync(RENDER_JOB_POLL_INTERVAL_MS); + }); + expect(getJobSpy).toHaveBeenCalledTimes(2); + expect(useCaseStore.getState().result).toBeNull(); + + // Third tick (done) — another interval later. + await act(async () => { + await vi.advanceTimersByTimeAsync(RENDER_JOB_POLL_INTERVAL_MS); + }); + expect(getJobSpy).toHaveBeenCalledTimes(3); + expect(useCaseStore.getState().result).not.toBeNull(); + expect(useCaseStore.getState().step).toBe("result"); + }); + + it("shows the friendly job failure error and offers retry + back", async () => { + vi.spyOn(claimsceneApi, "submitRenderJob").mockResolvedValue({ job_id: "job-1", status: "queued" }); + vi.spyOn(claimsceneApi, "getRenderJob").mockResolvedValue({ + job_id: "job-1", status: "failed", error: "RuntimeError", + }); + + renderStep(); + await waitFor(() => + expect(screen.getByRole("heading", { name: /Render failed/i })).toBeInTheDocument(), + ); + expect(screen.getByRole("alert")).toHaveTextContent("RuntimeError"); + expect(useCaseStore.getState().result).toBeNull(); + + // Back to review returns to the edit step. + fireEvent.click(screen.getByRole("button", { name: /Back to review/i })); + expect(useCaseStore.getState().step).toBe("review"); + }); + + it("surfaces a submit-level failure without ever polling", async () => { + vi.spyOn(claimsceneApi, "submitRenderJob").mockRejectedValue(new Error("render blew up")); + const getJobSpy = vi.spyOn(claimsceneApi, "getRenderJob"); + renderStep(); await waitFor(() => expect(screen.getByRole("heading", { name: /Render failed/i })).toBeInTheDocument(), ); expect(screen.getByRole("alert")).toHaveTextContent(/render blew up/i); + expect(getJobSpy).not.toHaveBeenCalled(); - // Retry re-fires the render (reset + start), a second attempt. - const spy = vi.mocked(claimsceneApi.render); + // Retry re-fires a fresh submit (a second attempt). + const spy = vi.mocked(claimsceneApi.submitRenderJob); const before = spy.mock.calls.length; fireEvent.click(screen.getByRole("button", { name: /Retry/i })); await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(before)); - - // Back to review returns to the edit step. - fireEvent.click(screen.getByRole("button", { name: /Back to review/i })); - expect(useCaseStore.getState().step).toBe("review"); }); }); diff --git a/frontend/src/components/steps/RenderStep.tsx b/frontend/src/components/steps/RenderStep.tsx index 8b653bd..c65bf9d 100644 --- a/frontend/src/components/steps/RenderStep.tsx +++ b/frontend/src/components/steps/RenderStep.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, Check, Loader2 } from "lucide-react"; -import { useRender } from "@/lib/queries"; +import { usePollRenderJob, useSubmitRenderJob } from "@/lib/queries"; import { useCaseStore } from "@/store/useCaseStore"; import { Button } from "../ui/button"; import { cn } from "@/lib/utils"; @@ -19,13 +19,22 @@ export function RenderStep() { const caseId = useCaseStore((s) => s.caseId); const setResult = useCaseStore((s) => s.setResult); const goTo = useCaseStore((s) => s.goTo); - const render = useRender(); + const submitJob = useSubmitRenderJob(); const fired = useRef(false); const [phase, setPhase] = useState(0); + // Async submit + poll: a real render can take minutes (an establish-shot + // still, then an image-to-video clip chained from it — see + // claimscene.api's module docstring), longer than the Firebase Hosting + // proxy cap, so this submits a background job (POST /cases/render/jobs) + // instead of blocking one request. `jobId` is reset to null on every fresh + // attempt so a retry's stale poll result/error never lingers even a beat. + const [jobId, setJobId] = useState(null); + const poll = usePollRenderJob(jobId); const start = useCallback(() => { if (!scene) return; setPhase(0); + setJobId(null); // Seal the AI→human approval receipt: send the frozen AI proposal + an honest // classification. The public demo is unauthenticated, so `interactive_demo` // is the truthful label (the server also enforces this). @@ -33,14 +42,14 @@ export function RenderStep() { proposedScene: proposedScene ?? undefined, reviewClassification: "interactive_demo", }; - render.mutate( + submitJob.mutate( scenario ? { scene, caseId, scenarioId: scenario.id, ...review } : { scene, caseId, files: photos.map((p) => p.file), roles: photos.map((p) => p.role), ...review }, - { onSuccess: (res) => setResult(res) }, + { onSuccess: (data) => setJobId(data.job_id) }, ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scene, proposedScene, scenario, photos, caseId, setResult]); + }, [scene, proposedScene, scenario, photos, caseId]); useEffect(() => { if (fired.current) return; @@ -48,25 +57,36 @@ export function RenderStep() { start(); }, [start]); - // Cosmetic staged feedback while the single real request is in flight. + // The polled job landed a sealed case — hand off to the store, which also + // flips the wizard to the result step. Offline/demo generation can finish + // near-instantly, so this may fire from the very first poll tick. useEffect(() => { - if (render.isError || render.isSuccess) return; + if (!poll.result) return; + setResult(poll.result); + }, [poll.result, setResult]); + + const isSuccess = poll.result !== null; + const error: Error | null = submitJob.isError ? submitJob.error : poll.error; + + // Cosmetic staged feedback while the real request is in flight. + useEffect(() => { + if (error || isSuccess) return; const t = window.setInterval(() => setPhase((p) => Math.min(p + 1, STAGES.length - 1)), 1400); return () => window.clearInterval(t); - }, [render.isError, render.isSuccess]); + }, [error, isSuccess]); return (
    -
    +

    - {render.isError ? "Render failed" : "Sealing your case"} + {error ? "Render failed" : "Sealing your case"}

    The schematic is deterministic; the illustration is generative and disclosed.

    - {!render.isError && ( + {!error && (
      {STAGES.map((label, i) => { const done = i < phase; @@ -92,13 +112,19 @@ export function RenderStep() {
    )} - {render.isError && ( + {error && (

    - {render.error.message} + {error.message}

    -