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