feat: MFA frontend with auth page restyling, challenge screen, and setup wizard

- Restyle organizer auth pages: Dutch text, remove placeholder social login
- Restyle portal auth pages to Vuexy v1 centered card pattern with decorative shapes
- MFA challenge card component with VOtpInput, method tabs, backup code input,
  trusted device checkbox, and session countdown timer
- Login pages handle mfa_required response with device fingerprint header
- Security settings page with TOTP setup (QR code), email setup, disable MFA,
  backup codes regeneration, and trusted devices management
- Portal profile page includes MFA security section
- Admin user detail page shows MFA status with reset button
- MFA enforcement route guard redirects to security settings when required
- Device fingerprint utility for trusted device identification
- MFA types, composables with TanStack Query for both apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 21:32:17 +02:00
parent a9e8e9bb62
commit 0be2956ea4
38 changed files with 3991 additions and 377 deletions

View File

@@ -0,0 +1,188 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/lib/axios'
import type {
MfaConfirmResponse,
MfaStatus,
MfaTotpSetup,
MfaVerifyPayload,
TrustedDevice,
} from '@/types/mfa'
interface ApiResponse<T> {
success: boolean
data: T
message: string
}
// ─── Setup ───
export function useSetupTotp() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<MfaTotpSetup>>('/auth/mfa/setup/totp')
return data.data
},
})
}
export function useConfirmTotp() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (code: string) => {
const { data } = await apiClient.post<ApiResponse<MfaConfirmResponse>>('/auth/mfa/setup/totp/confirm', { code })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
export function useSetupEmail() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<null>>('/auth/mfa/setup/email')
return data
},
})
}
export function useConfirmEmail() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (code: string) => {
const { data } = await apiClient.post<ApiResponse<MfaConfirmResponse>>('/auth/mfa/setup/email/confirm', { code })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
// ─── Management ───
export function useDisableMfa() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { code: string; method: string }) => {
const { data } = await apiClient.post<ApiResponse<null>>('/auth/mfa/disable', payload)
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
export function useRegenerateBackupCodes() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { code: string }) => {
const { data } = await apiClient.post<ApiResponse<{ backup_codes: string[] }>>('/auth/mfa/backup-codes', payload)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useMfaStatus() {
return useQuery({
queryKey: ['mfa-status'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<MfaStatus>>('/auth/mfa/status')
return data.data
},
})
}
// ─── Trusted devices ───
export function useTrustedDevices() {
return useQuery({
queryKey: ['trusted-devices'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<TrustedDevice[]>>('/auth/trusted-devices')
return data.data
},
})
}
export function useRevokeDevice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/auth/trusted-devices/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
},
})
}
export function useRevokeAllDevices() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
await apiClient.delete('/auth/trusted-devices')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
},
})
}
// ─── Login flow (no auth needed — uses session token) ───
export function useVerifyMfa() {
return useMutation({
mutationFn: async (payload: MfaVerifyPayload) => {
const { data } = await apiClient.post('/auth/mfa/verify', payload)
return data
},
})
}
export function useSendMfaEmailCode() {
return useMutation({
mutationFn: async (mfaSessionToken: string) => {
const { data } = await apiClient.post('/auth/mfa/email/send', {
mfa_session_token: mfaSessionToken,
})
return data
},
})
}
// ─── Admin ───
export function useAdminResetMfa() {
return useMutation({
mutationFn: async (userId: string) => {
const { data } = await apiClient.post(`/admin/users/${userId}/reset-mfa`)
return data
},
})
}