From a2760ffd64ad200458ba91b8c0de1dc9894deeb2 Mon Sep 17 00:00:00 2001
From: "bert.hausmans"
Date: Tue, 5 May 2026 21:15:10 +0200
Subject: [PATCH 1/9] feat(auth): add contexts + platform.is_super_admin to
/auth/me, factory role-category states
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
api/app/Http/Resources/Api/V1/MeResource.php | 92 +++++++++++++++----
api/database/factories/UserFactory.php | 47 +++++++++-
api/tests/Feature/Auth/AuthMeShapeTest.php | 96 ++++++++++++++++++++
apps/app/src/pages/forbidden.vue | 23 +++++
apps/app/typed-router.d.ts | 1 +
dev-docs/BACKLOG.md | 32 +++++++
6 files changed, 272 insertions(+), 19 deletions(-)
create mode 100644 api/tests/Feature/Auth/AuthMeShapeTest.php
create mode 100644 apps/app/src/pages/forbidden.vue
diff --git a/api/app/Http/Resources/Api/V1/MeResource.php b/api/app/Http/Resources/Api/V1/MeResource.php
index 76c1328e..1ac9b1c0 100644
--- a/api/app/Http/Resources/Api/V1/MeResource.php
+++ b/api/app/Http/Resources/Api/V1/MeResource.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use App\Models\Person;
+use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -13,6 +14,11 @@ final class MeResource extends JsonResource
{
public function toArray(Request $request): array
{
+ /** @var User $user */
+ $user = $this->resource;
+
+ $contexts = $this->resolveContexts($user);
+
return [
'id' => $this->id,
'first_name' => $this->first_name,
@@ -25,27 +31,32 @@ final class MeResource extends JsonResource
'locale' => $this->locale,
'avatar' => $this->avatar,
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
- 'organisations' => $this->whenLoaded('organisations', fn () =>
- $this->organisations->map(fn ($org) => [
- 'id' => $org->id,
- 'name' => $org->name,
- 'slug' => $org->slug,
- 'role' => $org->pivot->role,
- ])
+ 'organisations' => $this->whenLoaded('organisations', fn () => $this->organisations->map(fn ($org) => [
+ 'id' => $org->id,
+ 'name' => $org->name,
+ 'slug' => $org->slug,
+ 'role' => $org->pivot->role,
+ // Forward-compatible array form. The pivot stores a single
+ // role today; B2a emits it as a 1-element array so the
+ // frontend can treat the field as multi-role from day one.
+ // Multi-role pivot resolution is tracked in BACKLOG.md as
+ // TECH-PIVOT-ROLES-MULTI (ARCH discussion, not just a
+ // schema-column expansion).
+ 'roles' => [$org->pivot->role],
+ ])
),
'app_roles' => $this->getRoleNames()->values()->all(),
'permissions' => $this->getAllPermissions()->pluck('name')->values()->all(),
- 'portal_events' => $this->whenLoaded('persons', fn () =>
- $this->persons->map(fn (Person $person) => [
- 'event_id' => $person->event_id,
- 'event_name' => $person->event->name,
- 'event_slug' => $person->event->slug,
- 'organisation_name' => $person->event->organisation->name,
- 'person_id' => $person->id,
- 'person_status' => $person->status,
- 'start_date' => $person->event->start_date?->toDateString(),
- 'end_date' => $person->event->end_date?->toDateString(),
- ])
+ 'portal_events' => $this->whenLoaded('persons', fn () => $this->persons->map(fn (Person $person) => [
+ 'event_id' => $person->event_id,
+ 'event_name' => $person->event->name,
+ 'event_slug' => $person->event->slug,
+ 'organisation_name' => $person->event->organisation->name,
+ 'person_id' => $person->id,
+ 'person_status' => $person->status,
+ 'start_date' => $person->event->start_date?->toDateString(),
+ 'end_date' => $person->event->end_date?->toDateString(),
+ ])
),
'mfa' => [
'enabled' => $this->mfa_enabled,
@@ -53,6 +64,51 @@ final class MeResource extends JsonResource
'confirmed_at' => $this->mfa_confirmed_at?->toIso8601String(),
'setup_required' => app(MfaService::class)->isMfaRequired($this->resource) && ! $this->mfa_enabled,
],
+ 'platform' => [
+ 'is_super_admin' => $user->hasRole('super_admin'),
+ ],
+ 'contexts' => $contexts,
+ ];
+ }
+
+ /**
+ * Compute available + default UI contexts for this user.
+ *
+ * - portal: user has at least one Person record (volunteer-side).
+ * - organizer: super_admin OR membership in any Organisation pivot.
+ *
+ * Default precedence: super_admin → organizer; otherwise the first
+ * available context wins (organizer before portal, mirroring the
+ * "familiar context wins on first login" rule from
+ * ARCH-CONSOLIDATION-2026-04 §4.3). When neither context is
+ * available, default falls back to 'portal' so the post-login
+ * landing logic has a safe target to resolve against.
+ *
+ * @return array{available: list, default: string}
+ */
+ private function resolveContexts(User $user): array
+ {
+ $hasPortal = $user->persons->isNotEmpty();
+ $hasOrganizer = $user->hasRole('super_admin') || $user->organisations->isNotEmpty();
+
+ $available = [];
+ if ($hasPortal) {
+ $available[] = 'portal';
+ }
+ if ($hasOrganizer) {
+ $available[] = 'organizer';
+ }
+
+ $default = match (true) {
+ $user->hasRole('super_admin') => 'organizer',
+ $hasOrganizer => 'organizer',
+ $hasPortal => 'portal',
+ default => 'portal',
+ };
+
+ return [
+ 'available' => $available,
+ 'default' => $default,
];
}
}
diff --git a/api/database/factories/UserFactory.php b/api/database/factories/UserFactory.php
index 23719b3c..be9dec98 100644
--- a/api/database/factories/UserFactory.php
+++ b/api/database/factories/UserFactory.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Database\Factories;
+use App\Models\Organisation;
+use App\Models\Person;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
@@ -23,7 +25,7 @@ final class UserFactory extends Factory
'date_of_birth' => fake()->dateTimeBetween('-50 years', '-18 years')->format('Y-m-d'),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
- 'password' => static::$password ??= Hash::make('password'),
+ 'password' => self::$password ??= Hash::make('password'),
'timezone' => 'Europe/Amsterdam',
'locale' => 'nl',
'remember_token' => Str::random(10),
@@ -34,4 +36,47 @@ final class UserFactory extends Factory
{
return $this->state(fn () => ['email_verified_at' => null]);
}
+
+ /**
+ * Volunteer-only user — has a Person record (portal context),
+ * no Spatie role, no organisation membership.
+ */
+ public function volunteer(): static
+ {
+ return $this->afterCreating(function (User $user): void {
+ Person::factory()->create(['user_id' => $user->id]);
+ });
+ }
+
+ /**
+ * Organizer-only user — attached to a fresh Organisation as `org_admin`,
+ * no Person record, no Spatie role.
+ */
+ public function orgAdmin(): static
+ {
+ return $this->afterCreating(function (User $user): void {
+ $organisation = Organisation::factory()->create();
+ $organisation->users()->attach($user, ['role' => 'org_admin']);
+ });
+ }
+
+ /**
+ * Multi-role user — has both a Person record AND organisation membership.
+ */
+ public function volunteerAndOrganizer(): static
+ {
+ return $this->volunteer()->orgAdmin();
+ }
+
+ /**
+ * Platform admin — Spatie super_admin role. No org/person attachments
+ * by default (mirrors the production case where super_admins live above
+ * the org tree).
+ */
+ public function superAdmin(): static
+ {
+ return $this->afterCreating(function (User $user): void {
+ $user->assignRole('super_admin');
+ });
+ }
}
diff --git a/api/tests/Feature/Auth/AuthMeShapeTest.php b/api/tests/Feature/Auth/AuthMeShapeTest.php
new file mode 100644
index 00000000..b2075f78
--- /dev/null
+++ b/api/tests/Feature/Auth/AuthMeShapeTest.php
@@ -0,0 +1,96 @@
+seed(RoleSeeder::class);
+ }
+
+ public function test_volunteer_only_user_sees_portal_context(): void
+ {
+ $user = User::factory()->volunteer()->create();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/auth/me');
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'data' => [
+ 'platform' => ['is_super_admin'],
+ 'contexts' => ['available', 'default'],
+ ],
+ ])
+ ->assertJsonPath('data.platform.is_super_admin', false)
+ ->assertJsonPath('data.contexts.available', ['portal'])
+ ->assertJsonPath('data.contexts.default', 'portal');
+ }
+
+ public function test_organizer_only_user_sees_organizer_context(): void
+ {
+ $user = User::factory()->orgAdmin()->create();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/auth/me');
+
+ $response->assertOk()
+ ->assertJsonPath('data.platform.is_super_admin', false)
+ ->assertJsonPath('data.contexts.available', ['organizer'])
+ ->assertJsonPath('data.contexts.default', 'organizer');
+ }
+
+ public function test_multi_role_user_sees_both_contexts_with_organizer_default(): void
+ {
+ $user = User::factory()->volunteerAndOrganizer()->create();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/auth/me');
+
+ $response->assertOk()
+ ->assertJsonPath('data.contexts.available', ['portal', 'organizer'])
+ ->assertJsonPath('data.contexts.default', 'organizer');
+ }
+
+ public function test_super_admin_sees_organizer_context(): void
+ {
+ $user = User::factory()->superAdmin()->create();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/auth/me');
+
+ $response->assertOk()
+ ->assertJsonPath('data.platform.is_super_admin', true)
+ ->assertJsonPath('data.contexts.available', ['organizer'])
+ ->assertJsonPath('data.contexts.default', 'organizer');
+ }
+
+ public function test_organisations_emit_roles_array_in_addition_to_scalar_role(): void
+ {
+ $user = User::factory()->orgAdmin()->create();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/auth/me');
+
+ $response->assertOk()
+ ->assertJsonPath('data.organisations.0.role', 'org_admin')
+ ->assertJsonPath('data.organisations.0.roles', ['org_admin']);
+ }
+}
diff --git a/apps/app/src/pages/forbidden.vue b/apps/app/src/pages/forbidden.vue
new file mode 100644
index 00000000..fbdf066c
--- /dev/null
+++ b/apps/app/src/pages/forbidden.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Geen toegang
+
+
+ Je hebt geen toegang tot deze pagina.
+
+
+ Naar inloggen
+
+
+
diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts
index 46301a54..940bb6a5 100644
--- a/apps/app/typed-router.d.ts
+++ b/apps/app/typed-router.d.ts
@@ -33,6 +33,7 @@ declare module 'vue-router/auto-routes' {
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue }, { id: ParamValue }>,
'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue }, { id: ParamValue }>,
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue }, { id: ParamValue }>,
+ 'forbidden': RouteRecordInfo<'forbidden', '/forbidden', Record, Record>,
'forgot-password': RouteRecordInfo<'forgot-password', '/forgot-password', Record, Record>,
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue }, { token: ParamValue }>,
'login': RouteRecordInfo<'login', '/login', Record, Record>,
diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md
index 5a47d8da..a659aa17 100644
--- a/dev-docs/BACKLOG.md
+++ b/dev-docs/BACKLOG.md
@@ -823,6 +823,38 @@ introduceert is het natuurlijke moment.
---
+### TECH-PIVOT-ROLES-MULTI — Multi-role per (user, organisation) pivot
+
+**Aanleiding:** WS-3 PR-B2a maakt context-aware routing op
+`me.contexts.available` en `me.organisations[].roles`. Het pivot-veld
+`organisation_user.role` is vandaag een single string (één rol per user
+per org). De resource emit `roles` als 1-element array zodat het
+frontend-contract forward-compatible is, maar het schema ondersteunt
+nog niet meerdere rollen per relatie.
+**Wat:** Architectuur-discussie + design-document, niet een directe
+schema-uitbreiding. Te beantwoorden vragen voordat dit gepland wordt:
+
+- Spatie-permission-integratie: blijft `organisation_user.role`
+ een free-form string of komt het onder `model_has_roles` met
+ team-id = organisation_id? Spatie's "teams" feature is bedoeld voor
+ precies dit scenario.
+- Multi-role-precedence: als een user `org_admin` ÉN `event_manager` is
+ binnen dezelfde organisatie, hoe resolven policies? Hoogste
+ permissie-set? Meest restrictieve? Expliciete merge?
+- Migratie-pad: bestaande pivot-rijen (single string) → array of
+ pivot-tabel naar `model_has_roles`? Backfill-strategie?
+- Frontend impact: `organisations[].role` (scalar) blijft voorlopig
+ staan voor backward-compatibility. Wanneer mag dat veld weg?
+
+**Prioriteit:** Laag — geen blocker voor B2a, B2b of de 4 kern-workflows.
+Pas oppakken wanneer een concrete use case multi-role per (user, org)
+vereist (denkbaar: festival waarbij organizer ook als crew werkt).
+**Belangrijk:** dit is GEEN simpel "voeg een kolom toe" werk. Pak het
+niet op als drive-by tijdens een ander ticket; het verdient een eigen
+ARCH-discussie en RFC.
+
+---
+
### ~~TECH-02 — scopeForFestival helper op Event model~~ ✅ OPGELOST
---
From 13d7b18257f2e6197e4d4f0138f4390b53414e88 Mon Sep 17 00:00:00 2001
From: "bert.hausmans"
Date: Tue, 5 May 2026 21:18:55 +0200
Subject: [PATCH 2/9] refactor(axios): split lib/axios.ts into factory +
default + portal-token instances
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
apps/app/src/lib/axios/default.ts | 10 +++
.../src/lib/{axios.ts => axios/factory.ts} | 84 +++++++++++++++----
apps/app/src/lib/axios/index.ts | 8 ++
apps/app/src/lib/axios/portal-token.ts | 14 ++++
apps/app/src/plugins/3.axios-bindings.ts | 30 +++++--
5 files changed, 123 insertions(+), 23 deletions(-)
create mode 100644 apps/app/src/lib/axios/default.ts
rename apps/app/src/lib/{axios.ts => axios/factory.ts} (53%)
create mode 100644 apps/app/src/lib/axios/index.ts
create mode 100644 apps/app/src/lib/axios/portal-token.ts
diff --git a/apps/app/src/lib/axios/default.ts b/apps/app/src/lib/axios/default.ts
new file mode 100644
index 00000000..4c48b557
--- /dev/null
+++ b/apps/app/src/lib/axios/default.ts
@@ -0,0 +1,10 @@
+import { createApiClient } from './factory'
+
+/**
+ * Default API client — cookie-authenticated, sends X-Organisation-Id
+ * and X-Impersonate-User headers via the bindings plugin. Used by
+ * organizer + cookie-authenticated portal flows.
+ */
+const apiClient = createApiClient({ withCredentials: true })
+
+export default apiClient
diff --git a/apps/app/src/lib/axios.ts b/apps/app/src/lib/axios/factory.ts
similarity index 53%
rename from apps/app/src/lib/axios.ts
rename to apps/app/src/lib/axios/factory.ts
index 28e2f6cd..d3ec24d5 100644
--- a/apps/app/src/lib/axios.ts
+++ b/apps/app/src/lib/axios/factory.ts
@@ -3,36 +3,58 @@ import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
/**
* Seam contract between the HTTP client and the rest of the app.
- * `lib/axios.ts` knows nothing about Pinia, stores, or routing — it
- * only invokes these callbacks. The bindings plugin
+ * Factory + register* functions know nothing about Pinia, stores, or
+ * routing — they only invoke these callbacks. The bindings plugin
* (`plugins/3.axios-bindings.ts`) supplies the runtime closures.
+ *
+ * Originally introduced as `TECH-AXIOS-STORE-COUPLING` (closed in
+ * 53f6a7b). Preserving the seam during the WS-3 PR-B2a factory split
+ * was a deliberate decision (Bert, 2026-05-05).
*/
export interface AxiosBindingsDeps {
- getActiveOrgId: () => string | null
- getImpersonationTargetUserId: () => string | null
+ getActiveOrgId?: () => string | null
+ getImpersonationTargetUserId?: () => string | null
+ getPortalToken?: () => string | null
notify: (message: string, level: 'error' | 'warning') => void
onAuthFail: () => void
onImpersonationRevoked: () => void
}
-const apiClient: AxiosInstance = axios.create({
- baseURL: import.meta.env.VITE_API_URL,
- withCredentials: true,
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- timeout: 30000,
-})
+export interface CreateApiClientOptions {
+ withCredentials?: boolean
+ baseURL?: string
+}
-export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
+export function createApiClient(options: CreateApiClientOptions = {}): AxiosInstance {
+ const {
+ withCredentials = true,
+ baseURL = import.meta.env.VITE_API_URL,
+ } = options
+
+ return axios.create({
+ baseURL,
+ withCredentials,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ timeout: 30000,
+ })
+}
+
+/**
+ * Cookie-authenticated client interceptors. Attaches X-Organisation-Id
+ * + X-Impersonate-User on every request and routes 401/403/4xx/5xx
+ * through the deps callbacks.
+ */
+export function registerDefaultInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
- const orgId = deps.getActiveOrgId()
+ const orgId = deps.getActiveOrgId?.()
if (orgId)
config.headers['X-Organisation-Id'] = orgId
- const impersonationTargetUserId = deps.getImpersonationTargetUserId()
+ const impersonationTargetUserId = deps.getImpersonationTargetUserId?.()
if (impersonationTargetUserId)
config.headers['X-Impersonate-User'] = impersonationTargetUserId
@@ -44,6 +66,34 @@ export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsD
error => { throw error },
)
+ registerSharedResponseHandler(client, deps)
+}
+
+/**
+ * Portal-token (Bearer) client interceptors. Attaches
+ * Authorization: Bearer when the deps callback returns a
+ * non-null token. Does NOT attach org/impersonation headers — the
+ * Bearer flow has no organisation context.
+ */
+export function registerPortalTokenInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
+ client.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ const token = deps.getPortalToken?.()
+ if (token)
+ config.headers.Authorization = `Bearer ${token}`
+
+ if (import.meta.env.DEV)
+ console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
+
+ return config
+ },
+ error => { throw error },
+ )
+
+ registerSharedResponseHandler(client, deps)
+}
+
+function registerSharedResponseHandler(client: AxiosInstance, deps: AxiosBindingsDeps): void {
client.interceptors.response.use(
response => {
if (import.meta.env.DEV)
@@ -93,5 +143,3 @@ export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsD
},
)
}
-
-export { apiClient }
diff --git a/apps/app/src/lib/axios/index.ts b/apps/app/src/lib/axios/index.ts
new file mode 100644
index 00000000..79e5e394
--- /dev/null
+++ b/apps/app/src/lib/axios/index.ts
@@ -0,0 +1,8 @@
+export { default as apiClient } from './default'
+export { default as portalApiClient } from './portal-token'
+export {
+ createApiClient,
+ registerDefaultInterceptors,
+ registerPortalTokenInterceptors,
+} from './factory'
+export type { AxiosBindingsDeps, CreateApiClientOptions } from './factory'
diff --git a/apps/app/src/lib/axios/portal-token.ts b/apps/app/src/lib/axios/portal-token.ts
new file mode 100644
index 00000000..15c66b9d
--- /dev/null
+++ b/apps/app/src/lib/axios/portal-token.ts
@@ -0,0 +1,14 @@
+import { createApiClient } from './factory'
+
+/**
+ * Portal-token API client — Bearer auth, no cookies. Used by
+ * unauthenticated artist/supplier flows where the token comes from a
+ * URL parameter (artist-advance, supplier-intake — wired post-WS-3).
+ *
+ * The Bearer token is read at request time from
+ * `useAuthStore.portalToken` via the bindings plugin's
+ * `getPortalToken` callback.
+ */
+const portalApiClient = createApiClient({ withCredentials: false })
+
+export default portalApiClient
diff --git a/apps/app/src/plugins/3.axios-bindings.ts b/apps/app/src/plugins/3.axios-bindings.ts
index b58a2e3a..dc8a200e 100644
--- a/apps/app/src/plugins/3.axios-bindings.ts
+++ b/apps/app/src/plugins/3.axios-bindings.ts
@@ -1,5 +1,10 @@
import type { App } from 'vue'
-import { apiClient, registerInterceptors } from '@/lib/axios'
+import {
+ apiClient,
+ portalApiClient,
+ registerDefaultInterceptors,
+ registerPortalTokenInterceptors,
+} from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
@@ -10,10 +15,9 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
// inside each callback (not eagerly at plugin-init), which keeps the
// seam tolerant of any future plugin-ordering changes.
export default function (_: App): void {
- registerInterceptors(apiClient, {
- getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
- getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
- notify: (message, level) => useNotificationStore().show(message, level),
+ const sharedDeps = {
+ notify: (message: string, level: 'error' | 'warning') =>
+ useNotificationStore().show(message, level),
onAuthFail: () => {
const authStore = useAuthStore()
if (authStore.isInitialized)
@@ -23,5 +27,21 @@ export default function (_: App): void {
useImpersonationStore().clearState()
window.location.href = '/platform'
},
+ }
+
+ registerDefaultInterceptors(apiClient, {
+ ...sharedDeps,
+ getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
+ getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
+ })
+
+ registerPortalTokenInterceptors(portalApiClient, {
+ ...sharedDeps,
+
+ getPortalToken: () => {
+ const authStore = useAuthStore() as unknown as { portalToken?: string | null }
+
+ return authStore.portalToken ?? null
+ },
})
}
From f2b08ecb21538a8e9d4f9b518e6e0f80d3f0554c Mon Sep 17 00:00:00 2001
From: "bert.hausmans"
Date: Tue, 5 May 2026 21:25:24 +0200
Subject: [PATCH 3/9] refactor(auth): merge usePortalAuthStore into
useAuthStore with context-aware getters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
apps/app/.eslintrc.cjs | 8 +-
.../src/components/portal/UserAvatarMenu.vue | 4 +-
apps/app/src/layouts/PortalLayout.vue | 4 +-
.../layouts/__tests__/PortalLayout.spec.ts | 4 +-
apps/app/src/pages/portal/profiel.vue | 4 +-
apps/app/src/pages/portal/shifts/index.vue | 4 +-
.../src/stores/__tests__/useAuthStore.spec.ts | 233 +++++++++++++++++
.../src/stores/portal/usePortalAuthStore.ts | 114 ---------
apps/app/src/stores/useAuthStore.ts | 234 +++++++++++++++++-
apps/app/src/types/auth.ts | 57 +++++
10 files changed, 537 insertions(+), 129 deletions(-)
create mode 100644 apps/app/src/stores/__tests__/useAuthStore.spec.ts
delete mode 100644 apps/app/src/stores/portal/usePortalAuthStore.ts
diff --git a/apps/app/.eslintrc.cjs b/apps/app/.eslintrc.cjs
index 4c9a83d6..8da0dda4 100644
--- a/apps/app/.eslintrc.cjs
+++ b/apps/app/.eslintrc.cjs
@@ -230,7 +230,13 @@ module.exports = {
{ from: 'composables-forms', allow: ['types', 'utils', 'lib', 'composables-forms'] },
{ from: 'composables', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] },
{ from: 'stores-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] },
- { from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores'] },
+
+ // useAuthStore.clearAll() / .logout() invokes usePortalStore.reset()
+ // via dynamic import to clear portal sessionStorage on session-end.
+ // The merged auth store is the canonical session-cleanup hub — this
+ // edge replaces the deleted stores-portal/usePortalAuthStore which
+ // previously owned the cross-zone call (WS-3 PR-B2a).
+ { from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] },
{ from: 'navigation', allow: ['types', 'utils', 'navigation'] },
{ from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'components-shared'] },
{ from: 'components-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'components-shared', 'components-portal'] },
diff --git a/apps/app/src/components/portal/UserAvatarMenu.vue b/apps/app/src/components/portal/UserAvatarMenu.vue
index 8c7ed491..2949d239 100644
--- a/apps/app/src/components/portal/UserAvatarMenu.vue
+++ b/apps/app/src/components/portal/UserAvatarMenu.vue
@@ -1,7 +1,7 @@
+
+
+
+
+
+ {{ currentContext === 'portal' ? 'Portal' : 'Organizer' }}
+
+
+
+
+
+
+
+
+ Wissel naar {{ otherContext === 'portal' ? 'portal' : 'organizer' }}
+
+
+
+
+
diff --git a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
new file mode 100644
index 00000000..acca214e
--- /dev/null
+++ b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+const mockSetLastContext = vi.fn()
+const mockResolveLandingRoute = vi.fn(() => ({ path: '/portal/evenementen' }))
+const mockPush = vi.fn()
+
+const authStoreState: Record = {
+ showContextSwitcher: true,
+ setLastContext: mockSetLastContext,
+ resolveLandingRoute: mockResolveLandingRoute,
+}
+
+vi.mock('@/stores/useAuthStore', () => ({
+ useAuthStore: () => authStoreState,
+}))
+
+vi.mock('vue-router', async importOriginal => ({
+ ...(await importOriginal()),
+ useRoute: () => ({ meta: { context: 'organizer' } }),
+ useRouter: () => ({ push: mockPush }),
+}))
+
+const ContextSwitcher = (await import('../ContextSwitcher.vue')).default
+
+const stubs = {
+ VMenu: { template: '
' },
+ VBtn: { template: '' },
+ VList: { template: '
' },
+ VListItem: {
+ props: ['dataTest'],
+ template: '
',
+ emits: ['click'],
+ },
+ VListItemTitle: { template: '' },
+ VIcon: true,
+}
+
+describe('ContextSwitcher', () => {
+ it('renders when showContextSwitcher is true', () => {
+ authStoreState.showContextSwitcher = true
+
+ const wrapper = mount(ContextSwitcher, { global: { stubs } })
+
+ expect(wrapper.find('[data-test="menu"]').exists()).toBe(true)
+ })
+
+ it('does not render when showContextSwitcher is false (single-context user)', () => {
+ authStoreState.showContextSwitcher = false
+
+ const wrapper = mount(ContextSwitcher, { global: { stubs } })
+
+ expect(wrapper.find('[data-test="menu"]').exists()).toBe(false)
+ })
+
+ it('clicking the alternative context calls setLastContext and router.push', async () => {
+ authStoreState.showContextSwitcher = true
+ mockSetLastContext.mockClear()
+ mockResolveLandingRoute.mockClear()
+ mockPush.mockClear()
+
+ const wrapper = mount(ContextSwitcher, { global: { stubs } })
+
+ // current context is 'organizer' (from mocked route.meta.context),
+ // so the dropdown shows 'switch-to-portal'.
+ await wrapper.find('[data-test="switch-to-portal"]').trigger('click')
+
+ expect(mockSetLastContext).toHaveBeenCalledWith('portal')
+ expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal')
+ expect(mockPush).toHaveBeenCalledWith({ path: '/portal/evenementen' })
+ })
+})
diff --git a/apps/app/src/layouts/PortalLayout.vue b/apps/app/src/layouts/PortalLayout.vue
index 6c482b54..151a29ba 100644
--- a/apps/app/src/layouts/PortalLayout.vue
+++ b/apps/app/src/layouts/PortalLayout.vue
@@ -8,6 +8,7 @@
import { useRoute, useRouter } from 'vue-router'
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
+import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
@@ -138,8 +139,9 @@ async function logout() {
-
+
+
diff --git a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue
index a80c309e..f6e09810 100644
--- a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue
+++ b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue
@@ -11,6 +11,7 @@ import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
+import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
// @layouts plugin
@@ -76,6 +77,7 @@ const navItems = computed(() => {
+
From 38a94c78e97699b20d87034ea8e450b02bb5d359 Mon Sep 17 00:00:00 2001
From: "bert.hausmans"
Date: Tue, 5 May 2026 21:40:32 +0200
Subject: [PATCH 6/9] feat(auth): post-login landing route resolution per
context
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../shared/__tests__/ContextSwitcher.spec.ts | 4 +-
apps/app/src/pages/login.vue | 97 ++++++++++---------
.../plugins/1.router/__tests__/guards.spec.ts | 2 +-
.../src/stores/__tests__/useAuthStore.spec.ts | 14 +--
apps/app/src/stores/useAuthStore.ts | 20 ++--
.../utils/__tests__/postLoginRedirect.spec.ts | 38 ++++++++
apps/app/src/utils/postLoginRedirect.ts | 19 ++++
7 files changed, 127 insertions(+), 67 deletions(-)
create mode 100644 apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
create mode 100644 apps/app/src/utils/postLoginRedirect.ts
diff --git a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
index acca214e..d9e96a55 100644
--- a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
+++ b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const mockSetLastContext = vi.fn()
-const mockResolveLandingRoute = vi.fn(() => ({ path: '/portal/evenementen' }))
+const mockResolveLandingRoute = vi.fn(() => '/portal/evenementen')
const mockPush = vi.fn()
const authStoreState: Record = {
@@ -67,6 +67,6 @@ describe('ContextSwitcher', () => {
expect(mockSetLastContext).toHaveBeenCalledWith('portal')
expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal')
- expect(mockPush).toHaveBeenCalledWith({ path: '/portal/evenementen' })
+ expect(mockPush).toHaveBeenCalledWith('/portal/evenementen')
})
})
diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue
index cd245461..009535f6 100644
--- a/apps/app/src/pages/login.vue
+++ b/apps/app/src/pages/login.vue
@@ -9,11 +9,10 @@ import authV2MaskDark from '@images/pages/misc-mask-dark.png'
import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
-import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { emailValidator, requiredValidator } from '@core/utils/validators'
-import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
-import type { LoginCredentials, LoginResponse } from '@/types/auth'
+import { resolvePostLoginTarget as resolvePostLoginPath } from '@/utils/postLoginRedirect'
+import type { LoginCredentials } from '@/types/auth'
import type { MfaMethod } from '@/types/mfa'
definePage({
@@ -56,58 +55,64 @@ const authThemeImg = useGenerateImageVariant(
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
+function resolvePostLoginTarget(): string {
+ const rawTo = route.query.to ? String(route.query.to) : ''
+
+ return resolvePostLoginPath(rawTo, () => authStore.resolveLandingRoute())
+}
+
async function handleLogin() {
errors.value = {}
isPending.value = true
try {
- const fingerprint = generateDeviceFingerprint()
-
- const { data } = await apiClient.post('/auth/login', {
+ const credentials: LoginCredentials = {
email: form.value.email,
password: form.value.password,
- }, {
- headers: { 'X-Device-Fingerprint': fingerprint },
- })
-
- if (data.mfa_required) {
- mfaSessionToken.value = data.mfa_session_token!
- mfaMethods.value = (data.methods ?? []) as MfaMethod[]
- mfaPreferredMethod.value = data.preferred_method ?? 'totp'
- mfaExpiresIn.value = data.expires_in ?? 600
- showMfaChallenge.value = true
-
- return
}
- // Normal login success
- authStore.setUser(data.data.user)
+ const result = await authStore.login(credentials)
- if (data.mfa_setup_required) {
- router.replace('/account-settings?tab=security')
+ switch (result.kind) {
+ case 'mfa-required':
+ mfaSessionToken.value = result.sessionToken
+ mfaMethods.value = result.methods
+ mfaPreferredMethod.value = result.preferredMethod
+ mfaExpiresIn.value = result.expiresIn
+ showMfaChallenge.value = true
- return
- }
+ return
- const rawTo = route.query.to ? String(route.query.to) : ''
- const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
+ case 'authenticated':
+ if (authStore.mfaSetupRequired) {
+ router.replace('/account-settings?tab=security')
- router.replace(redirectTo)
- }
- catch (err: unknown) {
- const errorData = (err as { response?: { data?: { message?: string; errors?: Record } } }).response?.data
+ return
+ }
+ router.replace(resolvePostLoginTarget())
- if (errorData?.errors) {
- errors.value = {
- email: errorData.errors.email?.[0] ?? '',
- password: errorData.errors.password?.[0] ?? '',
- }
- }
- else if (errorData?.message) {
- errors.value = { email: errorData.message }
- }
- else {
- errors.value = { email: 'Er is een fout opgetreden. Probeer het opnieuw.' }
+ return
+
+ case 'must-set-password':
+ // Forward-compatible — backend doesn't surface this kind today.
+ // Placeholder route until the must-set-password flow is wired.
+ router.replace('/account-settings?tab=security')
+
+ return
+
+ case 'failed':
+ if (result.errors) {
+ errors.value = {
+ email: result.errors.email?.[0] ?? '',
+ password: result.errors.password?.[0] ?? '',
+ }
+ }
+ else if (result.reason === 'rate_limited') {
+ errors.value = { email: 'Te veel pogingen. Wacht even en probeer opnieuw.' }
+ }
+ else {
+ errors.value = { email: result.reason || 'Er is een fout opgetreden. Probeer het opnieuw.' }
+ }
}
}
finally {
@@ -116,14 +121,10 @@ async function handleLogin() {
}
function onMfaVerified() {
- // After MFA verify, the response sets the auth cookie. Use refreshUser()
- // (not initialize() — that's guarded by isInitialized and returns immediately)
- // to call GET /auth/me with the new cookie, populating the store.
+ // After MFA verify, the response sets the auth cookie. refreshUser()
+ // hydrates the store from /auth/me with the new cookie.
authStore.refreshUser().then(() => {
- const rawTo = route.query.to ? String(route.query.to) : ''
- const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
-
- router.replace(redirectTo)
+ router.replace(resolvePostLoginTarget())
})
}
diff --git a/apps/app/src/plugins/1.router/__tests__/guards.spec.ts b/apps/app/src/plugins/1.router/__tests__/guards.spec.ts
index 8d9231b8..8fc58299 100644
--- a/apps/app/src/plugins/1.router/__tests__/guards.spec.ts
+++ b/apps/app/src/plugins/1.router/__tests__/guards.spec.ts
@@ -246,7 +246,7 @@ describe('router guards (WS-3 PR-B2a)', () => {
() => {},
)
- expect(result).toEqual({ path: '/portal/evenementen' })
+ expect(result).toBe('/portal/evenementen')
})
it('MFA setup gate redirects to /account-settings when mfaSetupRequired', async () => {
diff --git a/apps/app/src/stores/__tests__/useAuthStore.spec.ts b/apps/app/src/stores/__tests__/useAuthStore.spec.ts
index 8d929520..d26290ab 100644
--- a/apps/app/src/stores/__tests__/useAuthStore.spec.ts
+++ b/apps/app/src/stores/__tests__/useAuthStore.spec.ts
@@ -126,7 +126,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
const target = store.resolveLandingRoute()
- expect(target).toEqual({ path: '/portal/evenementen' })
+ expect(target).toBe('/portal/evenementen')
})
it('routes to organizer dashboard when only organizer context is available', () => {
@@ -138,7 +138,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
contexts: { available: ['organizer'], default: 'organizer' },
}))
- expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
+ expect(store.resolveLandingRoute()).toBe('/dashboard')
})
it('routes to platform dashboard for super_admin without an active org', () => {
@@ -150,7 +150,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
contexts: { available: ['organizer'], default: 'organizer' },
}))
- expect(store.resolveLandingRoute()).toEqual({ name: 'platform' })
+ expect(store.resolveLandingRoute()).toBe('/platform')
})
it('multi-role user — defaultContext wins when no lastContext is set', () => {
@@ -162,7 +162,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
}))
- expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
+ expect(store.resolveLandingRoute()).toBe('/dashboard')
})
it('multi-role user — lastContext overrides defaultContext', () => {
@@ -176,7 +176,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
}))
- expect(store.resolveLandingRoute()).toEqual({ path: '/portal/evenementen' })
+ expect(store.resolveLandingRoute()).toBe('/portal/evenementen')
})
it('forceContext overrides both lastContext and defaultContext', () => {
@@ -189,13 +189,13 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
}))
store.setLastContext('organizer')
- expect(store.resolveLandingRoute('portal')).toEqual({ path: '/portal/evenementen' })
+ expect(store.resolveLandingRoute('portal')).toBe('/portal/evenementen')
})
it('returns forbidden when user has no contexts available', () => {
const store = useAuthStore()
- expect(store.resolveLandingRoute()).toEqual({ name: 'forbidden' })
+ expect(store.resolveLandingRoute()).toBe('/forbidden')
})
})
diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts
index 6420df7f..3522490b 100644
--- a/apps/app/src/stores/useAuthStore.ts
+++ b/apps/app/src/stores/useAuthStore.ts
@@ -1,6 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
-import type { RouteLocationRaw } from 'vue-router'
import { apiClient } from '@/lib/axios'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
@@ -135,29 +134,32 @@ export const useAuthStore = defineStore('auth', () => {
}
/**
- * Resolve the post-login or post-context-switch landing route. A
+ * Resolve the post-login or post-context-switch landing path. A
* `forceContext` overrides the lastContext + defaultContext precedence
* (used by the context-switcher when the user explicitly chooses).
+ *
+ * Returns a string path (not a RouteLocationRaw object) so consumers
+ * can pass it directly to the typed router without casting.
*/
- function resolveLandingRoute(forceContext?: AuthContext): RouteLocationRaw {
+ function resolveLandingRoute(forceContext?: AuthContext): string {
const ctx = forceContext ?? lastContext.value ?? defaultContext.value
if (ctx === 'portal' && availableContexts.value.includes('portal'))
- return { path: '/portal/evenementen' }
+ return '/portal/evenementen'
if (ctx === 'organizer' && availableContexts.value.includes('organizer')) {
if (isSuperAdmin.value && organisations.value.length === 0)
- return { name: 'platform' }
+ return '/platform'
- return { name: 'dashboard' }
+ return '/dashboard'
}
if (availableContexts.value.includes('organizer'))
- return { name: 'dashboard' }
+ return '/dashboard'
if (availableContexts.value.includes('portal'))
- return { path: '/portal/evenementen' }
+ return '/portal/evenementen'
- return { name: 'forbidden' }
+ return '/forbidden'
}
function clearState() {
diff --git a/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts b/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
new file mode 100644
index 00000000..343df18e
--- /dev/null
+++ b/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from 'vitest'
+import { resolvePostLoginTarget } from '@/utils/postLoginRedirect'
+
+describe('resolvePostLoginTarget — A13-3 minimum open-redirect guard', () => {
+ it('returns the relative ?to= path when it starts with /', () => {
+ const fallback = vi.fn(() => '/dashboard')
+ const result = resolvePostLoginTarget('/events/01ABC', fallback)
+
+ expect(result).toBe('/events/01ABC')
+ expect(fallback).not.toHaveBeenCalled()
+ })
+
+ it('falls back to the resolver when ?to= is missing', () => {
+ expect(resolvePostLoginTarget('', () => '/dashboard')).toBe('/dashboard')
+ expect(resolvePostLoginTarget(null, () => '/dashboard')).toBe('/dashboard')
+ expect(resolvePostLoginTarget(undefined, () => '/dashboard')).toBe('/dashboard')
+ })
+
+ it('rejects absolute URLs (open-redirect attempt)', () => {
+ expect(resolvePostLoginTarget('https://evil.com', () => '/dashboard')).toBe('/dashboard')
+ expect(resolvePostLoginTarget('http://evil.com/x', () => '/dashboard')).toBe('/dashboard')
+ })
+
+ it('rejects protocol-relative URLs (//evil.com)', () => {
+ expect(resolvePostLoginTarget('//evil.com', () => '/dashboard')).toBe('/dashboard')
+ expect(resolvePostLoginTarget('//evil.com/path', () => '/dashboard')).toBe('/dashboard')
+ })
+
+ it('rejects javascript: and data: schemes', () => {
+ expect(resolvePostLoginTarget('javascript:alert(1)', () => '/dashboard')).toBe('/dashboard')
+ expect(resolvePostLoginTarget('data:text/html,
@@ -193,7 +201,7 @@ async function handleVerify() {
Verifiëren
diff --git a/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts b/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
new file mode 100644
index 00000000..fbed77fd
--- /dev/null
+++ b/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
@@ -0,0 +1,161 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import type { MfaVerifyResult } from '@/types/auth'
+
+const mockVerifyMfa = vi.fn<(args: unknown) => Promise>()
+const mockSendEmailMutateAsync = vi.fn()
+
+vi.mock('@/stores/useAuthStore', () => ({
+ useAuthStore: () => ({
+ verifyMfa: mockVerifyMfa,
+ }),
+}))
+
+vi.mock('@/composables/api/useMfa', () => ({
+ useSendMfaEmailCode: () => ({
+ mutateAsync: mockSendEmailMutateAsync,
+ isPending: { value: false },
+ }),
+}))
+
+vi.mock('@/utils/deviceFingerprint', () => ({
+ generateDeviceFingerprint: () => 'fp-test',
+ getDeviceName: () => 'Chrome on macOS',
+}))
+
+const MfaChallengeCard = (await import('../MfaChallengeCard.vue')).default
+
+const stubs = {
+ VCard: { template: '
' },
+ VCardText: { template: '
' },
+ VChip: { template: '' },
+ VTabs: { template: '
' },
+ VTab: { template: '' },
+ VAlert: { template: '
' },
+ VForm: { template: '' },
+ VRow: { template: '
' },
+ VCol: { template: '
' },
+ VOtpInput: {
+ name: 'VOtpInput',
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue', 'finish'],
+ template: '',
+ },
+ AppTextField: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: '',
+ },
+ VCheckbox: {
+ name: 'VCheckbox',
+ props: ['modelValue', 'label'],
+ emits: ['update:modelValue'],
+ template: '',
+ },
+ VBtn: { template: '' },
+ VIcon: true,
+}
+
+describe('MfaChallengeCard — useAuthStore.verifyMfa migration (WS-3 PR-B2a)', () => {
+ beforeEach(() => {
+ mockVerifyMfa.mockReset()
+ mockSendEmailMutateAsync.mockReset()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ function makeWrapper() {
+ return mount(MfaChallengeCard, {
+ props: {
+ mfaSessionToken: 'session-abc',
+ methods: ['totp'],
+ preferredMethod: 'totp',
+ expiresIn: 600,
+ },
+ global: {
+ stubs,
+ mocks: {
+ $vuetify: { display: { smAndUp: true } },
+ },
+ },
+ })
+ }
+
+ async function triggerVerify(wrapper: ReturnType, code: string) {
+ const otp = wrapper.findComponent({ name: 'VOtpInput' })
+
+ otp.vm.$emit('finish', code)
+ await nextTick()
+ await nextTick()
+ }
+
+ it('calls authStore.verifyMfa with camelCase args and emits `verified` on success', async () => {
+ mockVerifyMfa.mockResolvedValue({ kind: 'authenticated' })
+
+ const wrapper = makeWrapper()
+
+ await triggerVerify(wrapper, '123456')
+
+ expect(mockVerifyMfa).toHaveBeenCalledTimes(1)
+ expect(mockVerifyMfa).toHaveBeenCalledWith({
+ sessionToken: 'session-abc',
+ code: '123456',
+ method: 'totp',
+ trustDevice: undefined,
+ deviceFingerprint: undefined,
+ deviceName: undefined,
+ })
+
+ expect(wrapper.emitted('verified')).toHaveLength(1)
+ expect(wrapper.emitted('verified')?.[0]).toEqual([])
+ })
+
+ it('displays the result.reason on failure and does NOT emit verified', async () => {
+ mockVerifyMfa.mockResolvedValue({
+ kind: 'failed',
+ reason: 'Code is incorrect.',
+ })
+
+ const wrapper = makeWrapper()
+
+ await triggerVerify(wrapper, '999999')
+
+ expect(wrapper.text()).toContain('Code is incorrect.')
+ expect(wrapper.emitted('verified')).toBeUndefined()
+ })
+
+ it('honours trustDevice flag with fingerprint + device name', async () => {
+ mockVerifyMfa.mockResolvedValue({ kind: 'authenticated' })
+
+ const wrapper = makeWrapper()
+ const trust = wrapper.findComponent({ name: 'VCheckbox' })
+
+ trust.vm.$emit('update:modelValue', true)
+ await nextTick()
+
+ await triggerVerify(wrapper, '123456')
+
+ expect(mockVerifyMfa).toHaveBeenCalledWith({
+ sessionToken: 'session-abc',
+ code: '123456',
+ method: 'totp',
+ trustDevice: true,
+ deviceFingerprint: 'fp-test',
+ deviceName: 'Chrome on macOS',
+ })
+ })
+
+ it('uses a generic fallback message when result.reason is empty', async () => {
+ mockVerifyMfa.mockResolvedValue({ kind: 'failed', reason: '' })
+
+ const wrapper = makeWrapper()
+
+ await triggerVerify(wrapper, '999999')
+
+ expect(wrapper.text()).toContain('Verificatie mislukt')
+ expect(wrapper.emitted('verified')).toBeUndefined()
+ })
+})
diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue
index 009535f6..42ecb448 100644
--- a/apps/app/src/pages/login.vue
+++ b/apps/app/src/pages/login.vue
@@ -121,11 +121,9 @@ async function handleLogin() {
}
function onMfaVerified() {
- // After MFA verify, the response sets the auth cookie. refreshUser()
- // hydrates the store from /auth/me with the new cookie.
- authStore.refreshUser().then(() => {
- router.replace(resolvePostLoginTarget())
- })
+ // useAuthStore.verifyMfa() already refreshed the store post-cookie;
+ // the page just needs to route to the resolved landing target.
+ router.replace(resolvePostLoginTarget())
}
function onMfaCancelled() {