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 @@ + + 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 @@ + + 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 @@