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:
188
apps/app/src/composables/api/useMfa.ts
Normal file
188
apps/app/src/composables/api/useMfa.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user