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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user