Commit Graph

251 Commits

Author SHA1 Message Date
5c53dcd2e4 chore(forms): remove unused vee-validate; formalize ref+validators+Zod as canonical pattern
Strict-regex sweep of apps/app/src/ confirms zero VeeValidate usage:
no `from 'vee-validate'` imports, no <Field|Form|ErrorMessage>,
no defineRule(), no useForm(). The 15 prior fuzzy matches were
false positives where /useForm/ matched useFormDraft/useFormSteps/
useFormSchemas/useFormFailures.

Changes:
- Remove `vee-validate` and `@vee-validate/zod` from apps/app/package.json
- Regenerate pnpm-lock.yaml (no other deps shifted)
- CLAUDE.md "Forms": replace VeeValidate prescription with the actual
  ref + @core/utils/validators + Zod-payload-schema pattern that the
  codebase already uses everywhere
- VUEXY_COMPONENTS.md: correct the stale "Registration uses VeeValidate"
  claim (the page actually uses useFormDraft + validators); update the
  "Form validation" reference row
- BACKLOG.md: close VEE-001 with the audit trail

All 319 existing tests still pass; vue-tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:21:49 +02:00
3616b06206 chore(timetable): refresh auto-generated declarations for new components + route
Drift from Session 4 step 11 — unplugin-vue-components and unplugin-vue-router
regenerated their .d.ts files for the new timetable surface. Was missed in the
original commit because the test runner doesn't trigger regen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:17:53 +02:00
39fdc0fa3d test(timetable): Phase C — 67 new tests (pure logic + composables + store + schemas)
apps/app/tests/unit/lib/timetable/:
  - snap.test.ts (5)            — rounding, clamp, edge cases
  - time-grid.test.ts (6)       — px↔min↔ISO roundtrips, formatTickLabel
  - conflict.test.ts (8)        — overlap, endpoint-touching, lane/stage scoping, cancelled exclusion
  - b2b.test.ts (6)             — 0min, 2:59, 3:01, overlap, side-set mapping, threshold constant
  - capacity.test.ts (7)        — null capacity, missing data, warn/critical, crew+guests preference
  - lane.test.ts (8)            — Pass 1 + Pass 2, cascade-bump preview, cancelled exclusion

apps/app/tests/unit/composables/:
  - useTimetableMutations.test.ts (5) — Idempotency-Key header, optimistic + cascade,
                                         409 VersionMismatch surfaced, park sends null,
                                         createStage POST path
  - useDragOrClick.test.ts (3)        — onClick fires under threshold, onDragStart+End
                                         above threshold, Esc cancels mid-flight

apps/app/tests/unit/schemas/timetable.test.ts (8) — payload + response zod parsers
apps/app/tests/unit/lib/idempotencyKey.test.ts (3) — 6-30 char range, 24-hex, uniqueness
apps/app/tests/unit/stores/useTimetableStore.test.ts (5) — defaults, toggleStatus, drag state, null guard

Refactor: useTimetableMutations.move now throws Error instances (no-throw-literal)
so AxiosError.message and the VersionMismatchError shape both bubble through .catch().

Test count: 252 → 319 (+67). All 42 files pass.

Out of scope this session (added to BACKLOG):
- ART-PERFORMANCEBLOCK-COMPONENT-TESTS — Vuetify intentionally not loaded in
  vitest.config.ts; a Vuexy-stub setup for component-mount tests is one PR of
  its own. Pure rendering logic (capacity, B2B, conflict) is fully covered at
  the lib/ layer.
- ART-AXE-CORE-A11Y-TESTS — axe-core not yet installed in the repo. The
  aria-label structure on PerformanceBlock + aria-live on the page entry are
  authored to pass an axe scan when added.
- ART-INTEGRATION-FLOW-TEST — full add → drag → resize → park flow needs
  Vuetify + router + msw setup; defer with the component tests above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:04:10 +02:00
43572a7812 feat(timetable): keyboard a11y composable + page entry — Session 4 step 11 + ship
useTimetableKeyboard (RFC v0.2 D20):
- Arrow ←/→ nudges by SNAP_MIN; Shift+Arrow = ±60min
- Arrow ↑/↓ shifts lane; Shift+Arrow ↑/↓ = ±1 stage
- [/] cycles stages preserving time + lane
- Space starts a "keyboard drag" (announced via aria-live), arrows
  accumulate the offset, Enter commits, Esc cancels
- Enter on a focused block opens the popover; Delete confirms+removes
- Pure orchestration — the actual mutation goes through useTimetableMutations
  so keyboard moves inherit optimistic update + 409 rollback

pages/events/[id]/timetable/index.vue:
- definePage with organizer context + navActiveLink=events
- ?day query param ↔ store.activeDayId in both directions
- Composes EventTabsNav, TimeAxis, GridBg, StageHeaderCell, StageRow,
  Wachtrij, PerformancePopover, AddPerformanceDialog, StageEditor,
  LineupMatrix, EmptyDayState
- Conflict pill in toolbar (header total) per prototype audit §4.8
- Status filter chips applied to canvas blocks via store.isStatusVisible
- usePointerDrag + useDragOrClick wires drag to a single move() call;
  on success flashes pulseSet on cascaded[] for 1.5s (D18 + D21 keyframe)
- aria-live region echoes keyboard-drag announcements

Tweaks for boundary/lint cleanliness:
- Dialog props switched from Ref<T> to T + toRef inside (Vue templates
  auto-unwrap refs; Ref-typed props clashed with template usage)
- Wachtrij counts shadow + sonarjs cleanup
- no-void watcher

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:56 +02:00
288aebcd69 feat(timetable): interactive components — Popover, AddPerformanceDialog, StageEditor, LineupMatrix, Wachtrij + WachtrijCard (Session 4 step 10)
- PerformancePopover.vue — teleported floating panel; closes on Esc; shows
  status chip, advancing %, computed Buma/VAT/total cost; deal-summary +
  delete + open-detail buttons. Position math (340px wide, 12px margin,
  flip side if no room) ports prototype's pickPos verbatim.

- AddPerformanceDialog.vue — Vuetify VDialog + raw ref form pattern (matches
  CreateShiftDialog and the rest of the codebase). Uses createPerformancePayloadSchema
  for client-side validation; falls back to surface-level errors map per field.

- StageEditor.vue — single-stage CRUD modal with name + capacity + 10-swatch
  palette picker. Window.confirm cascade-park warning on delete.

- LineupMatrix.vue — stages × sub-events checkbox matrix; only dirty stages
  fire replaceStageDays (atomic per stage).

- Wachtrij.vue — sidebar with search + 9 toggleable status chips with counts;
  reads/writes useTimetableStore.statusFilter and searchQuery.

- WachtrijCard.vue — initials avatar + status dot + dot label + cancelled
  strike-through. role=button, tabindex=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:53:02 +02:00
5b812771de feat(timetable): usePointerDrag + useDragOrClick composables (Session 4 step 9)
usePointerDrag — PointerEvents primitive with capture, escape-cancel,
keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy
mousedown stack the prototype used.

useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan,
matches prototype audit §4.1). Emits onClick when the pointer never crossed
the threshold; otherwise enters drag mode and emits onDragStart / onDragMove /
onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end
so the synthetic click never opens the popover.

RFC v0.2 D7 — implemented once instead of three times like the prototype.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:46:02 +02:00
4ed470ac35 feat(timetable): leaf visual components — TimeAxis, GridBg, StageHeaderCell, PerformanceBlock, StageRow, EmptyDayState (Session 4 step 8)
PerformanceBlock is the heart of the canvas:
- Status palette via CSS tokens (D21) — one class per booking_status enum value
- Cancelled hatch overlay + line-through (D5)
- Trashed-artist dashed border + ⌂ overlay icon (D27)
- Conflict ring + glow when warnings.includes('overlap') (D5)
- Capacity icon driven by evaluateCapacity() with warn/critical levels (D25)
- B2B left/right dots (D26 — 3-min threshold)
- Cascade-pulse class fired by parent on cascaded[] non-empty (D18)
- aria-label structure per D20: artist, stage, time window, status, advancing
- tabindex 0 + Enter/Space → select; Delete → emit delete

StageRow positions blocks by lane_resolved (D19) — server is authoritative.
StageHeaderCell uses Vuexy VMenu pattern for the per-stage actions.
EmptyDayState routes the user to LineupMatrix when no stages are active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:44:59 +02:00
6eb8ae7aa4 feat(timetable): pinia store + CSS tokens (Session 4 steps 5+7)
useTimetableStore — pinia composition store carrying:
- activeDayId synced to ?day query param at the page level
- selectedPerformanceId for popover anchor + keyboard focus
- drag state (dragPerformanceId / dragOriginSnapshot / dragGhost) for
  optimistic preview + 409 rollback
- statusFilter (defaults: all on except cancelled, per prototype §4.7)
- searchQuery for the wachtrij filter

styles/tokens/_timetable.scss — RFC v0.2 D21:
- 9 status palettes (bg / border / fg / dot custom properties)
- cancelled-hatch repeating gradient
- conflict / capacity-warn / capacity-critical / B2B / trashed colours
- lane geometry (height, gap, padding, block radius)
- canvas + axis backgrounds and tick lines
- drag-ghost + focus-ring + day-tab chrome
- tt-cascade-pulse keyframe animation for D18 cascaded[] visualisation

Imported once via assets/styles/styles.scss so the variables are available
everywhere via var(--tt-…).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:42:18 +02:00
3536358a59 feat(timetable): TanStack queries + mutations with optimistic move + cascade pulse (Session 4 steps 3+4)
useTimetable.ts (read side):
- useStages / usePerformances(?day=) / useWachtrij(?stage_id=null)
- useEngagement (popover deal info + advancing aggregate)
- useTimetable() aggregate with isLoading/isError/refetch
- 30s staleTime + refetchOnWindowFocus for multi-user awareness (RFC D14 — Echo deferred to ART-15)

useTimetableMutations.ts (write side):
- move (RFC D18) — optimistic patch on mutate, applies cascaded[] on success,
  snapshot rollback on 409 (VersionMismatch surfaced to caller for toast)
- park / unpark via the move endpoint with optimistic stage_id flip
- create / updateNotes / remove + stage CRUD + reorderStages (optimistic) + replaceStageDays
- Idempotency-Key generated per logical action (re-drag = new key)

Skipped a separate src/api/timetable.ts module to stay consistent with the
codebase's "api+composables together" pattern (useShifts.ts, useSections.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:41:04 +02:00
36525e729a feat(timetable): pure logic ports — snap, lane, conflict, b2b, capacity, time-grid (Session 4 step 2)
Ports the prototype's helpers.js + cascade-bump algorithm into typed
TypeScript modules in apps/app/src/lib/timetable/:

- snap.ts        — 5-minute snap (RFC D7) + 15-min minimum duration
- time-grid.ts   — pixel ↔ minute ↔ ISO-8601 coordinate conversions
- conflict.ts    — same-stage same-lane overlap detection (RFC D5)
- b2b.ts         — back-to-back marker links, 3-min threshold (RFC D26)
- capacity.ts    — 110% over-capacity warn level (RFC D25)
- lane.ts        — two-pass resolver + drag-preview cascade (D13/D18/D19,
                   client-side preview only; server is authoritative)

All functions are pure (no Vue, no DOM). Tested in Phase C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:39:14 +02:00
0a533a65fd feat(timetable): types + zod schemas + idempotency-key helper (Session 4 step 1)
- Extract generateIdempotencyKey() from useFormDraft into reusable lib/
- New types/timetable.ts mirrors PerformanceResource, ArtistEngagementResource,
  StageResource, GenreResource and the four enums verbatim
- New schemas/timetable.ts adds zod parsers for runtime validation of API
  responses + form payloads (createPerformance, createStage, moveTimetable)

RFC v0.2 §10 contract surface for the upcoming timetable canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:37:00 +02:00
5c42f27b26 fix: whitelist GlitchTip ingest host in CSP connect-src
PR-3 follow-up. Live smoke surfaced that the @sentry/vue SDK was
running correctly and emitting events, but Crewli's strict
connect-src directive blocked every POST at the browser layer. No
fallback — events evaporated silently with a CSP-violation log in
DevTools console only.

Updated locations (audited the CSP surface; only two locations actually
need the whitelist):

- apps/app/index.html — dev meta CSP, adds http://localhost:8200 to
  connect-src so local dev hits the docker-compose GlitchTip stack.
- deploy/nginx/csp-spa.conf — prod organizer SPA CSP, adds
  https://monitoring.hausdesign.nl to BOTH the report-only and enforce
  add_header lines so a future flip between modes can't silently break
  observability.

NOT updated (deviation from prompt):

- api/config/security.php — the API CSP is `default-src 'none';
  frame-ancestors 'none'` for JSON responses. Browsers don't enforce
  connect-src on JSON contexts (no document, no fetch origin). Adding
  connect-src would be semantically a no-op and confuse the deny-by-
  default policy.

Regression guard: tests/Feature/Security/CspConnectsToObservabilityTest.
Reads both the dev meta tag and the prod nginx conf directly (the SPA's
CSP is not Laravel-served, so $this->get() can't reach it). Apply-with-
revert verified: stashing both fixes makes both cases fail with a clear
"Refused to connect because it violates the following CSP directive"
hint; popping the stash restores green.

SECURITY_AUDIT.md A13-9 updated with a WS-7 follow-up note documenting
the GlitchTip whitelist as an explicit security control: outgoing
observability traffic restricted to a single known host.

Test count 1549 to 1551. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:36:05 +02:00
9247d89e4b test: scrubber + contextBinding regression coverage
WS-7 PR-3 commit 2.

- scrubber.spec.ts (18 cases): mirrors backend PiiScrubbingTest semantics.
  Body/header/query scrubbing, form_values wholesale replacement, all
  SENSITIVE_BODY_KEYS at top + nested levels, max_depth guard, cookies +
  storage + user.cookies sanitisation.
- contextBinding.spec.ts (11 cases): exercises the Vue Router beforeEach
  guard against a real router with mocked Sentry scope (capturing every
  setTag/setUser call into a per-test buffer). Cases:
    - portal-token zone — actor_scope=portal, no user_id
    - platform route + super_admin — actor_scope=platform
    - platform route without super_admin — does NOT tag platform
    - organizer route with active org — actor_scope=organisation +
      organisation_id
    - organizer route without active org — actor_scope=user, no org tag
    - unauthenticated public — actor_scope=anonymous
    - actor_type role hierarchy
    - RFC §3.8 ULID-only user identity (no email leakage)
    - route_name + app=app baseline tags
    - cross-zone leak guard: navigating from organizer to portal-token
      calls scope.clear() and does not bind user

Frontend test count 223 to 252. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:59:05 +02:00
bc477837eb feat: install @sentry/vue + observability module skeleton
WS-7 PR-3 commit 1. Frontend mirror of the backend SDK install
(commits bdb89a2..adab3be), wired against the existing apps/app SPA.

- pnpm add @sentry/vue@10.52.0 (pinned).
- src/observability/sentry.ts: initSentry() — empty DSN no-op (RFC §3.3),
  errors-only (tracesSampleRate=0, profilesSampleRate=0; RFC §2 amend.B),
  sendDefaultPii=false, Console integration off, beforeSend wired to the
  scrubber, initial scope tag app=app for GlitchTip filtering.
- src/observability/scrubber.ts: TypeScript port of backend
  SentryEventScrubber. RFC §3.7 frontend block — body / header / query
  scrubbing, form_values wholesale replacement, cookies wholesale,
  defensive strip of contexts.storage and user.cookies, max-depth guard.
- src/observability/contextBinding.ts: Vue Router beforeEach guard that
  binds RFC §3.6 auth-scope tags per navigation. Three zones via
  route.meta.public + route.path matching:
    - portal token zone (meta.public + meta.context=portal) → actor_scope=
      portal, no user_id (RFC §3.6 explicit)
    - /platform/* with super_admin → actor_scope=platform, no org tag
    - default authenticated → actor_scope=organisation when an active
      organisation is selected (useOrganisationStore.activeOrganisationId),
      otherwise actor_scope=user
    - unauthenticated public pages → actor_scope=anonymous
  Reads useAuthStore (user, appRoles, isSuperAdmin) and
  useOrganisationStore (activeOrganisationId) — corrected vs. RFC's
  speculative auth-store API.
- src/observability/index.ts: barrel.
- src/main.ts: initSentry runs before registerPlugins so Sentry's Vue
  errorHandler hooks before any plugin or component initialises;
  installContextBinding runs after registerPlugins so pinia is up.
- env.d.ts: VITE_SENTRY_DSN_FRONTEND + VITE_SENTRY_RELEASE typed.
- .env.example: new file (didn't exist before) documenting all SPA env
  vars including the new Sentry pair.
- vite.config.ts: build.sourcemap=true (RFC §3.5 — generated, uploaded
  to GlitchTip by deploy.sh, then stripped before nginx serves dist/).

Typecheck: green. Build: green, *.map files emitted alongside *.js
chunks as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:56:21 +02:00
289e735fd6 chore(types): regenerate components.d.ts to sync with PR-B2a additions 2026-05-06 01:09:04 +02:00
eb485573ce chore(types): regenerate auto-imports.d.ts to sync with PR-B2a additions 2026-05-06 01:04:05 +02:00
ad23847050 fix(deps): import flatpickr CSS via JS, add flatpickr direct dep 2026-05-06 01:03:25 +02:00
96cb1519de feat(security): full A13-3 open-redirect validation in postLoginRedirect
Replaces the WS-3 PR-B2a minimum precaution (`startsWith('/') &&
!startsWith('//')`) with a layered validator that rejects every input
that is not a strict relative path.

isSafeRelativePath rejects:
- Empty / null / undefined input
- Non-`/`-prefixed paths (including leading whitespace)
- Protocol-relative URLs (`//evil.com`)
- Backslash anywhere (browsers normalise `\` → `/` in some contexts;
  `/\evil.com` parses as `//evil.com`)
- ASCII control characters `\x00`–`\x1F` and `\x7F` (NUL, tab, LF, CR,
  DEL, etc. — header-injection vectors)
- Anything the URL constructor parses to a different origin than the
  synthetic invalid origin used as the resolution base

The URL-constructor check is the authoritative guard; the prefix and
character checks are fast pre-filters that short-circuit common
attack shapes without paying the URL allocation.

Test coverage expands from 6 → 16 cases. New cases pin the
backslash, control-character, leading-whitespace, and positive-
character-set contracts. The URL-encoded-slash-in-query case
documents that we don't false-positive on `%2F` in query strings.

Closes A13-3 (open-redirect on post-login).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:20:12 +02:00
b191fbe917 refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa
The card consumed the API directly via useVerifyMfa() (TanStack Query
mutation). Per Decision F's intent (store owns business logic, the
component consumes typed results), the card now calls
useAuthStore.verifyMfa() and pattern-matches on the MfaVerifyResult
discriminated union.

Changes:
- MfaChallengeCard: drop useVerifyMfa import; call authStore.verifyMfa
  with camelCase args (sessionToken, trustDevice, deviceFingerprint,
  deviceName); local isVerifying ref replaces verifyMutation.isPending.
  On result.kind === 'authenticated' emit `verified` (no payload —
  the store has already refreshed user state); on 'failed' surface
  result.reason with a generic fallback.
- emit signature: `verified: [data: unknown]` → `verified: []`.
- login.vue: onMfaVerified no longer calls authStore.refreshUser —
  authStore.verifyMfa() refreshes internally. Page just routes to
  resolvePostLoginTarget().

Adds 4 vitest specs in components/auth/__tests__/MfaChallengeCard.spec.ts
covering: success path emits `verified` with camelCase args, failure
path shows reason and suppresses emit, trustDevice toggle honours
fingerprint + device name, fallback message when reason is empty.

Test count 209 → 213. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:01:32 +02:00
eb7f3eb057 fix(portal): consume portal events from useAuthStore instead of duplicate /auth/me fetch
The auth-store merge made portal_events available on the unified
/auth/me response (held in useAuthStore.portalEvents). usePortalStore
now sources userEvents from the auth store, eliminating the duplicate
fetch that the legacy slim usePortalAuthStore had compensated for.

Changes:
- types/auth.ts: add portal_events?: PortalEvent[] to MeResponse
- useAuthStore: add portalEvents ref, populated in setUser from
  me.portal_events, cleared in clearState
- usePortalStore: replace loadUserEventsFromApiAndStorage (which
  fetched /auth/me) with syncEventsFromAuthStore (which reads
  authStore.portalEvents). A reactive watch keeps userEvents in sync
  whenever the auth store updates (login, refresh, logout). The
  sessionStorage merge stays as offline cache + post-registration
  bridge.
- types/portal.ts: drop the now-unused AuthMeUser type — MeResponse
  is the canonical shape post-merge.

Boundaries: usePortalStore (stores-portal) statically imports
useAuthStore (stores) — already allowed by the matrix
(stores-portal allow includes stores).

Adds 4 vitest specs covering: userEvents reflects auth.portalEvents,
no apiClient.get('/auth/me') call from the portal store,
sessionStorage fallback when auth has not hydrated, reactive update
on auth.portalEvents change.

Test count 205 → 209. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:57:40 +02:00
3019095a2a fix(security): A13-8 — migrate portal store to sessionStorage with explicit reset
usePortalStore now persists state in sessionStorage instead of
localStorage. Tab-close clears the session implicitly; explicit logout
+ 401 paths invoke reset() which iterates the `crewli:portal:` prefix
and removes every key (forward-compatible for future portal-namespaced
state).

Storage keys are renamed under the canonical prefix:
- crewli_portal_user_events_v1 → crewli:portal:events
- crewli_portal_active_event_id_v1 → crewli:portal:activeEventId

The single new prefix-clear function (clearStoragePrefix) replaces the
hand-listed key removals, so future portal-namespaced state additions
need no reset() change.

useAuthStore.handleUnauthorized() (the 401 interceptor target) is now
async and invokes clearAll() — the canonical session-cleanup hub —
restoring the portal-storage cleanup that the deleted
usePortalAuthStore.handleUnauthorized previously owned. The merge in
Phase E left this gap; this commit closes it.

Adds 7 vitest specs in stores/portal/__tests__/usePortalStore.spec.ts
covering: sessionStorage persistence, reset() prefix-iteration,
non-prefixed-key isolation, reactive state reset, useAuthStore.clearAll
+ handleUnauthorized integration.

Test count 198 → 205. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:43:40 +02:00
38a94c78e9 feat(auth): post-login landing route resolution per context
login.vue is rewritten to consume useAuthStore.login()'s discriminated
union — no more direct apiClient calls or branching on raw API response
shapes. The page maps result.kind to UI/routing decisions only:

- mfa-required → swap to MfaChallengeCard with the typed payload
- authenticated → resolvePostLoginTarget() (?to= relative, else
  auth.resolveLandingRoute())
- must-set-password → forward-compatible placeholder route
- failed → field-level errors + rate_limit message branch

resolveLandingRoute() now returns a string path instead of
RouteLocationRaw — the typed router accepts string-paths cleanly,
removes the cast at every call site, and lets useAuthStore.spec.ts +
guards.spec.ts assert the resolved path directly.

A13-3 minimum precaution lives in a new utility:
src/utils/postLoginRedirect.ts. The relative-only check
(`startsWith('/') && !startsWith('//')`) rejects absolute, protocol-
relative, javascript:, and data: schemes. Full domain validation lands
in WS-3 PR-B2b.

6 vitest specs in utils/__tests__/postLoginRedirect.spec.ts cover the
six rejection / passthrough scenarios.

Test count 192 → 198. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:40:32 +02:00
209e0ef682 feat(layout): context-switcher for multi-role users
Adds components/shared/ContextSwitcher.vue — a Vuetify menu-button
that renders only when useAuthStore.showContextSwitcher is true (i.e.
the user has both portal and organizer contexts available). Click
calls useAuthStore.setLastContext + resolveLandingRoute and pushes
the new route.

Wired into both layouts:
- PortalLayout.vue: navbar right section, before UserAvatarMenu
- DefaultLayoutWithVerticalNav.vue (organizer navbar host): before
  NavbarThemeSwitcher (OrganizerLayout.vue itself is a 10-line
  wrapper around DefaultLayoutWithVerticalNav, so the component
  wires into the actual navbar host).

Boundaries matrix update: components-shared now allows `stores` so
canonical shared chrome (ContextSwitcher, future global indicators)
can read useAuthStore directly without re-homing to
components/layout/. stores-portal stays disallowed for components-
shared by design — portal-specific state has no place in shared
chrome.

Adds 3 vitest specs covering: visibility gated by
showContextSwitcher, click invokes setLastContext + router.push.

Test count 189 → 192. Frontend lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:35:32 +02:00
473b22ac9e feat(router): context-aware guards with meta-driven role/context resolution
Rewrites plugins/1.router/guards.ts per ARCH-CONSOLIDATION §4.3. The
B1 portal-context carve-out is removed; portal/organizer routing is
now declarative via meta.context, role gates via meta.requiresRole.

Guard pipeline:
1. Initialize auth store on first navigation
2. Public routes pass through (authenticated user on guest-only path
   is bounced to resolveLandingRoute)
3. Auth required → /login?to=<path>
4. MFA setup gate → /account-settings?tab=security
5. requiresRole declarative check (replaces hardcoded /platform path
   prefix + isSuperAdmin)
6. Context routing — portal returns early, organizer falls through
   and sets lastContext
7. Org-selection check (organizer routes only)

Page meta updates (mechanical, idempotent):
- 4 portal pages: removed `requiresAuth: true` (auth is implicit)
- 4 pages: replaced `requiresAuth: false` with `meta.public: true`
  (registreren, wachtwoord-instellen, advance/[token],
  invitations/[token])
- 22 organizer pages: added `context: 'organizer'`
  (account-settings, events/**, organisation/form-failures/**,
  select-organisation, dashboard, events/index, members,
  organisation/{index,companies,settings})
- 8 platform pages: added `context: 'organizer'` +
  `requiresRole: 'super_admin'`
- 6 organizer pages had no definePage block — one was added with
  `context: 'organizer'`

Adds plugins/1.router/__tests__/guards.spec.ts (11 tests) covering
public passthrough, unauthenticated redirect, portal/organizer
context branching, declarative requiresRole, org-selection
redirect, MFA gate.

Test count 178 → 189 (11 new). Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:32:54 +02:00
f2b08ecb21 refactor(auth): merge usePortalAuthStore into useAuthStore with context-aware getters
usePortalAuthStore is deleted — its 114 lines were a slim wrapper over
the same /auth/me endpoint useAuthStore already consumes. The merged
store gains the full set of additions Bert specified for B2a:

State:
- availableContexts / defaultContext (from /auth/me contexts block)
- lastContext (localStorage-persisted)
- portalToken (in-memory only, for the bearer-axios flavour)

Getters: isPortalUser, isOrganizerUser, isPlatformAdmin (alias of
isSuperAdmin), showContextSwitcher, hasRole(), hasAnyRole().

Actions: login(), verifyMfa() — both return typed discriminated
unions so login.vue (Phase H) consumes results without branching on
raw API response shapes. setLastContext, setPortalToken,
resolveLandingRoute, clearAll. clearAll dynamically imports
usePortalStore.reset() to clear portal sessionStorage on session-end —
this is the canonical session-cleanup hub now that the merge has
happened.

5 source files migrated from usePortalAuthStore → useAuthStore. The
PortalLayout.spec.ts mock follows. The boundaries matrix gains a
single new edge (`stores → stores-portal`) replacing the deleted
stores-portal/usePortalAuthStore which previously owned that
cross-zone call.

Adds 16 vitest specs in src/stores/__tests__/useAuthStore.spec.ts
covering setUser context hydration, hasRole/hasAnyRole, lastContext
localStorage persistence, resolveLandingRoute precedence
(portal/organizer/super_admin/multi-role/forceContext/forbidden
fallback), portalToken state, and clearAll cleanup.

Test count 162 → 178 (16 new). Frontend lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:25:24 +02:00
13d7b18257 refactor(axios): split lib/axios.ts into factory + default + portal-token instances
The single axios.ts file becomes a directory with:
- factory.ts — createApiClient + the registerDefaultInterceptors /
  registerPortalTokenInterceptors seam (preserves the
  TECH-AXIOS-STORE-COUPLING decoupling — no store imports inside)
- default.ts — cookie-authenticated client (organizer + cookie-auth
  portal flows; existing 45 call sites resolve unchanged)
- portal-token.ts — Bearer-auth client for the artist-advance /
  supplier-intake flows (forward-compatible groundwork; no active
  consumers today)
- index.ts — re-exports apiClient + portalApiClient + the register* /
  createApiClient surface; the existing `import { apiClient } from
  '@/lib/axios'` continues to work directory-resolved.

The bindings plugin (plugins/3.axios-bindings.ts) now wires both
clients with a shared deps base + flavour-specific overrides. The
`getPortalToken` callback returns null until Phase E surfaces
`portalToken` on useAuthStore — no current consumers exercise the
Bearer path, so the null-return is intentional placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:18:55 +02:00
a2760ffd64 feat(auth): add contexts + platform.is_super_admin to /auth/me, factory role-category states
Additive enrichment to MeResource — existing fields untouched, MeTest stays green.
New fields:
- contexts.available: list<'portal'|'organizer'> derived from Person + Organisation memberships
- contexts.default: precedence super_admin > organizer > portal > fallback portal
- platform.is_super_admin: bool promoted from app_roles
- organisations[].roles: 1-element array form alongside the legacy scalar role,
  forward-compatible for the multi-role pivot work tracked in TECH-PIVOT-ROLES-MULTI

UserFactory gains volunteer(), orgAdmin(), volunteerAndOrganizer(), superAdmin()
state methods — codified role categories for reuse across future workstreams.

Adds forbidden.vue placeholder (PublicLayout) for the context-failure landing in
the upcoming guard rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:15:10 +02:00
0dceb437f3 refactor(register): drop auth-store dependency from success.vue, rely on query param
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:13 +02:00
a84742a01f chore(eslint): activate boundary sub-zones (TECH-WS3-BOUNDARIES-SUBZONES)
Adds the WS-3 §4.2 sub-zone classification to the apps/app
boundaries matrix:

- components-{shared,portal,organizer} alongside the legacy
  components type. components/{auth,settings} are folded into
  components-shared as the legacy cross-context home for MFA dialogs
  + PasswordRequirements (used by both organizer reset-password and
  portal wachtwoord-instellen / profiel).
- composables-forms (src/composables/forms/**) — pure form-runtime
  helpers reusable from organizer Form Builder later.
- stores-portal (src/stores/portal/**) — keeps the portal auth +
  portal store walled off from the organizer auth surface.
- pages-{register,portal,platform,organizer} alongside the legacy
  pages type — register pages cannot reach into stores or
  components-portal/-organizer; portal pages cannot reach
  components-organizer; organizer + platform pages cannot reach
  stores-portal or components-portal.

Cross-context edges are forbidden (organizer ↛ portal,
shared ↛ portal/organizer). Two pragmatic exceptions are documented
inline:
  - components-shared accepts the legacy auth/ + settings/ paths
    until PR-B2 cleanup re-homes them under shared/{auth,settings}/.
  - pages-register may read stores-portal because success.vue
    optionally enriches with the portal user when authenticated.
    PR-B2 may move success.vue into pages-portal so this drops.

Lint: 0 errors / 0 new warnings (only the pre-existing
boundaries v5→v6 deprecation warnings, which apply to all 19 rules
now). Tests: 23 / 162 pass. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:29:32 +02:00
5c689f42a0 feat(router): wire portal/register pages, portal-context guard carve-out, lint cleanup
Routing wiring (Phase D of WS-3 PR-B1):

- apps/app/src/plugins/1.router/guards.ts: add a single early-return
  carve-out before the org-selection redirect — `if (to.meta.context
  === 'portal') return`. Per ARCH-CONSOLIDATION-2026-04 §4.3,
  meta.context is the canonical contract; PR-B2 evolves the guards
  from this key to full context-aware logic (post-login landing,
  context-switcher, role checks).
- apps/app/env.d.ts: extend RouteMeta with the new layout names
  ('OrganizerLayout' | 'PortalLayout' | 'PublicLayout'), context,
  requiresAuth, requiresToken, navMode, navTitle.
- apps/app/typed-router.d.ts: regenerated by unplugin-vue-router to
  pick up portal/* and register/* route names.
- Page meta finalisation: portal pages have layout: 'PortalLayout',
  context: 'portal', preserving original requiresAuth + nav fields;
  register pages have layout: 'PublicLayout' + public: true (the
  apps/app guard convention for public routes, since meta.public is
  what the existing guard recognises).

Form-types restructure (boundaries cleanup):

- apps/app/src/composables/forms/types/formBuilder.ts → src/types/forms/
- apps/app/src/composables/forms/utils/{formValidation,validators}.ts
  → src/utils/forms/
- All `@/composables/forms/{types,utils}/*` imports rewritten across
  pages, components, composables, tests.
- This avoids a `types → composables` boundaries violation at
  src/types/formSchema.ts which re-exports primitives from the
  inlined form-schema. types/formSchema.ts now imports from
  @/types/forms/formBuilder which is in the same boundaries zone.

Lint cleanup for moved portal sources (apps/portal had no
.eslintrc.cjs; the migrated code now has to pass apps/app's stricter
config):

- axios.isAxiosError → named import { isAxiosError }
  (ClaimenTab, RoosterTab, profiel.vue)
- void schemaQuery.refetch() → schemaQuery.refetch()
  (register/[public_token].vue)
- if-then-else collapsed to single boolean return (formatFieldValue)
- :delay-on-touch-only="true" → delay-on-touch-only shorthand
  (FieldSectionPriority)
- ml-2 class → ms-2 (FieldAvailabilityPicker)
- multi-statement-per-line splits in profiel.vue + spec files
- unused emailConfigured ref removed (profiel.vue)
- one-component-per-file disabled with TODO TECH-WS3-PORTAL-LINT-CLEANUP
  ref (FieldOptionsLocale.spec.ts — multi-Wrapper test pattern)
- restored `import Draggable from 'vuedraggable'` after lint:fix
  removed it (template-only usage; the import IS needed)
- camelcase param renamed in FieldOptionsLocale harness factory
- typecheck nudge: spec state.data typed via PublicFormSectionOption[] /
  PublicFormTimeSlot[] aliases instead of Record<string, unknown>
- PortalLayout.vue: explicit `import { useRoute, useRouter }` so the
  vitest mock can intercept (the trimmed AutoImport set doesn't pull
  vue-router's auto-imports)

Vitest: 23 / 162 passing. Lint: 0 errors / 0 new warnings (only the
pre-existing boundaries v5→v6 deprecation warnings remain). Typecheck:
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:26:46 +02:00
e3452312d1 refactor(layouts): merge portal navbar/drawer into PortalLayout.vue
Migrates the navbar (event/platform two-mode toggle), mobile drawer
with avatar header + logout, RouterView Suspense wrapper, and footer
from apps/portal/src/layouts/portal.vue into the PortalLayout.vue
skeleton from PR-A. The skeleton's structure (VApp / VAppBar / VMain
/ VFooter) is preserved as the outer shell.

Notable adaptations:
  - useAuthStore → usePortalAuthStore (renamed in C.3)
  - usePortalStore import path → @/stores/portal/usePortalStore
  - mobile nav links now point at /portal/evenementen and /portal/profiel
    (the new sub-zone paths) instead of /evenementen and /profiel
  - explicit `import { useRoute, useRouter }` from vue-router so the
    vitest mock can intercept (auto-import not configured for these in
    the trimmed test config)

Updated PortalLayout.spec.ts to mock the two pinia stores plus
useSkins, vue-router, UserAvatarMenu, and AppLoadingIndicator. Tests
now assert the auth-conditional rendering: header + drawer hidden
when unauthenticated, main + footer always present.

Also pulls in the @form-schema → @/composables/forms/* import
rewrites in the C.4-moved composables that the previous commit's
rename-only diff left unstaged.

Vitest: 23 files / 162 tests, no errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:11:58 +02:00
7282861a7e refactor(portal): move composables, types, schemas; drop duplicates
Composables (apps/portal/src/composables → apps/app/src/composables/):
  - useFormDraft, publicFormInjection → composables/ (root, used by
    shared/public-form components)
  - api/usePublicForm, api/usePublicFormSections,
    api/usePublicFormTimeSlots → composables/api/ (no collisions)
  - api/usePortalShifts, api/usePortalProfile, api/useVolunteerRegistration
    → composables/api/portal/ (subfolder per WS-3 PR-B1 charter to
    leave room for organizer-side namesakes without clashes)
  - api/useMfa → DELETED (apps/app version is a strict superset
    with extra invalidateQueries calls and the admin-reset mutation)

Types (apps/portal/src/types → apps/app/src/types/):
  - api, portal-shift, portal, registration → moved
  - mfa → DELETED (byte-identical to apps/app/src/types/mfa.ts)

Schemas:
  - apps/portal/src/schemas/registrationSchema.ts → apps/app/src/schemas/

Utils:
  - deviceFingerprint, paginationMeta → DELETED (byte-identical
    duplicates already in apps/app/src/utils/)

Lib:
  - apps/portal/src/lib/{axios,query-client}.ts → DELETED. apps/app's
    callback-bound axios (post-PR-A) and query-client are the
    canonical versions. Portal pages currently importing
    `@/lib/axios#apiClient` resolve to apps/app's apiClient with no
    behavioral change for cookie-based requests.

Tests: 4 composable specs (useFormDraft x2, usePublicFormSections,
usePublicFormTimeSlots) moved into __tests__/ subdirs alongside
their composables.

@form-schema imports inside the moved files rewritten to
@/composables/forms/*.

Vitest now: 23 files / 162 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:08:53 +02:00
4fe1a0c517 refactor(portal): move stores and rename portal auth store
- apps/portal/src/stores/useAuthStore.ts →
  apps/app/src/stores/portal/usePortalAuthStore.ts. The export and
  defineStore id are renamed (useAuthStore → usePortalAuthStore,
  'auth' → 'portalAuth') so it can coexist with the organizer's
  apps/app/src/stores/useAuthStore. Lazy import inside
  resetPortalStoresSync() updated to the new path.
- apps/portal/src/stores/usePortalStore.ts →
  apps/app/src/stores/portal/usePortalStore.ts (no name change —
  apps/app does not have a usePortalStore).

All call sites in moved pages/components now import from
@/stores/portal/{usePortalStore,usePortalAuthStore} and call
usePortalAuthStore() instead of useAuthStore().

PR-B2 will merge this back into a single context-aware auth store.

Also includes the C.1 page meta-block updates (layout: 'PortalLayout'
| 'PublicLayout', context: 'portal') that were left unstaged after
the page-rename commit picked up only the path change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:06:08 +02:00
98ec51fcbd refactor(portal): move components to shared/public-form and portal/{event,*}
- public-form/** (18 files + 7 component tests) → shared/public-form/**
  This is the runtime form-renderer; goes into shared/ because it will
  be reused by the organizer-app Form Builder preview (S3b).
- event/{Claimen,Informatie,Overzicht,Rooster}Tab.vue → portal/event/**
- portal/{StatusCard,EventCard,UserAvatarMenu}.vue → portal/** (no
  path change — both apps had a portal/ subfolder).
- AppLoadingIndicator.vue, auth/{PasswordRequirements,MfaChallengeCard}.vue,
  settings/Mfa{Disable,Email,Totp}SetupDialog.vue: portal copies
  deleted as duplicates of pre-existing apps/app components (diffs
  were trivial formatting only).

Inside the moved files: rewrote @form-schema/* → @/composables/forms/*
and @/components/{public-form,event/[Tab]} → new sub-zone paths.

Updated apps/app/tsconfig.json to drop the @form-schema path alias
and the packages/form-schema include path. Updated formSchema.ts to
import from @/composables/forms/types/formBuilder. Carried the
crypto polyfill from apps/portal/tests/setup.ts into
apps/app/tests/setup.ts (needed by useFormDraft tests landing in C.4).

NOTE: Some moved tests still fail because they reference portal
composables (usePublicFormSections, useFormDraft) that move in C.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:04:49 +02:00
4cfcd5306a refactor(portal): move pages from apps/portal to apps/app
Per WS-3 PR-B1 charter §4.2: portal pages relocate into the
single-SPA layout under apps/app/src/pages/portal/** (authenticated
portal context) and apps/app/src/pages/register/** (public
token-based form-fill / confirmation).

Updated meta blocks:
  - Portal pages: layout: 'PortalLayout', context: 'portal'
    (preserving original requiresAuth + nav fields)
  - Register pages: layout: 'PublicLayout' (drop requiresAuth)

Skipped (apps/portal duplicates of pages already in apps/app):
  index.vue, login.vue, wachtwoord-{vergeten,resetten}.vue,
  verify-email-change.vue. Deleted: [...path].vue (apps/app already
  has [...error].vue catch-all).

NOTE: Component/store/composable imports inside these files still
point at apps/portal-relative paths and will be rewritten in the
next commits. Build will not be green again until commit 6
(composables/lib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:58:06 +02:00
79954aace6 refactor(forms): move packages/form-schema → apps/app/src/composables/forms
Inlines the form-schema source folder (no package.json, alias-only)
into apps/app/src/composables/forms. Drops the @form-schema alias
from apps/app/vite.config.ts (replaced by @/composables/forms via
the existing @ alias). apps/portal vite + vitest configs keep
@form-schema as a temporary alias pointing at the new location so
portal tests/build keep working until apps/portal is removed at the
end of this PR. Two pure-logic form-schema tests moved alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:50:52 +02:00
966ded3e44 chore(monorepo): scaffold target sub-folders for WS-3 PR-B1
Creates portal/register/shared/forms sub-folders ahead of the moves
in subsequent commits. Empty .gitkeep markers will be replaced by
real content as the moves land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:44:24 +02:00
de07ccac8e chore(apps/app): drop unnecessary async on synchronous error handlers
Both interceptor error handlers in lib/axios.ts were declared
`async` but contain zero `await` calls — the request handler
just rethrows, and the response handler walks a synchronous
status-code branching tree before rethrowing. axios accepts both
sync and async handler signatures, so dropping the keyword is
mechanical and behavior-neutral.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:40:12 +02:00
853939e8b8 refactor(apps/app): decouple axios from impersonation sessionStorage contract
Chose Option A from the follow-up brief: useImpersonationStore
already holds an `ImpersonationState` ref hydrated from
sessionStorage at store-init and exposes the active impersonation
target user as a public `targetUserId` computed. The store is the
canonical source; sessionStorage is just its persistence sidecar.

Adds a fifth callback `getImpersonationTargetUserId: () => string
| null` to AxiosBindingsDeps and replaces the
sessionStorage.getItem('crewli_impersonation') + JSON.parse block
in the request interceptor with a single `deps.getImpersonationTargetUserId()`
call. The bindings plugin wires it to
`useImpersonationStore().targetUserId`.

After this commit lib/axios.ts has zero references to
sessionStorage and zero magic strings about impersonation
persistence — the only persistence-mechanism knowledge left is in
useImpersonationStore (where it belongs) and in
plugins/3.axios-bindings.ts (allowed to know about stores). The
HTTP module is now unambiguously pure infrastructure.

Behavior preserved 1:1: the store hydrates from sessionStorage
synchronously inside the defineStore factory, so the very first
HTTP request after page load sees the same target user id as
before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:39:04 +02:00
26a92b3078 feat(apps/app): add plugins/3.axios-bindings.ts to wire stores into axios
Supplies the runtime closures that the registerInterceptors seam
needs. The plugin imports the four stores
(`useOrganisationStore`, `useNotificationStore`, `useAuthStore`,
`useImpersonationStore`) — allowed by the boundaries matrix
(`plugins → stores`) — and passes them as lazy callbacks so the
store factories only resolve when an HTTP call actually fires.

Numeric prefix `3.` runs after `2.pinia.ts` (auto-loaded by
`@core/utils/plugins.ts` in alphabetical-path order), so Pinia is
guaranteed active before the bindings register. No change to
`main.ts` is required — the file is picked up by the existing
`import.meta.glob('./plugins/*.{ts,js}')` glob.

Two redirects previously inside axios.ts now live where they
belong:
  - `window.location.href = '/platform'` on impersonation
    revocation, in the `onImpersonationRevoked` closure.
  - `handleUnauthorized()` (which itself redirects to `/login`)
    on 401, gated by `isInitialized` inside the `onAuthFail`
    closure — preserves the race-condition fix from sessie 1b-iii.

With this commit the two Vite mixed-import warnings
(useAuthStore + useImpersonationStore being both statically and
dynamically imported) disappear from `pnpm build`. Lint stays at
0 problems, typecheck clean, 49/49 tests pass.

Refs TECH-AXIOS-STORE-COUPLING.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:22:49 +02:00
53f6a7be73 refactor(apps/app): extract axios interceptors to registerInterceptors seam
Closes the lib → stores boundary violations that WS-3 sessie 1c
flagged. lib/axios.ts is now pure HTTP infrastructure: it exports
the configured `apiClient` plus a `registerInterceptors(client,
deps)` function that takes a typed `AxiosBindingsDeps` callback
bag (`getActiveOrgId`, `notify`, `onAuthFail`,
`onImpersonationRevoked`). All four `eslint-disable-next-line
boundaries/element-types` comments referencing
TECH-AXIOS-STORE-COUPLING are removed in the same change because
the imports they suppressed are gone — they would otherwise be
orphan disables.

Behavior is preserved 1:1: same status-code branching, same toast
messages, same DEV-only console logs, same sessionStorage-driven
X-Impersonate-User header (which never depended on a store and
stays in lib/axios.ts as before). The two redirects that used to
live in axios.ts (`/platform` on impersonation revocation,
`/login` on auth fail) move into the bindings-plugin closures so
the HTTP module stops knowing about routing.

The `apiClient` singleton is now exported without interceptors
attached — the bindings plugin
(`plugins/3.axios-bindings.ts`, follow-up commit) wires them up
during plugin-init, before `app.mount`.

Refs TECH-AXIOS-STORE-COUPLING.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:22:33 +02:00
31986989cd chore(types): regenerate typed-router.d.ts for form-failures pages
unplugin-vue-router regenerates this file at build time. Missed in an
earlier merge — probably during a WS-6 admin-UI consolidation. The
form-failures pages and tests are already in main; only the typed
declaration was stale.

Routes added to the typed declaration:
- /organisation/form-failures
- /organisation/form-failures/:id
- /platform/form-failures
- /platform/form-failures/:id

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 21:48:41 +02:00
bdbd5b0335 chore(cleanup): delete dead Vuexy views/ directory
src/views/ contained a single Vuexy-template file
(views/pages/authentication/AuthProvider.vue) with zero importers
in the repo. Vendored dead code from the original Vuexy template;
the §4.2 post-consolidation target layout drops views/ entirely.

Removed:
- apps/app/src/views/ (recursive)
- 'src/views/**' line from boundaries/ignore in .eslintrc.cjs

Closes TECH-DELETE-DEAD-VIEWS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 21:39:31 +02:00
fe3a2e1a52 chore(apps/app): mark lib/axios store imports for deferred refactor
WS-3 session 1c — Phase B Q1=B-revised (Bert's call after the
plugin-reality discovery).

eslint-plugin-boundaries treats both static `import` and dynamic
`await import(...)` as boundary edges. The original Q1=B mechanism
("convert static→dynamic to satisfy the rule") doesn't actually
satisfy the rule — all 4 store accesses in lib/axios.ts trip
boundaries/element-types: lines 3, 4 (static, pre-1c) and lines
61, 72 (dynamic, from 1b-iii).

Three options were on the table; Bert chose B-revised:
- A-reversal (allow lib→stores in matrix) was rejected because it
  permanently loosens the boundary for 4 imports — exactly the
  silent exception the zero-compromise principle forbids.
- B-extract (decouple axios.ts from stores via callback-injection)
  is real architectural work and deserves a focused session, not
  the tail-end of a tooling sprint. Filed as TECH-AXIOS-STORE-
  COUPLING in the next docs commit; the four sites carry per-line
  TODO references to it.
- B-revised (this commit) preserves the strict matrix:
  boundaries/element-types stays at 'error' globally; the four
  axios.ts sites are explicit per-line exceptions, not a rule
  loosening. Future lib/X.ts writers still hit the wall.

Behavior unchanged. Only lint visibility changed — 4 disable
comments added at:
- src/lib/axios.ts:3 (static useNotificationStore import)
- src/lib/axios.ts:5 (static useOrganisationStore import; was line 4)
- src/lib/axios.ts:63 (dynamic useImpersonationStore await import; was line 61)
- src/lib/axios.ts:75 (dynamic useAuthStore await import; was line 72)

Each comment is exactly:
  // eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.

Commit verb is `chore` not `refactor` per Bert: the code's behavior
doesn't change, only its lint-visibility does. Honest naming.

Tests + typecheck + build verified green:
- apps/app vitest: 49 passed
- apps/app vue-tsc: clean
- apps/app pnpm build: succeeded in 11.24s

Lint baseline: 4 → 0 errors. WS-3 1c acceptance criterion satisfied.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-30 23:23:52 +02:00
de71d31a2b chore(tooling): enable eslint-plugin-boundaries in apps/app
Adds 'boundaries' to plugins, the layered-architecture matrix to
rules, and the boundaries/elements + boundaries/ignore + boundaries/
include settings per the WS-3 1c audit (Phase A:
dev-docs/WS-3-SESSION-1C-AUDIT.md). Phase B sign-off (Bert):

- Q1=B — `lib → stores` is DISALLOWED in the matrix; lib/axios.ts is
  refactored in the next commit.
- Q2 — src/views/** added to boundaries/ignore (dead Vuexy file;
  TECH-DELETE-DEAD-VIEWS backlog item lands with the docs commit).
- Q3 — `navigation` allowed to import `types`, `utils` (forward
  headroom).
- Q4 — sub-zone enforcement deferred to TECH-WS3-BOUNDARIES-SUBZONES
  (lands when WS-3 PR-B brings the §4.2 components/{organizer,portal,
  shared} + pages/{(auth),portal,…} structure).

Forward-flag carried into the inline comment: when src/plugins/1.router/
migrates to a top-level src/router/ in a later WS-3 PR, add a
{ type: 'router', pattern: 'src/router/**' } element and a
{ from: 'router', allow: ['types','utils','lib','plugins','stores'] }
rule. Doc-side flag also lands in the ARCH-CONSOLIDATION 1c entry.

Boundaries plugin v6 emits a deprecation warning that the
'element-types' selector format is legacy (v5 syntax); the rule
still works on v5-compatible config and migrating to v6 object-
selector syntax is out of scope per the prompt's "only the two
.eslintrc.cjs changes listed are permitted" constraint. Filing a
TECH-BOUNDARIES-V6-SELECTOR-MIGRATION backlog item (in the docs
commit) so the migration happens deliberately.

Lint count after this commit: 4 errors, all in lib/axios.ts (lines
3, 4, 61, 72 — the 2 static + 2 dynamic store imports). The plugin
treats both static AND dynamic `await import('@/stores/...')` as
boundary edges; this is a deliberate intermediate state. The next
commit (refactor) resolves all 4 to land at lint = 0.

Tests + typecheck verified green (boundary errors are lint-only).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-30 23:16:57 +02:00
af1c54967f chore(deps): add eslint-plugin-boundaries to apps/app
Adds eslint-plugin-boundaries@6.0.2 (MIT, peerDeps eslint>=6,
engines node>=18.18) as a direct devDep in apps/app/package.json,
matching the exact-pin style of the other 14 eslint-plugin-* deps.

Direct dep — not hoisted transitive — per the
TECH-PORTAL-ESLINT-DEPS lesson (Cursor's ESLint extension uses
strict module resolution and silently fails on plugins reachable
only via pnpm hoisting).

Plugin not yet enabled in .eslintrc.cjs; enabling lands in the next
commit per WS-3 1c sequence (audit Phase A → install → enable →
refactor axios.ts → docs).

Tests + typecheck verified green post-install.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-30 23:13:03 +02:00
436980632a chore(tooling): add 15 missing direct ESLint deps + Cursor settings cleanup
Surfaced during WS-3 1c-prep follow-up: Cursor's ESLint extension uses
strict module resolution and crashed on every plugin in the
@antfu/eslint-config-vue extends-chain that was only resolvable via
pnpm-hoisting in terminal.

Direct deps added (versions match what was already in pnpm store —
zero version shifts):
- 12 unscoped ESLint plugins (eslint-plugin-{antfu,es-x,html,i,jest,
  jsdoc,jsonc,markdown,n,no-only-tests,unused-imports,yml,
  eslint-comments})
- vue-eslint-parser
- @antfu/eslint-config-basic + @antfu/eslint-config-ts (extends targets)
- @stylistic/eslint-plugin-js + @stylistic/eslint-plugin-ts

.vscode/settings.json: removed redundant root-level
editor.defaultFormatter (per-language overrides do the job).

ESLint extension now activates correctly, server runs, save-on-format
works for TS/Vue files. Verified via smoke test: double quote in
useImpersonationStore.ts:1 was auto-corrected to single quote on Cmd+S.

Note: package.json declares some deprecated dependencies that pnpm
warns about (@antfu/eslint-config-vue@0.43.1, eslint@8.57.1,
eslint-plugin-i@2.28.1, eslint-plugin-markdown@3.0.1). Those are
pre-existing — not introduced here. Migration to ESLint v9 + flat
config + @antfu/eslint-config (modern) is a separate workstream.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:28:44 +02:00
e4c99e23e9 fix(app): collapse nested if in useImpersonationStore
WS-3 session 1b-iii follow-up — sonarjs/no-collapsible-if.

useImpersonationStore.ts:103: collapsed nested 'if (state.value)'
into the parent 'else if (data.data.session)' clause. Both legs
are AND-conditions on the same path, so the merge is semantically
identical. Brings the apps/app lint baseline to 0 problems.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:05:29 +02:00
b164a4979d refactor(app): use async/await for axios response interceptor error handler
WS-3 session 1b-iii Task 3.

Rewrites the response-interceptor error handler from
\`error => { ... void import(...).then(...) }\` to
\`async error => { ... await import(...) }\`.

Motivation: session 1b-ii's Q4 chose option-a (\`void\` prefix on
the dynamic-import chains), but empirically that doesn't satisfy
the promise/no-promise-in-callback rule — the rule fires on any
promise creation inside a callback, regardless of discard pattern.
Two warnings remained on lib/axios.ts:61, 73.

The async/await rewrite is semantically identical:
- Both call sites already end in window.location.href = ... which
  navigates away, so the few ms of \`await\` resolution latency is
  unobservable.
- The original return Promise.reject(error) becomes throw error in
  an async function (async wraps throws in rejected promises).

Verified preserved byte-for-byte:
- 403 + impersonation_ended branch: clearState + redirect to /platform
  + rejection (now via throw)
- 401 branch: handleUnauthorized when authStore.isInitialized
- 403 / 404 / 422 / 503 / 5xx / !response notification branches
  (untouched in diff — all still in same order, same messages)
- Final rejection so calling code's catch fires (now via throw)
- Request interceptor not touched
- No imports added or removed

Tests + typecheck verified green. Build smoke: pnpm build succeeded
in 11.13s, zero warnings.

Lint baseline: 3 → 1 (the 2 promise/no-promise-in-callback warnings
on axios.ts:61, 73 are gone). The remaining 1 item is a pre-existing
sonarjs/no-collapsible-if at useImpersonationStore.ts:103 — see the
1b-iii final report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 18:48:28 +02:00
2fc2a569e7 fix(app): allow comments at SFC <script> block start
WS-3 session 1b-iii Task 2.

In Vue SFCs, the lines-around-comment rule conflicted with
vue/block-tag-newline at the <script setup>/comment boundary:
- lines-around-comment wants a blank line BEFORE the comment.
- vue/block-tag-newline wants exactly 1 line break after <script>.

Both can't be satisfied simultaneously when a script-block opens with
a leading comment. The session 1b-ii experiment (adding then reverting
blank lines) confirmed empirically these are mutually exclusive.

Resolution: per-*.vue override on lines-around-comment with
beforeBlockComment: false and beforeLineComment: false. Vue SFC
script blocks may now open with a leading comment without requiring
a preceding blank line. The base rule's allowBlockStart: true does
not help here because the <script> tag is not a JS block-start as
far as the AST sees it. All other rule options preserved
(allowBlockStart, allowClassStart, allowObjectStart, allowArrayStart,
ignorePattern: !SECTION).

Resolves the 3 items in PortalLayout.vue, PublicLayout.vue,
AppKpiCard.vue. Base rule remains in force for *.ts files.

Lint baseline: 6 → 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 18:46:21 +02:00