WS-3 PR-B2a: auth + routing consolidation (single SPA, dual axios, context-aware guards) #5

Merged
bert.hausmans merged 9 commits from feat/ws-3-pr-b2a-auth-routing-consolidation into main 2026-05-05 22:43:54 +02:00

Samenvatting

Consolideert auth + routing voor de single-SPA reality post-PR-B1: één useAuthStore
voor beide contexts (organizer + portal), één axios factory met aparte default- en
portal-token-instances, context-aware route-guards met meta-driven role/context
resolution, en een context-switcher voor multi-role users. Onderdeel van de
architectuur-consolidatie sprint (zie dev-docs/ARCH-CONSOLIDATION-2026-04.md).

Architectural decisions (locked tijdens prompt-engineering)

  • A. /auth/me additief uitgebreid: contexts.{available, default} block +
    promoted platform.is_super_admin. Geen breaking changes; MeTest.php blijft
    groen onaangeraakt. availableContexts is backend-driven (single source of truth
    in MeResource).
  • B. Axios factory respecteert registerInterceptors(client, deps) callback-seam
    uit 53f6a7b (TECH-AXIOS-STORE-COUPLING). Factory exporteert AxiosInstance(s);
    plugins/3.axios-bindings.ts doet de deps-wiring. Portal-token Bearer via
    bindings deps callback getPortalToken: () => string | null — geen lazy-import
    van stores binnen de factory.
  • C. Single store: usePortalAuthStore gemerged in useAuthStore met
    context-aware getters. Dubbele /auth/me fetch geëlimineerd.
  • D. Factory state-methods toegevoegd: volunteer(), orgAdmin(),
    volunteerAndOrganizer(), superAdmin() — herbruikbaar voor S3b en accreditation
    test scenarios.
  • E. organisations[].role emit als 1-element array (roles: [pivotRole]).
    Pivot-schema onveranderd; volledige multi-role discussie verplaatst naar backlog
    als TECH-PIVOT-ROLES-MULTI (ARCH-discussie over Spatie-permission integratie).
  • F. Login-flow geconsolideerd in useAuthStore.login(credentials) met
    typed LoginResult discriminated union (authenticated | mfa-required | must-set-password | failed). Page-component consumeert results en zet UI-state.
    verifyMfa() als zusteractie.
  • Context-typing. 'portal' | 'organizer' blijft string-union in B2a;
    enterprise-grade fix verplaatst naar dedicated workstream
    ARCH-API-RESPONSE-VALIDATION (zie dev-docs/ARCH-API-VALIDATION.md skeleton +
    BACKLOG entry, geland in commits babbbd9 / 145d0cb op main).

Commits (9, chronologisch)

SHA Wat
a2760ff feat(auth): add contexts + platform.is_super_admin to /auth/me, factory role-category states
13d7b18 refactor(axios): split lib/axios.ts into factory + default + portal-token instances
f2b08ec refactor(auth): merge usePortalAuthStore into useAuthStore with context-aware getters
473b22a feat(router): context-aware guards with meta-driven role/context resolution
209e0ef feat(layout): context-switcher for multi-role users
38a94c7 feat(auth): post-login landing route resolution per context
3019095 fix(security): A13-8 — migrate portal store to sessionStorage with explicit reset
eb7f3eb fix(portal): consume portal events from useAuthStore instead of duplicate /auth/me fetch
b191fbe refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa

Test impact

  • Backend: 1486 → 1491 (+5 in AuthMeShapeTest.php voor de contexts + platform
    enrichment; MeTest.php onaangeraakt en groen).
  • Frontend: 162 → 213 (+51: 43 in feature-commits + 8 cleanup, waarvan 4 voor
    portal-store sync-watcher en 4 voor MfaChallengeCard).
  • Lint + typecheck clean.

Buiten scope (uitdrukkelijk deferred naar PR-B2b)

  • A13-3 open-redirect fix — full domain validation. postLoginRedirect.ts
    is extension point; huidige logica is veilig maar niet uitputtend gevalideerd.
  • CookieBearerToken backend simplificatie — single-cookie-name post-consolidation
    is mogelijk maar buiten scope om backend untouched te houden in deze PR.
  • DirectAdmin deploy config — single-hostname migratie hoort bij DevOps-werk
    van B2b.

Bonus fix (opgemerkt tijdens cleanup)

hydrateAfterAuth / loadUserEventsFromApiAndStorage waren dead code — geen
callers in de codebase. Portal pages leunden op sessionStorage entries die
alleen door savePendingEventFromRegistration tijdens public registration
werden gevuld. De reactive watch in eb7f3eb zorgt dat userEvents nu
daadwerkelijk vanuit /auth/me populeert. Zie commit body voor details.

Self-review checklist

  • apps/portal/ is volledig verwijderd (gebeurd in PR-B1, geen restanten)
  • Boundaries-matrix: stores-portal → stores edge bestaat al, geen
    config-wijziging nodig (bevestigd in cleanup-rapport)
  • Geen rebase-conflicten verwacht (main is +3 commits ahead met
    ARCH-API-VALIDATION borging, geen overlap met deze branch)
  • MeTest.php onaangeraakt; AuthMeShapeTest.php is nieuw
  • Geen apiClient.get('/auth/me') meer in usePortalStore
  • Single source: MeResource::toArrayuseAuthStore.setUser
    usePortalStore watch

Refs

  • dev-docs/ARCH-CONSOLIDATION-2026-04.md — sprint scope
  • dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md — A13 security items
  • dev-docs/AUTH_ARCHITECTURE.md/auth/me shape contract
  • dev-docs/BACKLOG.mdARCH-API-RESPONSE-VALIDATION,
    TECH-PIVOT-ROLES-MULTI, B2b items
## Samenvatting Consolideert auth + routing voor de single-SPA reality post-PR-B1: één `useAuthStore` voor beide contexts (organizer + portal), één axios factory met aparte default- en portal-token-instances, context-aware route-guards met `meta`-driven role/context resolution, en een context-switcher voor multi-role users. Onderdeel van de architectuur-consolidatie sprint (zie `dev-docs/ARCH-CONSOLIDATION-2026-04.md`). ## Architectural decisions (locked tijdens prompt-engineering) - **A.** `/auth/me` additief uitgebreid: `contexts.{available, default}` block + promoted `platform.is_super_admin`. Geen breaking changes; `MeTest.php` blijft groen onaangeraakt. `availableContexts` is backend-driven (single source of truth in `MeResource`). - **B.** Axios factory respecteert `registerInterceptors(client, deps)` callback-seam uit `53f6a7b` (TECH-AXIOS-STORE-COUPLING). Factory exporteert AxiosInstance(s); `plugins/3.axios-bindings.ts` doet de deps-wiring. Portal-token Bearer via bindings deps callback `getPortalToken: () => string | null` — geen lazy-import van stores binnen de factory. - **C.** Single store: `usePortalAuthStore` gemerged in `useAuthStore` met context-aware getters. Dubbele `/auth/me` fetch geëlimineerd. - **D.** Factory state-methods toegevoegd: `volunteer()`, `orgAdmin()`, `volunteerAndOrganizer()`, `superAdmin()` — herbruikbaar voor S3b en accreditation test scenarios. - **E.** `organisations[].role` emit als 1-element array (`roles: [pivotRole]`). Pivot-schema onveranderd; volledige multi-role discussie verplaatst naar backlog als `TECH-PIVOT-ROLES-MULTI` (ARCH-discussie over Spatie-permission integratie). - **F.** Login-flow geconsolideerd in `useAuthStore.login(credentials)` met typed `LoginResult` discriminated union (`authenticated | mfa-required | must-set-password | failed`). Page-component consumeert results en zet UI-state. `verifyMfa()` als zusteractie. - **Context-typing.** `'portal' | 'organizer'` blijft string-union in B2a; enterprise-grade fix verplaatst naar dedicated workstream `ARCH-API-RESPONSE-VALIDATION` (zie `dev-docs/ARCH-API-VALIDATION.md` skeleton + BACKLOG entry, geland in commits `babbbd9` / `145d0cb` op main). ## Commits (9, chronologisch) | SHA | Wat | |-----|-----| | `a2760ff` | feat(auth): add contexts + platform.is_super_admin to /auth/me, factory role-category states | | `13d7b18` | refactor(axios): split lib/axios.ts into factory + default + portal-token instances | | `f2b08ec` | refactor(auth): merge usePortalAuthStore into useAuthStore with context-aware getters | | `473b22a` | feat(router): context-aware guards with meta-driven role/context resolution | | `209e0ef` | feat(layout): context-switcher for multi-role users | | `38a94c7` | feat(auth): post-login landing route resolution per context | | `3019095` | fix(security): A13-8 — migrate portal store to sessionStorage with explicit reset | | `eb7f3eb` | fix(portal): consume portal events from useAuthStore instead of duplicate /auth/me fetch | | `b191fbe` | refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa | ## Test impact - **Backend:** 1486 → 1491 (+5 in `AuthMeShapeTest.php` voor de `contexts` + `platform` enrichment; `MeTest.php` onaangeraakt en groen). - **Frontend:** 162 → 213 (+51: 43 in feature-commits + 8 cleanup, waarvan 4 voor portal-store sync-watcher en 4 voor `MfaChallengeCard`). - Lint + typecheck clean. ## Buiten scope (uitdrukkelijk deferred naar PR-B2b) - **A13-3 open-redirect fix** — full domain validation. `postLoginRedirect.ts` is extension point; huidige logica is veilig maar niet uitputtend gevalideerd. - **CookieBearerToken backend simplificatie** — single-cookie-name post-consolidation is mogelijk maar buiten scope om backend untouched te houden in deze PR. - **DirectAdmin deploy config** — single-hostname migratie hoort bij DevOps-werk van B2b. ## Bonus fix (opgemerkt tijdens cleanup) `hydrateAfterAuth` / `loadUserEventsFromApiAndStorage` waren dead code — geen callers in de codebase. Portal pages leunden op sessionStorage entries die alleen door `savePendingEventFromRegistration` tijdens public registration werden gevuld. De reactive watch in `eb7f3eb` zorgt dat `userEvents` nu daadwerkelijk vanuit `/auth/me` populeert. Zie commit body voor details. ## Self-review checklist - [ ] `apps/portal/` is volledig verwijderd (gebeurd in PR-B1, geen restanten) - [ ] Boundaries-matrix: `stores-portal → stores` edge bestaat al, geen config-wijziging nodig (bevestigd in cleanup-rapport) - [ ] Geen rebase-conflicten verwacht (main is +3 commits ahead met ARCH-API-VALIDATION borging, geen overlap met deze branch) - [ ] `MeTest.php` onaangeraakt; `AuthMeShapeTest.php` is nieuw - [ ] Geen `apiClient.get('/auth/me')` meer in `usePortalStore` - [ ] Single source: `MeResource::toArray` → `useAuthStore.setUser` → `usePortalStore` watch ## Refs - `dev-docs/ARCH-CONSOLIDATION-2026-04.md` — sprint scope - `dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` — A13 security items - `dev-docs/AUTH_ARCHITECTURE.md` — `/auth/me` shape contract - `dev-docs/BACKLOG.md` — `ARCH-API-RESPONSE-VALIDATION`, `TECH-PIVOT-ROLES-MULTI`, B2b items
bert.hausmans added 9 commits 2026-05-05 22:42:51 +02:00
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>
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>
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>
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>
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>
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>
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>
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>
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>
bert.hausmans merged commit 68f1e6f80c into main 2026-05-05 22:43:54 +02:00
bert.hausmans deleted branch feat/ws-3-pr-b2a-auth-routing-consolidation 2026-05-05 22:43:54 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bert.hausmans/crewli#5