Files
crewli-old/apps/portal/src/composables/api/useMfa.ts
bert.hausmans d5fb15e5fe feat: set preferred MFA method from account settings
Adds the ability for users to change their preferred/primary MFA method
when both TOTP and email are available.

Backend:
- Add PUT /auth/mfa/preferred-method endpoint with validation
  (method must be totp/email, MFA must be enabled, TOTP must be
  configured if selecting totp)
- Add totp_configured and email_configured fields to MFA status
  endpoint (totp = has secret + enabled, email = always when enabled)
- Fix setupEmail() to preserve mfa_secret so TOTP config survives
  when email is set up as a second method

Frontend (organizer + portal):
- Add useSetPreferredMethod() composable to useMfa.ts
- Add totp_configured/email_configured to MfaStatus type
- SecurityTab method cards now show "Primaire methode" chip on the
  preferred method and "Als primair instellen" button on the other
- Portal security section shows per-method rows with status chips
  and primary switching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:47:34 +02:00

189 lines
4.4 KiB
TypeScript

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'] })
},
})
}
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'] })
},
})
}
// ─── 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'] })
},
})
}
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
},
})
}
export function useSetPreferredMethod() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (method: 'totp' | 'email') => {
const { data } = await apiClient.put<ApiResponse<{ method: string }>>('/auth/mfa/preferred-method', { method })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
// ─── 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
},
})
}