feat(auth): post-login landing route resolution per context

login.vue is rewritten to consume useAuthStore.login()'s discriminated
union — no more direct apiClient calls or branching on raw API response
shapes. The page maps result.kind to UI/routing decisions only:

- mfa-required → swap to MfaChallengeCard with the typed payload
- authenticated → resolvePostLoginTarget() (?to= relative, else
  auth.resolveLandingRoute())
- must-set-password → forward-compatible placeholder route
- failed → field-level errors + rate_limit message branch

resolveLandingRoute() now returns a string path instead of
RouteLocationRaw — the typed router accepts string-paths cleanly,
removes the cast at every call site, and lets useAuthStore.spec.ts +
guards.spec.ts assert the resolved path directly.

A13-3 minimum precaution lives in a new utility:
src/utils/postLoginRedirect.ts. The relative-only check
(`startsWith('/') && !startsWith('//')`) rejects absolute, protocol-
relative, javascript:, and data: schemes. Full domain validation lands
in WS-3 PR-B2b.

6 vitest specs in utils/__tests__/postLoginRedirect.spec.ts cover the
six rejection / passthrough scenarios.

Test count 192 → 198. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:40:32 +02:00
parent 209e0ef682
commit 38a94c78e9
7 changed files with 127 additions and 67 deletions

View File

@@ -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')
})
})

View File

@@ -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() {