From 0a77080bca1c122dcd9c5b405ceb25a4dab7a5d2 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:01:04 +0200 Subject: [PATCH] feat: add MFA types, hooks, and APP_MFA_REQUIRED interceptor Co-Authored-By: Claude Sonnet 4.6 --- ui/src/api/client.ts | 8 ++++++ ui/src/api/tenant-hooks.ts | 56 +++++++++++++++++++++++++++++++++++++- ui/src/types/api.ts | 16 +++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 401a99f..aceb049 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -27,6 +27,14 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise throw new Error('Unauthorized'); } + if (response.status === 403) { + const errorHeader = response.headers.get('X-Cameleer-Error'); + if (errorHeader === 'APP_MFA_REQUIRED') { + window.location.href = '/platform/tenant/settings?mfa=required'; + throw new Error('MFA enrollment required'); + } + } + if (!response.ok) { const text = await response.text(); throw new Error(`API error ${response.status}: ${text}`); diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index 79988d7..0a7056c 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; -import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult } from '../types/api'; +import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse } from '../types/api'; export function useTenantDashboard() { return useQuery({ @@ -143,3 +143,57 @@ export function useTenantAuditLog(filters: Omit) { queryFn: () => api.get(`/tenant/audit?${params.toString()}`), }); } + +// MFA hooks +export function useMfaStatus() { + return useQuery({ + queryKey: ['tenant', 'mfa', 'status'], + queryFn: () => api.get('/tenant/mfa/status'), + }); +} + +export function useMfaSetup() { + return useMutation({ + mutationFn: () => api.post('/tenant/mfa/totp/setup'), + }); +} + +export function useMfaVerify() { + const qc = useQueryClient(); + return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({ + mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), + }); +} + +export function useMfaBackupCodes() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/tenant/mfa/backup-codes'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), + }); +} + +export function useMfaRemove() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete('/tenant/mfa/totp'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), + }); +} + +export function useResetTeamMemberMfa() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), + }); +} + +export function useUpdateTenantSettings() { + const qc = useQueryClient(); + return useMutation>({ + mutationFn: (updates) => api.patch('/tenant/settings', updates), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }), + }); +} diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index ac6aabe..ce1c36f 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -97,6 +97,7 @@ export interface TenantSettings { status: string; serverEndpoint: string | null; createdAt: string; + mfaRequired?: boolean; } // SSO connector types @@ -200,3 +201,18 @@ export interface TenantMetricsEntry { serverState: string; metrics: MetricsSummary | null; } + +// MFA types +export interface MfaStatus { + enrolled: boolean; + hasBackupCodes: boolean; +} + +export interface MfaSetupResponse { + secret: string; + secretQrCode: string; +} + +export interface BackupCodesResponse { + codes: string[]; +}