diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index 8bcbfbe1..36a4cd45 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -370,3 +370,25 @@ Task 10: complete (commit d41ed0c, review Approved, no fixes -- reviewer indepen === PANEL P1.1 (backend endpoints + Home 1c + event create) ALL 10 TASKS + 2 FOLLOW-UP FIXES COMPLETE — HEAD=d41ed0c, proceeding to final whole-branch review === Final whole-branch review (opus): Ready to merge = With fixes -> now Yes after committing. Full independent re-verification green (backend 288/288, panel 95/95 post-fix, @idento/ui 131/131, schema drift clean, Docker build succeeds with new deps openapi-react-query/zod/msw). Cross-cutting checks all passed: backend readiness endpoint's ready=attendees&&badge&&staff confirmed opaque/server-computed, panel never re-derives it; done/total fraction math verified correct (max 4/5 by design since equipment always not_done in P1, matches board copy); fresh-event path clean (POST /api/events sends no custom_fields, badge step correctly not_done, ReadinessCell's Draft collapse handles it); $api convention held with zero bypasses across the whole phase; i18n hygiene clean, RU translations genuine not copied-English; all 4 mid-phase fix-round patterns (Task3 badge map-shape, Task4 dynamicBaseUrl middleware, Task7 tooltip a11y, Task8 UTC display, Task9 getRouteApi) confirmed intact in final state with no destructive overlap; /register beforeLoad + protectedBeforeLoad regression guards confirmed still byte-for-byte intact. DEFERRED ITEM RESOLVED: reviewer fixed eventTiming.ts's local-setHours->setUTCHours (commit 7dda1b7) after finding this was NOT just "a day early" but a genuine internal contradiction within the phase itself -- reproduced empirically that a running event's own DISPLAYED day (UTC-pinned since Task 8's fix) could classify as "past" for a viewer behind UTC, meaning display and classification actively disagreed on which calendar day a date-only value denotes. Causation-checked via revert-reproduce-restore. New eventTiming.tz.test.ts added as deterministic regression guard (forces TZ=America/New_York). === PANEL P1.1 COMPLETE — HEAD=30a77ec, ready for finishing-a-development-branch === + +=== PANEL P1.2 (workspace 1f + settings 6a + organization) — branch panel/p1.2-workspace-settings === +Base: 4bdebd2 (plan commit). Plan: docs/superpowers/plans/2026-07-15-panel-p1.2-workspace-settings.md +Task 1: complete (commits 4bdebd2..0c362b6, review round1 2x Important fixes [status icon hardcoded muted-gray for all states -> STEP_STATUS_ICON lookup now pairs shape+color, done->text-success; amber unlock hint showed during loading (readiness undefined) since `!==true` -> gated to `readiness!==undefined && ready!==true`], round2 Approved). WorkspaceRail pure props-driven component (STEP_LABEL_KEYS exported from ReadinessCell.tsx with zero behavior change), 5-segment bar + fraction header reusing ReadinessCell math, Overview/Settings Links, 5 locked numbered step rows (not links), always-locked Check-in row, mt-auto amber hint. 116/116 tests, typecheck+lint clean. +Task 2: complete (commits 0c362b6..e79e23a, review clean [0 Critical/Important; Minor: redundant disabled+aria-disabled on launch button, board countdown-chip/breadcrumb gaps deferred, click() vs fireEvent style nit]). Router restructure: eventStubRoute -> eventWorkspaceRoute (layout, "/events/$eventId") + 2 children (index "/", "/settings" placeholders for Tasks 3-4) — /register beforeLoad + protectedBeforeLoad verified byte-for-byte untouched. EventWorkspaceLayout: header (name, UTC date pill via extracted eventDates.ts formatDateRange, launch button locked/padlock when ready!==true else opens coming-soon Dialog) + WorkspaceRail + Outlet. EventWorkspaceStub deleted, zero dangling refs. Cross-task fix: WorkspaceRail.tsx Overview Link needed activeOptions={{exact:true}} (fuzzy-match false-positive on /settings) — legitimate, Task 1 tests still pass. 119/119 tests, typecheck+lint+build clean. +Task 3: complete (pending review). New WorkspaceOverview.tsx mounted at the "/_app/events/$eventId/" index route, replacing the Task 2 placeholder (deleted, along with its now-unused workspaceComingSoon import in that file's Settings placeholder path left intact). Does its own independent data fetching (useEventReadiness, useEventStats(eventId) with no poll, $api zones query) rather than receiving props from EventWorkspaceLayout — confirmed via getRouteApi("/_app/events/$eventId").useParams() resolving fine from the child index route (same ancestor-route-id pattern as the layout itself, no circular import). "What's next" card: up to 2 not-done steps in FIXED pipeline order (attendees→badge→staff→equipment; zones excluded, verified with a deliberately-shuffled fixture), each a muted locked "Coming soon" chip (span, not a button) mirroring the rail's always-locked pattern; ready===true shows the all-ready message instead. 4 stat tiles (Attendees/Zones/Staff readiness counts, Checked-in from stats): independent per-tile Skeleton/em-dash("—", workspaceStatUnavailable)/real-value states — Zones tile's count comes from readiness (shares fate with Attendees/Staff) while its zone-names caption comes from the separate zones-list query and stays independent (fails without breaking Checked-in), matching the brief's per-tile-independence requirement. Zones-skipped shows "Optional — not used" and suppresses real zone names even when the (unused) zones query has data. No new/changed backend endpoints. 14 new i18n keys added to both en/ru (workspaceOverviewTitle/Subtitle, workspaceWhatsNext, workspaceNext{Attendees,Badge,Staff,Equipment}, workspaceStepComingSoon, workspaceAllReady, workspaceZonesNotUsed, workspaceStatUnavailable, workspaceStatCheckedIn — Attendees/Zones/Staff tile titles reuse existing readinessStep* keys per the brief). 8 new WorkspaceOverview tests + 127/127 full suite, typecheck+lint clean. +Task 3: complete (commits e79e23a..b0edd9b, review clean [0 Critical/Important; Minor: report/ledger said "14 keys" vs actual 12, workspaceLoadError reuse for readiness-fetch-failure is imprecise wording but untested edge case non-blocking, RU checked-in translation matches existing homeCheckedIn convention]). WorkspaceOverview: independent readiness/stats/zones queries; "What's next" card (fixed pipeline order attendees->badge->staff->equipment, zones never a candidate, max 2 rows, locked "Coming soon" chips not buttons; ready=true -> single all-ready message); 4-tile grid (Attendees/Zones/Staff/Checked-in) each with fully independent loading/error state (Skeleton/em-dash, never fabricated 0 -- verified against backend readiness.go non-nil Count fields). 127/127 tests, typecheck+lint clean. +Task 4: complete (commits e79e23a..e9bfc94, pending review). Ported useScrollSpy (web/src/hooks/useScrollSpy.ts -> panel/src/shared/hooks/, root:null instead of closest('main') since panel scrolls the window; self-contained MockIntersectionObserver ported into the test, setup.ts untouched). EventSettingsPage: 200px anchor rail (General/Fonts/API keys/Danger zone, reconciliation #5 narrows board 6a's 7-item rail) + 4
blocks, only General real (Fonts/API-keys/Danger-zone are Task 5-7 inline placeholders), danger rail link always text-destructive regardless of scroll position. GeneralCard: name/starts/ends/location, baseline-vs-form dirty tracking, scoped PATCH sends only changed fields (verified $api's actual queryKey shape [method,path,init] against openapi-react-query source, not hooks.ts's list-query precedent which omits init entirely); location clears via explicit "" (verified backend Location *string pointer semantics), dates cannot clear via PATCH (verified backend/openapi.yaml + events.go) so clearing a previously-set date disables Save + shows settingsDatesCannotClear instead of silently dropping it. Mutation error/success state resets on next edit (per-keystroke, not just per-submit). 1 new i18n key beyond the brief's list (settingsSaveError, for the server-error-surfaces test case); workspaceComingSoon removed as orphaned after WorkspaceSettingsPlaceholder deletion. 145/145 tests, typecheck+lint clean. +Task 4: review complete, Approved (0 Critical/Important; Minor: report test-count breakdown transcription slip, name-field dirty-tracking not trim-aware [harmless idempotent extra PATCH], General card layout intentionally diverges from board 6a per brief's Starts/Ends split). PATCH partial-update correctness independently re-verified against backend/internal/handler/events.go + openapi.yaml (Location *string clears via "", dates cannot clear via PATCH -- confirmed). Query-key invalidation shape independently re-verified against openapi-react-query source. HEAD=f56838e. +Task 5: complete (commits f56838e..c70b642, review clean [0 Critical/Important; Minor: delete-font mutation has no onError handler + confirm button not disabled during isPending -- silent failure on rare DELETE error, follow-up candidate for final review; jsdom/MSW multipart-body regex parser is file-local, extraction to shared test helper deferred until 2nd occurrence]). Multipart bodySerializer mechanism independently re-verified via installed openapi-fetch@0.17.0 source read + live jsdom probe: FormData body auto-skips Content-Type (coreFetch never sets it for FormData instances), dynamicBaseUrl middleware is FormData-safe (raw arrayBuffer clone, no re-serialization) -- no manual header manipulation needed. Real jsdom/undici bug found+worked around: request.formData() throws on File parts in this environment (webidl.is.File assertion), MSW test parses raw multipart body via regex instead. DELETE actually returns 200 {status} not 204 as brief claimed -- doesn't affect implementation (mutation only invalidates on any-2xx). FontsCard: list w/ UPLOADED pill + format/size/UTC-date caption, tier-1 destructive remove confirm, immediate upload from click-only dropzone (name/family derived from filename), always-visible warning-token license box. 152/152 tests, typecheck+lint clean. +Task 6: complete (commit c652d36, pending review). ApiKeysCard swapped in for the EventSettingsPage placeholder: table-ish list (grid-row rows, EventRow-style, not a ) of Name / masked key_preview (mono chip) / created / last-used (UTC-pinned dates, em-dash when unset) / actions; revoked rows dimmed (opacity-50) with a StatusPill status="error" "Revoked" label replacing the Revoke action. Revoke is tier-1 destructive ConfirmDialog -> DELETE -> list invalidate. Create dialog: optional name Input only (no expiry picker -- explicit YAGNI per brief, API still accepts expires_at) -> POST -> same Dialog swaps its content to a reveal state (mono plain_key block, Copy button via navigator.clipboard.writeText, warning caption, single Done button) instead of closing. DELETE /api/events/{event_id}/api-keys/{key_id} independently re-verified against schema.d.ts's `revokeApiKey` operation: returns 200 {message: string}, NOT 204 -- same pattern as Task 5's font-delete finding, mutation's onSuccess doesn't inspect the body so this doesn't affect correctness. Mutation-reset-on-close: a useEffect on the create dialog's open->closed transition clears name/plainKey/copied and calls createKey.reset(), covered by a dedicated test that opens->creates->reveals->closes-via-Done->reopens and asserts a fresh empty form (no stale plain_key, no stale error) -- the plain key is never retained in component state past the close. Test-infra finding: `userEvent.setup()` unconditionally calls testing-library's own `attachClipboardStubToView`, which redefines `navigator.clipboard` as a getter-only accessor with its OWN Clipboard stub *after* setup() runs -- so a `navigator.clipboard` mock installed in `beforeEach` (before `userEvent.setup()` is called inside each test) gets silently clobbered; fixed by installing the mock (via `Object.defineProperty`, not assignment, since the property has no setter) only after `userEvent.setup()` in the one test that needs it. 12 new ApiKeysCard tests + 159/159 full suite, typecheck+lint clean. +Task 6: complete (commits c70b642..2290b10, review round1 1x Critical fix [close-during-pending race: cancelling create dialog before POST resolved let mutation's onSuccess still fire and write a stray unlabeled plain_key into state, resurfacing on next dialog open -- fixed via createAbortedRef reset on every open, checked in onSuccess before setPlainKey; regression test with delayed MSW handler verified to fail pre-fix] + 1x Minor fix [revoke mutation had no onError/pending-guard -> inline i18n'd error (settingsKeyRevokeError) + confirmDisabled prop added to shared @idento/ui ConfirmDialog (additive, default-safe, verified other consumers unaffected)], round2 Approved). ApiKeysCard: list (Name/masked key_preview mono chip/created/last-used/actions, no Scope column, revoked-row dimmed+StatusPill error, UTC dates), tier-1 destructive revoke confirm, create dialog swaps in-place to reveal state (mono plain_key, clipboard copy, single Done), plain_key never persisted beyond transient state. DELETE independently reconfirmed 200 {message} not 204. 162/162 panel tests + 131/131 @idento/ui tests, typecheck+lint clean. +Task 7: complete (commits 2290b10..ba32324, review round1 2x Important fixes [cancel-during-pending race identical to Task 6's class -> deleteAbortedRef reset-on-open, checked in onSuccess/onError, all close paths (Cancel/Escape/overlay/X) route through handleDialogOpenChange while success path closes directly without flipping the ref; dialog auto-closed on failure forcing full retype of exact event name -> now stays open on failure, error rendered inside ConfirmDialog via dynamic description reusing settingsDeleteError, typed input survives for same-dialog retry, only closes on success or explicit user cancel], round2 Approved, fixes verified to compose correctly together). DangerZoneCard: destructive-bordered card, typed ConfirmDialog (event NAME, case-sensitive), DELETE (independently confirmed genuine 204) -> invalidate events list + navigate("/") on success. event prop threaded from EventSettingsPage (single fetch). 169/169 tests, typecheck+lint clean. +Task 8: complete (commits ba32324..adb081a, review clean [0 Critical/Important; Minor: Save button unmounted (not rendered-disabled) for non-admins -- inconsistent with fields' visible-but-disabled treatment, functionally satisfies brief but worth a UX glance; no explicit 200-char boundary test]). OrganizationPage: router.tsx surgical 2-line swap (regression guard verified untouched), role from same getTenant response (no extra query) gates all inputs disabled + notice, dirty-tracked scoped PUT (only changed fields, deliberate "" for cleared optional fields -- independently re-verified against backend/internal/handler/tenants.go pointer semantics), 403-despite-guard -> orgForbiddenError, null tenant -> homeLoadError only. No shared useDirtyFields extracted (reasoned: <2 call sites don't share cleanly). 178/178 tests, typecheck+lint+build clean. +Task 9: complete (commit pending, final verification + i18n sweep). Dead-key sweep: placeholderComingSoon still referenced (PlaceholderPage.tsx) -> kept; workspaceComingSoon already removed in Task 4 -> zero remaining refs confirmed, nothing further to remove. Hardcoded-string sweep across all 9 Tasks 1-8 files (WorkspaceRail/EventWorkspaceLayout/WorkspaceOverview/EventSettingsPage/GeneralCard/FontsCard/ApiKeysCard/DangerZoneCard/OrganizationPage): zero hardcoded user-facing strings found, every JSX text node routed through t(...); no fix needed. panel/AGENTS.md: added 1-line note documenting src/shared/hooks/ as the home for cross-cutting feature-agnostic hooks (useScrollSpy), since the file's feature-sliced-layout bullet previously only described app/shared/features and this is a genuinely new directory this phase introduced. Full verification suite all green: backend go test (288 passed, OPENAPI_COVERAGE=1), panel typecheck/test(178/178)/build/eslint all clean, @idento/ui test(131/131)+typecheck clean, schema.d.ts regenerated with zero diff (confirms zero backend drift, phase stayed frontend-only per Global Constraints), docker build --load succeeded. +=== PANEL P1.2 (workspace 1f + settings 6a + organization) ALL 9 TASKS COMPLETE — proceeding to final whole-branch review === +Task 9: complete (commits adb081a..e741546, review round1 1x Important fix [settingsKeyCreatedAt/settingsKeyLastUsed i18n keys added in Task 6 were dead -- ApiKeysCard's list had no header row at all -> added header row via shared KEY_ROW_GRID constant applied to both header and rows for real column alignment, empty cells for key-preview/actions columns], round2 Approved). Verification: backend 288 tests green (zero backend drift confirmed via schema.d.ts regen + diff), panel typecheck/test(179)/build/eslint green, @idento/ui 131 tests green, docker build succeeded. Hardcoded-string sweep across all 9 phase files: zero found. panel/AGENTS.md +2 lines documenting src/shared/hooks/ as new home for cross-cutting hooks (useScrollSpy). +=== PANEL P1.2 (workspace 1f + settings 6a + organization) ALL 9 TASKS COMPLETE — HEAD=e741546, proceeding to final whole-branch review === +Final whole-branch review (fable): 3 Important + 2 Minor fixed (commits 68e4cc4, 88c1ee0) -- OrganizationForm missing key={tenant.id} (stale form state survives an in-place tenant switch via OrgSwitcher, real cross-task integration gap between Task 8 and P0.2 shell), WorkspaceRail stale `as never` casts on Settings Link (route existed since Task 2, casts never removed, defeated compiler route-id check), FontsCard delete flow brought up to ApiKeysCard/DangerZoneCard's error-handling standard (onError + confirmDisabled + settingsFontRemoveError key), DangerZoneCard invalidateQueries reordered to run before the abort-ref check (abort guard must only suppress UI reactions, never cache correctness). 3 Minor findings explicitly deferred as acceptable (workspaceLoadError reuse for readiness-error wording, GeneralCard trim-unaware dirty tracking, Organization Save unmounted-vs-disabled for non-admins) -- reasoning: low value / no user-facing correctness impact. Abort-ref DRY question: duplication ruled acceptable at 2 instances, extract when P2 adds a 3rd (regenerate-codes). 181/181 tests, typecheck+lint+build clean. +Final whole-branch re-review (fable): all 5 targeted fixes verified genuine, zero new issues introduced, full suite green (181/181 panel, 131/131 @idento/ui, typecheck/lint/build clean, backend confirmed untouched). READY TO MERGE = YES. +=== PANEL P1.2 COMPLETE — HEAD=e8f9ca9, ready for finishing-a-development-branch === +PR #64 CodeRabbit review round: 10 actionable comments, 9 real (all fixed, commits 8c28ccc/81b88e9/e90d6a3/2f0b3a2/d5fe39a/c8f719c/49e49fc), 1 declined with reasoning (plan doc's npx eslint fallback is deliberate, not an oversight). Most significant: GeneralCard had a genuine Critical bug missed by all 10 prior internal review rounds -- patchEvent.reset() doesn't cancel the in-flight PATCH, so editing a field again while a save was pending let the stale response silently overwrite the newer unsaved edit; fixed with an edit-version guard. Also: ApiKeysCard/DangerZoneCard's abort-ref fix from the internal review rounds had a second-order gap (boolean reset-on-reopen doesn't survive a SECOND cancel-then-reopen cycle) -- upgraded both to session-id refs. FontsCard's license notice was decorative text, not an actual consent gate -- now a real checkbox. Plus: clipboard-write-failure-as-success (ApiKeysCard), silent Promise-truthy test assertion (WorkspaceOverview.test.tsx), unbounded rAF polling (useScrollSpy.ts, capped). 187/187 tests (up from 181), typecheck/build/eslint clean. All 10 GraphQL review threads replied to and resolved. diff --git a/docs/superpowers/plans/2026-07-15-panel-p1.2-workspace-settings.md b/docs/superpowers/plans/2026-07-15-panel-p1.2-workspace-settings.md new file mode 100644 index 00000000..6e6c2e88 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-panel-p1.2-workspace-settings.md @@ -0,0 +1,295 @@ +# P1.2 — Event workspace 1f + Event Settings 6a + Organization — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the event workspace (board 1f: readiness-pipeline rail + Overview "what's next"), Event Settings (board 6a: anchor-rail cards with scoped saves — General/Fonts/API keys/Danger zone), and the Organization screen — replacing the P1.1 workspace stub and the Organization placeholder. + +**Architecture:** Frontend-only phase — every consumed endpoint already exists and is contract-tested (P0.3/P1.1); zero backend changes expected. The flat `/events/$eventId` stub route becomes a layout route (workspace header + rail + `Outlet`) with two children: index = Overview, `/settings` = Settings. Components build bottom-up: pure `WorkspaceRail` first, then layout+routing, then Overview, then the four settings cards (each its own task/gate), then Organization. + +**Tech Stack:** React 19, TanStack Router (layout routes, `getRouteApi`), `$api` (openapi-react-query), MSW tests, `@idento/ui`. One ported hook: `useScrollSpy` from `web/src/hooks/useScrollSpy.ts` (the board's 6a option label explicitly says "Anchor rail (`useScrollSpy` from the console)"). + +**Spec:** `docs/superpowers/specs/2026-07-14-panel-p1-events-workspace-design.md` §5 (workspace), §6 (settings + organization), §7 (testing). +**Board extract (implementers of Tasks 1–7 MUST read it — exact layout, copy, and token mappings):** `.superpowers/sdd/p1.2-board-1f-6a-extract.md` + +## Global Constraints + +- Branch: `panel/p1.2-workspace-settings` (exists, from `main@88b9f73`; plan committed on it). +- **Zero new/changed backend endpoints.** If a task seems to need one, STOP and escalate — that's a plan error, not something to improvise (the openapi-first pipeline would be required, and nothing in this scope should trigger it). +- All data hooks through `$api` (`panel/src/shared/api/query.ts`); never raw `fetch`, never bare `api.GET` in features. New tests use `startMswServer` (`panel/src/test/msw.ts`); pre-P1.1 hand-stubbed-fetch tests are legacy — don't migrate them. +- Typed route params/hooks via `getRouteApi("/_app/…")` — NEVER import route objects from `router.tsx` into components (caused a circular import in P1.1, fixed once already). +- **router.tsx regression guard:** never touch `/register`'s `beforeLoad` or `protectedBeforeLoad` while editing that file (real P0.2 incident; every review re-verifies byte-for-byte). +- **Date-only semantics (established across three P1.1 fix rounds — keep exactly consistent):** date-only values are UTC-midnight ISO; `isDateOnly()` from `panel/src/features/events/eventTiming.ts` tells them apart from real timestamps; date DISPLAY pins `Intl.DateTimeFormat(..., {timeZone:"UTC"})`; `` values convert via `new Date(value).toISOString()` (never `+"T00:00:00"`); empty fields are OMITTED from request bodies, not sent as `""`. +- i18n: every user-facing string in BOTH `panel/src/shared/i18n/en.json` and `ru.json` (keyParity enforces). zod validators get message KEYS — a bare `.max(n)` leaks raw English (real P1.1 finding). +- No fabricated data in UI states: loading → `Skeleton`, error → i18n'd message (+ retry where sensible); never `0 / 0`-style placeholders (real P1.1 finding). Reset mutations on dialog close (`mutation.reset()`) or stale errors reappear (real P1.1 finding). +- WCAG 1.4.1: status = icon + text + color, never icon/color alone (real P1.1 finding). +- UI primitives only from `@idento/ui`; token classes only, no raw hex (the extract's hexes map to tokens: `#00935e`→`success`, `#d97706`/`#fdf2df`/`#b45309`→`warning` family, `#dc2626`/`#b91c1c`/`#f5cfcf`→`destructive` family, greys→`muted`/`border`/`muted-foreground`). +- Verification before every commit: `npm run typecheck -w panel && npm test -w panel` + `cd panel && npx eslint .` (plain `npm run lint -w panel` may garble output — local proxy quirk, use npx). +- Commit after every green step. + +## Verified codebase facts (do not re-derive) + +- **Router (`panel/src/app/router.tsx`):** `eventStubRoute` is a flat route (`path: "/events/$eventId"`, component `EventWorkspaceStub`) child of `protectedLayoutRoute` (`id: "_app"`). `organizationRoute` renders ``. Both get replaced here. +- **AppShell** (`panel/src/app/shell/AppShell.tsx`): global header (app name, OrgSwitcher, nav tabs, switchers) + `
{children}
`. The page scrolls via the WINDOW, not `
` — relevant for the scroll-spy port. +- **Existing hooks** (`panel/src/features/events/hooks.ts`): `useEventsQuery()`, `useEventStats(eventId, {poll?})`, `useEventReadiness(eventId)`, `useCreateEvent()`. +- **Readiness shape:** `{ready: boolean, steps: [{key: "attendees"|"badge"|"zones"|"staff"|"equipment", status: "done"|"not_done"|"skipped", count?: number}]}` — fixed pipeline order. NO "in-progress/draft" status exists (board's amber "draft" chip has no data source until P3). +- **`STEP_LABEL_KEYS`** is a module-private const in `panel/src/features/home/ReadinessCell.tsx` mapping step keys → i18n keys (`readinessStepAttendees` etc.) — Task 1 exports it for the rail. +- **Fonts endpoints** (generated, `panel/src/shared/api/schema.d.ts`): `GET /api/events/{event_id}/fonts` → `FontListItem[]` (`{id, name, family, weight, style, format: "woff2"|"woff"|"truetype"|"opentype", size, created_at}` — NO Cyrillic-coverage or built-in-flag fields); `POST` same path is **multipart/form-data** `{file (binary), name, family, weight?, style?, license_accepted}` where `license_accepted` MUST be the literal string `"true"` (400 otherwise); `DELETE /api/events/{event_id}/fonts/{font_id}` → 204. +- **API-keys endpoints:** `GET /api/events/{event_id}/api-keys` → `APIKey[]` (`{id, event_id, name, key_preview, expires_at?, last_used_at?, revoked_at?, created_at}` — NO scope field; secret never returned); `POST` same path body `{name?, expires_at?}` → 201 `CreateAPIKeyResponse {api_key, plain_key}` — **`plain_key` is returned exactly once, unrecoverable afterward**; `DELETE /api/events/{event_id}/api-keys/{key_id}` → revoke. +- **Event ops:** `PATCH /api/events/{id}` (partial: only provided fields change; `custom_fields` structurally immune); `DELETE /api/events/{id}` → 204 soft-delete; `GET /api/events/{event_id}/zones` → `EventZone[]` (has `name` — used for the Overview zones tile caption). +- **Tenant ops:** `GET /api/tenants/{id}` → `TenantMembership` `{id, name, settings|null, logo_url|null, website|null, contact_email|null, created_at, updated_at, role}` — **includes the caller's `role`**; `PUT /api/tenants/{id}` is pointer-based PARTIAL update despite being PUT (verified in `backend/internal/handler/tenants.go:123-129`: nil = leave unchanged) and **admin-only** (403 `"Admin access required"` for any other role). Current tenant id: `getCurrentTenant()` from `panel/src/shared/api/session.ts` (`{id, name} | null`). +- **`ConfirmDialog`** (`packages/ui`): props `{open, onOpenChange, title, description, confirmLabel, cancelLabel, closeLabel, onConfirm, destructive?}` + typed tier `{typedConfirmation: string, typedConfirmationLabel: string}` (confirm disabled until input === typedConfirmation; input auto-clears on close). Built for exactly the danger zone. +- **`useScrollSpy`** exists at `web/src/hooks/useScrollSpy.ts` (+ test with a `MockIntersectionObserver` at `web/src/hooks/__tests__/useScrollSpy.test.ts`) — port, don't reinvent. Its root resolution (`closest('main')`) assumes web/'s scrolling `
`; the panel scrolls the window, so the ported version must use `root: null` when the nearest `main` isn't a scroll container (simplest correct adaptation: just use `root: null` — document why). jsdom has no IntersectionObserver; the ported test carries its own mock (panel `setup.ts` stays untouched). +- **Multipart upload via openapi-fetch:** the shared `api` client JSON-stringifies bodies by default. For the fonts upload, pass a per-call `bodySerializer` that builds a `FormData` — verify the exact mechanics against the INSTALLED `node_modules/openapi-fetch/src/index.js` (how it treats a FormData body and whether Content-Type must be unset so the browser sets the multipart boundary) before writing the hook; do not trust this plan's sketch blindly. +- **`getRouteApi` ids after the Task 2 restructure:** parent layout `"/_app/events/$eventId"`, index child `"/_app/events/$eventId/"`, settings child `"/_app/events/$eventId/settings"` — TypeScript will reject a wrong id string against the registered route tree, use the compiler to confirm. + +## Board-reconciliation decisions (already made — implementers follow these, not the board verbatim) + +1. **Launch button placement:** the board puts "Launch check-in" in a per-event top bar (breadcrumb row) that REPLACES the global nav tabs inside an event. We are NOT restructuring AppShell in P1.2 — the workspace layout gets its own header row (event name + date pill + launch button) below the global shell header. The rail-bottom amber helper notice stays per the board. (Shell breadcrumb unification can be revisited at P5 polish.) +2. **No amber "in progress" chip state** — readiness has `done`/`not_done`/`skipped` only. Rail chips: done → green check; not_done → grey dashed circle; zones `skipped` → grey dashed + muted "optional" suffix. The board's amber "draft" badge state arrives with P3's real draft detection. +3. **"What's next" rows** derive from readiness: for each required step with `status === "not_done"` (order: badge, attendees, staff — actually pipeline order: attendees, badge, staff) plus equipment (always not_done), show up to TWO rows (board shows exactly two). Every row's target screen is a future phase (Attendees P2, Badge P3, Staff P2, Equipment P4), so rows carry a muted "coming soon"-style locked chip instead of an active CTA — honest, no dead buttons. +4. **Stat tiles:** Attendees (readiness count), Zones (readiness count; caption = up to 2 zone names from `GET .../zones`, or the "optional" note when skipped), Staff (readiness count), **Checked in** (`checked_in / total_attendees` from stats) — the board's "Badges printed" tile has no P1 data source; Checked-in is the honest substitute. +5. **Settings sections = exactly the spec's four:** General, Fonts, API keys, Danger zone. Board's Check-in card (P4 station settings, no endpoint), Badge printing (P3), Attendee fields (P2), and the danger zone's "Regenerate all attendee codes" row (P2 attendees scope) are all deferred — the anchor rail lists only the four real sections. +6. **Fonts card:** only uploaded fonts (API returns nothing else — no built-in rows, no Cyrillic-coverage metadata until P3). Metadata line = `format · size · created date`. License disclaimer box always visible; upload sends `license_accepted: "true"` (the disclaimer's own wording — "By uploading, you confirm…" — is the consent, matching the board). +7. **API keys:** no Scope column (no field). The show-once reveal (board doesn't depict it) is a Dialog: mono `plain_key`, copy button, caption "won't be shown again". Revoke uses tier-1 ConfirmDialog (plain, destructive) — the board's typed-confirm list (1b) covers delete-event/bulk/regenerate/delete-zone, not key revoke. +8. **Danger-zone typed confirmation = the event NAME** (spec's decision; the board's slug doesn't exist in the model). +9. **General card = name + Starts + Ends + Location** (two date inputs like the create dialog; the board's single "Date" field doesn't fit the real start/end model). Explicit "Save" button (the board shows one only on General — consistent with our scoped-save spec for a text-field card). +10. **Organization screen is not on the board** — minimal single-card form styled like 6a's General card: name, website, contact email, logo URL (text input; no upload endpoint). Non-admin (`role !== "admin"` from `getTenant`) sees the form read-only (disabled inputs) + an i18n'd notice; 403 on save still surfaces an error message (belt and braces). + +--- + +### Task 1: WorkspaceRail component (pure, props-driven) + STEP_LABEL_KEYS export + +**Files:** +- Modify: `panel/src/features/home/ReadinessCell.tsx` (export `STEP_LABEL_KEYS`, no behavior change) +- Create: `panel/src/features/workspace/WorkspaceRail.tsx` +- Create: `panel/src/features/workspace/WorkspaceRail.test.tsx` +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `EventReadinessResponse` type (`components["schemas"]["EventReadinessResponse"]`), `STEP_LABEL_KEYS` (newly exported), `Skeleton`/`Separator` from `@idento/ui`, `Link` from TanStack Router. +- Produces: `WorkspaceRailProps = { eventId: string; readiness: EventReadinessResponse | undefined; active: "overview" | "settings" }`; `` — Task 2 mounts it in the layout. + +Read the board extract §2–3 first. Structure (top→bottom): progress header (`workspaceReadyFraction` = reuse `homeReadyFraction` key with the same done/total math as ReadinessCell: done = steps with status `done`, total = steps with status !== `skipped`; plus muted `workspaceZonesOptional` suffix when zones is skipped) + 5-segment bar (reuse the segment rendering approach from ReadinessCell — done→`bg-success`, else `bg-muted`); "Overview" item (unnumbered, `Link` to the workspace index, active state `bg-success/10 text-success` when `active === "overview"`); five numbered step rows (NOT links — locked until their phases; each: `{n} · {t(STEP_LABEL_KEYS[key])}`, status icon per reconciliation #2 with an `sr-only`/visible text state label reusing `readinessStatusDone`/`readinessStatusNotDone`/`readinessSkipped` — WCAG rule, icon never alone; trailing mono `count` when present; zones gets muted `workspaceOptionalSuffix` text when skipped); `Separator`; "Check-in" locked row (padlock icon + `workspaceCheckinLocked` "locked" trailing label, `text-muted-foreground`, not a link — ALWAYS locked-looking in the rail regardless of `ready`; the actionable unlock lives on the header button, Task 2); "Settings" `Link` (active when `active === "settings"`); `mt-auto` amber helper notice (`bg-warning/10 border border-warning/30 text-warning` rounded box) with `workspaceUnlockHint` ("Finish the badge and run a test print to unlock check-in.") shown ONLY while `readiness?.ready !== true`. Loading (`readiness === undefined`) → Skeleton rows. + +- [ ] **Step 1:** Export `STEP_LABEL_KEYS` from `ReadinessCell.tsx` (`export const STEP_LABEL_KEYS ...`), run the home tests to confirm zero behavior change: `cd panel && npx vitest run src/features/home/`. +- [ ] **Step 2: Failing tests** — `WorkspaceRail.test.tsx` (MSW not needed — pure props; router harness needed for the two `Link`s, reuse `LoginScreen.test.tsx`'s `RouterContextProvider` pattern; note the links target `/events/$eventId` + `/events/$eventId/settings` which don't exist in the minimal harness router — mock at the type level the same way `LiveStrip.test.tsx` handles its links, or build the harness with a matching route shape like `ProtectedLayout.test.tsx` does; read both before choosing). Cases: (a) full readiness → "4 of 5 ready" fraction, per-step icons+text labels, counts rendered, zones "optional" suffix when skipped; (b) `ready: false` → unlock hint visible; `ready: true` → hint absent; (c) `undefined` readiness → skeletons, no step rows; (d) active section highlight switches between overview/settings; (e) step rows are NOT links (assert no `role="link"` on them), Check-in row is not a link and shows the locked text. +- [ ] **Step 3:** Verify failure, implement, verify green. +- [ ] **Step 4: i18n keys** (EN shown, RU in the same commit): `workspaceZonesOptional` ("Zones optional"), `workspaceOptionalSuffix` ("optional"), `workspaceCheckin` ("Check-in"), `workspaceCheckinLocked` ("locked"), `workspaceSettings` ("Settings"), `workspaceOverview` ("Overview"), `workspaceUnlockHint` ("Finish the badge and run a test print to unlock check-in."). +- [ ] **Step 5:** Full panel suite + typecheck + eslint green. Commit: `feat(panel): workspace readiness rail` + +--- + +### Task 2: Workspace layout route restructure + header + launch button + +**Files:** +- Modify: `panel/src/app/router.tsx` (targeted: replace `eventStubRoute` with a layout + 2 children; do NOT touch anything else) +- Create: `panel/src/features/workspace/EventWorkspaceLayout.tsx` +- Create: `panel/src/features/workspace/EventWorkspaceLayout.test.tsx` +- Delete: `panel/src/features/events/EventWorkspaceStub.tsx`, `EventWorkspaceStub.test.tsx` +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `WorkspaceRail` (Task 1), `useEventReadiness`, `$api` event fetch, `getRouteApi`, `isDateOnly`+the UTC date-format idiom (copy the exact `Intl.DateTimeFormat(..., {timeZone:"UTC"})` pattern from `EventRow.tsx`), `Dialog`/`Button` from `@idento/ui`. +- Produces: routes — layout `"/_app/events/$eventId"` (component `EventWorkspaceLayout`: header row + flex `[rail | ]`), index child `"/_app/events/$eventId/"` (Task 3's Overview — until Task 3 lands, a minimal inline `WorkspaceOverviewPlaceholder` in the layout file marked for Task 3 to replace), settings child `"/_app/events/$eventId/settings"` (Task 4's page — until then, a minimal inline placeholder marked for Task 4). Deriving `active` for the rail: from `useRouterState`/matched route (verify the idiomatic TanStack way — e.g. `useMatchRoute()` or comparing `location.pathname.endsWith("/settings")`; pick what typechecks cleanly). + +Router restructure (in `router.tsx` — replace `eventStubRoute` and its `addChildren` entry): + +```tsx +const eventWorkspaceRoute = createRoute({ + getParentRoute: () => protectedLayoutRoute, + path: "/events/$eventId", + component: EventWorkspaceLayout, +}); +const eventOverviewRoute = createRoute({ + getParentRoute: () => eventWorkspaceRoute, + path: "/", + component: WorkspaceOverviewPlaceholder, // Task 3 swaps to WorkspaceOverview +}); +const eventSettingsRoute = createRoute({ + getParentRoute: () => eventWorkspaceRoute, + path: "/settings", + component: WorkspaceSettingsPlaceholder, // Task 4 swaps to EventSettingsPage +}); +// addChildren: eventWorkspaceRoute.addChildren([eventOverviewRoute, eventSettingsRoute]) +``` + +**Critical regression guard (P0.2 lesson, enforced in every P1.1 review): the `/register` `beforeLoad` and `protectedBeforeLoad` must remain byte-for-byte untouched.** + +`EventWorkspaceLayout`: `getRouteApi("/_app/events/$eventId")` → `useParams()`; fetch event via `$api.useQuery("get", "/api/events/{id}", ...)` + `useEventReadiness(eventId)`. Header row: event name (`text-page-title`), date pill (UTC-pinned date range — reuse the formatting approach from `LiveStrip.tsx`'s `formatDateRange`, extracted or re-derived; if extracting, move it to `panel/src/features/events/eventDates.ts` and update LiveStrip's import — small, safe refactor), and the launch button: `readiness?.ready !== true` → disabled `Button` (`variant="outline"`, padlock icon from lucide, `workspaceLaunchCheckin` label) — disabled buttons still need their state discoverable: add visible/`sr-only` text or `aria-disabled` semantics per WCAG; `ready === true` → enabled primary Button that opens a Dialog (`workspaceLaunchComingSoonTitle`/`Body` — the real 3-step ceremony is P4). Below: `flex` row — `` (`w-[236px] shrink-0 border-r bg-card`) + `
`. Event-fetch loading → skeleton header; error → `workspaceLoadError` (existing key) + back-Home link (preserve the stub's behavior). + +- [ ] **Step 1: Failing tests** — layout renders event name + rail + outlet content for a mocked event/readiness (MSW; router harness mirroring the real `_app` + workspace + index shape — read `ProtectedLayout.test.tsx` and the deleted stub's test for the established harness pattern); locked button disabled when `ready:false`; enabled button opens the coming-soon dialog when `ready:true`; fetch error → error text + home link. +- [ ] **Step 2:** Implement (router edit + layout + delete stub files + adjust any test referencing the stub). `getRouteApi("/_app/events/$eventId")` replaces the stub's usage — the id string is compiler-checked. +- [ ] **Step 3: i18n** — `workspaceLaunchCheckin` ("Launch check-in"), `workspaceLaunchComingSoonTitle` ("Launch ceremony arrives with the check-in station"), `workspaceLaunchComingSoonBody` ("Confirming the event, loading station settings and the printer check land in a later update."), `workspaceDialogClose` ("Close"). Remove `workspaceComingSoon` if now unreferenced (grep first). +- [ ] **Step 4:** Full suite + typecheck + eslint + `npm run build -w panel` green. Commit: `feat(panel): event workspace layout, rail wiring, launch gate` + +--- + +### Task 3: Overview panel — "What's next" + stat tiles + +**Files:** +- Create: `panel/src/features/workspace/WorkspaceOverview.tsx`, `WorkspaceOverview.test.tsx` +- Modify: `panel/src/features/workspace/EventWorkspaceLayout.tsx` (swap placeholder → real component in the route def; the placeholder function is deleted) +- Modify: `panel/src/app/router.tsx` ONLY if the placeholder lived there (it lives in the layout file per Task 2 — then router.tsx is untouched) +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useEventReadiness`, `useEventStats`, `$api.useQuery("get", "/api/events/{event_id}/zones", ...)`, `getRouteApi("/_app/events/$eventId/")` (or reuse the parent id — params inherit; verify which id typechecks for the index route), `STEP_LABEL_KEYS`, `Card`/`Skeleton` from `@idento/ui`. +- Produces: `` mounted at the workspace index route. + +Read board extract §4. Composition: H2 `workspaceOverviewTitle` ("Overview") + subtitle `workspaceOverviewSubtitle` ("Everything your team needs before doors open."); "What's next" card (`workspaceWhatsNext`): up to TWO rows for not-done steps in pipeline order (attendees→badge→staff→equipment; skip `zones` — never required), each row: status icon + step label (`STEP_LABEL_KEYS`) + one-line description (`workspaceNextAttendees` "Import your attendee list to start printing badges." / `workspaceNextBadge` "Design the badge template in the editor." / `workspaceNextStaff` "Assign staff and print their QR login cards." / `workspaceNextEquipment` "Connect the venue printer and run a test print.") + a muted locked chip `workspaceStepComingSoon` ("Coming soon") — NOT an active button (reconciliation #3). `ready === true` → the card shows `workspaceAllReady` ("Everything's ready — launch check-in from the header when doors open.") instead of rows. Below: 4 stat tiles (grid `md:grid-cols-4`): Attendees (readiness attendees count, mono big number), Zones (count; caption = first 2 zone names joined " · " from the zones query, or `workspaceZonesNotUsed` "Optional — not used" when skipped), Staff (count), Checked in (`checked_in` big + `/ total` caption from `useEventStats(eventId)`, no poll). Loading → Skeleton tiles; per-query errors → the tile renders `workspaceStatUnavailable` ("—") with the error-styled caption, NOT fabricated zeros (P1.1 rule). + +- [ ] **Step 1: Failing tests** (MSW): (a) two not-done steps → exactly those two rows in pipeline order with locked chips, no buttons; (b) all-ready → `workspaceAllReady`, no rows; (c) tiles render real counts + zone names; (d) zones skipped → "Optional — not used" caption; (e) stats error → em-dash tile, no "0 / 0". +- [ ] **Step 2:** Implement; swap the route component; delete the placeholder. +- [ ] **Step 3: i18n** — keys above + `workspaceStatAttendees`/`Zones`/`Staff`/`CheckedIn` tile titles (reuse `readinessStep*` where identical — attendees/zones/staff labels already exist; only CheckedIn tile title is new: `workspaceStatCheckedIn` "Checked in" — check for collision with `homeCheckedIn` ("checked in") and reuse it if the casing works, else add the new key). +- [ ] **Step 4:** Full suite + typecheck + eslint green. Commit: `feat(panel): workspace overview — what's next + stat tiles` + +--- + +### Task 4: Settings page scaffold — anchor rail + useScrollSpy port + General card + +**Files:** +- Create: `panel/src/shared/hooks/useScrollSpy.ts` (ported), `panel/src/shared/hooks/useScrollSpy.test.ts` (ported, self-contained IO mock) +- Create: `panel/src/features/workspace/settings/EventSettingsPage.tsx`, `EventSettingsPage.test.tsx` +- Create: `panel/src/features/workspace/settings/GeneralCard.tsx`, `GeneralCard.test.tsx` +- Modify: `panel/src/features/workspace/EventWorkspaceLayout.tsx` (swap settings placeholder → `EventSettingsPage`) +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `web/src/hooks/useScrollSpy.ts` as the porting source (adapt root: panel scrolls the WINDOW — use `root: null`; keep the rAF-retry-until-sections-exist logic and the `sectionIds.join(',')` dep idiom; port the test's `MockIntersectionObserver` into the new test file, panel `setup.ts` untouched); `$api.useMutation("patch", "/api/events/{id}", ...)`, event data via `$api.useQuery("get", "/api/events/{id}", ...)`, `zod`, date-conversion idiom from `CreateEventDialog.tsx` (read it — same `new Date(value).toISOString()` + omit-empty pattern, plus the mutation-reset-on-close analog: here, reset server-error display when the user edits again). +- Produces: `EventSettingsPage` — anchor rail (`useScrollSpy(["settings-general","settings-fonts","settings-api-keys","settings-danger"])`) + stacked card sections, each `
`; `SettingsCardProps`-style slots for Tasks 5–7: the page renders `` (this task) + ``/``/`` (minimal inline placeholders that Tasks 5–7 replace one-by-one). + +Settings page layout per extract §7: left anchor rail (`w-[200px] shrink-0`, no card chrome, item list with active pill `bg-success/10 text-success`, danger item ALWAYS `text-destructive`), anchor links `href="#settings-..."`; content column `flex-1 max-w-3xl flex flex-col gap-4`. Rail items: `settingsGeneral` ("General"), `settingsFonts` ("Fonts"), `settingsApiKeys` ("API keys"), `settingsDanger` ("Danger zone") — only the four real sections (reconciliation #5). + +`GeneralCard`: fields name (required), Starts, Ends (`type="date"` — display existing dates via the UTC-safe `iso.slice(0, 10)` when `isDateOnly(iso)`, else the date part of the real timestamp; be precise: `new Date(iso).toISOString().slice(0,10)` is the UTC calendar date for both cases — use that), Location; zod schema mirroring `CreateEventDialog`'s (same message keys `createEventNameRequired`/`createEventNameTooLong`/`createEventDatesOrder`/`createEventLocationTooLong` — reuse, don't duplicate); dirty-tracking (save button disabled until a field differs from the loaded event); submit → `PATCH` with ONLY the changed fields (scoped save — the whole point of PATCH; changed dates convert via `new Date(value).toISOString()`, cleared-to-empty optional fields: location can be sent as `""`? NO — PATCH's contract: absent = unchanged; sending `location: ""` clears it, which IS the user's intent when they empty the field — verify against the PATCH openapi description (`*string` pointer: present-empty-string sets empty) and send `""` deliberately for location-clearing; DATES cannot be cleared via PATCH (nil = skip, documented P1.1 limitation) — if the user clears a date input, disable save for that transition with a muted note `settingsDatesCannotClear` ("Clearing a date isn't supported yet.") rather than silently ignoring). Success → toast-less inline `settingsSaved` ("Saved") caption that fades (simple state + `setTimeout`; keep it minimal) + invalidate `["get", "/api/events/{id}", ...]` (match the exact query-key shape `$api` generates — `[method, path, init]`, verify against `hooks.ts`'s invalidation precedent which used `["get", "/api/events"]`). + +- [ ] **Step 1:** Port `useScrollSpy` + its test; run it: `cd panel && npx vitest run src/shared/hooks/useScrollSpy.test.ts`. +- [ ] **Step 2: Failing tests** — page: renders 4 anchor sections + rail links, danger link has destructive styling; GeneralCard: loads event values; save disabled until dirty; valid edit PATCHes only changed fields (MSW body assertion); validation errors via existing i18n keys; date-clear transition disables save + shows the note; server error surfaces + clears on next edit. +- [ ] **Step 3:** Implement; swap the settings route component. +- [ ] **Step 4: i18n** — `settingsTitle` ("Settings"), `settingsGeneral`, `settingsFonts`, `settingsApiKeys`, `settingsDanger` ("Danger zone"), `settingsSave` ("Save"), `settingsSaved` ("Saved"), `settingsDatesCannotClear`, `settingsLoadError` ("Couldn't load settings.") — reuse `createEvent*` field labels? No: settings context differs — reuse `createEventNameLabel`/`StartLabel`/`EndLabel`/`LocationLabel` directly (they're neutral: "Event name"/"Starts"/"Ends"/"Location"). RU for all new keys. +- [ ] **Step 5:** Full suite + typecheck + eslint green. Commit: `feat(panel): event settings scaffold, scroll-spy anchor rail, general card` + +--- + +### Task 5: Fonts card + +**Files:** +- Create: `panel/src/features/workspace/settings/FontsCard.tsx`, `FontsCard.test.tsx` +- Modify: `panel/src/features/workspace/settings/EventSettingsPage.tsx` (swap placeholder) +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `$api.useQuery("get", "/api/events/{event_id}/fonts", ...)`, `$api.useMutation("post", "/api/events/{event_id}/fonts", ...)` with a **multipart bodySerializer** (see below), `$api.useMutation("delete", "/api/events/{event_id}/fonts/{font_id}", ...)`, `ConfirmDialog` (tier-1 destructive for Remove), `FontListItem` type. +- Produces: ``. + +**Multipart upload — the one technical risk of this task.** The shared client JSON-stringifies by default. Per-call override sketch (VERIFY against installed `node_modules/openapi-fetch/src/index.js` — how `bodySerializer` output is used and whether a FormData body auto-drops the Content-Type header so the browser can set the boundary; if it doesn't auto-drop, pass `headers: {"Content-Type": null}` per openapi-fetch's documented null-header-removal convention — confirm which is needed by reading the source, then encode the finding in a comment): + +```ts +uploadFont.mutate({ + params: { path: { event_id: eventId } }, + body: { file, name, family, license_accepted: "true" }, + bodySerializer: (body) => { + const fd = new FormData(); + fd.append("file", body.file as unknown as File); + fd.append("name", body.name); + fd.append("family", body.family); + fd.append("license_accepted", "true"); + return fd; + }, +}); +``` + +UI per extract §8/reconciliation #6: list of uploaded fonts (name, `UPLOADED` pill — `StatusPill status="ready"` with `settingsFontUploaded` label; metadata caption `format · {size} · {UTC date}` — humanize size KB/MB), red `Remove…` per row → tier-1 destructive ConfirmDialog (`settingsFontRemoveTitle`/`Body` with the font name interpolated) → DELETE + invalidate list. Empty list → EmptyState-lite caption `settingsFontsEmpty` ("No custom fonts yet — the badge editor uses the built-in font until you add one."). Upload affordance: a file input (`accept=".ttf,.otf,.woff,.woff2"`) styled as the dashed drop-zone + `settingsFontBrowse` ("Browse…") — actual drag-drop wiring optional (input-click is enough for P1.2; if adding drop handlers is trivial, fine, but don't build a dropzone library). On file pick: derive `name` from the filename (strip extension), `family` likewise (simplest honest default; the P3 editor will manage families properly) — then upload immediately. Show upload progress state (`isPending` → disabled zone + `settingsFontUploading` "Uploading…"), server 400 (bad extension/size) → the API's message via the standard error surface (`settingsFontUploadError` "Couldn't upload the font." — the ApiError message may be technical; show the i18n'd generic + keep it simple). License disclaimer box (amber, `bg-warning/10 border-warning/30 text-warning`): `settingsFontLicense` ("You are fully responsible for font licensing. By uploading, you confirm you hold the rights to use this font for printing badges."). Loading → Skeleton rows. + +- [ ] **Step 1: Failing tests** (MSW): list renders fonts with metadata; remove flow (confirm dialog → DELETE called → list invalidated/refetched via handler-hit assertion); upload posts multipart FormData (MSW: assert `await request.formData()` contains file/name/family/license_accepted="true"); empty state; upload error surfaces. +- [ ] **Step 2:** Implement (verify the bodySerializer mechanics against the installed source FIRST — the report must state what was found). +- [ ] **Step 3: i18n** — keys above + `settingsFontsSubtitle` ("Custom fonts for badge templates — available in the editor's font picker for this event."), `settingsFontRemove` ("Remove…"), `settingsFontRemoveTitle` ("Remove font"), `settingsFontRemoveBody` ("\"{{name}}\" disappears from the editor's font picker. Badges already printed are unaffected."), `settingsFontRemoveConfirm` ("Remove"), `settingsFontDropHint` ("Drop a .ttf / .otf / .woff file here or"), cancel/close reuse `createEventCancel`/existing keys where sensible. RU for all. +- [ ] **Step 4:** Full suite + typecheck + eslint green. Commit: `feat(panel): settings fonts card — list, multipart upload, remove` + +--- + +### Task 6: API keys card + +**Files:** +- Create: `panel/src/features/workspace/settings/ApiKeysCard.tsx`, `ApiKeysCard.test.tsx` +- Modify: `panel/src/features/workspace/settings/EventSettingsPage.tsx` (swap placeholder) +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `$api` get/post/delete for api-keys, `Dialog`/`Button`/`Input`/`Label`/`ConfirmDialog` from `@idento/ui`, `APIKey`/`CreateAPIKeyResponse` types. +- Produces: ``. + +Per extract §8/reconciliation #7: table-ish list (Name / masked key / created / last-used / actions — drop Scope; columns via the same grid-row approach as Home's EventRow, not a `
` unless it reads cleaner — implementer's call, keep it accessible either way): `key_preview` in a mono chip + copy button (copies the PREVIEW — caption makes clear the full key is gone; actually copying a preview is useless — instead NO copy button on rows, the mono chip alone; copy exists only in the create-reveal dialog where the real key is present); `revoked_at` set → row dimmed + `settingsKeyRevoked` ("Revoked") pill (StatusPill `error`), revoke action hidden; `expires_at`/`last_used_at` rendered as UTC-pinned dates or `—`. Row action `settingsKeyRevoke` ("Revoke…") → tier-1 destructive ConfirmDialog (`settingsKeyRevokeTitle`/`Body` — "Integrations using this key stop working immediately.") → DELETE + invalidate. Below: `settingsKeyCreate` ("+ Create key") button + caption `settingsKeyShownOnce` ("The full key is shown once at creation — store it safely. Revoking is immediate."). Create flow: Dialog with optional name Input (+ skip expiry in P1.2 — the API accepts `expires_at` but the board doesn't show an expiry picker and YAGNI; note it as deferred) → POST → on 201 swap the dialog content to the REVEAL state: mono `plain_key` block, copy Button (`navigator.clipboard.writeText`; jsdom lacks clipboard — mock in test), `settingsKeyRevealWarning` ("This is the only time the key is shown."), single `settingsKeyDone` ("Done") button; closing the dialog in reveal state clears the plain key from component state (never keep it after close) and resets the mutation (P1.1 rule). + +- [ ] **Step 1: Failing tests** (MSW): list renders keys incl. revoked-state treatment; revoke flow (confirm → DELETE → invalidate); create flow (POST body `{name}` → reveal shows plain_key ONCE → after close+reopen create dialog, no stale key/error — assert component state reset); copy button writes to (mocked) clipboard; empty list caption `settingsKeysEmpty` ("No API keys yet — create one to push attendees from your registration forms or CRM."). +- [ ] **Step 2:** Implement. +- [ ] **Step 3: i18n** — keys above + `settingsApiKeysSubtitle` ("Integrate registration forms or your CRM — push attendees and read check-in status for this event."), `settingsKeyName` ("Name"), `settingsKeyCreatedAt` ("Created"), `settingsKeyLastUsed` ("Last used"), `settingsKeyCopy` ("Copy"), `settingsKeyCopied` ("Copied"), `settingsKeyRevokeConfirm` ("Revoke"). RU for all. +- [ ] **Step 4:** Full suite + typecheck + eslint green. Commit: `feat(panel): settings API-keys card — list, create with show-once reveal, revoke` + +--- + +### Task 7: Danger zone card + +**Files:** +- Create: `panel/src/features/workspace/settings/DangerZoneCard.tsx`, `DangerZoneCard.test.tsx` +- Modify: `panel/src/features/workspace/settings/EventSettingsPage.tsx` (swap placeholder) +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `$api.useMutation("delete", "/api/events/{id}", ...)`, `ConfirmDialog` TYPED tier (`typedConfirmation={event.name}` + `typedConfirmationLabel`), `useNavigate`, `useQueryClient` (invalidate `["get", "/api/events"]` after delete so Home's list is fresh), event name via the parent layout's already-fetched event (pass `event` down as a prop from `EventSettingsPage` — it already loads the event for GeneralCard; thread it through rather than refetching). +- Produces: ``. + +Per extract §8 + reconciliations #5/#8: card with destructive-tinted border (`border-destructive/30`), title `settingsDanger` in `text-destructive`. ONE row (regenerate-codes deferred to P2): `settingsDeleteEventTitle` ("Delete this event") / `settingsDeleteEventBody` ("Attendees, check-in history and the badge design — gone. Typed confirmation required.") → solid destructive Button `settingsDeleteEventAction` ("Delete event…") → typed ConfirmDialog: title `settingsDeleteConfirmTitle` ("Delete event"), description `settingsDeleteConfirmBody` ("Deletes all attendees, check-in history and the badge design. This cannot be undone.") — interpolate the attendee count if cheaply available from readiness (optional; plain text fine), `typedConfirmationLabel` = `settingsDeleteConfirmLabel` ("Type {{name}} to confirm" — NOTE: ConfirmDialog's label prop is a plain string; interpolate via `t("settingsDeleteConfirmLabel", {name: event.name})`), `typedConfirmation={event.name}`, `destructive`, `confirmLabel` = `settingsDeleteEventConfirm` ("Delete event"). onConfirm → DELETE; success → invalidate events list + `navigate({to: "/"})`; failure → i18n'd `settingsDeleteError` ("Couldn't delete the event. Please try again.") inline in the card + mutation reset when the dialog closes. + +- [ ] **Step 1: Failing tests** (MSW): confirm button disabled until the exact event name is typed (drive the ConfirmDialog input); successful delete → DELETE called with the right id, navigate("/") (mock useNavigate like `CreateEventDialog.test.tsx` does), events-list invalidation (handler-hit assertion); failed delete → error line, and closing+reopening shows no stale error. +- [ ] **Step 2:** Implement. **Do not use `window.confirm` or anything besides the @idento/ui ConfirmDialog typed tier.** +- [ ] **Step 3: i18n** — keys above, RU included. +- [ ] **Step 4:** Full suite + typecheck + eslint green. Commit: `feat(panel): settings danger zone — typed-confirm event deletion` + +--- + +### Task 8: Organization screen + +**Files:** +- Create: `panel/src/features/organization/OrganizationPage.tsx`, `OrganizationPage.test.tsx` +- Modify: `panel/src/app/router.tsx` (targeted: `organizationRoute.component` → `OrganizationPage`; nothing else — regression guard applies) +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `getCurrentTenant()` from `panel/src/shared/api/session.ts` (`{id, name} | null`), `$api.useQuery("get", "/api/tenants/{id}", ...)` (→ `TenantMembership` incl. `role`), `$api.useMutation("put", "/api/tenants/{id}", ...)` (pointer-partial: send only changed fields), `zod`, `Card`/`Input`/`Label`/`Button` from `@idento/ui`. +- Produces: `` at `/organization`. + +Single card styled like GeneralCard (reconciliation #10): fields name (required, max 200 — key `orgNameRequired`/`orgNameTooLong`), website, contact email (zod `.email()` with key `orgEmailInvalid` — only validate when non-empty), logo URL (plain text input, `.url()` with key `orgLogoUrlInvalid` when non-empty). `role !== "admin"` → all inputs `disabled` + notice `orgReadOnlyNotice` ("Only organization admins can edit these settings.") — role comes from the SAME `getTenant` response, no extra query. Dirty-tracking + save-only-changed-fields (same pattern as GeneralCard — if both tasks land similar logic, extracting a tiny `useDirtyFields` helper into `panel/src/shared/hooks/` is acceptable DRY, but only if it genuinely simplifies both; don't force it). Save success → `settingsSaved` (reuse); 403 despite the guard → `orgForbiddenError` ("Only organization admins can save changes."). `getCurrentTenant()` null (shouldn't happen inside `_app`) → render `homeLoadError` + nothing else. Clearing optional fields: PUT's pointer semantics mean sending `""` explicitly clears — same deliberate-clear treatment as GeneralCard's location. + +- [ ] **Step 1: Failing tests** (MSW): admin sees editable form loaded with tenant values, save PUTs only changed fields; non-admin (`role: "member"`) → disabled inputs + notice, no save possible; email/url validation keys render; 403 on save → forbidden error. +- [ ] **Step 2:** Implement + router swap (one line + import). +- [ ] **Step 3: i18n** — `orgTitle` ("Organization"), `orgNameLabel` ("Organization name"), `orgWebsiteLabel` ("Website"), `orgContactEmailLabel` ("Contact email"), `orgLogoUrlLabel` ("Logo URL"), + validation/notice keys above. RU included. +- [ ] **Step 4:** Full suite + typecheck + eslint + build green. Commit: `feat(panel): organization settings screen` + +--- + +### Task 9: Final verification + i18n sweep + AGENTS.md touch + +**Files:** +- Modify: `panel/AGENTS.md` (ONLY if a new reusable convention emerged — e.g. one line on `useScrollSpy`'s home in `src/shared/hooks/`; ≤2 lines, match tone; skip entirely if nothing qualifies) + +- [ ] **Step 1:** Sweep: `grep -rn "placeholderComingSoon\|workspaceComingSoon" panel/src` — remove dead i18n keys from both locales if unreferenced (keyParity must stay green). Grep new feature files for hardcoded user-facing strings that bypassed i18n (recurring defect class — check every new `.tsx` from Tasks 1–8). +- [ ] **Step 2:** Full verification: + +```bash +cd backend && OPENAPI_COVERAGE=1 go test ./... -count=1 # must stay green (untouched) +npm run typecheck -w panel && npm test -w panel && npm run build -w panel && (cd panel && npx eslint .) +npm test -w @idento/ui && npm run typecheck -w @idento/ui +npm run generate:api -w panel && git diff --exit-code -- panel/src/shared/api/schema.d.ts # drift: must be clean (no backend changes happened) +docker build --load -f panel/Dockerfile -t idento-panel:p1.2-verify . +``` + +- [ ] **Step 3:** Commit: `docs(agents): P1.2 verification` (or fold into a final chore commit). + +--- + +## Self-review notes (already applied) + +- Spec §5 ↔ Tasks 1–3 (rail with locked future steps ✓, launch gate + placeholder dialog ✓, Overview what's-next + stats ✓); §6 ↔ Tasks 4–8 (four scoped-save cards ✓, show-once key reveal ✓, typed-confirm delete by NAME ✓, Organization with role gating ✓); §7 testing expectations distributed per task. +- Board corrections encoded: launch button in workspace header (not rail bottom — rail bottom gets the amber hint), what's-next = top-2 rows not full checklist, no amber draft state until P3, stat tile #4 = Checked-in, settings rail = 4 real sections only. +- Type consistency: `WorkspaceRailProps.active` union matches the two real sections; `getRouteApi` id strings written once here and compiler-verified in Tasks 2–4; `STEP_LABEL_KEYS` exported in Task 1 and consumed in Tasks 1/3; `event` threaded from `EventSettingsPage` to `DangerZoneCard` (single fetch). +- The only genuinely new technical mechanism is the multipart `bodySerializer` (Task 5) — flagged as the task's explicit risk with a verify-against-installed-source instruction, consistent with how P1.1 handled openapi-fetch internals. diff --git a/packages/ui/src/components/confirm-dialog.tsx b/packages/ui/src/components/confirm-dialog.tsx index a884a231..eb2a0e27 100644 --- a/packages/ui/src/components/confirm-dialog.tsx +++ b/packages/ui/src/components/confirm-dialog.tsx @@ -16,6 +16,10 @@ export type ConfirmDialogProps = { closeLabel: string; onConfirm: () => void; destructive?: boolean; + // Caller-driven disable on top of the typed-confirmation check below — + // e.g. while the confirm action's mutation is in flight, so a slow + // network can't be double-clicked into firing the action twice. + confirmDisabled?: boolean; } & ( // Tier 2 (typed confirm) needs a visible label for the input's accessible // name — @idento/ui has no i18n fallback text to supply one, so the type @@ -26,7 +30,7 @@ export type ConfirmDialogProps = { export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel, cancelLabel, closeLabel, - onConfirm, destructive = false, typedConfirmation, typedConfirmationLabel, + onConfirm, destructive = false, typedConfirmation, typedConfirmationLabel, confirmDisabled = false, }: ConfirmDialogProps) { const [typed, setTyped] = React.useState(""); const inputId = React.useId(); @@ -35,7 +39,7 @@ export function ConfirmDialog({ if (!open) setTyped(""); }, [open]); - const confirmDisabled = typedConfirmation !== undefined && typed !== typedConfirmation; + const disabled = confirmDisabled || (typedConfirmation !== undefined && typed !== typedConfirmation); return ( @@ -62,7 +66,7 @@ export function ConfirmDialog({ - - ); - } - - const event = eventQuery.data; - - return ( -
-

{event.name}

-

{t("workspaceComingSoon")}

- -
- ); -} diff --git a/panel/src/features/events/eventDates.ts b/panel/src/features/events/eventDates.ts new file mode 100644 index 00000000..403f46e6 --- /dev/null +++ b/panel/src/features/events/eventDates.ts @@ -0,0 +1,24 @@ +import type { ApiEvent } from "./eventTiming"; + +// A single date, or "start – end" when the dates differ, in the viewer's +// locale — no date library per plan constraints. `start_date`/`end_date` +// are bare calendar dates stored as UTC-midnight ISO timestamps (see +// CreateEventDialog), so the formatter is pinned to UTC to keep the +// displayed date stable regardless of the viewer's local timezone (without +// it, viewers behind UTC see the date roll back by one day). +// +// Extracted from LiveStrip.tsx (P1.1) so the workspace header (P1.2 Task 2) +// can reuse the exact same UTC-pinned formatting instead of re-deriving it. +export function formatDateRange(event: ApiEvent, locale: string): string | null { + if (!event.start_date) return null; + const dateFmt = new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", + }); + const start = dateFmt.format(new Date(event.start_date)); + if (!event.end_date) return start; + const end = dateFmt.format(new Date(event.end_date)); + return start === end ? start : `${start} – ${end}`; +} diff --git a/panel/src/features/home/LiveStrip.tsx b/panel/src/features/home/LiveStrip.tsx index 0c28aeea..673508d7 100644 --- a/panel/src/features/home/LiveStrip.tsx +++ b/panel/src/features/home/LiveStrip.tsx @@ -1,6 +1,7 @@ import { Button, Card, Progress, Skeleton } from "@idento/ui"; import { Link } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; +import { formatDateRange } from "../events/eventDates"; import { isDateOnly, type ApiEvent } from "../events/eventTiming"; import { useEventReadiness, useEventStats } from "../events/hooks"; @@ -42,26 +43,6 @@ function formatRunningWindow(event: ApiEvent, locale: string, allDayLabel: strin return parts.length > 0 ? parts.join(" · ") : null; } -// A single date, or "start – end" when the dates differ, in the viewer's -// locale — no date library per plan constraints. `start_date`/`end_date` -// are bare calendar dates stored as UTC-midnight ISO timestamps (see -// CreateEventDialog), so the formatter is pinned to UTC to keep the -// displayed date stable regardless of the viewer's local timezone (without -// it, viewers behind UTC see the date roll back by one day). -function formatDateRange(event: ApiEvent, locale: string): string | null { - if (!event.start_date) return null; - const dateFmt = new Intl.DateTimeFormat(locale, { - day: "numeric", - month: "short", - year: "numeric", - timeZone: "UTC", - }); - const start = dateFmt.format(new Date(event.start_date)); - if (!event.end_date) return start; - const end = dateFmt.format(new Date(event.end_date)); - return start === end ? start : `${start} – ${end}`; -} - function RunningCard({ event }: { event: ApiEvent }) { const { t, i18n } = useTranslation(); const stats = useEventStats(event.id, { poll: true }); diff --git a/panel/src/features/home/ReadinessCell.tsx b/panel/src/features/home/ReadinessCell.tsx index 31487b41..bcbb056e 100644 --- a/panel/src/features/home/ReadinessCell.tsx +++ b/panel/src/features/home/ReadinessCell.tsx @@ -6,7 +6,8 @@ import type { components } from "../../shared/api/schema"; type EventReadinessResponse = components["schemas"]["EventReadinessResponse"]; type ReadinessStep = components["schemas"]["ReadinessStep"]; -const STEP_LABEL_KEYS: Record = { +// eslint-disable-next-line react-refresh/only-export-components -- Shared step-label lookup belongs with the readiness pipeline it describes; not a real Fast Refresh issue for this pattern. +export const STEP_LABEL_KEYS: Record = { attendees: "readinessStepAttendees", badge: "readinessStepBadge", zones: "readinessStepZones", diff --git a/panel/src/features/organization/OrganizationPage.test.tsx b/panel/src/features/organization/OrganizationPage.test.tsx new file mode 100644 index 00000000..d815d0fa --- /dev/null +++ b/panel/src/features/organization/OrganizationPage.test.tsx @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { OrganizationPage } from "./OrganizationPage"; +import { startMswServer } from "../../test/msw"; +import "../../shared/i18n"; +import type { components } from "../../shared/api/schema"; + +type TenantMembership = components["schemas"]["TenantMembership"]; + +const BASE_TENANT: TenantMembership = { + id: "t1", + name: "Acme Events", + settings: null, + logo_url: "https://cdn.example.com/logo.png", + website: "https://acme.example.com", + contact_email: "ops@acme.example.com", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + role: "admin", +}; + +let currentTenant: TenantMembership = BASE_TENANT; +let putCount = 0; +let lastPutBody: unknown; +let putStatusOverride: number | null = null; + +const server = startMswServer( + http.get("http://api.test/api/tenants/:id", () => HttpResponse.json(currentTenant)), + http.put("http://api.test/api/tenants/:id", async ({ request }) => { + putCount += 1; + lastPutBody = await request.json(); + if (putStatusOverride) { + return HttpResponse.json({ error: "forbidden" }, { status: putStatusOverride }); + } + const body = lastPutBody as Record; + return HttpResponse.json({ + id: currentTenant.id, + name: body.name ?? currentTenant.name, + status: "active", + logo_url: body.logo_url ?? currentTenant.logo_url, + website: body.website ?? currentTenant.website, + contact_email: body.contact_email ?? currentTenant.contact_email, + created_at: currentTenant.created_at, + updated_at: "2026-01-02T00:00:00.000Z", + }); + }), +); +void server; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("OrganizationPage", () => { + beforeEach(() => { + currentTenant = BASE_TENANT; + putCount = 0; + lastPutBody = undefined; + putStatusOverride = null; + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.setItem("current_tenant", JSON.stringify({ id: "t1", name: "Acme Events" })); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("loads the tenant's current values into the fields for an admin", async () => { + renderWithProviders(); + + expect(await screen.findByLabelText("Organization name")).toHaveValue("Acme Events"); + expect(screen.getByLabelText("Website")).toHaveValue("https://acme.example.com"); + expect(screen.getByLabelText("Contact email")).toHaveValue("ops@acme.example.com"); + expect(screen.getByLabelText("Logo URL")).toHaveValue("https://cdn.example.com/logo.png"); + expect(screen.getByLabelText("Organization name")).toBeEnabled(); + }); + + it("PUTs only the changed field and shows the saved caption", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByLabelText("Organization name"); + await user.clear(screen.getByLabelText("Website")); + await user.type(screen.getByLabelText("Website"), "https://acme.example.org"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(putCount).toBe(1)); + expect(lastPutBody).toEqual({ website: "https://acme.example.org" }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + }); + + it("sends an explicit empty string to clear an optional field", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByLabelText("Organization name"); + await user.clear(screen.getByLabelText("Website")); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(putCount).toBe(1)); + expect(lastPutBody).toEqual({ website: "" }); + }); + + it("shows a localized invalid-email error and does not call the API", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByLabelText("Organization name"); + await user.clear(screen.getByLabelText("Contact email")); + await user.type(screen.getByLabelText("Contact email"), "not-an-email"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Enter a valid email address.")).toBeInTheDocument(); + expect(putCount).toBe(0); + }); + + it("shows a localized invalid-url error for the logo URL and does not call the API", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByLabelText("Organization name"); + await user.clear(screen.getByLabelText("Logo URL")); + await user.type(screen.getByLabelText("Logo URL"), "not-a-url"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Enter a valid URL.")).toBeInTheDocument(); + expect(putCount).toBe(0); + }); + + it("shows a required-name error and does not call the API when the name is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByLabelText("Organization name"); + await user.clear(screen.getByLabelText("Organization name")); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Give the organization a name.")).toBeInTheDocument(); + expect(putCount).toBe(0); + }); + + it("disables all inputs and shows a read-only notice for a non-admin, with no save action reachable", async () => { + currentTenant = { ...BASE_TENANT, role: "member" }; + renderWithProviders(); + + expect(await screen.findByLabelText("Organization name")).toBeDisabled(); + expect(screen.getByLabelText("Website")).toBeDisabled(); + expect(screen.getByLabelText("Contact email")).toBeDisabled(); + expect(screen.getByLabelText("Logo URL")).toBeDisabled(); + expect(screen.getByText("Only organization admins can edit these settings.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + }); + + it("shows a forbidden error if the save is rejected with 403 despite the role guard", async () => { + putStatusOverride = 403; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByLabelText("Organization name"); + await user.type(screen.getByLabelText("Organization name"), "!"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Only organization admins can save changes.")).toBeInTheDocument(); + }); + + it("renders the load-error message and nothing else when getCurrentTenant() returns null", () => { + localStorage.removeItem("current_tenant"); + renderWithProviders(); + + expect(screen.getByText("Couldn't load your events.")).toBeInTheDocument(); + expect(screen.queryByLabelText("Organization name")).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/features/organization/OrganizationPage.tsx b/panel/src/features/organization/OrganizationPage.tsx new file mode 100644 index 00000000..64f422b4 --- /dev/null +++ b/panel/src/features/organization/OrganizationPage.tsx @@ -0,0 +1,262 @@ +import { + Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Skeleton, +} from "@idento/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { ApiError } from "../../shared/api/ApiError"; +import { $api } from "../../shared/api/query"; +import type { components } from "../../shared/api/schema"; +import { getCurrentTenant } from "../../shared/api/session"; + +type TenantMembership = components["schemas"]["TenantMembership"]; + +// Optional fields (website/contactEmail/logoUrl) are validated with zod's +// `.email()`/`.url()` only when non-empty — an empty string is a legitimate +// "not set" value here, not an error, matching the brief's "validated ONLY +// when non-empty" rule. `.superRefine` (rather than a plain `.email()` on +// the field) is what makes that "skip when empty" conditional possible. +const orgSchema = z.object({ + name: z.string().trim().min(1, "orgNameRequired").max(200, "orgNameTooLong"), + website: z.string(), + contactEmail: z.string(), + logoUrl: z.string(), +}).superRefine((val, ctx) => { + if (val.contactEmail !== "" && !z.string().email().safeParse(val.contactEmail).success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "orgEmailInvalid", path: ["contactEmail"] }); + } + if (val.logoUrl !== "" && !z.string().url().safeParse(val.logoUrl).success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "orgLogoUrlInvalid", path: ["logoUrl"] }); + } +}); + +type FieldErrors = Partial>; + +type FormState = { + name: string; + website: string; + contactEmail: string; + logoUrl: string; +}; + +function toFormState(tenant: TenantMembership): FormState { + return { + name: tenant.name, + website: tenant.website ?? "", + contactEmail: tenant.contact_email ?? "", + logoUrl: tenant.logo_url ?? "", + }; +} + +interface OrganizationFormProps { + tenantId: string; + tenant: TenantMembership; +} + +// Mirrors GeneralCard.tsx's dirty-tracking + scoped-save shape (Task 4), but +// isn't extracted into a shared hook: the field set (email/url validation, +// role-gated disable, no date-clear-block analog) differs enough that a +// shared `useDirtyFields` would need its own escape hatches for both call +// sites' quirks, which isn't a net simplification for two call sites. +function OrganizationForm({ tenantId, tenant }: OrganizationFormProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const isAdmin = tenant.role === "admin"; + const [baseline, setBaseline] = React.useState(() => toFormState(tenant)); + const [form, setForm] = React.useState(() => toFormState(tenant)); + const [fieldErrors, setFieldErrors] = React.useState({}); + const [saved, setSaved] = React.useState(false); + const savedTimeoutRef = React.useRef(undefined); + + React.useEffect(() => () => window.clearTimeout(savedTimeoutRef.current), []); + + const updateTenant = $api.useMutation("put", "/api/tenants/{id}", { + onSuccess: (updated) => { + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/tenants/{id}", { params: { path: { id: tenantId } } }], + }); + const next: FormState = { + name: updated.name, + website: updated.website ?? "", + contactEmail: updated.contact_email ?? "", + logoUrl: updated.logo_url ?? "", + }; + setBaseline(next); + setForm(next); + setSaved(true); + window.clearTimeout(savedTimeoutRef.current); + savedTimeoutRef.current = window.setTimeout(() => setSaved(false), 2000); + }, + }); + + function updateField(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + setFieldErrors({}); + setSaved(false); + updateTenant.reset(); + } + + const isDirty = + form.name !== baseline.name || + form.website !== baseline.website || + form.contactEmail !== baseline.contactEmail || + form.logoUrl !== baseline.logoUrl; + + const saveDisabled = !isDirty || updateTenant.isPending; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const parsed = orgSchema.safeParse(form); + if (!parsed.success) { + const errors: FieldErrors = {}; + for (const issue of parsed.error.issues) { + const key = issue.path[0]; + if (typeof key === "string" && !(key in errors)) { + errors[key as keyof FieldErrors] = issue.message; + } + } + setFieldErrors(errors); + return; + } + setFieldErrors({}); + + const body: { + name?: string; + website?: string; + contact_email?: string; + logo_url?: string; + } = {}; + if (form.name !== baseline.name) body.name = parsed.data.name; + // Website/contact email/logo URL deliberately allow an explicit "" — + // PUT's *pointer* semantics (despite the HTTP verb) treat a present + // empty string as "clear this field", which is exactly the user's + // intent when they empty one of these inputs (same deliberate-clear + // treatment as GeneralCard's location field). + if (form.website !== baseline.website) body.website = form.website; + if (form.contactEmail !== baseline.contactEmail) body.contact_email = form.contactEmail; + if (form.logoUrl !== baseline.logoUrl) body.logo_url = form.logoUrl; + + updateTenant.mutate({ params: { path: { id: tenantId } }, body }); + } + + const saveErrorKey = + updateTenant.error instanceof ApiError && updateTenant.error.status === 403 + ? "orgForbiddenError" + : "settingsSaveError"; + + return ( + + + {t("orgTitle")} + + +
+ {!isAdmin ? ( +

{t("orgReadOnlyNotice")}

+ ) : null} +
+ + updateField("name", e.target.value)} + /> + {fieldErrors.name ?

{t(fieldErrors.name)}

: null} +
+
+ + updateField("website", e.target.value)} + /> +
+
+ + updateField("contactEmail", e.target.value)} + /> + {fieldErrors.contactEmail ? ( +

{t(fieldErrors.contactEmail)}

+ ) : null} +
+
+ + updateField("logoUrl", e.target.value)} + /> + {fieldErrors.logoUrl ? ( +

{t(fieldErrors.logoUrl)}

+ ) : null} +
+ {updateTenant.isError ?

{t(saveErrorKey)}

: null} + {isAdmin ? ( +
+ + {saved ? {t("settingsSaved")} : null} +
+ ) : null} + +
+
+ ); +} + +// Task 8's Organization screen — not on the design board (reconciliation +// #10 in the task brief); styled to match GeneralCard.tsx's single-card, +// dirty-tracked, scoped-save look instead. `getCurrentTenant()` returning +// null shouldn't happen inside the protected `_app` layout (every route +// under it requires an active tenant), but is handled honestly rather than +// crashing or rendering a blank card. +export function OrganizationPage() { + const { t } = useTranslation(); + const tenant = getCurrentTenant(); + + const tenantQuery = $api.useQuery( + "get", + "/api/tenants/{id}", + { params: { path: { id: tenant?.id ?? "" } } }, + { enabled: tenant !== null }, + ); + + if (tenant === null) { + return

{t("homeLoadError")}

; + } + + if (tenantQuery.isLoading) { + return ( +
+ + +
+ ); + } + + if (tenantQuery.isError || !tenantQuery.data) { + return

{t("settingsLoadError")}

; + } + + return ( +
+ {/* `key={tenant.id}` forces a full remount (fresh `useState` + initializers) whenever the active tenant changes — OrgSwitcher + switches tenants without navigating away from this route, so + without this key the same component instance would keep the + previous org's baseline/form state and could PUT stale values + into the newly-selected tenant's record. */} + +
+ ); +} diff --git a/panel/src/features/workspace/EventWorkspaceLayout.test.tsx b/panel/src/features/workspace/EventWorkspaceLayout.test.tsx new file mode 100644 index 00000000..7419d061 --- /dev/null +++ b/panel/src/features/workspace/EventWorkspaceLayout.test.tsx @@ -0,0 +1,141 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, +} from "@tanstack/react-router"; +import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { EventWorkspaceLayout } from "./EventWorkspaceLayout"; +import { startMswServer } from "../../test/msw"; +import "../../shared/i18n"; + +// Isolated route tree matching the real app's shape closely enough for +// `EventWorkspaceLayout`'s `getRouteApi("/_app/events/$eventId").useParams()` +// and the rail's `active` derivation (from the current pathname) to resolve +// — same rationale as the deleted EventWorkspaceStub.test.tsx's harness. +// The child routes use throwaway marker components rather than the real +// Task 3/4 placeholders: this test only owns the layout's job of rendering +// the header/rail/Outlet, not the placeholder copy those routes show. +function buildRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const homeRoute = createRoute({ getParentRoute: () => appLayoutRoute, path: "/", component: () =>
home content
}); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: EventWorkspaceLayout, + }); + const overviewRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "/", + component: () =>
overview content
, + }); + const settingsRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "/settings", + component: () =>
settings content
, + }); + const routeTree = rootRoute.addChildren([ + appLayoutRoute.addChildren([homeRoute, workspaceRoute.addChildren([overviewRoute, settingsRoute])]), + ]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +function renderAt(path: string) { + const router = buildRouter(path); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* Cast, not @ts-expect-error: this test router's route shape differs + from the app's registered singleton (./router.tsx via module + augmentation) — same rationale as ProtectedLayout.test.tsx. */} + + , + ); +} + +const READY_TRUE = { ready: true, steps: [{ key: "attendees", status: "done", count: 340 }] }; +const READY_FALSE = { ready: false, steps: [{ key: "attendees", status: "not_done" }] }; + +let readinessResponse: unknown = READY_FALSE; + +const server = startMswServer( + http.get("http://api.test/api/events/:id", ({ params }) => { + if (params.id === "evt-missing") { + return HttpResponse.json({ error: "not found" }, { status: 404 }); + } + return HttpResponse.json({ + id: params.id, + tenant_id: "t1", + name: "Partner Day — Autumn", + start_date: "2026-09-03T00:00:00.000Z", + created_at: "", + updated_at: "", + }); + }), + http.get("http://api.test/api/events/:id/readiness", () => HttpResponse.json(readinessResponse)), +); +void server; + +describe("EventWorkspaceLayout", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + readinessResponse = READY_FALSE; + }); + + it("renders the event name, the rail, and the matched child route's outlet content", async () => { + renderAt("/events/evt-1"); + + expect(await screen.findByRole("heading", { name: "Partner Day — Autumn" })).toBeInTheDocument(); + expect(screen.getByText("overview content")).toBeInTheDocument(); + // Rail is mounted (Task 1 component) — its static Settings nav item is + // present, and Overview is marked active for the index route. + expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Settings" })).not.toHaveAttribute("aria-current"); + }); + + it("marks Settings active in the rail and renders the settings child route's outlet content", async () => { + renderAt("/events/evt-1/settings"); + + expect(await screen.findByRole("heading", { name: "Partner Day — Autumn" })).toBeInTheDocument(); + expect(screen.getByText("settings content")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current"); + }); + + it("disables the launch-check-in button with a discoverable locked reason when the event isn't ready", async () => { + readinessResponse = READY_FALSE; + renderAt("/events/evt-1"); + + await screen.findByRole("heading", { name: "Partner Day — Autumn" }); + // WCAG 1.4.1: the locked state must be discoverable via text, not just + // the padlock icon/dimming — the accessible name carries both the label + // and the "locked" reason. + const button = await screen.findByRole("button", { name: "Launch check-in locked" }); + expect(button).toBeDisabled(); + }); + + it("enables the launch-check-in button and opens the coming-soon dialog when the event is ready", async () => { + readinessResponse = READY_TRUE; + renderAt("/events/evt-1"); + + await screen.findByRole("heading", { name: "Partner Day — Autumn" }); + const button = await screen.findByRole("button", { name: "Launch check-in" }); + expect(button).toBeEnabled(); + + button.click(); + + expect(await screen.findByText("Launch ceremony arrives with the check-in station")).toBeInTheDocument(); + expect( + screen.getByText("Confirming the event, loading station settings and the printer check land in a later update."), + ).toBeInTheDocument(); + }); + + it("renders the load-error message and a back-Home link when the event fetch fails", async () => { + renderAt("/events/evt-missing"); + + expect(await screen.findByText("Couldn't load this event.")).toBeInTheDocument(); + const link = screen.getByRole("link", { name: /Back to Home/ }); + expect(link).toHaveAttribute("href", "/"); + await waitFor(() => expect(screen.queryByRole("heading")).not.toBeInTheDocument()); + }); +}); diff --git a/panel/src/features/workspace/EventWorkspaceLayout.tsx b/panel/src/features/workspace/EventWorkspaceLayout.tsx new file mode 100644 index 00000000..d99f44b1 --- /dev/null +++ b/panel/src/features/workspace/EventWorkspaceLayout.tsx @@ -0,0 +1,104 @@ +import { + Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Skeleton, +} from "@idento/ui"; +import { Link, Outlet, getRouteApi, useRouterState } from "@tanstack/react-router"; +import { Lock } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { WorkspaceRail } from "./WorkspaceRail"; +import { formatDateRange } from "../events/eventDates"; +import { useEventReadiness } from "../events/hooks"; +import { $api } from "../../shared/api/query"; + +// Layout route for the event workspace (board 1f) — replaces the P1.1 stub. +// Owns the header row (event name, date pill, launch-check-in gate) and +// mounts the readiness rail (Task 1) beside whichever child route +// (overview/settings) is currently matched via ``. +// +// `getRouteApi` with the route's string id ("/_app/events/$eventId") rather +// than importing the route object from app/router.tsx avoids a circular +// import between this module and router.tsx (which imports this component +// for the layout route's `component:` field) — same rationale the deleted +// EventWorkspaceStub carried forward. +const routeApi = getRouteApi("/_app/events/$eventId"); + +// `active` is derived from the current pathname rather than route-id +// matching (e.g. `useMatchRoute`): this layout only ever has two children, +// so a plain suffix check is simpler and doesn't tie this component's types +// to the exact child route objects declared in router.tsx. +function useActiveRailTab(): "overview" | "settings" { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + return pathname.endsWith("/settings") ? "settings" : "overview"; +} + +export function EventWorkspaceLayout() { + const { t, i18n } = useTranslation(); + const { eventId } = routeApi.useParams(); + const eventQuery = $api.useQuery("get", "/api/events/{id}", { params: { path: { id: eventId } } }); + const readiness = useEventReadiness(eventId); + const active = useActiveRailTab(); + const [launchDialogOpen, setLaunchDialogOpen] = React.useState(false); + + if (eventQuery.isLoading) { + return ( +
+ + +
+ ); + } + + if (eventQuery.isError || !eventQuery.data) { + return ( +
+

{t("workspaceLoadError")}

+ +
+ ); + } + + const event = eventQuery.data; + const dateRange = formatDateRange(event, i18n.language); + const ready = readiness.data?.ready === true; + + return ( +
+
+

{event.name}

+ {dateRange ? ( + {dateRange} + ) : null} +
+ {ready ? ( + + ) : ( + // Locked state: disabled, but the "locked" reason is real text + // (sr-only, not just the dimmed/disabled look or the padlock + // icon alone) so it stays discoverable per WCAG 1.4.1. + + )} +
+
+
+ +
+ +
+
+ + + + {t("workspaceLaunchComingSoonTitle")} + {t("workspaceLaunchComingSoonBody")} + + + +
+ ); +} diff --git a/panel/src/features/workspace/WorkspaceOverview.test.tsx b/panel/src/features/workspace/WorkspaceOverview.test.tsx new file mode 100644 index 00000000..f16a048f --- /dev/null +++ b/panel/src/features/workspace/WorkspaceOverview.test.tsx @@ -0,0 +1,211 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, +} from "@tanstack/react-router"; +import { render, screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { WorkspaceOverview } from "./WorkspaceOverview"; +import { startMswServer } from "../../test/msw"; +import "../../shared/i18n"; + +// Mirrors EventWorkspaceLayout.test.tsx's harness shape (rootRoute -> "_app" +// -> "/events/$eventId" -> index "/") so `getRouteApi("/_app/events/$eventId") +// .useParams()` inside WorkspaceOverview resolves the same way it does for +// the real app's route tree — but this component does its OWN data fetching +// (readiness/stats/zones), so the parent layout route here is just a plain +// `` marker, not the real EventWorkspaceLayout. +function buildRouter() { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ getParentRoute: () => appLayoutRoute, path: "/events/$eventId", component: () => }); + const overviewRoute = createRoute({ getParentRoute: () => workspaceRoute, path: "/", component: WorkspaceOverview }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute.addChildren([overviewRoute])])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: ["/events/evt-1"] }) }); +} + +function renderOverview() { + const router = buildRouter(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* Cast, not @ts-expect-error: this test router's route shape differs + from the app's registered singleton — same rationale as + EventWorkspaceLayout.test.tsx / ProtectedLayout.test.tsx. */} + + , + ); +} + +const TWO_NOT_DONE_READINESS = { + ready: false, + // Deliberately NOT in pipeline order (staff before badge) — the component + // must render "next" rows in fixed pipeline order (attendees, badge, + // staff, equipment), not raw array order, and zones is never a "next" + // candidate even though it's not done here either. + steps: [ + { key: "attendees", status: "done", count: 340 }, + { key: "staff", status: "not_done" }, + { key: "badge", status: "not_done" }, + { key: "zones", status: "not_done" }, + { key: "equipment", status: "not_done" }, + ], +}; + +const ALL_READY = { + ready: true, + steps: [ + { key: "attendees", status: "done", count: 340 }, + { key: "badge", status: "done" }, + { key: "zones", status: "done", count: 2 }, + { key: "staff", status: "done", count: 3 }, + { key: "equipment", status: "done" }, + ], +}; + +const ZONES_SKIPPED_READINESS = { + ready: false, + steps: [ + { key: "attendees", status: "done", count: 340 }, + { key: "badge", status: "not_done" }, + { key: "zones", status: "skipped", count: 0 }, + { key: "staff", status: "not_done" }, + { key: "equipment", status: "not_done" }, + ], +}; + +let readinessResponse: unknown = TWO_NOT_DONE_READINESS; +let statsResponse: unknown = { total_attendees: 200, checked_in: 120 }; +let zonesResponse: unknown = [ + { id: "z1", event_id: "evt-1", name: "Main hall", zone_type: "general", order_index: 0, is_registration_zone: false, requires_registration: false, is_active: true, created_at: "" }, + { id: "z2", event_id: "evt-1", name: "VIP", zone_type: "vip", order_index: 1, is_registration_zone: false, requires_registration: false, is_active: true, created_at: "" }, + { id: "z3", event_id: "evt-1", name: "Backstage", zone_type: "vip", order_index: 2, is_registration_zone: false, requires_registration: false, is_active: true, created_at: "" }, +]; + +const server = startMswServer( + http.get("http://api.test/api/events/:id/readiness", () => HttpResponse.json(readinessResponse)), + http.get("http://api.test/api/events/:eventId/stats", () => HttpResponse.json(statsResponse)), + http.get("http://api.test/api/events/:eventId/zones", () => HttpResponse.json(zonesResponse)), +); +void server; + +describe("WorkspaceOverview", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + readinessResponse = TWO_NOT_DONE_READINESS; + statsResponse = { total_attendees: 200, checked_in: 120 }; + zonesResponse = [ + { id: "z1", event_id: "evt-1", name: "Main hall", zone_type: "general", order_index: 0, is_registration_zone: false, requires_registration: false, is_active: true, created_at: "" }, + { id: "z2", event_id: "evt-1", name: "VIP", zone_type: "vip", order_index: 1, is_registration_zone: false, requires_registration: false, is_active: true, created_at: "" }, + { id: "z3", event_id: "evt-1", name: "Backstage", zone_type: "vip", order_index: 2, is_registration_zone: false, requires_registration: false, is_active: true, created_at: "" }, + ]; + }); + + it("renders the H2 title and subtitle", async () => { + renderOverview(); + expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument(); + expect(screen.getByText("Everything your team needs before doors open.")).toBeInTheDocument(); + }); + + it("renders exactly the two not-done steps in fixed pipeline order, each with a locked chip and no buttons", async () => { + renderOverview(); + + const list = await screen.findByTestId("workspace-next-steps"); + // badge and staff are not_done; attendees is done (excluded), zones is + // never a "next" candidate even though it's not_done, and equipment is + // the 3rd not_done step so it's excluded by the "up to two" cap. + expect(within(list).getByText("Design the badge template in the editor.")).toBeInTheDocument(); + expect(within(list).getByText("Assign staff and print their QR login cards.")).toBeInTheDocument(); + expect(within(list).queryByText("Import your attendee list to start printing badges.")).not.toBeInTheDocument(); + expect(within(list).queryByText("Connect the venue printer and run a test print.")).not.toBeInTheDocument(); + + // Fixed pipeline order (badge before staff), not raw API array order + // (which listed staff before badge). + const badgeIndex = list.textContent?.indexOf("Design the badge template in the editor.") ?? -1; + const staffIndex = list.textContent?.indexOf("Assign staff and print their QR login cards.") ?? -1; + expect(badgeIndex).toBeGreaterThanOrEqual(0); + expect(staffIndex).toBeGreaterThan(badgeIndex); + + expect(within(list).getAllByText("Coming soon")).toHaveLength(2); + expect(within(list).queryAllByRole("button")).toHaveLength(0); + }); + + it("shows the all-ready message instead of rows when ready === true", async () => { + readinessResponse = ALL_READY; + renderOverview(); + + expect( + await screen.findByText("Everything's ready — launch check-in from the header when doors open."), + ).toBeInTheDocument(); + expect(screen.queryByTestId("workspace-next-steps")).not.toBeInTheDocument(); + expect(screen.queryByText("Coming soon")).not.toBeInTheDocument(); + }); + + it("renders real readiness counts, checked-in stats, and the first two zone names", async () => { + readinessResponse = ALL_READY; + renderOverview(); + + const tiles = await screen.findByTestId("workspace-stat-tiles"); + expect(await within(tiles).findByText("340")).toBeInTheDocument(); // attendees + expect(await within(tiles).findByText("2")).toBeInTheDocument(); // zones count + expect(await within(tiles).findByText("3")).toBeInTheDocument(); // staff + expect(await within(tiles).findByText("120")).toBeInTheDocument(); // checked in + expect(await within(tiles).findByText("/ 200")).toBeInTheDocument(); + expect(await within(tiles).findByText("Main hall · VIP")).toBeInTheDocument(); + expect(within(tiles).queryByText(/Backstage/)).not.toBeInTheDocument(); + }); + + it("shows the 'optional, not used' caption on the Zones tile when the zones step is skipped, never real zone names", async () => { + readinessResponse = ZONES_SKIPPED_READINESS; + renderOverview(); + + const tiles = await screen.findByTestId("workspace-stat-tiles"); + expect(await within(tiles).findByText("Optional — not used")).toBeInTheDocument(); + expect(within(tiles).queryByText(/Main hall/)).not.toBeInTheDocument(); + }); + + it("shows an em-dash placeholder on the Checked-in tile (not a fabricated 0 / 0) when stats fail to load", async () => { + // All-ready readiness so the other three tiles show real (non-zero) + // counts — isolates this test to the Checked-in tile's own error state + // instead of colliding with legitimate `0` values elsewhere. + readinessResponse = ALL_READY; + server.use( + http.get("http://api.test/api/events/:eventId/stats", () => HttpResponse.json({ error: "boom" }, { status: 500 })), + ); + renderOverview(); + + const tiles = await screen.findByTestId("workspace-stat-tiles"); + expect(await within(tiles).findByText("340")).toBeInTheDocument(); // other tiles unaffected + expect(await within(tiles).findByText("—")).toBeInTheDocument(); + expect(within(tiles).queryByText(/\/ 0/)).not.toBeInTheDocument(); + expect(within(tiles).queryByText(/0 \/ 0/)).not.toBeInTheDocument(); + }); + + it("shows an em-dash placeholder on only the Zones tile when the zones query fails, leaving the Checked-in tile's real stats intact", async () => { + readinessResponse = ALL_READY; + server.use( + http.get("http://api.test/api/events/:eventId/zones", () => HttpResponse.json({ error: "boom" }, { status: 500 })), + ); + renderOverview(); + + const tiles = await screen.findByTestId("workspace-stat-tiles"); + expect(await within(tiles).findByText("120")).toBeInTheDocument(); + expect(await within(tiles).findByText("/ 200")).toBeInTheDocument(); + // Zones tile's count still comes from readiness (independent of the + // failed zones-list query) — only its caption falls back to the + // unavailable placeholder instead of a fabricated/missing zone-name list. + expect(within(tiles).getByText("2")).toBeInTheDocument(); + expect(await within(tiles).findByText("—")).toBeInTheDocument(); + expect(within(tiles).queryByText(/Main hall/)).not.toBeInTheDocument(); + }); + + it("shows skeleton placeholders, not fabricated zeros, while readiness/stats/zones are still loading", () => { + renderOverview(); + + // Synchronous assertion right after render, before the MSW-mocked + // responses resolve — mirrors LiveStrip.test.tsx's loading-state guard. + expect(screen.queryByText("340")).not.toBeInTheDocument(); + expect(screen.queryByText("120")).not.toBeInTheDocument(); + expect(screen.queryByText(/0 \/ 0/)).not.toBeInTheDocument(); + expect(screen.queryByTestId("workspace-next-steps")).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/features/workspace/WorkspaceOverview.tsx b/panel/src/features/workspace/WorkspaceOverview.tsx new file mode 100644 index 00000000..0c433f5a --- /dev/null +++ b/panel/src/features/workspace/WorkspaceOverview.tsx @@ -0,0 +1,191 @@ +import { + Card, CardContent, CardHeader, CardTitle, Skeleton, +} from "@idento/ui"; +import { getRouteApi } from "@tanstack/react-router"; +import { Circle, Lock } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { STEP_LABEL_KEYS } from "../home/ReadinessCell"; +import { useEventReadiness, useEventStats } from "../events/hooks"; +import { $api } from "../../shared/api/query"; +import type { components } from "../../shared/api/schema"; + +type ReadinessStep = components["schemas"]["ReadinessStep"]; +type EventZone = components["schemas"]["EventZone"]; +type EventZoneWithStats = components["schemas"]["EventZoneWithStats"]; + +// Same rationale as EventWorkspaceLayout.tsx: `getRouteApi` with the parent +// layout route's string id (not an import from app/router.tsx) avoids a +// circular import, and params declared on that ancestor route are resolvable +// from this component even though it's mounted one level down at the child +// index route ("/_app/events/$eventId/"). +const routeApi = getRouteApi("/_app/events/$eventId"); + +// The readiness pipeline steps that can ever appear as a "what's next" row — +// zones is intentionally excluded (board 1f: it's optional and never blocks, +// so it never earns a spot in the top-2 outstanding-items list even when +// it's not_done). +type NextStepKey = Exclude; + +const NEXT_STEP_ORDER: NextStepKey[] = ["attendees", "badge", "staff", "equipment"]; + +const NEXT_DESCRIPTION_KEYS: Record = { + attendees: "workspaceNextAttendees", + badge: "workspaceNextBadge", + staff: "workspaceNextStaff", + equipment: "workspaceNextEquipment", +}; + +function zoneName(entry: EventZone | EventZoneWithStats): string { + return "zone" in entry ? entry.zone.name : entry.name; +} + +// Board 1f §4 — the workspace index route's Overview panel: a "What's next" +// card surfacing up to the top two outstanding readiness steps (or an +// all-ready message), plus a 4-tile stat grid. Mounted at the index route via +// ``, so it fetches readiness/stats/zones itself rather than +// receiving them as props from EventWorkspaceLayout. +export function WorkspaceOverview() { + const { t } = useTranslation(); + const { eventId } = routeApi.useParams(); + const readiness = useEventReadiness(eventId); + const stats = useEventStats(eventId); + const zonesQuery = $api.useQuery("get", "/api/events/{event_id}/zones", { params: { path: { event_id: eventId } } }); + + const steps = readiness.data?.steps; + const stepsByKey = new Map(steps?.map((step) => [step.key, step]) ?? []); + const ready = readiness.data?.ready === true; + + const nextSteps = NEXT_STEP_ORDER.map((key) => ({ key, step: stepsByKey.get(key) })).filter( + (entry): entry is { key: NextStepKey; step: ReadinessStep } => entry.step?.status === "not_done", + ).slice(0, 2); + + const zonesStep = stepsByKey.get("zones"); + const zonesSkipped = zonesStep?.status === "skipped"; + const zoneNamesCaption = zonesQuery.data?.slice(0, 2).map(zoneName).join(" · "); + + return ( +
+
+

{t("workspaceOverviewTitle")}

+

{t("workspaceOverviewSubtitle")}

+
+ + + + {t("workspaceWhatsNext")} + + + {readiness.isLoading ? ( + <> + + + + ) : readiness.isError ? ( +

{t("workspaceLoadError")}

+ ) : ready ? ( +

{t("workspaceAllReady")}

+ ) : ( +
+ {nextSteps.map(({ key, step }) => ( + + ))} +
+ )} +
+
+ +
+ + {t("workspaceZonesNotUsed")}

+ ) : zonesQuery.isLoading ? ( + + ) : zonesQuery.isError ? ( +

{t("workspaceStatUnavailable")}

+ ) : zoneNamesCaption ? ( +

{zoneNamesCaption}

+ ) : null + } + /> + + / {stats.data?.total_attendees ?? 0}

} + /> +
+
+ ); +} + +function NextStepRow({ stepKey, step }: { stepKey: NextStepKey; step: ReadinessStep }) { + const { t } = useTranslation(); + return ( +
+ +
+

{t(STEP_LABEL_KEYS[step.key])}

+

{t(NEXT_DESCRIPTION_KEYS[stepKey])}

+
+ {/* Muted, non-interactive chip — not a button/link. Every target + screen (Attendees/Badge/Staff/Equipment) doesn't exist yet, so this + mirrors the rail's always-locked, never-a-dead-link pattern + (WorkspaceRail's Check-in row) rather than offering a fake CTA. */} + + + {t("workspaceStepComingSoon")} + +
+ ); +} + +interface StatTileProps { + title: string; + value: number | undefined; + isLoading: boolean; + isError: boolean; + caption?: ReactNode; +} + +// Loading -> Skeleton; per-query error -> em-dash placeholder styled as an +// error caption, NEVER a fabricated 0 (P1.1 rule, restated in the P1.2 +// brief). `caption` (when provided) only renders in the success branch, so a +// tile's secondary line (e.g. Zones' zone names, Checked-in's "/ total") +// never appears alongside a loading/error primary value either. +function StatTile({ title, value, isLoading, isError, caption }: StatTileProps) { + const { t } = useTranslation(); + return ( + + {title} + {isLoading ? ( + + ) : isError ? ( +

{t("workspaceStatUnavailable")}

+ ) : ( + <> +

{value ?? 0}

+ {caption} + + )} +
+ ); +} diff --git a/panel/src/features/workspace/WorkspaceRail.test.tsx b/panel/src/features/workspace/WorkspaceRail.test.tsx new file mode 100644 index 00000000..f2c6fdb4 --- /dev/null +++ b/panel/src/features/workspace/WorkspaceRail.test.tsx @@ -0,0 +1,147 @@ +import { RouterContextProvider, createRootRoute, createRouter } from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { WorkspaceRail } from "./WorkspaceRail"; +import type { components } from "../../shared/api/schema"; +import "../../shared/i18n"; + +type EventReadinessResponse = components["schemas"]["EventReadinessResponse"]; + +// WorkspaceRail renders two `Link`s (Overview -> /events/$eventId, Settings -> +// /events/$eventId/settings), which need a router context to resolve hrefs. +// These tests exercise the rail's own rendering, not routing, so the minimal +// single-route harness from LoginScreen.test.tsx / LiveStrip.test.tsx is +// enough — no need to reconstruct the app's real route shape (contrast +// ProtectedLayout.test.tsx / EventWorkspaceStub.test.tsx, which build a +// matching-route-shape harness because they exercise actual navigation / +// `getRouteApi` param resolution). Both routes are registered in the app's +// real router (router.tsx) by now, so `Link`'s `to`/`params` typecheck +// normally with no cast — this harness only needs to satisfy `Link`'s +// runtime need for *a* router in context, not one that actually contains +// the route. +const testRouter = createRouter({ routeTree: createRootRoute({ component: () => null }) }); + +function renderRail(ui: ReactNode) { + return render({ui}); +} + +// Mirrors board 1f's exact snapshot numbers verbatim: attendees/badge/zones/ +// staff done, equipment not done -> "4 of 5 ready", zones NOT skipped (so the +// "optional" suffixes stay off in this fixture; that's covered separately). +const FULL_READINESS: EventReadinessResponse = { + ready: true, + steps: [ + { key: "attendees", status: "done", count: 340 }, + { key: "badge", status: "done" }, + { key: "zones", status: "done", count: 2 }, + { key: "staff", status: "done", count: 3 }, + { key: "equipment", status: "not_done" }, + ], +}; + +const ZONES_SKIPPED_READINESS: EventReadinessResponse = { + ready: false, + steps: [ + { key: "attendees", status: "done", count: 100 }, + { key: "badge", status: "not_done" }, + { key: "zones", status: "skipped" }, + { key: "staff", status: "done", count: 5 }, + { key: "equipment", status: "not_done" }, + ], +}; + +describe("WorkspaceRail", () => { + it("renders the fraction, per-step icons+text labels, and counts for a full readiness snapshot", () => { + renderRail(); + + expect(screen.getByText("4 of 5 ready")).toBeInTheDocument(); + expect(screen.getByText("340")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + + // WCAG 1.4.1: every step's status must have a text label, not just an + // icon/color. Four steps are done, one is not — both texts must appear. + expect(screen.getAllByText("Done")).toHaveLength(4); + expect(screen.getByText("Not done")).toBeInTheDocument(); + + // Zones isn't skipped in this fixture, so neither optional suffix shows. + expect(screen.queryByText("Zones optional")).not.toBeInTheDocument(); + expect(screen.queryByText("optional")).not.toBeInTheDocument(); + }); + + it("colors each step icon by status — success for done, muted for not_done/skipped", () => { + // ZONES_SKIPPED_READINESS has one of every status (2 done, 2 not_done, + // 1 skipped), so a wrong/hardcoded color mapping shows up immediately. + // Icon color is asserted on the itself (not the row's text color) + // via the same `text-success`/`text-muted-foreground` token classes the + // rest of this component and ReadinessCell already use — the Check-in + // row's Lock icon has no color class of its own (inherits from its + // parent), so it can't false-positive into these counts. + const { container } = renderRail(); + + expect(container.querySelectorAll("svg.text-success")).toHaveLength(2); // attendees, staff + expect(container.querySelectorAll("svg.text-muted-foreground")).toHaveLength(3); // badge, zones, equipment + }); + + it("shows the muted zones-optional suffixes (header + row) only when zones is skipped", () => { + renderRail(); + + expect(screen.getByText("2 of 4 ready")).toBeInTheDocument(); + expect(screen.getByText("Zones optional")).toBeInTheDocument(); + expect(screen.getByText("optional")).toBeInTheDocument(); + expect(screen.getAllByText("Skipped")).toHaveLength(1); + }); + + it("shows the unlock hint when the event isn't ready", () => { + renderRail(); + expect(screen.getByText("Finish the badge and run a test print to unlock check-in.")).toBeInTheDocument(); + }); + + it("hides the unlock hint when the event is ready", () => { + renderRail(); + expect(screen.queryByText("Finish the badge and run a test print to unlock check-in.")).not.toBeInTheDocument(); + }); + + it("shows skeleton placeholders and no step rows while readiness is loading", () => { + renderRail(); + + expect(screen.queryByText(/of \d+ ready/)).not.toBeInTheDocument(); + expect(screen.queryByText("Attendees")).not.toBeInTheDocument(); + expect(screen.queryByText("Badge")).not.toBeInTheDocument(); + // Check-in/Settings are static nav, not readiness-derived, so they still render. + expect(screen.getByText("Check-in")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("hides the unlock hint while readiness is still loading (undefined), even though ready !== true", () => { + // Regression guard: `readiness?.ready !== true` is true both while + // loading (readiness undefined) and once loaded-but-not-ready — only the + // latter should render the hint. Rendering it during loading asserts + // "not ready yet" before the component actually knows the answer. + renderRail(); + expect(screen.queryByText("Finish the badge and run a test print to unlock check-in.")).not.toBeInTheDocument(); + }); + + it("highlights Overview as active when active is 'overview'", () => { + renderRail(); + expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Settings" })).not.toHaveAttribute("aria-current"); + }); + + it("highlights Settings as active when active is 'settings'", () => { + renderRail(); + expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current"); + }); + + it("renders step rows and the Check-in row as non-links, always locked", () => { + renderRail(); + + const linkNames = screen.getAllByRole("link").map((link) => link.textContent); + expect(linkNames.some((name) => name?.includes("Attendees"))).toBe(false); + expect(linkNames.some((name) => name?.includes("Check-in"))).toBe(false); + + expect(screen.getByText("Check-in").closest("a")).toBeNull(); + expect(screen.getByText("locked")).toBeInTheDocument(); + }); +}); diff --git a/panel/src/features/workspace/WorkspaceRail.tsx b/panel/src/features/workspace/WorkspaceRail.tsx new file mode 100644 index 00000000..0c00467b --- /dev/null +++ b/panel/src/features/workspace/WorkspaceRail.tsx @@ -0,0 +1,149 @@ +import { cn, Separator, Skeleton } from "@idento/ui"; +import { Link } from "@tanstack/react-router"; +import { CheckCircle2, Circle, Lock, MinusCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { STEP_LABEL_KEYS } from "../home/ReadinessCell"; +import type { components } from "../../shared/api/schema"; + +type EventReadinessResponse = components["schemas"]["EventReadinessResponse"]; +type ReadinessStep = components["schemas"]["ReadinessStep"]; + +export interface WorkspaceRailProps { + eventId: string; + readiness: EventReadinessResponse | undefined; + active: "overview" | "settings"; +} + +// Board 1f — the left-rail readiness pipeline for the event workspace. Pure, +// props-driven: this task only builds the rail itself (Task 2 mounts it in +// the workspace layout route and supplies real `readiness`/`active`). +export function WorkspaceRail({ eventId, readiness, active }: WorkspaceRailProps) { + const { t } = useTranslation(); + const steps = readiness?.steps; + const zonesSkipped = steps?.find((step) => step.key === "zones")?.status === "skipped"; + const done = steps?.filter((step) => step.status === "done").length ?? 0; + const total = steps?.filter((step) => step.status !== "skipped").length ?? 0; + + return ( +
+ {steps ? ( + <> +
+ {t("homeReadyFraction", { done, total })} + {zonesSkipped ? ( + {t("workspaceZonesOptional")} + ) : null} +
+
+ {steps.map((step) => ( +
+ ))} +
+ + ) : ( + <> + + + + )} +
+ + + + • + + {t("workspaceOverview")} + + +
+ {steps + ? steps.map((step, index) => ) + : Array.from({ length: 5 }, (_, index) => )} +
+ + + + {/* Check-in is always shown locked in the rail regardless of `ready` — + the actionable unlock lives on the workspace header button (Task 2). */} +
+ + {t("workspaceCheckin")} + {t("workspaceCheckinLocked")} +
+ + + + {t("workspaceSettings")} + + + {readiness !== undefined && readiness.ready !== true ? ( +
+ {t("workspaceUnlockHint")} +
+ ) : null} + + ); +} + +// Status -> icon shape + color, kept as one lookup so the two never drift +// apart (a `done` step is both the CheckCircle2 shape *and* the success +// color; the reviewer flagged that the color half of this pairing was +// missing entirely). +const STEP_STATUS_ICON: Record = { + done: { icon: CheckCircle2, className: "text-success" }, + not_done: { icon: Circle, className: "text-muted-foreground" }, + skipped: { icon: MinusCircle, className: "text-muted-foreground" }, +}; + +function StepRow({ step, index }: { step: ReadinessStep; index: number }) { + const { t } = useTranslation(); + const { icon: Icon, className: iconClassName } = STEP_STATUS_ICON[step.status]; + // Icon + color alone can't convey status to assistive tech (WCAG 1.4.1) — + // every status gets a real (sr-only) text label here, mirroring + // ReadinessCell's tooltip pattern of always pairing icon + text + color. + const statusText = + step.status === "done" ? t("readinessStatusDone") : step.status === "skipped" ? t("readinessSkipped") : t("readinessStatusNotDone"); + + return ( +
+ + + {index} · {t(STEP_LABEL_KEYS[step.key])} + {step.key === "zones" && step.status === "skipped" ? ( + {t("workspaceOptionalSuffix")} + ) : null} + + {statusText} + {step.count !== undefined ? {step.count} : null} +
+ ); +} diff --git a/panel/src/features/workspace/settings/ApiKeysCard.test.tsx b/panel/src/features/workspace/settings/ApiKeysCard.test.tsx new file mode 100644 index 00000000..ea6720ff --- /dev/null +++ b/panel/src/features/workspace/settings/ApiKeysCard.test.tsx @@ -0,0 +1,435 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { delay, http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { ApiKeysCard } from "./ApiKeysCard"; +import { startMswServer } from "../../../test/msw"; +import "../../../shared/i18n"; +import type { components } from "../../../shared/api/schema"; + +type APIKey = components["schemas"]["APIKey"]; + +const ACTIVE: APIKey = { + id: "key-1", + event_id: "evt-1", + name: "CRM sync", + key_preview: "idnt_live_a1b2c3d4...", + last_used_at: "2026-06-10T09:15:00.000Z", + created_at: "2026-06-01T12:00:00.000Z", +}; + +const REVOKED: APIKey = { + id: "key-2", + event_id: "evt-1", + name: "Old integration", + key_preview: "idnt_live_zzzz9999...", + revoked_at: "2026-06-15T00:00:00.000Z", + created_at: "2026-05-01T00:00:00.000Z", +}; + +let keys: APIKey[] = [ACTIVE, REVOKED]; +let listHitCount = 0; +let deleteCount = 0; +let lastDeletedKeyId: string | undefined; +let deleteStatusOverride: number | null = null; +let deleteDelayMs = 0; +let createCount = 0; +let lastCreateBody: unknown; +let createStatusOverride: number | null = null; +let createDelayMs = 0; + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/api-keys", () => { + listHitCount += 1; + return HttpResponse.json(keys); + }), + http.post("http://api.test/api/events/:eventId/api-keys", async ({ request }) => { + createCount += 1; + lastCreateBody = await request.json(); + if (createDelayMs) await delay(createDelayMs); + if (createStatusOverride) { + return HttpResponse.json({ error: "bad request" }, { status: createStatusOverride }); + } + const created: APIKey = { + id: "key-new", + event_id: "evt-1", + name: (lastCreateBody as { name?: string }).name ?? "", + key_preview: "idnt_live_newnewnew...", + created_at: "2026-06-20T00:00:00.000Z", + }; + keys = [...keys, created]; + return HttpResponse.json({ api_key: created, plain_key: "idnt_live_newnewnewSECRETVALUE" }, { status: 201 }); + }), + http.delete("http://api.test/api/events/:eventId/api-keys/:keyId", async ({ params }) => { + deleteCount += 1; + lastDeletedKeyId = params.keyId as string; + if (deleteDelayMs) await delay(deleteDelayMs); + if (deleteStatusOverride) { + return HttpResponse.json({ error: "server error" }, { status: deleteStatusOverride }); + } + keys = keys.map((k) => (k.id === params.keyId ? { ...k, revoked_at: "2026-06-21T00:00:00.000Z" } : k)); + return HttpResponse.json({ message: "revoked" }); + }), +); +void server; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("ApiKeysCard", () => { + beforeEach(() => { + keys = [ACTIVE, REVOKED]; + listHitCount = 0; + deleteCount = 0; + lastDeletedKeyId = undefined; + deleteStatusOverride = null; + deleteDelayMs = 0; + createCount = 0; + lastCreateBody = undefined; + createStatusOverride = null; + createDelayMs = 0; + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + // jsdom itself has no Clipboard implementation, but `userEvent.setup()` + // (called per-test, after this helper must run) auto-attaches its own + // getter-only clipboard stub to `navigator` (real jsdom has none; the + // stub is testing-library's) — a mock installed before `setup()` is + // clobbered by it, so this must be called AFTER `userEvent.setup()`, and + // must use `defineProperty` (assignment throws on a getter-only property). + function mockClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + return writeText; + } + + it("renders a header row with Name / Created / Last used column labels above the list", async () => { + renderWithProviders(); + + await screen.findByText("CRM sync"); + + const nameHeader = screen.getByText("Name"); + const createdHeader = screen.getByText("Created"); + const lastUsedHeader = screen.getByText("Last used"); + const headerRow = nameHeader.closest("div"); + expect(headerRow).not.toBeNull(); + expect(within(headerRow as HTMLElement).getByText("Created")).toBe(createdHeader); + expect(within(headerRow as HTMLElement).getByText("Last used")).toBe(lastUsedHeader); + }); + + it("renders keys with name, masked key preview, created/last-used dates, and a dimmed revoked row with a Revoked pill", async () => { + renderWithProviders(); + + expect(await screen.findByText("CRM sync")).toBeInTheDocument(); + expect(screen.getByText("idnt_live_a1b2c3d4...")).toBeInTheDocument(); + expect(screen.getByText("2026-06-01")).toBeInTheDocument(); + expect(screen.getByText("2026-06-10")).toBeInTheDocument(); + + // Revoked row: name still shown, dimmed, "Revoked" pill instead of a + // revoke action, em-dash for the never-set last-used date. + const revokedRow = screen.getByText("Old integration").closest("li"); + expect(revokedRow).not.toBeNull(); + expect(revokedRow?.className).toContain("opacity"); + expect(within(revokedRow as HTMLElement).getByText("Revoked")).toBeInTheDocument(); + expect(within(revokedRow as HTMLElement).getByText("—")).toBeInTheDocument(); + expect(within(revokedRow as HTMLElement).queryByRole("button", { name: "Revoke…" })).not.toBeInTheDocument(); + + // Active row still offers Revoke… + const activeRow = screen.getByText("CRM sync").closest("li"); + expect(within(activeRow as HTMLElement).getByRole("button", { name: "Revoke…" })).toBeInTheDocument(); + }); + + it("shows a muted empty-state caption when there are no keys", async () => { + keys = []; + renderWithProviders(); + + expect( + await screen.findByText("No API keys yet — create one to push attendees from your registration forms or CRM."), + ).toBeInTheDocument(); + }); + + it("revokes a key: confirm dialog -> DELETE -> list invalidated", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + const initialHits = listHitCount; + + const activeRow = screen.getByText("CRM sync").closest("li") as HTMLElement; + await user.click(within(activeRow).getByRole("button", { name: "Revoke…" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(/Integrations using this key stop working immediately\./)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: "Revoke" })); + + await waitFor(() => expect(deleteCount).toBe(1)); + expect(lastDeletedKeyId).toBe("key-1"); + await waitFor(() => expect(listHitCount).toBeGreaterThan(initialHits)); + }); + + it("creates a key: POST with the entered name -> reveal shows the plain key once, with a copy button and warning", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + await user.click(screen.getByRole("button", { name: "+ Create key" })); + + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Registration form"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + + await waitFor(() => expect(createCount).toBe(1)); + expect(lastCreateBody).toEqual({ name: "Registration form" }); + + expect(await within(dialog).findByText("idnt_live_newnewnewSECRETVALUE")).toBeInTheDocument(); + expect(within(dialog).getByText("This is the only time the key is shown.")).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Done" })).toBeInTheDocument(); + + // list refetched after create too + await waitFor(() => expect(screen.getAllByText(/CRM sync|Registration form/)).toHaveLength(2)); + }); + + it("copies the revealed plain key to the clipboard", async () => { + const user = userEvent.setup(); + const writeText = mockClipboard(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + await user.click(screen.getByRole("button", { name: "+ Create key" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + + await within(dialog).findByText("idnt_live_newnewnewSECRETVALUE"); + await user.click(within(dialog).getByRole("button", { name: "Copy" })); + + expect(writeText).toHaveBeenCalledWith("idnt_live_newnewnewSECRETVALUE"); + }); + + it("does not show 'Copied' when the clipboard write is rejected (e.g. permission blocked)", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockRejectedValue(new Error("permission denied")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + renderWithProviders(); + + await screen.findByText("CRM sync"); + await user.click(screen.getByRole("button", { name: "+ Create key" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + + await within(dialog).findByText("idnt_live_newnewnewSECRETVALUE"); + await user.click(within(dialog).getByRole("button", { name: "Copy" })); + + expect(writeText).toHaveBeenCalledWith("idnt_live_newnewnewSECRETVALUE"); + // Give the rejected promise's handler a tick to run, then assert the + // button never flipped to "Copied" — a failed write must not lie about + // success. + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(within(dialog).queryByRole("button", { name: "Copied" })).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Copy" })).toBeInTheDocument(); + }); + + it("clears the plain key and resets the create mutation when the dialog is closed in reveal state, so reopening shows a fresh form", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + await user.click(screen.getByRole("button", { name: "+ Create key" })); + let dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + await within(dialog).findByText("idnt_live_newnewnewSECRETVALUE"); + + // Close while still in the reveal state (Done button acts as close). + await user.click(within(dialog).getByRole("button", { name: "Done" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + // Reopen: must be a fresh create form, not stale reveal content. + await user.click(screen.getByRole("button", { name: "+ Create key" })); + dialog = await screen.findByRole("dialog"); + expect(within(dialog).queryByText("idnt_live_newnewnewSECRETVALUE")).not.toBeInTheDocument(); + expect(within(dialog).getByLabelText("Name")).toHaveValue(""); + expect(within(dialog).getByRole("button", { name: "+ Create key" })).toBeInTheDocument(); + }); + + it("resets a stale create error on close/reopen, not just the reveal state", async () => { + createStatusOverride = 400; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + await user.click(screen.getByRole("button", { name: "+ Create key" })); + let dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + + expect(await within(dialog).findByText("Couldn't create the key.")).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: "+ Create key" })); + dialog = await screen.findByRole("dialog"); + expect(within(dialog).queryByText("Couldn't create the key.")).not.toBeInTheDocument(); + }); + + // Regression test for the close-during-pending race: the create POST is + // still in flight when the user closes the dialog (Cancel here, but X / + // Escape / overlay all funnel through the same setCreateOpen(false)). + // `createKey.reset()` on close only detaches the mutation observer — it + // does not cancel the in-flight request or stop `onSuccess` from firing + // once the response lands late. Pre-fix, that late `onSuccess` still sets + // `plainKey`, and reopening the dialog then shows that stray, unlabeled + // secret directly in the reveal state instead of a fresh form. + it("never resurfaces a stray plain_key if the create dialog is closed before the POST resolves (close-during-pending race)", async () => { + createDelayMs = 50; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + await user.click(screen.getByRole("button", { name: "+ Create key" })); + let dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Race key"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + + // Close (Cancel) while the delayed POST is still in flight. + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + // Let the delayed response land well after the close. + await waitFor(() => expect(createCount).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, createDelayMs + 100)); + + // Reopen: must be a genuinely fresh create form — the stray secret from + // the aborted attempt above must never surface, labeled or not. + await user.click(screen.getByRole("button", { name: "+ Create key" })); + dialog = await screen.findByRole("dialog"); + expect(within(dialog).queryByText("idnt_live_newnewnewSECRETVALUE")).not.toBeInTheDocument(); + expect(within(dialog).getByLabelText("Name")).toHaveValue(""); + expect(within(dialog).getByRole("button", { name: "+ Create key" })).toBeInTheDocument(); + }); + + // Regression test for the SECOND cancel-then-reopen race: a plain boolean + // ref reset to false on every reopen can't tell "a response from THIS + // session" apart from "a response from a PREVIOUSLY-closed session" once + // the dialog has been reopened at least once — the second, still-pending + // create's stale sibling from the first (already-cancelled) attempt can + // land after the reopen and slip past a re-armed boolean guard. A + // monotonically-incrementing session id (bumped on every close) closes + // that gap: the first request's captured session id can never match the + // current one again, no matter how many times the dialog reopens. + it("never shows a stale plain_key from a FIRST cancelled create, even after a second create is submitted following a reopen", async () => { + // Distinguish the two requests' responses by name-derived secret, and + // have the FIRST (already-cancelled) request resolve well AFTER the + // second (current) one — the exact race this fix targets: a stale + // response from a previously-closed session landing after a reopen and + // a fresh submit. + let callIndex = 0; + server.use( + http.post("http://api.test/api/events/:eventId/api-keys", async ({ request }) => { + callIndex += 1; + const index = callIndex; + const body = (await request.json()) as { name?: string }; + createCount += 1; + lastCreateBody = body; + await delay(index === 1 ? 150 : 20); + const secret = index === 1 ? "SECRET_FIRST_ABORTED" : "SECRET_SECOND_CURRENT"; + const created: APIKey = { + id: `key-${index}`, + event_id: "evt-1", + name: body.name ?? "", + key_preview: "idnt_live_xxxx...", + created_at: "2026-06-20T00:00:00.000Z", + }; + keys = [...keys, created]; + return HttpResponse.json({ api_key: created, plain_key: secret }, { status: 201 }); + }), + ); + + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + + // First attempt: open, submit, cancel while still pending. + await user.click(screen.getByRole("button", { name: "+ Create key" })); + let dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "First (aborted)"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + // Reopen and submit a second, current create — before the first + // request's delayed response has landed. + await user.click(screen.getByRole("button", { name: "+ Create key" })); + dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Second (current)"); + await user.click(within(dialog).getByRole("button", { name: "+ Create key" })); + + // The second (current) request resolves first — its secret must appear. + expect(await within(dialog).findByText("SECRET_SECOND_CURRENT")).toBeInTheDocument(); + + // Now let the first (already-cancelled) request's late response land too. + await waitFor(() => expect(createCount).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // The stale first response must never surface — the dialog must still + // show only the second (current) request's secret. + expect(within(dialog).queryByText("SECRET_FIRST_ABORTED")).not.toBeInTheDocument(); + expect(within(dialog).getByText("SECRET_SECOND_CURRENT")).toBeInTheDocument(); + }); + + it("shows an inline error when revoking fails, and clears it on a subsequent successful revoke", async () => { + deleteStatusOverride = 500; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + const activeRow = screen.getByText("CRM sync").closest("li") as HTMLElement; + await user.click(within(activeRow).getByRole("button", { name: "Revoke…" })); + + let dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Revoke" })); + + await waitFor(() => expect(deleteCount).toBe(1)); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(await screen.findByText("Couldn't revoke the key. Try again.")).toBeInTheDocument(); + // The key is still active — the failed DELETE must not be reflected as + // if it succeeded. + expect(within(activeRow).getByRole("button", { name: "Revoke…" })).toBeInTheDocument(); + + // A subsequent successful revoke clears the stale error. + deleteStatusOverride = null; + await user.click(within(activeRow).getByRole("button", { name: "Revoke…" })); + dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Revoke" })); + + await waitFor(() => expect(deleteCount).toBe(2)); + await waitFor(() => expect(screen.queryByText("Couldn't revoke the key. Try again.")).not.toBeInTheDocument()); + }); + + it("disables the ConfirmDialog's confirm button while the revoke request is pending, to prevent a double DELETE", async () => { + deleteDelayMs = 50; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("CRM sync"); + const activeRow = screen.getByText("CRM sync").closest("li") as HTMLElement; + await user.click(within(activeRow).getByRole("button", { name: "Revoke…" })); + + const dialog = await screen.findByRole("dialog"); + const confirmButton = within(dialog).getByRole("button", { name: "Revoke" }); + await user.click(confirmButton); + + expect(confirmButton).toBeDisabled(); + await waitFor(() => expect(deleteCount).toBe(1)); + }); +}); diff --git a/panel/src/features/workspace/settings/ApiKeysCard.tsx b/panel/src/features/workspace/settings/ApiKeysCard.tsx new file mode 100644 index 00000000..2eeb64fc --- /dev/null +++ b/panel/src/features/workspace/settings/ApiKeysCard.tsx @@ -0,0 +1,290 @@ +import { + Button, Card, CardContent, CardHeader, CardTitle, CardDescription, ConfirmDialog, Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, Input, Label, Skeleton, StatusPill, cn, +} from "@idento/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { $api } from "../../../shared/api/query"; +import type { components } from "../../../shared/api/schema"; + +type APIKey = components["schemas"]["APIKey"]; + +export interface ApiKeysCardProps { + eventId: string; +} + +// Shared between the header row and each key row so the header labels stay +// pixel-aligned with their columns (Name / masked key preview / Created / +// Last used / Actions). +const KEY_ROW_GRID = "grid grid-cols-[1fr_180px_100px_100px_90px] items-center gap-3"; + +// UTC calendar date, same convention GeneralCard.tsx/FontsCard.tsx use for +// date-only display of a full timestamp. +function formatUtcDate(iso: string): string { + return new Date(iso).toISOString().slice(0, 10); +} + +// Board 6a's API keys card: a table-ish list (Name / masked key preview / +// created / last-used / actions — Scope is dropped, the API has no such +// field) plus a "+ Create key" affordance whose dialog swaps into a +// show-once reveal state on success. No expiry picker in P1.2 — the API +// accepts `expires_at` but the board doesn't show a picker for it and it's +// explicit YAGNI per the task brief; only `name` is collected here. +export function ApiKeysCard({ eventId }: ApiKeysCardProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [revokeTarget, setRevokeTarget] = React.useState(null); + const [revokeError, setRevokeError] = React.useState(false); + const [createOpen, setCreateOpen] = React.useState(false); + const [name, setName] = React.useState(""); + const [plainKey, setPlainKey] = React.useState(null); + const [copied, setCopied] = React.useState(false); + const copiedTimeoutRef = React.useRef(undefined); + // Monotonically-incrementing session id, bumped every time the create + // dialog closes for any reason (Cancel/X/Escape/overlay). + // `createKey.reset()` on close only detaches the mutation observer — it + // does NOT cancel the in-flight request or stop `onSuccess` from firing + // when the response lands late. A plain boolean re-armed on every reopen + // isn't enough: a SECOND cancel-then-reopen cycle can let a stale response + // from the FIRST (already-abandoned) request pass the guard again once + // it's reset to false on reopen. An incrementing id captured at + // mutate-time (via `onMutate`) and compared exactly in `onSuccess` means a + // reopen never "un-stales" a response tied to a previously-closed session + // — every close permanently invalidates all in-flight responses from + // before it. + const createSessionRef = React.useRef(0); + + React.useEffect(() => () => window.clearTimeout(copiedTimeoutRef.current), []); + + const listQueryKey = ["get", "/api/events/{event_id}/api-keys", { params: { path: { event_id: eventId } } }] as const; + + const keysQuery = $api.useQuery("get", "/api/events/{event_id}/api-keys", { + params: { path: { event_id: eventId } }, + }); + + const revokeKey = $api.useMutation("delete", "/api/events/{event_id}/api-keys/{key_id}", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: listQueryKey }); + setRevokeError(false); + setRevokeTarget(null); + }, + onError: () => { + // Close the dialog so the inline error below is actually visible + // (it lives in the card, behind the modal overlay while open), and + // leave revokeTarget's key un-revoked in the list. + setRevokeError(true); + setRevokeTarget(null); + }, + }); + + const createKey = $api.useMutation("post", "/api/events/{event_id}/api-keys", { + onMutate: () => ({ sessionId: createSessionRef.current }), + onSuccess: (created, _vars, onMutateResult) => { + void queryClient.invalidateQueries({ queryKey: listQueryKey }); + // See createSessionRef above: if the dialog was closed (and possibly + // reopened) since this particular request was submitted, its session + // id no longer matches the current one, and the secret must never be + // shown. + if (onMutateResult?.sessionId !== createSessionRef.current) return; + setPlainKey(created.plain_key); + }, + }); + + // Reset all create-dialog state on the open->closed transition — the + // secret must never survive a close (P1.1 mutation-reset-on-close rule, + // re-confirmed for Task 4/5's dialogs), and reopening must show a fresh + // form rather than a stale error or the previous reveal. + React.useEffect(() => { + if (createOpen) return; + // Any response still in flight from the closed session is now + // permanently stale — a later reopen gets a new session id, so it can + // never match again. + createSessionRef.current += 1; + setName(""); + setPlainKey(null); + setCopied(false); + window.clearTimeout(copiedTimeoutRef.current); + createKey.reset(); + // createKey is a fresh mutation object each render; including it in the + // deps would reset on every render instead of only on close. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createOpen]); + + function handleCreateSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = name.trim(); + createKey.mutate({ + params: { path: { event_id: eventId } }, + body: trimmed ? { name: trimmed } : {}, + }); + } + + function handleCopy() { + if (!plainKey) return; + // Await the write and only claim success once it actually resolves — a + // rejected clipboard write (e.g. permission blocked) must not flip the + // button to "Copied" and mislead the user into thinking the one-time + // secret made it onto their clipboard. + navigator.clipboard.writeText(plainKey).then( + () => { + setCopied(true); + window.clearTimeout(copiedTimeoutRef.current); + copiedTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + }, + () => { + // Leave `copied` false — no distinct failure-state i18n key exists + // for this yet, and adding one is more than this fix warrants. + }, + ); + } + + const keys = keysQuery.data ?? []; + + return ( + <> + + + {t("settingsApiKeys")} + {t("settingsApiKeysSubtitle")} + + + {keysQuery.isLoading ? ( +
+ + +
+ ) : keysQuery.isError ? ( +

{t("settingsLoadError")}

+ ) : keys.length > 0 ? ( +
+
+ {t("settingsKeyName")} + + {t("settingsKeyCreatedAt")} + {t("settingsKeyLastUsed")} + +
+
    + {keys.map((key) => { + const revoked = Boolean(key.revoked_at); + return ( +
  • + {key.name} + {key.key_preview} + {formatUtcDate(key.created_at)} + + {key.last_used_at ? formatUtcDate(key.last_used_at) : "—"} + +
    + {revoked ? ( + + ) : ( + + )} +
    +
  • + ); + })} +
+
+ ) : ( +

{t("settingsKeysEmpty")}

+ )} + + {revokeError ?

{t("settingsKeyRevokeError")}

: null} + +
+ +

{t("settingsKeyShownOnce")}

+
+
+
+ + {revokeTarget ? ( + { + if (!open) setRevokeTarget(null); + }} + title={t("settingsKeyRevokeTitle")} + description={t("settingsKeyRevokeBody")} + confirmLabel={t("settingsKeyRevokeConfirm")} + cancelLabel={t("createEventCancel")} + closeLabel={t("workspaceDialogClose")} + destructive + confirmDisabled={revokeKey.isPending} + onConfirm={() => + revokeKey.mutate({ + params: { path: { event_id: eventId, key_id: revokeTarget.id } }, + }) + } + /> + ) : null} + + + + {plainKey ? ( + <> + + {t("settingsKeyCreate")} + +
+ + {plainKey} + + +

{t("settingsKeyRevealWarning")}

+
+ + + + + ) : ( + <> + + {t("settingsKeyCreate")} + +
+
+ + setName(e.target.value)} /> +
+ {createKey.isError ? ( +

{t("settingsKeyCreateError")}

+ ) : null} + + + + + + + )} +
+
+ + ); +} diff --git a/panel/src/features/workspace/settings/DangerZoneCard.test.tsx b/panel/src/features/workspace/settings/DangerZoneCard.test.tsx new file mode 100644 index 00000000..d6236aa7 --- /dev/null +++ b/panel/src/features/workspace/settings/DangerZoneCard.test.tsx @@ -0,0 +1,321 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { delay, http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { DangerZoneCard } from "./DangerZoneCard"; +import { $api } from "../../../shared/api/query"; +import { startMswServer } from "../../../test/msw"; +import "../../../shared/i18n"; +import type { components } from "../../../shared/api/schema"; + +type ApiEvent = components["schemas"]["Event"]; + +// DangerZoneCard navigates to Home via `useNavigate` (TanStack Router) after +// a successful delete. Mocked the same way CreateEventDialog.test.tsx does, +// for the same reason: asserting the exact navigation args without standing +// up a full router harness with a registered "/" route. +const navigateMock = vi.fn(); +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const EVENT: ApiEvent = { + id: "evt-1", + tenant_id: "t1", + name: "Partner Day — Autumn", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", +}; + +let listHitCount = 0; +let deleteCount = 0; +let lastDeletedId: string | undefined; +let deleteStatusOverride: number | null = null; +let deleteDelayMs = 0; + +const server = startMswServer( + http.get("http://api.test/api/events", () => { + listHitCount += 1; + return HttpResponse.json([]); + }), + http.delete("http://api.test/api/events/:id", async ({ params }) => { + deleteCount += 1; + lastDeletedId = params.id as string; + if (deleteDelayMs) await delay(deleteDelayMs); + if (deleteStatusOverride) { + return HttpResponse.json({ error: "server error" }, { status: deleteStatusOverride }); + } + // deleteEvent's actual schema.d.ts response is 204 No Content (verified + // against backend/internal/handler/events.go's c.NoContent call) — unlike + // Tasks 5/6's fonts/api-keys endpoints, this one's 204 claim holds. + return new HttpResponse(null, { status: 204 }); + }), +); +void server; + +// Keeps the events-list query active (an observer subscribed to it) so that +// `queryClient.invalidateQueries` on that key actually triggers a refetch we +// can observe via `listHitCount`, mirroring how Home would behave in the +// real app. DangerZoneCard itself never fetches this query. +function ListObserver() { + $api.useQuery("get", "/api/events"); + return null; +} + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + {ui} + , + ); +} + +describe("DangerZoneCard", () => { + beforeEach(() => { + listHitCount = 0; + deleteCount = 0; + lastDeletedId = undefined; + deleteStatusOverride = null; + deleteDelayMs = 0; + navigateMock.mockClear(); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it("renders a destructive-tinted card with the delete-event row", async () => { + renderWithProviders(); + + const heading = await screen.findByRole("heading", { name: "Danger zone" }); + expect(heading.className).toContain("text-destructive"); + expect(heading.closest("div.rounded-lg")?.className).toContain("border-destructive/30"); + + expect(screen.getByText("Delete this event")).toBeInTheDocument(); + expect( + screen.getByText("Attendees, check-in history and the badge design — gone. Typed confirmation required."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete event…" })).toBeInTheDocument(); + }); + + it("keeps the confirm button disabled until the exact event name is typed", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const dialog = await screen.findByRole("dialog"); + + const confirmButton = screen.getByRole("button", { name: "Delete event" }); + expect(confirmButton).toBeDisabled(); + + const input = screen.getByLabelText("Type Partner Day — Autumn to confirm"); + await user.type(input, "Partner Day"); + expect(confirmButton).toBeDisabled(); + + await user.type(input, " — Autumn"); + expect(confirmButton).toBeEnabled(); + + // A near-miss (wrong case/whitespace) must not satisfy the exact match. + await user.clear(input); + await user.type(input, "partner day — autumn"); + expect(confirmButton).toBeDisabled(); + + void dialog; + expect(deleteCount).toBe(0); + }); + + it("deletes on confirm: DELETE with the correct id, navigates home, and invalidates the events list", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(listHitCount).toBe(1)); + + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const input = screen.getByLabelText("Type Partner Day — Autumn to confirm"); + await user.type(input, "Partner Day — Autumn"); + await user.click(screen.getByRole("button", { name: "Delete event" })); + + await waitFor(() => expect(deleteCount).toBe(1)); + expect(lastDeletedId).toBe("evt-1"); + + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith({ to: "/" })); + await waitFor(() => expect(listHitCount).toBeGreaterThan(1)); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("keeps the dialog open with the error shown inside it and the typed name preserved when the delete fails, then a retry from that same dialog succeeds", async () => { + deleteStatusOverride = 500; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const dialog = await screen.findByRole("dialog"); + const input = screen.getByLabelText("Type Partner Day — Autumn to confirm"); + await user.type(input, "Partner Day — Autumn"); + await user.click(within(dialog).getByRole("button", { name: "Delete event" })); + + await waitFor(() => expect(deleteCount).toBe(1)); + // The failure must NOT auto-close the dialog — ConfirmDialog wipes its + // typed input on any close, and this is a typed-confirmation flow, so + // auto-closing on a transient failure would force a full, exact, + // case-sensitive retype for no reason. + expect(await within(dialog).findByText("Couldn't delete the event. Please try again.")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBe(dialog); + expect(input).toHaveValue("Partner Day — Autumn"); + expect(navigateMock).not.toHaveBeenCalled(); + + // Retry from the same still-open dialog — no retyping needed. + deleteStatusOverride = null; + await user.click(within(dialog).getByRole("button", { name: "Delete event" })); + + await waitFor(() => expect(deleteCount).toBe(2)); + expect(lastDeletedId).toBe("evt-1"); + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith({ to: "/" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("clears the error and closes normally when the user explicitly clicks Cancel after a failure", async () => { + deleteStatusOverride = 500; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const dialog = await screen.findByRole("dialog"); + await user.type(screen.getByLabelText("Type Partner Day — Autumn to confirm"), "Partner Day — Autumn"); + await user.click(within(dialog).getByRole("button", { name: "Delete event" })); + + expect(await within(dialog).findByText("Couldn't delete the event. Please try again.")).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.queryByText("Couldn't delete the event. Please try again.")).not.toBeInTheDocument(); + + // Reopening starts a genuinely fresh attempt: no stale error, no stale + // typed input, confirm disabled until the name is retyped. + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const reopened = await screen.findByRole("dialog"); + expect(within(reopened).queryByText("Couldn't delete the event. Please try again.")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Type Partner Day — Autumn to confirm")).toHaveValue(""); + expect(within(reopened).getByRole("button", { name: "Delete event" })).toBeDisabled(); + }); + + // Regression test for the cancel-during-pending race (same class as + // ApiKeysCard's create-dialog fix): the DELETE is still in flight when the + // user clicks Cancel. `deleteEvent.reset()` on close only detaches the + // mutation observer — it does not abort the in-flight request or stop + // `onSuccess`/`onError` from firing once the response lands late. Pre-fix, + // that late `onSuccess` still force-navigated the user to Home for a + // delete they believed they'd cancelled, and a late `onError` would have + // surfaced a card-level error the user never expected to see again — the + // abort guard correctly suppresses both of those UI-visible reactions. + // Cache invalidation is a different matter: the event is genuinely deleted + // server-side either way (that can't be un-cancelled from the client), so + // the events-list query must still be invalidated even though the dialog + // was aborted — otherwise the list would keep showing a phantom deleted + // event. See DangerZoneCard.tsx's onSuccess for why invalidation runs + // unconditionally, before the abort-ref check. + it("still invalidates the events list, but does not navigate or surface an error, if the confirm dialog is closed (Cancel) before a pending DELETE resolves", async () => { + deleteDelayMs = 50; + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(listHitCount).toBe(1)); + + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const dialog = await screen.findByRole("dialog"); + await user.type(screen.getByLabelText("Type Partner Day — Autumn to confirm"), "Partner Day — Autumn"); + await user.click(within(dialog).getByRole("button", { name: "Delete event" })); + + // Cancel while the delayed DELETE is still in flight (the Cancel button + // is never disabled by `confirmDisabled`, unlike the confirm button). + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + // Let the delayed response land well after the close. + await waitFor(() => expect(deleteCount).toBe(1)); + await waitFor(() => expect(listHitCount).toBeGreaterThan(1)); + + expect(navigateMock).not.toHaveBeenCalled(); + expect(screen.queryByText("Couldn't delete the event. Please try again.")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + // Regression test for the SECOND cancel-then-reopen race (same class as + // ApiKeysCard's equivalent create-session test): a plain boolean ref reset + // on every reopen can't distinguish "a response from THIS session" from "a + // response from a PREVIOUSLY-cancelled session" once the dialog has been + // reopened at least once. The first (already-cancelled) delete's late + // response must never navigate or surface an error, even after a second + // delete is submitted from a reopened dialog. + it("never navigates or surfaces an error from a FIRST cancelled delete, even after a second delete is submitted following a reopen", async () => { + let callIndex = 0; + server.use( + http.delete("http://api.test/api/events/:id", async ({ params }) => { + callIndex += 1; + const index = callIndex; + deleteCount += 1; + lastDeletedId = params.id as string; + // First (already-cancelled) delete resolves well AFTER the second. + await delay(index === 1 ? 150 : 20); + if (index === 1) { + // Simulate the aborted attempt's response landing late as an + // error — this must not surface after the reopen either. + return HttpResponse.json({ error: "server error" }, { status: 500 }); + } + return new HttpResponse(null, { status: 204 }); + }), + ); + + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(listHitCount).toBe(1)); + + // First attempt: open, type, confirm, then cancel while still pending. + await user.click(screen.getByRole("button", { name: "Delete event…" })); + let dialog = await screen.findByRole("dialog"); + await user.type(screen.getByLabelText("Type Partner Day — Autumn to confirm"), "Partner Day — Autumn"); + await user.click(within(dialog).getByRole("button", { name: "Delete event" })); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + // Reopen and submit a second, current delete — before the first + // request's delayed response has landed. + await user.click(screen.getByRole("button", { name: "Delete event…" })); + dialog = await screen.findByRole("dialog"); + await user.type(screen.getByLabelText("Type Partner Day — Autumn to confirm"), "Partner Day — Autumn"); + await user.click(within(dialog).getByRole("button", { name: "Delete event" })); + + // The second (current) delete resolves first and navigates home. + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith({ to: "/" })); + + // Now let the first (already-cancelled) delete's late error response + // land too — it must not surface anywhere, since the user is already + // gone from this screen in a real app (simulated here by the card still + // being mounted). + await waitFor(() => expect(deleteCount).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Couldn't delete the event. Please try again.")).not.toBeInTheDocument(); + }); + + it("disables the confirm button while the delete is pending, to prevent a double DELETE", async () => { + const user = userEvent.setup(); + server.use( + http.delete("http://api.test/api/events/:id", async () => { + deleteCount += 1; + await new Promise((resolve) => setTimeout(resolve, 50)); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Delete event…" })); + const input = screen.getByLabelText("Type Partner Day — Autumn to confirm"); + await user.type(input, "Partner Day — Autumn"); + const confirmButton = screen.getByRole("button", { name: "Delete event" }); + await user.click(confirmButton); + + expect(confirmButton).toBeDisabled(); + await waitFor(() => expect(deleteCount).toBe(1)); + }); +}); diff --git a/panel/src/features/workspace/settings/DangerZoneCard.tsx b/panel/src/features/workspace/settings/DangerZoneCard.tsx new file mode 100644 index 00000000..ea1e8a40 --- /dev/null +++ b/panel/src/features/workspace/settings/DangerZoneCard.tsx @@ -0,0 +1,155 @@ +import { + Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, +} from "@idento/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { $api } from "../../../shared/api/query"; +import type { components } from "../../../shared/api/schema"; + +type ApiEvent = components["schemas"]["Event"]; + +export interface DangerZoneCardProps { + event: ApiEvent; +} + +// Board 6a's Danger zone card: red-tinted border, title in text-destructive. +// Only one action row is implemented — "Regenerate all attendee codes" is +// deferred to P2 per reconciliation #8 in the task brief, so this is just +// the delete-event row. Deleting is a real, user-data-destroying action +// (attendees, check-in history, badge design), so it goes through the +// typed-confirmation ConfirmDialog tier keyed on the event's actual name — +// never `window.confirm`, never a bespoke dialog. +export function DangerZoneCard({ event }: DangerZoneCardProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [confirmOpen, setConfirmOpen] = React.useState(false); + // Whether the last confirmed delete attempt (within the currently open + // dialog) failed. Shown inside the ConfirmDialog itself via a dynamic + // `description` — the dialog stays open on failure (see below), so unlike + // the old auto-close design there's no card-visible-error race to guard + // against here. + const [deleteError, setDeleteError] = React.useState(false); + // Monotonically-incrementing session id, bumped every time the user + // explicitly closes the confirm dialog (Cancel/Escape/overlay — routed + // through `handleDialogOpenChange` below). Deliberately NOT bumped when + // the dialog closes because the delete succeeded (that's a programmatic + // `setConfirmOpen(false)` in `onSuccess`, which never touches this ref). + // Same race class + fix as ApiKeysCard's `createSessionRef`: without this, + // clicking Cancel while a DELETE is in flight doesn't abort the request, + // and the late-arriving response's onSuccess/onError would still + // force-navigate or surface an error for a delete the user believed they'd + // cancelled (the event is still deleted server-side either way — that + // can't be undone from the client — but the UI must not surprise the user + // who explicitly backed out). A plain boolean re-armed on every reopen + // isn't enough to survive a SECOND cancel-then-reopen cycle — see + // ApiKeysCard's createSessionRef comment for the full failure mode — so + // this uses the same incrementing-id shape, captured at mutate-time via + // `onMutate` and compared exactly in `onSuccess`/`onError`. + const deleteSessionRef = React.useRef(0); + + const deleteEvent = $api.useMutation("delete", "/api/events/{id}", { + onMutate: () => ({ sessionId: deleteSessionRef.current }), + onSuccess: (_data, _vars, onMutateResult) => { + // Invalidation is cache-correctness, not UI reaction: the delete + // already happened server-side regardless of whether the user + // "cancelled" the dialog, so this must run unconditionally — only the + // user-visible reactions below (closing the dialog, navigating) are + // gated on the session check. + void queryClient.invalidateQueries({ queryKey: ["get", "/api/events"] }); + if (onMutateResult?.sessionId !== deleteSessionRef.current) return; + setConfirmOpen(false); + void navigate({ to: "/" }); + }, + onError: (_error, _vars, onMutateResult) => { + if (onMutateResult?.sessionId !== deleteSessionRef.current) return; + // Stay open (Fix: typed-confirmation input must survive a transient + // failure) and show the error inside the dialog via `description` + // below — not the card, which is hidden behind the modal overlay + // while the dialog is open. + setDeleteError(true); + }, + }); + + // Routed to as the ConfirmDialog's `onOpenChange` for every user-driven + // close path (Cancel button, Escape, overlay click) — a programmatic + // close from a successful delete calls `setConfirmOpen(false)` directly in + // `onSuccess` and never reaches this function, so reaching here with + // `open === false` always means the user explicitly backed out. + function handleDialogOpenChange(open: boolean) { + if (!open) { + // Any response still in flight from this session is now permanently + // stale — a later reopen gets a new session id, so it can never match + // again, even across a second cancel-then-reopen cycle. + deleteSessionRef.current += 1; + setDeleteError(false); + // Mutation-reset-on-close (P1.1 rule): only on an explicit user close, + // not automatically on every close, since a success-driven close never + // leaves an error to clear. + deleteEvent.reset(); + } + setConfirmOpen(open); + } + + return ( + <> + + + {t("settingsDanger")} + + +
+
+

{t("settingsDeleteEventTitle")}

+

{t("settingsDeleteEventBody")}

+
+ +
+
+
+ + ` — block-level + // elements like `

`/`

` can't nest inside it (invalid HTML, + // React DOM-nesting warning), so a second line of text here has to + // be a `` instead of a `

`. + deleteError ? ( + <> + {t("settingsDeleteConfirmBody")} + {t("settingsDeleteError")} + + ) : ( + t("settingsDeleteConfirmBody") + ) + } + confirmLabel={t("settingsDeleteEventConfirm")} + cancelLabel={t("createEventCancel")} + closeLabel={t("workspaceDialogClose")} + destructive + typedConfirmation={event.name} + typedConfirmationLabel={t("settingsDeleteConfirmLabel", { name: event.name })} + confirmDisabled={deleteEvent.isPending} + onConfirm={() => { + setDeleteError(false); + deleteEvent.mutate({ params: { path: { id: event.id } } }); + }} + /> + + ); +} diff --git a/panel/src/features/workspace/settings/EventSettingsPage.test.tsx b/panel/src/features/workspace/settings/EventSettingsPage.test.tsx new file mode 100644 index 00000000..39c94d83 --- /dev/null +++ b/panel/src/features/workspace/settings/EventSettingsPage.test.tsx @@ -0,0 +1,142 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, +} from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { EventSettingsPage } from "./EventSettingsPage"; +import { startMswServer } from "../../../test/msw"; +import "../../../shared/i18n"; + +// jsdom has no IntersectionObserver (see useScrollSpy.test.ts) and panel's +// global test/setup.ts intentionally doesn't stub one — this page mounts +// the real useScrollSpy hook, so without a stub its rAF-retry loop throws +// once the section elements exist. A minimal no-op stub is enough: these +// tests don't exercise scroll-spy activation itself (useScrollSpy.test.ts +// owns that), just that the page renders without it blowing up. +class NoopIntersectionObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} + +beforeEach(() => { + vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// Mirrors WorkspaceOverview.test.tsx's harness shape: a throwaway route tree +// whose id/path structure matches the real app closely enough for +// `getRouteApi("/_app/events/$eventId").useParams()` to resolve. +function buildRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: () => , + }); + const settingsRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "/settings", + component: EventSettingsPage, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute.addChildren([settingsRoute])])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +function renderAt(path: string) { + const router = buildRouter(path); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* Cast, not @ts-expect-error: this test router's route shape differs + from the app's registered singleton — same rationale as + EventWorkspaceLayout.test.tsx. */} + + , + ); +} + +const server = startMswServer( + http.get("http://api.test/api/events/:id", ({ params }) => { + if (params.id === "evt-missing") { + return HttpResponse.json({ error: "not found" }, { status: 404 }); + } + return HttpResponse.json({ + id: params.id, + tenant_id: "t1", + name: "Partner Day — Autumn", + start_date: "2026-09-03T00:00:00.000Z", + end_date: "2026-09-05T00:00:00.000Z", + location: "Hyatt Regency", + created_at: "", + updated_at: "", + }); + }), + // FontsCard (mounted in the Fonts section) fetches this for real — + // stubbed here (this file only cares about page-level assembly, not + // FontsCard's own behavior, which FontsCard.test.tsx owns). + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json([])), + // Same rationale for ApiKeysCard, mounted in the API keys section — + // ApiKeysCard.test.tsx owns its own behavior. + http.get("http://api.test/api/events/:eventId/api-keys", () => HttpResponse.json([])), +); +void server; + +describe("EventSettingsPage", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it("renders the four anchor sections and matching rail links", async () => { + renderAt("/events/evt-1/settings"); + + expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); + + expect(document.getElementById("settings-general")).toBeInTheDocument(); + expect(document.getElementById("settings-fonts")).toBeInTheDocument(); + expect(document.getElementById("settings-api-keys")).toBeInTheDocument(); + expect(document.getElementById("settings-danger")).toBeInTheDocument(); + + expect(screen.getByRole("link", { name: "General" })).toHaveAttribute("href", "#settings-general"); + expect(screen.getByRole("link", { name: "Fonts" })).toHaveAttribute("href", "#settings-fonts"); + expect(screen.getByRole("link", { name: "API keys" })).toHaveAttribute("href", "#settings-api-keys"); + expect(screen.getByRole("link", { name: "Danger zone" })).toHaveAttribute("href", "#settings-danger"); + }); + + it("always styles the danger-zone rail link as destructive, regardless of scroll position", async () => { + renderAt("/events/evt-1/settings"); + await screen.findByRole("heading", { name: "General" }); + + const dangerLink = screen.getByRole("link", { name: "Danger zone" }); + expect(dangerLink.className).toContain("text-destructive"); + + const generalLink = screen.getByRole("link", { name: "General" }); + expect(generalLink.className).not.toContain("text-destructive"); + }); + + it("mounts the real GeneralCard with the loaded event's values inside the General section", async () => { + renderAt("/events/evt-1/settings"); + await screen.findByRole("heading", { name: "General" }); + + expect(screen.getByLabelText("Event name")).toHaveValue("Partner Day — Autumn"); + }); + + it("shows a load-error message when the event fetch fails", async () => { + renderAt("/events/evt-missing/settings"); + + expect(await screen.findByText("Couldn't load settings.")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "General" })).not.toBeInTheDocument(); + }); + + it("shows loading skeletons, not fabricated content, before the event loads", () => { + renderAt("/events/evt-1/settings"); + + expect(screen.queryByRole("heading", { name: "General" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "General" })).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/features/workspace/settings/EventSettingsPage.tsx b/panel/src/features/workspace/settings/EventSettingsPage.tsx new file mode 100644 index 00000000..aaf02c63 --- /dev/null +++ b/panel/src/features/workspace/settings/EventSettingsPage.tsx @@ -0,0 +1,90 @@ +import { Skeleton, cn } from "@idento/ui"; +import { getRouteApi } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { ApiKeysCard } from "./ApiKeysCard"; +import { DangerZoneCard } from "./DangerZoneCard"; +import { FontsCard } from "./FontsCard"; +import { GeneralCard } from "./GeneralCard"; +import { $api } from "../../../shared/api/query"; +import { useScrollSpy } from "../../../shared/hooks/useScrollSpy"; + +// Same rationale as WorkspaceOverview.tsx: `getRouteApi` with the ancestor +// layout route's string id avoids a circular import with app/router.tsx. +const routeApi = getRouteApi("/_app/events/$eventId"); + +const SECTION_IDS = ["settings-general", "settings-fonts", "settings-api-keys", "settings-danger"] as const; + +const RAIL_ITEMS: { id: (typeof SECTION_IDS)[number]; labelKey: string; destructive?: boolean }[] = [ + { id: "settings-general", labelKey: "settingsGeneral" }, + { id: "settings-fonts", labelKey: "settingsFonts" }, + { id: "settings-api-keys", labelKey: "settingsApiKeys" }, + { id: "settings-danger", labelKey: "settingsDanger", destructive: true }, +]; + +// Board 6a — the event settings page: a left anchor rail (scroll-spy +// active-highlighting via the ported useScrollSpy) beside stacked card +// sections. All four cards — General, Fonts, API keys, Danger zone — are +// real, fully-implemented components (reconciliation #5 in the task brief +// narrows the board's 7-item rail down to these 4). +export function EventSettingsPage() { + const { t } = useTranslation(); + const { eventId } = routeApi.useParams(); + const activeId = useScrollSpy([...SECTION_IDS]); + const eventQuery = $api.useQuery("get", "/api/events/{id}", { params: { path: { id: eventId } } }); + + if (eventQuery.isLoading) { + return ( +

+ + +
+ ); + } + + if (eventQuery.isError || !eventQuery.data) { + return

{t("settingsLoadError")}

; + } + + const event = eventQuery.data; + + return ( +
+

{t("settingsTitle")}

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/panel/src/features/workspace/settings/FontsCard.test.tsx b/panel/src/features/workspace/settings/FontsCard.test.tsx new file mode 100644 index 00000000..4d478007 --- /dev/null +++ b/panel/src/features/workspace/settings/FontsCard.test.tsx @@ -0,0 +1,325 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { delay, http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { FontsCard } from "./FontsCard"; +import { startMswServer } from "../../../test/msw"; +import "../../../shared/i18n"; +import type { components } from "../../../shared/api/schema"; + +// NOTE on why this file parses multipart bodies by hand instead of calling +// `await request.formData()` (which the task brief's sketch suggested): +// jsdom (this project's Vitest `environment: "jsdom"`) ships its own +// File/Blob/FormData classes, but `Request`/`fetch` here are Node's native +// (undici-backed) globals — jsdom's `Request` merely subclasses the native +// one. When a jsdom File is appended to a jsdom FormData and handed to the +// native Request as its body, undici's multipart body-builder doesn't +// recognize jsdom's Blob/File as blob-like (a cross-realm identity/webidl- +// brand mismatch), so it silently serializes the file part as an empty +// blob with a lost filename. Re-parsing that malformed body via +// `request.formData()` then throws an internal `webidl.is.File` assertion +// deep in undici. This was verified directly: constructing +// `new Request(url, { body: someJsdomFormData })` and immediately calling +// `.formData()` on it — with NO app code or MSW involved at all — already +// throws the same assertion, so it's a test-environment limitation, not a +// bug in FontsCard's bodySerializer (confirmed separately: the same +// FormData→Request→formData() round trip works perfectly under plain +// Node, outside jsdom). Reading the raw body as text and matching the +// well-known multipart wire format sidesteps the broken parser while still +// verifying the real bytes our code sent over the (mocked) network. +function readMultipartField(raw: string, field: string): string | undefined { + const match = raw.match(new RegExp(`name="${field}"\\r\\n\\r\\n([^\\r]*)\\r`)); + return match?.[1]; +} + +type FontListItem = components["schemas"]["FontListItem"]; + +const ACME: FontListItem = { + id: "font-1", + name: "Acme Grotesk", + family: "Acme Grotesk", + weight: "normal", + style: "normal", + format: "woff2", + size: 52224, // 51 KB + created_at: "2026-06-01T12:00:00.000Z", +}; + +const VEKTOR: FontListItem = { + id: "font-2", + name: "Vektor Display", + family: "Vektor Display", + weight: "bold", + style: "normal", + format: "truetype", + size: 2202009, // ~2.1 MB + created_at: "2026-06-05T08:30:00.000Z", +}; + +let fonts: FontListItem[] = [ACME, VEKTOR]; +let listHitCount = 0; +let deleteCount = 0; +let lastDeletedFontId: string | undefined; +let deleteStatusOverride: number | null = null; +let deleteDelayMs = 0; +let uploadCount = 0; +let lastUploadFields: Record | undefined; +let uploadStatusOverride: number | null = null; +let uploadDelayMs = 0; + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/fonts", () => { + listHitCount += 1; + return HttpResponse.json(fonts); + }), + http.post("http://api.test/api/events/:eventId/fonts", async ({ request }) => { + uploadCount += 1; + if (uploadDelayMs) await delay(uploadDelayMs); + if (uploadStatusOverride) { + return HttpResponse.json({ error: "bad extension" }, { status: uploadStatusOverride }); + } + const raw = await request.text(); + lastUploadFields = { + hasFilePart: raw.includes('name="file"') ? "true" : "false", + name: readMultipartField(raw, "name"), + family: readMultipartField(raw, "family"), + license_accepted: readMultipartField(raw, "license_accepted"), + }; + const created: FontListItem = { + id: "font-new", + name: lastUploadFields.name ?? "", + family: lastUploadFields.family ?? "", + weight: "normal", + style: "normal", + format: "woff2", + size: 12345, + created_at: "2026-06-10T00:00:00.000Z", + }; + fonts = [...fonts, created]; + return HttpResponse.json(created, { status: 201 }); + }), + http.delete("http://api.test/api/events/:eventId/fonts/:fontId", async ({ params }) => { + deleteCount += 1; + lastDeletedFontId = params.fontId as string; + if (deleteDelayMs) await delay(deleteDelayMs); + if (deleteStatusOverride) { + return HttpResponse.json({ error: "server error" }, { status: deleteStatusOverride }); + } + fonts = fonts.filter((f) => f.id !== params.fontId); + return HttpResponse.json({ status: "deleted" }); + }), +); +void server; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +function makeFile(name: string, sizeBytes: number, type: string) { + const file = new File([new Uint8Array(sizeBytes)], name, { type }); + return file; +} + +describe("FontsCard", () => { + beforeEach(() => { + fonts = [ACME, VEKTOR]; + listHitCount = 0; + deleteCount = 0; + lastDeletedFontId = undefined; + deleteStatusOverride = null; + deleteDelayMs = 0; + uploadCount = 0; + lastUploadFields = undefined; + uploadStatusOverride = null; + uploadDelayMs = 0; + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it("renders uploaded fonts with name, UPLOADED pill, and metadata caption", async () => { + renderWithProviders(); + + expect(await screen.findByText("Acme Grotesk")).toBeInTheDocument(); + expect(screen.getByText("Vektor Display")).toBeInTheDocument(); + + const pills = screen.getAllByText("UPLOADED"); + expect(pills).toHaveLength(2); + + // format · size · UTC date + expect(screen.getByText("woff2 · 51.0 KB · 2026-06-01")).toBeInTheDocument(); + expect(screen.getByText("truetype · 2.1 MB · 2026-06-05")).toBeInTheDocument(); + }); + + it("shows a muted empty-state caption when there are no fonts", async () => { + fonts = []; + renderWithProviders(); + + expect(await screen.findByText("No custom fonts yet — the badge editor uses the built-in font until you add one.")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("removes a font: confirm dialog with the font name -> DELETE called -> list invalidated", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + const initialHits = listHitCount; + + const removeButtons = screen.getAllByRole("button", { name: "Remove…" }); + await user.click(removeButtons[0]); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(/Acme Grotesk/)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: "Remove" })); + + await waitFor(() => expect(deleteCount).toBe(1)); + expect(lastDeletedFontId).toBe("font-1"); + // list must be refetched (invalidated), not just locally mutated + await waitFor(() => expect(listHitCount).toBeGreaterThan(initialHits)); + await waitFor(() => expect(screen.queryByText("Acme Grotesk")).not.toBeInTheDocument()); + }); + + it("uploads a picked file as multipart/form-data with name/family derived from the filename and license_accepted='true'", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + + await user.click(screen.getByRole("checkbox")); + const file = makeFile("Roboto Bold.woff2", 12345, "font/woff2"); + const input = screen.getByLabelText("Drop a .ttf / .otf / .woff file here or") as HTMLInputElement; + await user.upload(input, file); + + await waitFor(() => expect(uploadCount).toBe(1)); + expect(lastUploadFields?.hasFilePart).toBe("true"); + expect(lastUploadFields?.name).toBe("Roboto Bold"); + expect(lastUploadFields?.family).toBe("Roboto Bold"); + expect(lastUploadFields?.license_accepted).toBe("true"); + + await waitFor(() => expect(screen.getByText("Roboto Bold")).toBeInTheDocument()); + }); + + it("shows a disabled/uploading state on the drop-zone while the upload is pending", async () => { + uploadDelayMs = 50; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + + await user.click(screen.getByRole("checkbox")); + const file = makeFile("Slow.woff2", 100, "font/woff2"); + const input = screen.getByLabelText("Drop a .ttf / .otf / .woff file here or") as HTMLInputElement; + await user.upload(input, file); + + expect(await screen.findByText("Uploading…")).toBeInTheDocument(); + await waitFor(() => expect(uploadCount).toBe(1)); + }); + + it("shows a generic i18n'd error message when the upload fails (e.g. server 400)", async () => { + uploadStatusOverride = 400; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + + await user.click(screen.getByRole("checkbox")); + // Extension must pass the input's own `accept` filter (client-side, + // enforced by user-event) so the pick reaches our onChange handler at + // all — the 400 here simulates a server-side rejection (e.g. the + // 5 MB size limit) that the client can't pre-validate. + const file = makeFile("TooBig.woff2", 100, "font/woff2"); + const input = screen.getByLabelText("Drop a .ttf / .otf / .woff file here or") as HTMLInputElement; + await user.upload(input, file); + + expect(await screen.findByText("Couldn't upload the font.")).toBeInTheDocument(); + }); + + it("shows an inline error when removing a font fails, and clears it on a subsequent successful remove", async () => { + deleteStatusOverride = 500; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + + let removeButtons = screen.getAllByRole("button", { name: "Remove…" }); + await user.click(removeButtons[0]); + + let dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Remove" })); + + await waitFor(() => expect(deleteCount).toBe(1)); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(await screen.findByText("Couldn't remove the font. Try again.")).toBeInTheDocument(); + // The font is still listed — the failed DELETE must not be reflected as + // if it succeeded. + expect(screen.getByText("Acme Grotesk")).toBeInTheDocument(); + + // A subsequent successful remove clears the stale error. + deleteStatusOverride = null; + removeButtons = screen.getAllByRole("button", { name: "Remove…" }); + await user.click(removeButtons[0]); + dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Remove" })); + + await waitFor(() => expect(deleteCount).toBe(2)); + await waitFor(() => expect(screen.queryByText("Couldn't remove the font. Try again.")).not.toBeInTheDocument()); + }); + + it("disables the ConfirmDialog's confirm button while the remove request is pending, to prevent a double DELETE", async () => { + deleteDelayMs = 50; + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + + const removeButtons = screen.getAllByRole("button", { name: "Remove…" }); + await user.click(removeButtons[0]); + + const dialog = await screen.findByRole("dialog"); + const confirmButton = within(dialog).getByRole("button", { name: "Remove" }); + await user.click(confirmButton); + + expect(confirmButton).toBeDisabled(); + await waitFor(() => expect(deleteCount).toBe(1)); + }); + + it("always shows the license disclaimer box near the upload affordance", async () => { + renderWithProviders(); + + expect( + await screen.findByText( + "You are fully responsible for font licensing. By uploading, you confirm you hold the rights to use this font for printing badges.", + ), + ).toBeInTheDocument(); + }); + + // Fix: the "accepted" flag must actually mean something — the upload + // affordance is unusable until the user has explicitly checked the + // license box, not just decorative text next to an always-true flag. + it("disables the upload input until the license checkbox is checked, and enables it once checked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Acme Grotesk"); + + const checkbox = screen.getByRole("checkbox"); + const input = screen.getByLabelText("Drop a .ttf / .otf / .woff file here or") as HTMLInputElement; + expect(checkbox).not.toBeChecked(); + expect(input).toBeDisabled(); + + // Uploading while unchecked must not reach the server at all — user-event + // itself refuses to fire onChange on a disabled input. + const file = makeFile("Blocked.woff2", 100, "font/woff2"); + await user.upload(input, file); + expect(uploadCount).toBe(0); + + await user.click(checkbox); + expect(checkbox).toBeChecked(); + expect(input).toBeEnabled(); + + await user.upload(input, file); + await waitFor(() => expect(uploadCount).toBe(1)); + }); +}); diff --git a/panel/src/features/workspace/settings/FontsCard.tsx b/panel/src/features/workspace/settings/FontsCard.tsx new file mode 100644 index 00000000..6428c8ea --- /dev/null +++ b/panel/src/features/workspace/settings/FontsCard.tsx @@ -0,0 +1,265 @@ +import { + Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ConfirmDialog, Skeleton, StatusPill, cn, +} from "@idento/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { $api } from "../../../shared/api/query"; +import type { components } from "../../../shared/api/schema"; + +type FontListItem = components["schemas"]["FontListItem"]; + +export interface FontsCardProps { + eventId: string; +} + +// Simplest honest size humanizer for the metadata caption — KB below 1 MiB, +// MB above. No locale-aware Intl.NumberFormat is warranted here; a plain +// fixed-point KB/MB split matches the board's "filename · size · ..." copy. +function formatSize(bytes: number): string { + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// UTC calendar date, same convention GeneralCard.tsx documents/uses for +// date-only display of a full timestamp. +function formatUtcDate(iso: string): string { + return new Date(iso).toISOString().slice(0, 10); +} + +function stripExtension(filename: string): string { + const idx = filename.lastIndexOf("."); + return idx > 0 ? filename.slice(0, idx) : filename; +} + +// Board 6a's Fonts card: a plain list of uploaded fonts (name, UPLOADED +// pill, metadata caption) plus a dashed drop-zone that uploads immediately +// on file pick — no separate "confirm upload" step, no drag-and-drop +// library (YAGNI per the task brief; click-to-pick via the native file +// input is enough for P1.2). name/family are derived from the filename +// (extension stripped) as the simplest honest default; a real font-family +// manager is a later phase. +export function FontsCard({ eventId }: FontsCardProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [removeTarget, setRemoveTarget] = React.useState(null); + const [deleteError, setDeleteError] = React.useState(false); + const [uploadError, setUploadError] = React.useState(false); + // Real consent gate: the upload body's `license_accepted` flag is + // meaningless if it's sent unconditionally regardless of whether the user + // actually acknowledged the notice below — this tracks the checkbox the + // user must explicitly check before the upload affordance is usable. + const [licenseAccepted, setLicenseAccepted] = React.useState(false); + + const listQueryKey = ["get", "/api/events/{event_id}/fonts", { params: { path: { event_id: eventId } } }] as const; + + const fontsQuery = $api.useQuery("get", "/api/events/{event_id}/fonts", { + params: { path: { event_id: eventId } }, + }); + + const deleteFont = $api.useMutation("delete", "/api/events/{event_id}/fonts/{font_id}", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: listQueryKey }); + setDeleteError(false); + setRemoveTarget(null); + }, + onError: () => { + // Close the dialog so the inline error below is actually visible (it + // lives in the card, behind the modal overlay while open), and leave + // removeTarget's font un-deleted in the list — same shape as + // ApiKeysCard's revokeKey.onError. + setDeleteError(true); + setRemoveTarget(null); + }, + }); + + const uploadFont = $api.useMutation("post", "/api/events/{event_id}/fonts", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: listQueryKey }); + }, + onError: () => { + setUploadError(true); + }, + }); + + function handleFilePick(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + // Reset the input so picking the same filename again still fires onChange. + e.target.value = ""; + if (!file) return; + setUploadError(false); + const derived = stripExtension(file.name); + uploadFont.mutate({ + params: { path: { event_id: eventId } }, + // The generated request-body type models the multipart `file` field as + // `string` (openapi-typescript's mapping for `format: binary`), but the + // real value we hold is a `File` — cast through `unknown` here and back + // in `bodySerializer` below, which is the only place the value is + // actually read. + body: { + file: file as unknown as string, + name: derived, + family: derived, + // Wired from real state rather than a hardcoded literal — the + // upload input is disabled unless `licenseAccepted` is true (see + // below), so this mutation is only ever reachable when it's "true", + // but the value itself must still reflect the actual consent state + // for correctness/clarity rather than lying by construction. + license_accepted: licenseAccepted ? "true" : "false", + }, + // Multipart investigation (verified against the INSTALLED + // node_modules/openapi-fetch/src/index.js@0.17.0, not assumed): + // 1. `bodySerializer` IS a supported per-call option — coreFetch() + // destructures it from the object passed to `.mutate()`/the client + // method call (`bodySerializer = globalBodySerializer ?? + // defaultBodySerializer` from `fetchOptions`) and calls it with + // `(body, mergedHeaders)` to produce `serializedBody`. + // 2. Returning a `FormData` instance requires NO manual Content-Type + // removal. coreFetch() itself special-cases this + // (`serializedBody instanceof FormData ? {} : { "Content-Type": + // "application/json" }` when building `finalHeaders`) — it simply + // never adds the JSON header when the body is FormData, rather than + // adding-then-relying-on-a-null-header-unset. Passing + // `headers: { "Content-Type": null }` (the brief's fallback sketch) + // is therefore unnecessary here; the browser's `Request`/`fetch` + // computes the correct `multipart/form-data; boundary=...` header + // on its own once no Content-Type is present in `requestInit`. + // 3. This project's `dynamicBaseUrl` middleware (shared/api/http.ts) + // re-wraps every outgoing Request to rewrite its origin. It copies + // `request.headers` as-is (already carrying the browser-computed + // boundary) and re-reads the body via + // `request.clone().arrayBuffer()` — i.e. the already-multipart- + // encoded raw bytes, not a re-serialization — so the boundary and + // body stay in sync through that rewrite. + bodySerializer: (body) => { + const fd = new FormData(); + fd.append("file", body.file as unknown as File); + fd.append("name", body.name); + fd.append("family", body.family); + fd.append("license_accepted", body.license_accepted); + return fd; + }, + }); + } + + const fonts = fontsQuery.data ?? []; + + return ( + <> + + + {t("settingsFonts")} + {t("settingsFontsSubtitle")} + + + {fontsQuery.isLoading ? ( +
+ + +
+ ) : fontsQuery.isError ? ( +

{t("settingsLoadError")}

+ ) : fonts.length > 0 ? ( +
    + {fonts.map((font) => ( +
  • +
    +
    + {font.name} + +
    +

    + {font.format} · {formatSize(font.size)} · {formatUtcDate(font.created_at)} +

    +
    + +
  • + ))} +
+ ) : ( +

{t("settingsFontsEmpty")}

+ )} + + {deleteError ?

{t("settingsFontRemoveError")}

: null} + + + +
+
+ + {removeTarget ? ( + { + if (!open) setRemoveTarget(null); + }} + title={t("settingsFontRemoveTitle")} + description={t("settingsFontRemoveBody", { name: removeTarget.name })} + confirmLabel={t("settingsFontRemoveConfirm")} + cancelLabel={t("createEventCancel")} + closeLabel={t("workspaceDialogClose")} + destructive + confirmDisabled={deleteFont.isPending} + onConfirm={() => + deleteFont.mutate({ + params: { path: { event_id: eventId, font_id: removeTarget.id } }, + }) + } + /> + ) : null} + + ); +} diff --git a/panel/src/features/workspace/settings/GeneralCard.test.tsx b/panel/src/features/workspace/settings/GeneralCard.test.tsx new file mode 100644 index 00000000..dae3581e --- /dev/null +++ b/panel/src/features/workspace/settings/GeneralCard.test.tsx @@ -0,0 +1,209 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { GeneralCard } from "./GeneralCard"; +import { startMswServer } from "../../../test/msw"; +import "../../../shared/i18n"; +import type { components } from "../../../shared/api/schema"; + +type ApiEvent = components["schemas"]["Event"]; + +const BASE_EVENT: ApiEvent = { + id: "evt-1", + tenant_id: "t1", + name: "Partner Day — Autumn", + start_date: "2026-09-03T00:00:00.000Z", + end_date: "2026-09-05T00:00:00.000Z", + location: "Hyatt Regency", + created_at: "", + updated_at: "", +}; + +let patchCount = 0; +let lastPatchBody: unknown; +let patchResponseOverride: unknown = null; + +const server = startMswServer( + http.patch("http://api.test/api/events/:id", async ({ request }) => { + patchCount += 1; + lastPatchBody = await request.json(); + return HttpResponse.json( + patchResponseOverride ?? { ...BASE_EVENT, ...(lastPatchBody as object) }, + ); + }), +); +void server; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("GeneralCard", () => { + beforeEach(() => { + patchCount = 0; + lastPatchBody = undefined; + patchResponseOverride = null; + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + it("loads the event's current values into the fields", () => { + renderWithProviders(); + + expect(screen.getByLabelText("Event name")).toHaveValue("Partner Day — Autumn"); + expect(screen.getByLabelText("Starts")).toHaveValue("2026-09-03"); + expect(screen.getByLabelText("Ends")).toHaveValue("2026-09-05"); + expect(screen.getByLabelText("Location")).toHaveValue("Hyatt Regency"); + }); + + it("keeps Save disabled until a field is dirty", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + await user.type(screen.getByLabelText("Location"), "!"); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("PATCHes only the changed field and shows the saved caption", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(screen.getByLabelText("Location")); + await user.type(screen.getByLabelText("Location"), "Marriott"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(patchCount).toBe(1)); + expect(lastPatchBody).toEqual({ location: "Marriott" }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + }); + + it("sends an explicit empty string to clear the location field", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(screen.getByLabelText("Location")); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(patchCount).toBe(1)); + expect(lastPatchBody).toEqual({ location: "" }); + }); + + it("converts a changed date to an ISO string and omits unchanged fields", async () => { + renderWithProviders(); + + fireEvent.change(screen.getByLabelText("Ends"), { target: { value: "2026-09-10" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(patchCount).toBe(1)); + expect(lastPatchBody).toEqual({ end_date: new Date("2026-09-10").toISOString() }); + }); + + it("shows a localized required-name error and does not call the API when the name is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(screen.getByLabelText("Event name")); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Give the event a name.")).toBeInTheDocument(); + expect(patchCount).toBe(0); + }); + + it("shows a dates-order error and does not call the API when Ends precedes Starts", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + fireEvent.change(screen.getByLabelText("Ends"), { target: { value: "2026-09-01" } }); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("End date can't be before the start date.")).toBeInTheDocument(); + expect(patchCount).toBe(0); + }); + + it("disables Save and shows a muted note instead of PATCHing when a previously-set date is cleared", () => { + renderWithProviders(); + + fireEvent.change(screen.getByLabelText("Starts"), { target: { value: "" } }); + + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(screen.getByText("Clearing a date isn't supported yet.")).toBeInTheDocument(); + expect(patchCount).toBe(0); + }); + + it("re-enables Save once a cleared date is restored", () => { + renderWithProviders(); + + fireEvent.change(screen.getByLabelText("Starts"), { target: { value: "" } }); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("Starts"), { target: { value: "2026-09-03" } }); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); // back to baseline, no longer dirty + expect(screen.queryByText("Clearing a date isn't supported yet.")).not.toBeInTheDocument(); + }); + + // Regression test for the Critical stale-PATCH-response race: `reset()` on + // every keystroke only clears the mutation observer's local state — it + // does NOT cancel the in-flight PATCH or stop `onSuccess` from firing when + // a stale response lands late. Save being disabled during `isPending` + // doesn't stop further typing, so the user can make a second, newer edit + // before the first save's response arrives; that first response must not + // silently overwrite the newer, still-unsaved edit. + it("does not let a stale PATCH response overwrite a newer, still-unsaved edit made while the first save was pending", async () => { + let releaseFirstPatch: (() => void) | undefined; + server.use( + http.patch("http://api.test/api/events/:id", async ({ request }) => { + patchCount += 1; + lastPatchBody = await request.json(); + // Hold this response open until the test explicitly releases it, so + // the test controls exactly when the stale PATCH resolves relative + // to the second, newer edit below. + await new Promise((resolve) => { + releaseFirstPatch = resolve; + }); + return HttpResponse.json({ ...BASE_EVENT, ...(lastPatchBody as object) }); + }), + ); + + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(screen.getByLabelText("Location")); + await user.type(screen.getByLabelText("Location"), "First edit"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(patchCount).toBe(1)); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + // A legitimate second edit while the first save is still pending. + await user.clear(screen.getByLabelText("Location")); + await user.type(screen.getByLabelText("Location"), "Second edit"); + + // Now let the first (stale) PATCH's response land. + releaseFirstPatch?.(); + + // Give the stale onSuccess a chance to run (it must not apply), then + // assert the newer, still-unsaved edit is untouched. + await waitFor(() => expect(screen.queryByText("Saved")).not.toBeInTheDocument()); + expect(screen.getByLabelText("Location")).toHaveValue("Second edit"); + }); + + it("surfaces a server error on failed save and clears it once the user edits again", async () => { + server.use( + http.patch("http://api.test/api/events/:id", () => HttpResponse.json({ error: "boom" }, { status: 500 })), + ); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText("Location"), "!"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByText("Couldn't save your changes. Please try again.")).toBeInTheDocument(); + + await user.type(screen.getByLabelText("Location"), "!"); + expect(screen.queryByText("Couldn't save your changes. Please try again.")).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/features/workspace/settings/GeneralCard.tsx b/panel/src/features/workspace/settings/GeneralCard.tsx new file mode 100644 index 00000000..fe6d58ec --- /dev/null +++ b/panel/src/features/workspace/settings/GeneralCard.tsx @@ -0,0 +1,230 @@ +import { + Button, Card, CardContent, CardHeader, CardTitle, Input, Label, +} from "@idento/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { $api } from "../../../shared/api/query"; +import type { components } from "../../../shared/api/schema"; + +type ApiEvent = components["schemas"]["Event"]; + +// Mirrors CreateEventDialog's schema exactly (same message keys, reused — +// not duplicated in en.json/ru.json). Empty date-input strings are valid +// here too ("" means "not set" for the refine check below), matching the +// create dialog's optional-field handling. +const generalSchema = z + .object({ + name: z.string().trim().min(1, "createEventNameRequired").max(200, "createEventNameTooLong"), + startDate: z.string().optional(), + endDate: z.string().optional(), + location: z.string().max(300, "createEventLocationTooLong").optional(), + }) + .refine((v) => !v.startDate || !v.endDate || v.endDate >= v.startDate, { + message: "createEventDatesOrder", + path: ["endDate"], + }); + +type FieldErrors = Partial>; + +type FormState = { + name: string; + startDate: string; + endDate: string; + location: string; +}; + +// `new Date(iso).toISOString().slice(0, 10)` is the UTC calendar date +// whether `iso` is a bare date-only UTC-midnight timestamp or a real +// timestamp with a time component — safe for both, per the P1.1 date-only +// rules this task must stay consistent with. +function toFormState(event: ApiEvent): FormState { + return { + name: event.name, + startDate: event.start_date ? new Date(event.start_date).toISOString().slice(0, 10) : "", + endDate: event.end_date ? new Date(event.end_date).toISOString().slice(0, 10) : "", + location: event.location ?? "", + }; +} + +export interface GeneralCardProps { + event: ApiEvent; +} + +// Board 6a's General card: name/starts/ends/location with a scoped PATCH — +// only the fields the user actually changed are sent (PATCH's contract: +// absent = unchanged). Dates can't be cleared via PATCH (nil = unchanged, +// documented P1.1 limitation), so clearing a previously-set date input +// disables Save entirely for that dirty-state rather than silently dropping +// the clear attempt or sending a value that wouldn't actually clear it. +export function GeneralCard({ event }: GeneralCardProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [baseline, setBaseline] = React.useState(() => toFormState(event)); + const [form, setForm] = React.useState(() => toFormState(event)); + const [fieldErrors, setFieldErrors] = React.useState({}); + const [saved, setSaved] = React.useState(false); + const savedTimeoutRef = React.useRef(undefined); + // Monotonically-incrementing edit version, bumped on every `updateField` + // call. Captured at mutate-time (via `onMutate`) and compared exactly in + // `onSuccess`: `patchEvent.reset()` on every keystroke only clears the + // mutation OBSERVER's local state — it does NOT cancel the in-flight PATCH + // or stop `onSuccess` from firing when a stale response lands late. Save + // being disabled during `isPending` doesn't stop further typing, so a + // user can edit again after clicking Save but before the response lands; + // without this guard, that later, still-unsaved edit gets silently + // clobbered by the earlier PATCH's (now-stale) response. Same + // session/version-ref shape as ApiKeysCard's `createSessionRef` — here it + // gates whether to overwrite form state rather than whether to reveal a + // secret. + const editVersionRef = React.useRef(0); + + // Cancel the pending fade-out on unmount so a save that succeeds right + // before the user navigates away doesn't call setSaved on an unmounted + // component. + React.useEffect(() => () => window.clearTimeout(savedTimeoutRef.current), []); + + const patchEvent = $api.useMutation("patch", "/api/events/{id}", { + onMutate: () => ({ editVersion: editVersionRef.current }), + onSuccess: (updated, _vars, onMutateResult) => { + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/events/{id}", { params: { path: { id: event.id } } }], + }); + // If the user has edited the form again since this save was + // submitted, the version captured at mutate-time no longer matches — + // applying this (now-stale) response would overwrite their newer, + // still-unsaved edit. + if (onMutateResult?.editVersion !== editVersionRef.current) return; + const next = toFormState(updated); + setBaseline(next); + setForm(next); + setSaved(true); + window.clearTimeout(savedTimeoutRef.current); + savedTimeoutRef.current = window.setTimeout(() => setSaved(false), 2000); + }, + }); + + function updateField(key: K, value: FormState[K]) { + editVersionRef.current += 1; + setForm((prev) => ({ ...prev, [key]: value })); + setFieldErrors({}); + setSaved(false); + // Stale mutation state (a prior save's error) must not persist across a + // new edit — same rule CreateEventDialog applies on dialog reopen. + patchEvent.reset(); + } + + const startCleared = baseline.startDate !== "" && form.startDate === ""; + const endCleared = baseline.endDate !== "" && form.endDate === ""; + const dateClearAttempted = startCleared || endCleared; + + const isDirty = + form.name !== baseline.name || + form.startDate !== baseline.startDate || + form.endDate !== baseline.endDate || + form.location !== baseline.location; + + const saveDisabled = !isDirty || dateClearAttempted || patchEvent.isPending; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (dateClearAttempted) return; + + const parsed = generalSchema.safeParse(form); + if (!parsed.success) { + const errors: FieldErrors = {}; + for (const issue of parsed.error.issues) { + const key = issue.path[0]; + if (typeof key === "string" && !(key in errors)) { + errors[key as keyof FieldErrors] = issue.message; + } + } + setFieldErrors(errors); + return; + } + setFieldErrors({}); + + const body: { + name?: string; + start_date?: string; + end_date?: string; + location?: string; + } = {}; + if (form.name !== baseline.name) body.name = parsed.data.name; + if (form.startDate !== baseline.startDate && form.startDate) { + body.start_date = new Date(form.startDate).toISOString(); + } + if (form.endDate !== baseline.endDate && form.endDate) { + body.end_date = new Date(form.endDate).toISOString(); + } + // Location deliberately allows an explicit "" — PATCH's *string pointer + // semantics treat a present empty string as "clear this field", which is + // exactly the user's intent when they empty the input (unlike dates, + // which have no clear-via-PATCH support at all). + if (form.location !== baseline.location) body.location = form.location; + + patchEvent.mutate({ params: { path: { id: event.id } }, body }); + } + + return ( + + + {t("settingsGeneral")} + + +
+
+ + updateField("name", e.target.value)} + /> + {fieldErrors.name ?

{t(fieldErrors.name)}

: null} +
+
+ + updateField("startDate", e.target.value)} + /> +
+
+ + updateField("endDate", e.target.value)} + /> + {fieldErrors.endDate ?

{t(fieldErrors.endDate)}

: null} +
+ {dateClearAttempted ? ( +

{t("settingsDatesCannotClear")}

+ ) : null} +
+ + updateField("location", e.target.value)} + /> + {fieldErrors.location ? ( +

{t(fieldErrors.location)}

+ ) : null} +
+ {patchEvent.isError ?

{t("settingsSaveError")}

: null} +
+ + {saved ? {t("settingsSaved")} : null} +
+ +
+
+ ); +} diff --git a/panel/src/shared/hooks/useScrollSpy.test.ts b/panel/src/shared/hooks/useScrollSpy.test.ts new file mode 100644 index 00000000..005abbd8 --- /dev/null +++ b/panel/src/shared/hooks/useScrollSpy.test.ts @@ -0,0 +1,151 @@ +import { act, renderHook } from "@testing-library/react"; +import { useScrollSpy } from "./useScrollSpy"; + +// jsdom (this project's test environment) has no IntersectionObserver at +// all, and panel's global src/test/setup.ts intentionally doesn't stub one +// (no other suite needs it) — so this mock is self-contained to this file, +// ported from web/src/hooks/__tests__/useScrollSpy.test.ts's own mock. +class MockIntersectionObserver { + callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} + +describe("useScrollSpy", () => { + let observerInstance: MockIntersectionObserver; + + beforeEach(() => { + // No
wrapper: the panel port scrolls the window (root: null), + // unlike web/'s closest('main') resolution, so sections can sit + // anywhere in the document. + document.body.innerHTML = ` +
+
+ `; + vi.stubGlobal( + "IntersectionObserver", + vi.fn(function (cb: IntersectionObserverCallback) { + observerInstance = new MockIntersectionObserver(cb); + return observerInstance; + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("defaults to the first section id", () => { + const { result } = renderHook(() => useScrollSpy(["summary", "lifecycle"])); + expect(result.current).toBe("summary"); + }); + + it("updates to the section reported as intersecting", () => { + const { result } = renderHook(() => useScrollSpy(["summary", "lifecycle"])); + const lifecycleEl = document.getElementById("lifecycle")!; + act(() => { + observerInstance.callback( + [{ isIntersecting: true, target: lifecycleEl } as unknown as IntersectionObserverEntry], + observerInstance as unknown as IntersectionObserver, + ); + }); + expect(result.current).toBe("lifecycle"); + }); +}); + +describe("useScrollSpy - sections mounted after an async loading gate", () => { + let observerInstance: MockIntersectionObserver; + let rafCallback: FrameRequestCallback | null; + + beforeEach(() => { + // Mirrors EventSettingsPage: the hook mounts while the page is still + // showing a loading skeleton, before the section elements exist. + document.body.innerHTML = ""; + rafCallback = null; + + vi.stubGlobal( + "IntersectionObserver", + vi.fn(function (cb: IntersectionObserverCallback) { + observerInstance = new MockIntersectionObserver(cb); + return observerInstance; + }), + ); + // Capture the rAF callback instead of invoking it immediately, so the + // test controls exactly when the retry fires (no real timing waits, + // and no risk of an unbounded synchronous retry loop). + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((cb: FrameRequestCallback) => { + rafCallback = cb; + return 1; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("stops rescheduling rAF retries once the retry cap is exhausted if sections never appear", () => { + const rafSpy = vi.mocked(requestAnimationFrame); + renderHook(() => useScrollSpy(["summary", "lifecycle"])); + + // Sections never get added to the DOM. Flush the captured rAF callback + // repeatedly, standing in for real frames — each flush re-schedules + // itself (capturing a new rafCallback) until the retry cap is hit, at + // which point it stops rescheduling and requestAnimationFrame stops + // being called again. + const callsBeforeCap = rafSpy.mock.calls.length; + for (let i = 0; i < 200; i += 1) { + const cb = rafCallback; + if (cb === null) break; + rafCallback = null; + act(() => { + cb(0); + }); + } + + // The observer must never have been attached (no sections ever existed), + // and the retry loop must have stopped well short of 200 (the bounded + // cap is on the order of 100-150) instead of running forever. + expect(observerInstance).toBeUndefined(); + expect(rafCallback).toBeNull(); + expect(rafSpy.mock.calls.length - callsBeforeCap).toBeLessThan(200); + expect(rafSpy.mock.calls.length - callsBeforeCap).toBeGreaterThanOrEqual(100); + }); + + it("attaches the observer once sections appear post-mount and reflects intersection", () => { + const { result } = renderHook(() => useScrollSpy(["summary", "lifecycle"])); + + // No elements exist yet, so the hook must not have set up an observer + // and must have scheduled a retry instead of giving up silently. + expect(result.current).toBe("summary"); + expect(rafCallback).not.toBeNull(); + + // Simulate the loading gate resolving and the real sections mounting. + document.body.innerHTML = ` +
+
+ `; + + // Flush the pending retry — this is when the observer actually attaches. + act(() => { + rafCallback!(0); + }); + + const lifecycleEl = document.getElementById("lifecycle")!; + act(() => { + observerInstance.callback( + [{ isIntersecting: true, target: lifecycleEl } as unknown as IntersectionObserverEntry], + observerInstance as unknown as IntersectionObserver, + ); + }); + + expect(result.current).toBe("lifecycle"); + }); +}); diff --git a/panel/src/shared/hooks/useScrollSpy.ts b/panel/src/shared/hooks/useScrollSpy.ts new file mode 100644 index 00000000..aaa6fc9d --- /dev/null +++ b/panel/src/shared/hooks/useScrollSpy.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; + +/** + * Tracks which of the given section element IDs is currently most visible, + * for driving an anchor rail's active-link highlight. + * + * Ported from web/src/hooks/useScrollSpy.ts. That app's page scroll + * container is a styled `
`, so it resolved the + * IntersectionObserver root via `elements[0].closest('main')`. The panel + * app's `
` (see AppShell.tsx) carries no scroll-container styling — + * the WINDOW scrolls instead — so this port passes `root: null`, the + * IntersectionObserver default meaning "the browser viewport". + */ +// Bound on how many rAF retries `trySetup` will schedule while waiting for +// `sectionIds` to appear in the DOM. ~150 attempts at ~60fps is roughly +// 2.5s — comfortably longer than any real data-loading scenario in this +// codebase (sections appear promptly once a query resolves, or the caller +// renders a permanent error state instead). A full MutationObserver rewrite +// would handle unbounded/async DOM mutation, but that's not this call site's +// pattern (EventSettingsPage.tsx either resolves its query or shows an error +// page), so it's YAGNI here — a bounded retry counter is enough to cap the +// busy-loop if the caller stays stuck in a loading state indefinitely. +const MAX_SETUP_RETRIES = 150; + +export function useScrollSpy(sectionIds: string[]): string { + const [activeId, setActiveId] = useState(sectionIds[0] ?? ""); + + useEffect(() => { + let observer: IntersectionObserver | null = null; + let rafId: number | null = null; + let retries = 0; + + function trySetup() { + const elements = sectionIds + .map((id) => document.getElementById(id)) + .filter((el): el is HTMLElement => el !== null); + + if (elements.length === 0) { + // Sections may not be in the DOM yet (e.g. still behind an async + // loading gate in the caller). Keep polling via rAF until they + // appear, up to MAX_SETUP_RETRIES — past that, stop rescheduling and + // leave activeId at its last value (typically sectionIds[0]) with no + // observer attached; an acceptable degraded state since the caller + // isn't rendering real content in that scenario anyway. + retries += 1; + if (retries >= MAX_SETUP_RETRIES) return; + rafId = requestAnimationFrame(trySetup); + return; + } + + observer = new IntersectionObserver( + (entries) => { + const visible = entries.filter((e) => e.isIntersecting); + if (visible.length > 0) { + setActiveId(visible[0].target.id); + } + }, + { root: null, rootMargin: "-10% 0px -70% 0px", threshold: 0 }, + ); + + elements.forEach((el) => observer!.observe(el)); + } + + trySetup(); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + observer?.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- sectionIds is a stable literal array from the caller + }, [sectionIds.join(",")]); + + return activeId; +} diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index 4d7f54aa..256f1b1a 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -73,9 +73,80 @@ "readinessStatusDone": "Done", "readinessStatusNotDone": "Not done", "placeholderComingSoon": "This section is coming in a later phase.", - "workspaceComingSoon": "The event workspace arrives in the next update.", "workspaceBackHome": "← Back to Home", "workspaceLoadError": "Couldn't load this event.", + "workspaceZonesOptional": "Zones optional", + "workspaceOptionalSuffix": "optional", + "workspaceCheckin": "Check-in", + "workspaceCheckinLocked": "locked", + "workspaceSettings": "Settings", + "workspaceOverview": "Overview", + "workspaceOverviewTitle": "Overview", + "workspaceOverviewSubtitle": "Everything your team needs before doors open.", + "workspaceWhatsNext": "What's next", + "workspaceNextAttendees": "Import your attendee list to start printing badges.", + "workspaceNextBadge": "Design the badge template in the editor.", + "workspaceNextStaff": "Assign staff and print their QR login cards.", + "workspaceNextEquipment": "Connect the venue printer and run a test print.", + "workspaceStepComingSoon": "Coming soon", + "workspaceAllReady": "Everything's ready — launch check-in from the header when doors open.", + "workspaceZonesNotUsed": "Optional — not used", + "workspaceStatUnavailable": "—", + "workspaceStatCheckedIn": "Checked in", + "workspaceUnlockHint": "Finish the badge and run a test print to unlock check-in.", + "workspaceLaunchCheckin": "Launch check-in", + "workspaceLaunchComingSoonTitle": "Launch ceremony arrives with the check-in station", + "workspaceLaunchComingSoonBody": "Confirming the event, loading station settings and the printer check land in a later update.", + "workspaceDialogClose": "Close", + "settingsTitle": "Settings", + "settingsGeneral": "General", + "settingsFonts": "Fonts", + "settingsApiKeys": "API keys", + "settingsDanger": "Danger zone", + "settingsSave": "Save", + "settingsSaved": "Saved", + "settingsSaveError": "Couldn't save your changes. Please try again.", + "settingsDatesCannotClear": "Clearing a date isn't supported yet.", + "settingsLoadError": "Couldn't load settings.", + "settingsFontsSubtitle": "Custom fonts for badge templates — available in the editor's font picker for this event.", + "settingsFontUploaded": "UPLOADED", + "settingsFontsEmpty": "No custom fonts yet — the badge editor uses the built-in font until you add one.", + "settingsFontRemove": "Remove…", + "settingsFontRemoveTitle": "Remove font", + "settingsFontRemoveBody": "\"{{name}}\" disappears from the editor's font picker. Badges already printed are unaffected.", + "settingsFontRemoveConfirm": "Remove", + "settingsFontRemoveError": "Couldn't remove the font. Try again.", + "settingsFontDropHint": "Drop a .ttf / .otf / .woff file here or", + "settingsFontBrowse": "Browse…", + "settingsFontUploading": "Uploading…", + "settingsFontUploadError": "Couldn't upload the font.", + "settingsFontLicense": "You are fully responsible for font licensing. By uploading, you confirm you hold the rights to use this font for printing badges.", + "settingsApiKeysSubtitle": "Integrate registration forms or your CRM — push attendees and read check-in status for this event.", + "settingsKeyName": "Name", + "settingsKeyCreatedAt": "Created", + "settingsKeyLastUsed": "Last used", + "settingsKeysEmpty": "No API keys yet — create one to push attendees from your registration forms or CRM.", + "settingsKeyRevoke": "Revoke…", + "settingsKeyRevoked": "Revoked", + "settingsKeyRevokeTitle": "Revoke key", + "settingsKeyRevokeBody": "Integrations using this key stop working immediately.", + "settingsKeyRevokeConfirm": "Revoke", + "settingsKeyRevokeError": "Couldn't revoke the key. Try again.", + "settingsKeyCreate": "+ Create key", + "settingsKeyCreateError": "Couldn't create the key.", + "settingsKeyShownOnce": "The full key is shown once at creation — store it safely. Revoking is immediate.", + "settingsKeyRevealWarning": "This is the only time the key is shown.", + "settingsKeyCopy": "Copy", + "settingsKeyCopied": "Copied", + "settingsKeyDone": "Done", + "settingsDeleteEventTitle": "Delete this event", + "settingsDeleteEventBody": "Attendees, check-in history and the badge design — gone. Typed confirmation required.", + "settingsDeleteEventAction": "Delete event…", + "settingsDeleteConfirmTitle": "Delete event", + "settingsDeleteConfirmBody": "Deletes all attendees, check-in history and the badge design. This cannot be undone.", + "settingsDeleteConfirmLabel": "Type {{name}} to confirm", + "settingsDeleteEventConfirm": "Delete event", + "settingsDeleteError": "Couldn't delete the event. Please try again.", "tenantSuspendedTitle": "Your account is paused", "tenantSuspendedBody": "Your events and attendee data are safe — nothing is deleted.", "tenantSuspendedContactSupport": "Contact support", @@ -89,5 +160,16 @@ "languageSwitcherLabel": "Language", "orgSwitcherCount_one": "{{count}} organization", "orgSwitcherCount_other": "{{count}} organizations", - "onPremVersionTag": "ON-PREM · v{{version}}" + "onPremVersionTag": "ON-PREM · v{{version}}", + "orgTitle": "Organization", + "orgNameLabel": "Organization name", + "orgWebsiteLabel": "Website", + "orgContactEmailLabel": "Contact email", + "orgLogoUrlLabel": "Logo URL", + "orgNameRequired": "Give the organization a name.", + "orgNameTooLong": "Keep the name under 200 characters.", + "orgEmailInvalid": "Enter a valid email address.", + "orgLogoUrlInvalid": "Enter a valid URL.", + "orgReadOnlyNotice": "Only organization admins can edit these settings.", + "orgForbiddenError": "Only organization admins can save changes." } diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index 0bf3a9d6..7f7bc595 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -73,9 +73,80 @@ "readinessStatusDone": "Готово", "readinessStatusNotDone": "Не готово", "placeholderComingSoon": "Этот раздел появится на следующем этапе.", - "workspaceComingSoon": "Рабочее пространство мероприятия появится в следующем обновлении.", "workspaceBackHome": "← На главную", "workspaceLoadError": "Не удалось загрузить это мероприятие.", + "workspaceZonesOptional": "Зоны необязательны", + "workspaceOptionalSuffix": "необязательно", + "workspaceCheckin": "Регистрация", + "workspaceCheckinLocked": "заблокировано", + "workspaceSettings": "Настройки", + "workspaceOverview": "Обзор", + "workspaceOverviewTitle": "Обзор", + "workspaceOverviewSubtitle": "Всё, что нужно вашей команде перед открытием дверей.", + "workspaceWhatsNext": "Что дальше", + "workspaceNextAttendees": "Импортируйте список участников, чтобы начать печать бейджей.", + "workspaceNextBadge": "Разработайте шаблон бейджа в редакторе.", + "workspaceNextStaff": "Назначьте персонал и распечатайте их QR-карты для входа.", + "workspaceNextEquipment": "Подключите принтер на площадке и сделайте тестовую печать.", + "workspaceStepComingSoon": "Скоро", + "workspaceAllReady": "Всё готово — запустите регистрацию из шапки, когда откроются двери.", + "workspaceZonesNotUsed": "Необязательно — не используется", + "workspaceStatUnavailable": "—", + "workspaceStatCheckedIn": "Зарегистрировано", + "workspaceUnlockHint": "Завершите шаблон бейджа и сделайте тестовую печать, чтобы разблокировать регистрацию.", + "workspaceLaunchCheckin": "Запустить регистрацию", + "workspaceLaunchComingSoonTitle": "Церемония запуска появится вместе со станцией регистрации", + "workspaceLaunchComingSoonBody": "Подтверждение мероприятия, загрузка настроек станции и проверка принтера появятся в следующем обновлении.", + "workspaceDialogClose": "Закрыть", + "settingsTitle": "Настройки", + "settingsGeneral": "Основное", + "settingsFonts": "Шрифты", + "settingsApiKeys": "API-ключи", + "settingsDanger": "Опасная зона", + "settingsSave": "Сохранить", + "settingsSaved": "Сохранено", + "settingsSaveError": "Не удалось сохранить изменения. Попробуйте ещё раз.", + "settingsDatesCannotClear": "Очистка даты пока не поддерживается.", + "settingsLoadError": "Не удалось загрузить настройки.", + "settingsFontsSubtitle": "Пользовательские шрифты для шаблонов бейджей — доступны в выборе шрифта редактора для этого мероприятия.", + "settingsFontUploaded": "ЗАГРУЖЕН", + "settingsFontsEmpty": "Пользовательских шрифтов пока нет — редактор бейджей использует встроенный шрифт, пока вы не добавите свой.", + "settingsFontRemove": "Удалить…", + "settingsFontRemoveTitle": "Удалить шрифт", + "settingsFontRemoveBody": "\"{{name}}\" исчезнет из выбора шрифтов редактора. Уже напечатанные бейджи не пострадают.", + "settingsFontRemoveConfirm": "Удалить", + "settingsFontRemoveError": "Не удалось удалить шрифт. Попробуйте ещё раз.", + "settingsFontDropHint": "Перетащите файл .ttf / .otf / .woff сюда или", + "settingsFontBrowse": "Обзор…", + "settingsFontUploading": "Загрузка…", + "settingsFontUploadError": "Не удалось загрузить шрифт.", + "settingsFontLicense": "Вы несёте полную ответственность за лицензирование шрифта. Загружая файл, вы подтверждаете, что обладаете правами на использование этого шрифта для печати бейджей.", + "settingsApiKeysSubtitle": "Интеграция с формами регистрации или вашей CRM — передавайте участников и читайте статус check-in для этого мероприятия.", + "settingsKeyName": "Название", + "settingsKeyCreatedAt": "Создан", + "settingsKeyLastUsed": "Последнее использование", + "settingsKeysEmpty": "API-ключей пока нет — создайте ключ, чтобы передавать участников из форм регистрации или CRM.", + "settingsKeyRevoke": "Отозвать…", + "settingsKeyRevoked": "Отозван", + "settingsKeyRevokeTitle": "Отозвать ключ", + "settingsKeyRevokeBody": "Интеграции, использующие этот ключ, немедленно перестанут работать.", + "settingsKeyRevokeConfirm": "Отозвать", + "settingsKeyRevokeError": "Не удалось отозвать ключ. Попробуйте ещё раз.", + "settingsKeyCreate": "+ Создать ключ", + "settingsKeyCreateError": "Не удалось создать ключ.", + "settingsKeyShownOnce": "Полный ключ показывается один раз при создании — сохраните его в надёжном месте. Отзыв необратим.", + "settingsKeyRevealWarning": "Это единственный раз, когда ключ отображается.", + "settingsKeyCopy": "Копировать", + "settingsKeyCopied": "Скопировано", + "settingsKeyDone": "Готово", + "settingsDeleteEventTitle": "Удалить это мероприятие", + "settingsDeleteEventBody": "Участники, история регистрации и дизайн бейджа — исчезнут. Требуется ввод подтверждения.", + "settingsDeleteEventAction": "Удалить мероприятие…", + "settingsDeleteConfirmTitle": "Удалить мероприятие", + "settingsDeleteConfirmBody": "Удаляет всех участников, историю регистрации и дизайн бейджа. Это необратимо.", + "settingsDeleteConfirmLabel": "Введите {{name}} для подтверждения", + "settingsDeleteEventConfirm": "Удалить мероприятие", + "settingsDeleteError": "Не удалось удалить мероприятие. Попробуйте ещё раз.", "tenantSuspendedTitle": "Ваш аккаунт приостановлен", "tenantSuspendedBody": "Ваши мероприятия и данные участников в безопасности — ничего не удалено.", "tenantSuspendedContactSupport": "Связаться с поддержкой", @@ -91,5 +162,16 @@ "orgSwitcherCount_few": "{{count}} организации", "orgSwitcherCount_many": "{{count}} организаций", "orgSwitcherCount_other": "{{count}} организации", - "onPremVersionTag": "ON-PREM · v{{version}}" + "onPremVersionTag": "ON-PREM · v{{version}}", + "orgTitle": "Организация", + "orgNameLabel": "Название организации", + "orgWebsiteLabel": "Веб-сайт", + "orgContactEmailLabel": "Контактный email", + "orgLogoUrlLabel": "URL логотипа", + "orgNameRequired": "Укажите название организации.", + "orgNameTooLong": "Название должно быть короче 200 символов.", + "orgEmailInvalid": "Введите корректный email.", + "orgLogoUrlInvalid": "Введите корректный URL.", + "orgReadOnlyNotice": "Изменять эти настройки могут только администраторы организации.", + "orgForbiddenError": "Сохранять изменения могут только администраторы организации." }