feat: add MFA types, hooks, and APP_MFA_REQUIRED interceptor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 14:01:04 +02:00
parent a5b30cd1ea
commit 0a77080bca
3 changed files with 79 additions and 1 deletions

View File

@@ -27,6 +27,14 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
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}`);

View File

@@ -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<DashboardData>({
@@ -143,3 +143,57 @@ export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
queryFn: () => api.get(`/tenant/audit?${params.toString()}`),
});
}
// MFA hooks
export function useMfaStatus() {
return useQuery<MfaStatus>({
queryKey: ['tenant', 'mfa', 'status'],
queryFn: () => api.get('/tenant/mfa/status'),
});
}
export function useMfaSetup() {
return useMutation<MfaSetupResponse, Error, void>({
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<BackupCodesResponse, Error, void>({
mutationFn: () => api.post('/tenant/mfa/backup-codes'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useMfaRemove() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.delete('/tenant/mfa/totp'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useResetTeamMemberMfa() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
});
}
export function useUpdateTenantSettings() {
const qc = useQueryClient();
return useMutation<void, Error, Record<string, unknown>>({
mutationFn: (updates) => api.patch('/tenant/settings', updates),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
});
}

View File

@@ -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[];
}