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

@@ -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<LoginResponse>('/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<string, string[]> } } }).response?.data
return
}
router.replace(resolvePostLoginTarget())
if (errorData?.errors) {
errors.value = {
email: errorData.errors.email?.[0] ?? '',
password: errorData.errors.password?.[0] ?? '',
}
}
else if (errorData?.message) {
errors.value = { email: errorData.message }
}
else {
errors.value = { email: 'Er is een fout opgetreden. Probeer het opnieuw.' }
return
case 'must-set-password':
// Forward-compatible — backend doesn't surface this kind today.
// Placeholder route until the must-set-password flow is wired.
router.replace('/account-settings?tab=security')
return
case 'failed':
if (result.errors) {
errors.value = {
email: result.errors.email?.[0] ?? '',
password: result.errors.password?.[0] ?? '',
}
}
else if (result.reason === 'rate_limited') {
errors.value = { email: 'Te veel pogingen. Wacht even en probeer opnieuw.' }
}
else {
errors.value = { email: result.reason || 'Er is een fout opgetreden. Probeer het opnieuw.' }
}
}
}
finally {
@@ -116,14 +121,10 @@ async function handleLogin() {
}
function onMfaVerified() {
// After MFA verify, the response sets the auth cookie. Use refreshUser()
// (not initialize() — that's guarded by isInitialized and returns immediately)
// to call GET /auth/me with the new cookie, populating the store.
// After MFA verify, the response sets the auth cookie. refreshUser()
// hydrates the store from /auth/me with the new cookie.
authStore.refreshUser().then(() => {
const rawTo = route.query.to ? String(route.query.to) : ''
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
router.replace(redirectTo)
router.replace(resolvePostLoginTarget())
})
}