Commit Graph

194 Commits

Author SHA1 Message Date
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
f4e0de0e4e fix(app): set indent SwitchCase to 1
WS-3 session 1b-iii Task 1.

Codebase consistently uses SwitchCase: 1 (cases indented 2 spaces from
switch keyword), but the eslint rule was running with default
SwitchCase: 0 (cases at the same column as switch). This produced 24
unfixable indent items in useTimeSlotDropdown.ts (and 0 in other
files because they didn't have switch statements with this pattern).

Resolution: pass { SwitchCase: 1 } to the indent rule's options so
its expectation matches the codebase reality. The autofix would
reformat the codebase to match the default if SwitchCase: 0 were
correct, but our codebase deliberately uses 1 — this is the
zero-compromise path, no codebase rewrite needed.

Lint baseline: 32 → 6 (Task 1 alone).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 18:44:35 +02:00
1289b217d0 fix(app): resolve Bucket E.2-E.5 lint findings
WS-3 session 1b-ii Task 5b+c (audit Bucket E.2-E.5 — 6 items resolved,
2 promise/no-promise-in-callback warnings remain on dynamic-import
sites — see deviations).

This commit is split out from the originally-planned grouped Task 5
because the API stream timed out mid-session. E.1 (isAxiosError) is in
the preceding commit 0f155d9.

E.2 — vitest spec to Composition API (1× vue/component-api-style):
- useFormFailures.spec.ts: rewrote the test wrapper from
  \`{ setup() { return { result } }, render: () => h('div') }\`
  to \`setup(_, { expose }) { expose({ result }); return () => h('div') }\`.
  Pure Composition API: setup returns the render function; expose()
  declares the instance-visible \`result\` that the 7 \`vm.result.*\`
  assertions consume. Tests still pass green (49 tests).

E.3 — REAL BUG: missing return in computed (1× vue/return-in-computed-property):
- useTimeSlotDropdown.ts:80: the \`fetchParams\` computed had a switch
  over the \`DropdownScenario\` type (4 string-literal cases) without
  a \`default\` branch. If \`scenario.value\` ever returned a value
  outside the four narrowed cases (e.g. via a future type-assertion
  drift), the computed silently returned \`undefined\`, and the
  consumer code (\`fetchParams.value.includeParent\`) would throw
  \`Cannot read property 'includeParent' of undefined\`. Added a
  \`default\` branch returning \`{ includeParent: false, includeChildren: false }\`
  — same as the 'flat' case (the safest baseline: include only own
  slots, no hierarchy).

E.4 — SECURITY (1× vue/no-template-target-blank):
- pages/organisation/index.vue:343: the external website anchor had
  \`target='_blank'\` with \`rel='noopener'\` (only one). The rule
  requires the full \`rel='noopener noreferrer'\` pair. Updated.
  Mitigates reverse-tabnabbing (window.opener) AND referrer-leakage
  to the linked third-party site.

E.5 — axios fire-and-forget (3× promise/no-promise-in-callback,
1 fully resolved + 2 warnings remain):
- lib/axios.ts:42: changed \`error => Promise.reject(error)\` to
  \`async error => { throw error }\`. Semantically identical (axios
  interceptor onRejected returns a rejected promise either way) and
  satisfies the lint rule.
- lib/axios.ts:61, 73: prefixed the dynamic-import chains with \`void\`
  per Q4's option-a decision (\`void import('@/stores/...').then(...)\`).
  This makes the discard intent explicit, but empirically does NOT
  satisfy promise/no-promise-in-callback — the rule fires on any
  promise creation inside a callback, regardless of the discard
  pattern. The 2 warnings remain in the post-Task-5 baseline.
  Resolution path is Bert's call: either keep \`void\` and accept
  the warnings as documentation, or rewrite to \`async error => {
  const { useStore } = await import(...); ... }\` which sequentializes
  the dynamic-import resolution with the rejection. Out of scope for
  this session per the literal Q4 recipe.

Tests + typecheck verified green.

Lint baseline: 34 → 32.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:15:29 +02:00
0f155d9e5d fix(app): resolve Bucket E.1 — switch to named isAxiosError import
WS-3 session 1b-ii Task 5a (audit Bucket E.1 — 2 items).

EventTabsNav.vue:
- Replaced \`import axios from 'axios'\` with
  \`import { isAxiosError } from 'axios'\` (no other axios.* usage in
  the file).
- Updated both call sites: \`axios.isAxiosError(...)\` → \`isAxiosError(...)\`
  on lines 53 and 76.

Modern axios pattern; resolves the import/no-named-as-default-member
warnings flagged in the WS-3 1b-i audit. No behaviour change — the
named export is the same function.

Note: this commit is split out from the originally-planned grouped
Task 5 commit because the API stream timed out mid-task. E.2-E.5
follow in subsequent commits.

Tests + typecheck verified green.

Lint baseline: 36 → 34.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:10:59 +02:00
b4f5bbe7c2 fix(app): resolve Bucket A/C/D lint items (trivial / style / Vuetify class)
WS-3 session 1b-ii Task 4 (audit Buckets A, C, D — 26 items resolved
this commit; 24 indent items in useTimeSlotDropdown.ts remain — see
deviations).

Bucket A — Trivial fixes (12 items resolved):
- A.1: second-pass eslint --fix on App.vue resolved 4 multi-attribute
  warnings. AppKpiCard / PortalLayout / PublicLayout
  lines-around-comment items were attempted via blank-line addition,
  but that introduced an equal number of vue/block-tag-newline
  errors (the rules conflict at the SFC <script>-tag boundary). The
  blank-line additions were reverted; net-zero, the 3 items remain
  for a 1b-iii .eslintrc.cjs override decision.
- A.3: 6 unused-imports / unused-vars manual deletes:
  * OrganisationSwitcher.vue: removed orphan toggleMenu() function
  * CreateShiftDialog.vue: removed unused 'scenario' from destructure
  * pages/events/[id]/time-slots/index.vue: removed unused 'event'
    slot scope binding (template <#default="{ event }"> → <#default>)
  * pages/organisation/companies.vue: removed unused authStore
    declaration + import
  * pages/platform/activity-log/index.vue: removed unused
    search/searchDebounced pair
  * PersonDetailPanel.vue:77: removed redundant single-statement
    if-braces (curly autofix that the original pass didn't reach)

Bucket C — Style preference (8 items resolved):
- DismissFailureDialog.vue:43: collapsed two consecutive `if cond return false`
  branches into `return !(cond)`
- FormFailureDetail.vue:44: replaced `void clipboard.writeText(...)` with
  `clipboard.writeText(...).catch(() => {})` — fire-and-forget with
  silent rejection (the no-void rule wants the void operator gone;
  .catch() handles it semantically).
- AssignShiftDialog.vue:40-46: hasOverlapWarning collapsed from
  always-false branching to `computed(() => false)` (the early-return
  was dead code; backend enforces the constraint).
- SectionsShiftsPanel.vue:333 + registration-fields.vue:335: rewrote
  `:delay-on-touch-only="true"` to attribute-shorthand `delay-on-touch-only`.
- AssignPersonDialog.vue:120-128: collapsed two `if outer { if inner ... }`
  pairs into single `if (outer && inner)` form (sonarjs/no-collapsible-if).
- useImpersonationStore.ts:99-104: collapsed the same nested-if pattern
  into `if (!data.data.active && state.value)`.

Bucket D — Vuetify utility class rename (5 items, 3 files):
- ml-1 → ms-1 (PersonDetailPanel:271, SectionsShiftsPanel:357,
  AssignPersonDialog:496)
- pl-4 → ps-4 (AssignPersonDialog:457)
- ml-auto → ms-auto (AssignPersonDialog:471)
LTR/RTL-aware Vuetify utilities, matching the Vuexy reference idiom.

Tests + typecheck verified green.

Lint baseline: 62 → 36.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:20:34 +02:00
d407cd17de fix(app): resolve Bucket B (type safety) lint items
WS-3 session 1b-ii Task 3 (audit Bucket B — 34 items: 21 absorbed
via ignorePatterns + 14 real fixes; the count of 21 is the actual
non-Tier-3 lint-count drop from the .eslintrc edit, slightly above
the audit's predicted 20 because additional vendored-Vuexy items
beyond the 23 no-explicit-any landed in those paths too).

Config:
- .eslintrc.cjs: add src/@core/** and src/@layouts/** to ignorePatterns.
  Vendored Vuexy code, precedent: src/plugins/iconify/*.js. The
  CLAUDE.md no-any rule remains in force for our own code under src/.

Real type-safety fixes:
- B.1 ref<any> in our code (3 occurrences):
  * blank.vue / default.vue: AppLoadingIndicator template ref now
    typed as InstanceType<typeof AppLoadingIndicator> | null. Picks
    up the defineExpose'd fallbackHandle / resolveHandle methods.
  * NavSearchBar.vue:109: useApi<any>(...) → useApi<SearchResults[]>(...)
    matching the existing searchResult ref type.
- B.2 ShiftDetailPanel.vue: moved the Cancel-dialog ref declarations
  (isCancelDialogOpen, cancellingAssignment) from line 305-307 to
  line 248 — directly above the onCancel handler that uses them.
  Resolves all 7 no-use-before-define items in one move. Same-file,
  no logic change.
- B.3 useImpersonationStore.ts:119: renamed inner 'stored' to
  'storedSnapshot' to resolve shadowing of the outer 'stored' on
  line 18.
- B.4 useFormSchemas.ts:97-99: renamed local mutationFn parameter
  'confirmed_name' to camelCase 'confirmedName'. Wire-format key
  stays snake_case via destructure-alias:
    params: confirmedName ? { confirmed_name: confirmedName } : undefined
  No callers found in apps/app/src — safe rename.

Tests + typecheck verified green.

Lint baseline: 97 → 62.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:11:05 +02:00
dcb8d53b61 style(app): apply eslint --fix to Tier 3 config files
WS-3 session 1b-ii Task 2 (audit Bucket X — 134 items).

Hand-reviewed quote-style/semi/arrow-parens autofixes on the three
config files explicitly excluded from session 1b-i:

- vite.config.ts (91 items, @typescript-eslint/quotes / semi /
  arrow-parens / curly). Plugin order verified unchanged by diff
  inspection (VueRouter → vue → vueJsx → vuetify → MetaLayouts →
  Components → AutoImport → svgLoader). One curly-add at the
  apexcharts resolver: \`if (componentName === 'VueApexCharts') { return {...} }\`
  — behaviorally identical to the prior braces-omitted form.
  Build smoke: pnpm build succeeded in 12.13s, zero warnings.

- themeConfig.ts (42 items, quotes / semi). Theme token values
  byte-identical, only surrounding quotes flipped from double to
  single. App title 'Crewli', logo span style, language labels and
  i18n codes all preserved.

- vitest.config.ts (1 item, lines-around-comment). Trivial: blank
  line added before the dts:false comment block.

No semantic changes. apps/app vitest 49 passed, vue-tsc clean,
pnpm build succeeded.

Lint baseline: 231 → 97 (Bucket X resolved).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:07:13 +02:00
1a8f592518 chore(app): decouple pnpm lint from --fix
WS-3 session 1b-ii Task 1.

Splits the apps/app lint script:
- \`pnpm lint\` → no-fix; reports problems (used in CI, in audits).
- \`pnpm lint:fix\` → --fix; explicit autofix on demand.

Resolves the cause of the WS-3 1b-i pre-flight confusion: when 'pnpm
lint' silently ran --fix, ad-hoc invocations reported the post-fix
remainder as if it were the baseline (the wrong '105' number that
broke session 1b-i's first attempt).

No code changes. Behaviour change is opt-in per script invocation.

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