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/.eslintrc.cjs b/apps/app/.eslintrc.cjs
index 4c9a83d6..7ff5661b 100644
--- a/apps/app/.eslintrc.cjs
+++ b/apps/app/.eslintrc.cjs
@@ -230,9 +230,21 @@ 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'] },
+
+ // components-shared may read app-wide stores (useAuthStore,
+ // useNotificationStore — not stores-portal) so canonical shared
+ // chrome (ContextSwitcher, future global indicators) can stay in
+ // components/shared/ without re-homing to components/layout/.
+ // Portal-specific state stays out of shared by design (WS-3 PR-B2a).
+ { from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared'] },
{ from: 'components-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'components-shared', 'components-portal'] },
{ from: 'components-organizer', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared', 'components-organizer'] },
{ from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },
diff --git a/apps/app/src/components/auth/MfaChallengeCard.vue b/apps/app/src/components/auth/MfaChallengeCard.vue
index d675c28c..2d88cd01 100644
--- a/apps/app/src/components/auth/MfaChallengeCard.vue
+++ b/apps/app/src/components/auth/MfaChallengeCard.vue
@@ -1,5 +1,6 @@
@@ -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/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..d9e96a55
--- /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(() => '/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('/portal/evenementen')
+ })
+})
diff --git a/apps/app/src/layouts/PortalLayout.vue b/apps/app/src/layouts/PortalLayout.vue
index 43ba3c4d..151a29ba 100644
--- a/apps/app/src/layouts/PortalLayout.vue
+++ b/apps/app/src/layouts/PortalLayout.vue
@@ -8,15 +8,16 @@
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 { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
+import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
-const authStore = usePortalAuthStore()
+const authStore = useAuthStore()
const portal = usePortalStore()
const route = useRoute()
const router = useRouter()
@@ -138,8 +139,9 @@ async function logout() {
-
+
+
diff --git a/apps/app/src/layouts/__tests__/PortalLayout.spec.ts b/apps/app/src/layouts/__tests__/PortalLayout.spec.ts
index 481049f0..1893045f 100644
--- a/apps/app/src/layouts/__tests__/PortalLayout.spec.ts
+++ b/apps/app/src/layouts/__tests__/PortalLayout.spec.ts
@@ -13,8 +13,8 @@ vi.mock('vue-router', async importOriginal => ({
useRoute: () => ({ meta: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
-vi.mock('@/stores/portal/usePortalAuthStore', () => ({
- usePortalAuthStore: () => ({
+vi.mock('@/stores/useAuthStore', () => ({
+ useAuthStore: () => ({
isAuthenticated: false,
user: null,
logout: vi.fn(),
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(() => {
+
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/pages/account-settings/index.vue b/apps/app/src/pages/account-settings/index.vue
index 0a7e8a0c..effeb0a7 100644
--- a/apps/app/src/pages/account-settings/index.vue
+++ b/apps/app/src/pages/account-settings/index.vue
@@ -5,6 +5,7 @@ import NotificationsTab from '@/components/account-settings/NotificationsTab.vue
definePage({
meta: {
+ context: 'organizer',
navActiveLink: 'account-settings',
},
})
diff --git a/apps/app/src/pages/dashboard/index.vue b/apps/app/src/pages/dashboard/index.vue
index 095949aa..d144efc7 100644
--- a/apps/app/src/pages/dashboard/index.vue
+++ b/apps/app/src/pages/dashboard/index.vue
@@ -1,6 +1,12 @@
+
+
+
+
+ Geen toegang
+
+
+ Je hebt geen toegang tot deze pagina.
+
+
+ Naar inloggen
+
+
+
diff --git a/apps/app/src/pages/invitations/[token].vue b/apps/app/src/pages/invitations/[token].vue
index 7070f83b..d8d46500 100644
--- a/apps/app/src/pages/invitations/[token].vue
+++ b/apps/app/src/pages/invitations/[token].vue
@@ -11,7 +11,7 @@ import type { ApiErrorResponse } from '@/types/auth'
definePage({
meta: {
layout: 'blank',
- requiresAuth: false,
+ public: true,
},
})
diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue
index cd245461..42ecb448 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,15 +121,9 @@ 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.
- authStore.refreshUser().then(() => {
- const rawTo = route.query.to ? String(route.query.to) : ''
- const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
-
- router.replace(redirectTo)
- })
+ // useAuthStore.verifyMfa() already refreshed the store post-cookie;
+ // the page just needs to route to the resolved landing target.
+ router.replace(resolvePostLoginTarget())
}
function onMfaCancelled() {
diff --git a/apps/app/src/pages/members/index.vue b/apps/app/src/pages/members/index.vue
index a1bcfa3f..e355e30c 100644
--- a/apps/app/src/pages/members/index.vue
+++ b/apps/app/src/pages/members/index.vue
@@ -10,6 +10,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue'
import type { Member, OrganisationRole } from '@/types/member'
+definePage({
+ meta: {
+ context: 'organizer',
+ },
+})
+
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
diff --git a/apps/app/src/pages/organisation/companies.vue b/apps/app/src/pages/organisation/companies.vue
index 3e082cee..c22b7d79 100644
--- a/apps/app/src/pages/organisation/companies.vue
+++ b/apps/app/src/pages/organisation/companies.vue
@@ -4,6 +4,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
import CompanyDialog from '@/components/organisation/CompanyDialog.vue'
import type { Company } from '@/types/organisation'
+definePage({
+ meta: {
+ context: 'organizer',
+ },
+})
+
const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
diff --git a/apps/app/src/pages/organisation/form-failures/[id].vue b/apps/app/src/pages/organisation/form-failures/[id].vue
index 5f54f8a2..38c2a07f 100644
--- a/apps/app/src/pages/organisation/form-failures/[id].vue
+++ b/apps/app/src/pages/organisation/form-failures/[id].vue
@@ -6,6 +6,7 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
definePage({
meta: {
+ context: 'organizer',
navActiveLink: 'organisation-form-failures',
},
})
diff --git a/apps/app/src/pages/organisation/form-failures/index.vue b/apps/app/src/pages/organisation/form-failures/index.vue
index b136f482..a776f39c 100644
--- a/apps/app/src/pages/organisation/form-failures/index.vue
+++ b/apps/app/src/pages/organisation/form-failures/index.vue
@@ -5,6 +5,7 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
definePage({
meta: {
+ context: 'organizer',
navActiveLink: 'organisation-form-failures',
},
})
diff --git a/apps/app/src/pages/organisation/index.vue b/apps/app/src/pages/organisation/index.vue
index 9cd7c7ab..14d4cdbb 100644
--- a/apps/app/src/pages/organisation/index.vue
+++ b/apps/app/src/pages/organisation/index.vue
@@ -4,6 +4,12 @@ import { useAuthStore } from '@/stores/useAuthStore'
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
import type { ActivityLogEntry, Organisation } from '@/types/organisation'
+definePage({
+ meta: {
+ context: 'organizer',
+ },
+})
+
const authStore = useAuthStore()
const router = useRouter()
diff --git a/apps/app/src/pages/organisation/settings.vue b/apps/app/src/pages/organisation/settings.vue
index 9bf386ce..be28b96b 100644
--- a/apps/app/src/pages/organisation/settings.vue
+++ b/apps/app/src/pages/organisation/settings.vue
@@ -8,6 +8,12 @@ import SettingsEmailTemplates from '@/components/organisation/settings/SettingsE
import SettingsEmailLog from '@/components/organisation/settings/SettingsEmailLog.vue'
import DangerZoneTab from '@/components/organisation/settings/DangerZoneTab.vue'
+definePage({
+ meta: {
+ context: 'organizer',
+ },
+})
+
const route = useRoute()
const router = useRouter()
const orgStore = useOrganisationStore()
diff --git a/apps/app/src/pages/platform/activity-log/index.vue b/apps/app/src/pages/platform/activity-log/index.vue
index fc45a111..4e1fa01e 100644
--- a/apps/app/src/pages/platform/activity-log/index.vue
+++ b/apps/app/src/pages/platform/activity-log/index.vue
@@ -3,6 +3,8 @@ import { useAdminActivityLog } from '@/composables/api/useAdmin'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-activity-log',
},
})
diff --git a/apps/app/src/pages/platform/form-failures/[id].vue b/apps/app/src/pages/platform/form-failures/[id].vue
index dbdb31c1..8eafc829 100644
--- a/apps/app/src/pages/platform/form-failures/[id].vue
+++ b/apps/app/src/pages/platform/form-failures/[id].vue
@@ -5,6 +5,8 @@ import FormFailureDetail from '@/components/form-failures/FormFailureDetail.vue'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-form-failures',
},
})
diff --git a/apps/app/src/pages/platform/form-failures/index.vue b/apps/app/src/pages/platform/form-failures/index.vue
index c0ca6d95..37e67fb9 100644
--- a/apps/app/src/pages/platform/form-failures/index.vue
+++ b/apps/app/src/pages/platform/form-failures/index.vue
@@ -3,6 +3,8 @@ import FormFailuresTable from '@/components/form-failures/FormFailuresTable.vue'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-form-failures',
},
})
diff --git a/apps/app/src/pages/platform/index.vue b/apps/app/src/pages/platform/index.vue
index a39e12d8..69bbe582 100644
--- a/apps/app/src/pages/platform/index.vue
+++ b/apps/app/src/pages/platform/index.vue
@@ -4,6 +4,8 @@ import type { BillingStatus } from '@/types/admin'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform',
},
})
diff --git a/apps/app/src/pages/platform/organisations/[id].vue b/apps/app/src/pages/platform/organisations/[id].vue
index 7cbb1934..db427ecd 100644
--- a/apps/app/src/pages/platform/organisations/[id].vue
+++ b/apps/app/src/pages/platform/organisations/[id].vue
@@ -16,6 +16,8 @@ import type { InviteMemberPayload, OrganisationRole } from '@/types/member'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-organisations',
},
})
diff --git a/apps/app/src/pages/platform/organisations/index.vue b/apps/app/src/pages/platform/organisations/index.vue
index 3481d1f3..2df48f72 100644
--- a/apps/app/src/pages/platform/organisations/index.vue
+++ b/apps/app/src/pages/platform/organisations/index.vue
@@ -4,6 +4,8 @@ import type { AdminOrganisation, BillingStatus, CreateOrganisationPayload } from
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-organisations',
},
})
diff --git a/apps/app/src/pages/platform/users/[id].vue b/apps/app/src/pages/platform/users/[id].vue
index ac7069b7..ba4352e1 100644
--- a/apps/app/src/pages/platform/users/[id].vue
+++ b/apps/app/src/pages/platform/users/[id].vue
@@ -9,6 +9,8 @@ import type { UpdateAdminUserPayload } from '@/types/admin'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-users',
},
})
diff --git a/apps/app/src/pages/platform/users/index.vue b/apps/app/src/pages/platform/users/index.vue
index 378ec13e..27b9b4a6 100644
--- a/apps/app/src/pages/platform/users/index.vue
+++ b/apps/app/src/pages/platform/users/index.vue
@@ -8,6 +8,8 @@ import type { AdminUser } from '@/types/admin'
definePage({
meta: {
+ context: 'organizer',
+ requiresRole: 'super_admin',
navActiveLink: 'platform-users',
},
})
diff --git a/apps/app/src/pages/portal/advance/[token].vue b/apps/app/src/pages/portal/advance/[token].vue
index 28bf7dee..0ca1a670 100644
--- a/apps/app/src/pages/portal/advance/[token].vue
+++ b/apps/app/src/pages/portal/advance/[token].vue
@@ -3,8 +3,7 @@ definePage({
name: 'artist-advance',
meta: {
layout: 'PortalLayout',
- requiresAuth: false,
- requiresToken: true,
+ public: true,
context: 'portal',
},
})
diff --git a/apps/app/src/pages/portal/evenementen/[eventId].vue b/apps/app/src/pages/portal/evenementen/[eventId].vue
index df8b5b79..b4433e33 100644
--- a/apps/app/src/pages/portal/evenementen/[eventId].vue
+++ b/apps/app/src/pages/portal/evenementen/[eventId].vue
@@ -9,7 +9,6 @@ definePage({
name: 'portal-event-detail',
meta: {
layout: 'PortalLayout',
- requiresAuth: true,
context: 'portal',
navMode: 'event',
},
diff --git a/apps/app/src/pages/portal/evenementen/index.vue b/apps/app/src/pages/portal/evenementen/index.vue
index a6b3aa23..d966191d 100644
--- a/apps/app/src/pages/portal/evenementen/index.vue
+++ b/apps/app/src/pages/portal/evenementen/index.vue
@@ -6,7 +6,6 @@ definePage({
name: 'portal-evenementen',
meta: {
layout: 'PortalLayout',
- requiresAuth: true,
context: 'portal',
navMode: 'platform',
navTitle: 'Mijn evenementen',
diff --git a/apps/app/src/pages/portal/profiel.vue b/apps/app/src/pages/portal/profiel.vue
index da2c7db0..a6cd205e 100644
--- a/apps/app/src/pages/portal/profiel.vue
+++ b/apps/app/src/pages/portal/profiel.vue
@@ -1,6 +1,6 @@