2026-01-06 15:33:03 +00:00
|
|
|
// API Client for Gatehouse Backend
|
2026-01-07 14:29:40 +00:00
|
|
|
// Uses Bearer token authentication
|
2026-01-06 15:33:03 +00:00
|
|
|
|
2026-01-06 15:42:41 +00:00
|
|
|
import { config } from '@/config';
|
2026-01-06 15:33:03 +00:00
|
|
|
|
|
|
|
|
interface ApiResponse<T = unknown> {
|
|
|
|
|
version: string;
|
|
|
|
|
success: boolean;
|
|
|
|
|
code: number;
|
|
|
|
|
message: string;
|
|
|
|
|
data?: T;
|
|
|
|
|
error?: {
|
|
|
|
|
type: string;
|
|
|
|
|
details: Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface User {
|
|
|
|
|
id: string;
|
|
|
|
|
email: string;
|
2026-01-06 16:06:53 +00:00
|
|
|
email_verified: boolean;
|
2026-01-06 15:33:03 +00:00
|
|
|
full_name: string | null;
|
|
|
|
|
avatar_url: string | null;
|
2026-01-06 16:06:53 +00:00
|
|
|
status: string;
|
|
|
|
|
last_login_at: string | null;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Organization {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
logo_url: string | null;
|
2026-01-06 15:33:03 +00:00
|
|
|
is_active: boolean;
|
2026-01-06 16:06:53 +00:00
|
|
|
role: string;
|
2026-01-06 15:33:03 +00:00
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 16:06:53 +00:00
|
|
|
export interface OrganizationsResponse {
|
|
|
|
|
organizations: Organization[];
|
|
|
|
|
count: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 17:31:25 +10:30
|
|
|
export interface MfaComplianceOrgSummary {
|
|
|
|
|
organization_id: string;
|
|
|
|
|
organization_name: string;
|
|
|
|
|
status: string;
|
|
|
|
|
deadline_at: string | null;
|
|
|
|
|
effective_mode: string;
|
|
|
|
|
applied_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MfaComplianceSummary {
|
|
|
|
|
overall_status: string;
|
|
|
|
|
missing_methods: string[];
|
|
|
|
|
deadline_at: string | null;
|
|
|
|
|
orgs: MfaComplianceOrgSummary[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if MFA is required for the user based on their compliance status.
|
|
|
|
|
* This checks if any organization has an effective_mode that starts with "require_",
|
|
|
|
|
* which handles require_webauthn, require_totp, or any future MFA methods.
|
|
|
|
|
*/
|
|
|
|
|
export function isMfaRequired(compliance: MfaComplianceSummary | null): boolean {
|
|
|
|
|
if (!compliance || !compliance.orgs) return false;
|
|
|
|
|
return compliance.orgs.some(
|
|
|
|
|
org => org.effective_mode && org.effective_mode.startsWith('require_')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 15:33:03 +00:00
|
|
|
export interface LoginResponse {
|
2026-01-14 07:21:55 +00:00
|
|
|
user?: User;
|
|
|
|
|
token?: string;
|
|
|
|
|
expires_at?: string;
|
2026-01-12 06:28:36 +00:00
|
|
|
requires_totp?: boolean;
|
2026-01-16 17:50:56 +10:30
|
|
|
requires_webauthn?: boolean;
|
2026-01-16 17:31:25 +10:30
|
|
|
requires_mfa_enrollment?: boolean;
|
|
|
|
|
mfa_compliance?: MfaComplianceSummary;
|
2026-01-12 06:28:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TotpEnrollResponse {
|
|
|
|
|
secret: string;
|
|
|
|
|
provisioning_uri: string;
|
|
|
|
|
qr_code: string; // base64 PNG
|
|
|
|
|
backup_codes: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TotpStatusResponse {
|
|
|
|
|
totp_enabled: boolean;
|
|
|
|
|
verified_at: string | null;
|
|
|
|
|
backup_codes_remaining: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TotpVerifyResponse {
|
|
|
|
|
user: User;
|
|
|
|
|
token: string;
|
|
|
|
|
expires_at: string;
|
2026-01-06 15:33:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ProfileResponse {
|
|
|
|
|
user: User;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 15:32:30 +00:00
|
|
|
// WebAuthn types
|
|
|
|
|
export interface PasskeyCredential {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
transports: string[];
|
|
|
|
|
device_type: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
last_used_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WebAuthnStatusResponse {
|
|
|
|
|
webauthn_enabled: boolean;
|
|
|
|
|
credential_count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WebAuthnCredentialsResponse {
|
|
|
|
|
credentials: PasskeyCredential[];
|
|
|
|
|
count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WebAuthnLoginCompleteResponse {
|
|
|
|
|
user: User;
|
|
|
|
|
token: string;
|
|
|
|
|
expires_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:54:11 +10:30
|
|
|
export interface ExternalProviderListResponse {
|
|
|
|
|
providers: ExternalProvider[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LinkedAccountsResponse {
|
|
|
|
|
linked_accounts: LinkedAccount[];
|
|
|
|
|
unlink_available: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExternalProvider {
|
|
|
|
|
id: ExternalProviderId;
|
|
|
|
|
name: string;
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
scopes: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExternalProviderConfig {
|
|
|
|
|
client_id?: string;
|
|
|
|
|
client_secret?: string;
|
|
|
|
|
auth_url: string;
|
|
|
|
|
token_url: string;
|
|
|
|
|
userinfo_url: string;
|
|
|
|
|
scopes: string[];
|
|
|
|
|
redirect_uris: string[];
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
settings?: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LinkedAccount {
|
|
|
|
|
id: string;
|
|
|
|
|
provider_type: ExternalProviderId;
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
picture?: string;
|
|
|
|
|
provider_user_id?: string;
|
|
|
|
|
linked_at: string;
|
|
|
|
|
last_used_at?: string;
|
|
|
|
|
verified?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OAuthAuthorizeResponse {
|
|
|
|
|
authorization_url: string;
|
|
|
|
|
state: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OAuthCallbackResponse {
|
|
|
|
|
success: boolean;
|
|
|
|
|
token?: string;
|
|
|
|
|
user?: User;
|
|
|
|
|
expires_in?: number;
|
|
|
|
|
requires_mfa?: boolean;
|
|
|
|
|
mfa_token?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
error_type?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 15:33:03 +00:00
|
|
|
class ApiError extends Error {
|
|
|
|
|
code: number;
|
|
|
|
|
type: string;
|
|
|
|
|
details: Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
constructor(message: string, code: number, type: string, details: Record<string, unknown> = {}) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = 'ApiError';
|
|
|
|
|
this.code = code;
|
|
|
|
|
this.type = type;
|
|
|
|
|
this.details = details;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 14:29:40 +00:00
|
|
|
// Token storage keys
|
|
|
|
|
const TOKEN_KEY = 'gatehouse_token';
|
|
|
|
|
const TOKEN_EXPIRY_KEY = 'gatehouse_token_expiry';
|
|
|
|
|
|
|
|
|
|
// Token management
|
|
|
|
|
export const tokenManager = {
|
|
|
|
|
getToken: (): string | null => {
|
|
|
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
|
|
|
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
|
|
|
|
|
|
|
|
// Check if token is expired
|
|
|
|
|
if (token && expiry) {
|
|
|
|
|
const expiryDate = new Date(expiry);
|
|
|
|
|
if (expiryDate <= new Date()) {
|
2026-01-16 11:35:21 +10:30
|
|
|
console.log('[TokenManager] Token expired, clearing');
|
2026-01-07 14:29:40 +00:00
|
|
|
tokenManager.clearToken();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 11:35:21 +10:30
|
|
|
if (token) {
|
|
|
|
|
console.log('[TokenManager] Token retrieved:', token.substring(0, 20) + '...');
|
|
|
|
|
} else {
|
|
|
|
|
console.log('[TokenManager] No token found in localStorage');
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 14:29:40 +00:00
|
|
|
return token;
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-15 23:15:04 +00:00
|
|
|
setToken: (token: string, expiresAt?: string | null): void => {
|
2026-01-16 11:35:21 +10:30
|
|
|
console.log('[TokenManager] Setting token, expiresAt:', expiresAt);
|
2026-01-07 14:29:40 +00:00
|
|
|
localStorage.setItem(TOKEN_KEY, token);
|
2026-01-15 23:15:04 +00:00
|
|
|
if (expiresAt) {
|
|
|
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
|
|
|
|
|
} else {
|
|
|
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
|
|
|
}
|
2026-01-16 11:35:21 +10:30
|
|
|
console.log('[TokenManager] Token set successfully');
|
2026-01-07 14:29:40 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clearToken: (): void => {
|
2026-01-16 11:35:21 +10:30
|
|
|
console.log('[TokenManager] Clearing token from localStorage');
|
2026-01-07 14:29:40 +00:00
|
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
|
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
hasValidToken: (): boolean => {
|
2026-01-16 11:35:21 +10:30
|
|
|
const hasToken = tokenManager.getToken() !== null;
|
|
|
|
|
console.log('[TokenManager] hasValidToken:', hasToken);
|
|
|
|
|
return hasToken;
|
2026-01-07 14:29:40 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-14 02:10:23 +00:00
|
|
|
// Error types that indicate the session/token is truly invalid
|
|
|
|
|
const SESSION_INVALID_ERROR_TYPES = [
|
|
|
|
|
'INVALID_TOKEN',
|
|
|
|
|
'TOKEN_EXPIRED',
|
|
|
|
|
'SESSION_EXPIRED',
|
|
|
|
|
'AUTH_ERROR',
|
|
|
|
|
'UNAUTHORIZED',
|
|
|
|
|
];
|
|
|
|
|
|
2026-01-16 17:31:25 +10:30
|
|
|
export const AUTHORIZATION_ERROR_TYPES = ['AUTHORIZATION_ERROR'] as const;
|
|
|
|
|
|
2026-01-14 02:10:23 +00:00
|
|
|
interface RequestConfig {
|
|
|
|
|
// Controls token clearing on 401:
|
|
|
|
|
// - 'auto' (default): Clear only if error type indicates invalid session
|
|
|
|
|
// - true: Always clear token on 401
|
|
|
|
|
// - false: Never clear token on 401
|
|
|
|
|
clearTokenOn401?: boolean | 'auto';
|
2026-01-16 17:31:25 +10:30
|
|
|
// Optional callback for handling 403 authorization errors
|
|
|
|
|
on403?: (error: ApiError) => void;
|
2026-01-14 02:10:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-07 14:29:40 +00:00
|
|
|
// Central request function - all API calls go through here
|
2026-01-06 15:33:03 +00:00
|
|
|
async function request<T>(
|
|
|
|
|
endpoint: string,
|
2026-01-07 14:29:40 +00:00
|
|
|
options: RequestInit = {},
|
2026-01-14 02:10:23 +00:00
|
|
|
requiresAuth = true,
|
|
|
|
|
requestConfig: RequestConfig = {}
|
2026-01-06 15:33:03 +00:00
|
|
|
): Promise<T> {
|
2026-01-16 17:31:25 +10:30
|
|
|
const { clearTokenOn401 = 'auto', on403 } = requestConfig;
|
2026-01-14 02:10:23 +00:00
|
|
|
|
2026-01-07 14:29:40 +00:00
|
|
|
const headers: Record<string, string> = {
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-01-12 01:24:59 +00:00
|
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
|
|
|
'Pragma': 'no-cache',
|
2026-01-07 14:29:40 +00:00
|
|
|
...(options.headers as Record<string, string>),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Add Authorization header if we have a token and auth is required
|
|
|
|
|
if (requiresAuth) {
|
|
|
|
|
const token = tokenManager.getToken();
|
|
|
|
|
if (token) {
|
|
|
|
|
headers['Authorization'] = `Bearer ${token}`;
|
2026-01-16 11:35:21 +10:30
|
|
|
console.log('[API] Added Authorization header for endpoint:', endpoint);
|
|
|
|
|
} else {
|
|
|
|
|
console.log('[API] WARNING: No token available for authenticated endpoint:', endpoint);
|
2026-01-07 14:29:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 15:42:41 +00:00
|
|
|
const response = await fetch(`${config.api.baseUrl}${endpoint}`, {
|
2026-01-06 15:33:03 +00:00
|
|
|
...options,
|
2026-01-07 14:29:40 +00:00
|
|
|
headers,
|
2026-01-15 22:46:27 +00:00
|
|
|
credentials: 'include', // Always include cookies for session consistency
|
2026-01-12 01:24:59 +00:00
|
|
|
cache: 'no-store',
|
2026-01-06 15:33:03 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const json: ApiResponse<T> = await response.json();
|
|
|
|
|
|
|
|
|
|
if (!json.success) {
|
2026-01-14 02:10:23 +00:00
|
|
|
const errorType = json.error?.type || 'UNKNOWN_ERROR';
|
|
|
|
|
|
|
|
|
|
// Handle 401 token clearing based on configuration
|
2026-01-07 14:29:40 +00:00
|
|
|
if (json.code === 401) {
|
2026-01-14 02:10:23 +00:00
|
|
|
const shouldClearToken =
|
|
|
|
|
clearTokenOn401 === true ||
|
|
|
|
|
(clearTokenOn401 === 'auto' && SESSION_INVALID_ERROR_TYPES.includes(errorType));
|
|
|
|
|
|
|
|
|
|
if (shouldClearToken) {
|
|
|
|
|
tokenManager.clearToken();
|
|
|
|
|
if (import.meta.env.DEV) {
|
|
|
|
|
console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`);
|
|
|
|
|
}
|
|
|
|
|
} else if (import.meta.env.DEV) {
|
|
|
|
|
console.log(`[API] 401 received but token preserved (type: ${errorType}, endpoint: ${endpoint})`);
|
|
|
|
|
}
|
2026-01-07 14:29:40 +00:00
|
|
|
}
|
2026-01-16 17:31:25 +10:30
|
|
|
|
|
|
|
|
// Handle 403 authorization errors
|
|
|
|
|
if (json.code === 403) {
|
|
|
|
|
const error = new ApiError(
|
|
|
|
|
json.message || 'Access denied',
|
|
|
|
|
json.code,
|
|
|
|
|
errorType,
|
|
|
|
|
json.error?.details || {}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (on403) {
|
|
|
|
|
on403(error);
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2026-01-07 14:29:40 +00:00
|
|
|
|
2026-01-06 15:33:03 +00:00
|
|
|
throw new ApiError(
|
|
|
|
|
json.message || 'An error occurred',
|
|
|
|
|
json.code,
|
2026-01-14 02:10:23 +00:00
|
|
|
errorType,
|
2026-01-06 15:33:03 +00:00
|
|
|
json.error?.details || {}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return json.data as T;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 14:29:40 +00:00
|
|
|
// Centralized API client - all routes defined here
|
2026-01-06 15:33:03 +00:00
|
|
|
export const api = {
|
|
|
|
|
auth: {
|
2026-01-07 14:29:40 +00:00
|
|
|
login: async (email: string, password: string, remember_me = false): Promise<LoginResponse> => {
|
|
|
|
|
const response = await request<LoginResponse>('/auth/login', {
|
2026-01-06 15:33:03 +00:00
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ email, password, remember_me }),
|
2026-01-14 07:21:55 +00:00
|
|
|
credentials: 'include', // Required for TOTP session tracking
|
2026-01-07 14:29:40 +00:00
|
|
|
}, false); // Login doesn't require auth
|
|
|
|
|
|
2026-01-14 07:21:55 +00:00
|
|
|
// Only store token if login is complete (no TOTP required)
|
2026-01-15 23:15:04 +00:00
|
|
|
if (response.token && !response.requires_totp) {
|
|
|
|
|
tokenManager.setToken(response.token, response.expires_at ?? null);
|
2026-01-14 07:21:55 +00:00
|
|
|
}
|
2026-01-07 14:29:40 +00:00
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
},
|
2026-01-06 15:33:03 +00:00
|
|
|
|
2026-01-07 14:29:40 +00:00
|
|
|
logout: async (): Promise<void> => {
|
|
|
|
|
try {
|
|
|
|
|
await request<void>('/auth/logout', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
// Always clear token on logout
|
|
|
|
|
tokenManager.clearToken();
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-01-06 15:33:03 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
users: {
|
2026-01-14 02:10:23 +00:00
|
|
|
// me() is the canonical session validity check - always clear token on 401
|
|
|
|
|
me: () => request<ProfileResponse>('/users/me', {}, true, { clearTokenOn401: true }),
|
2026-01-06 15:33:03 +00:00
|
|
|
|
|
|
|
|
updateMe: (data: { full_name?: string; avatar_url?: string }) =>
|
|
|
|
|
request<ProfileResponse>('/users/me', {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
}),
|
2026-01-06 16:06:53 +00:00
|
|
|
|
2026-01-16 17:31:25 +10:30
|
|
|
organizations: (requestConfig?: RequestConfig) =>
|
|
|
|
|
request<OrganizationsResponse>('/users/me/organizations', {}, true, requestConfig),
|
2026-01-11 08:17:15 +00:00
|
|
|
|
2026-01-14 02:10:23 +00:00
|
|
|
// Password change can return 401 for wrong current password - don't clear token
|
2026-01-11 08:17:15 +00:00
|
|
|
changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) =>
|
|
|
|
|
request<{ message: string }>('/users/me/password', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
current_password: currentPassword,
|
|
|
|
|
new_password: newPassword,
|
|
|
|
|
new_password_confirm: newPasswordConfirm,
|
|
|
|
|
}),
|
2026-01-14 02:10:23 +00:00
|
|
|
}, true, { clearTokenOn401: false }),
|
2026-01-06 15:33:03 +00:00
|
|
|
},
|
2026-01-12 06:28:36 +00:00
|
|
|
|
|
|
|
|
totp: {
|
|
|
|
|
// Initiate TOTP enrollment - returns secret, QR code, and backup codes
|
|
|
|
|
enroll: () =>
|
|
|
|
|
request<TotpEnrollResponse>('/auth/totp/enroll', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
}),
|
|
|
|
|
|
2026-01-14 02:10:23 +00:00
|
|
|
// Verify TOTP enrollment - wrong code should not log user out
|
2026-01-12 06:28:36 +00:00
|
|
|
verifyEnrollment: (code: string) =>
|
|
|
|
|
request<{ message: string }>('/auth/totp/verify-enrollment', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ code }),
|
2026-01-14 02:10:23 +00:00
|
|
|
}, true, { clearTokenOn401: false }),
|
2026-01-12 06:28:36 +00:00
|
|
|
|
|
|
|
|
// Verify TOTP code during login (no auth required - uses session state)
|
2026-01-14 07:21:55 +00:00
|
|
|
verify: async (code: string, isBackupCode = false): Promise<TotpVerifyResponse> => {
|
|
|
|
|
const response = await request<TotpVerifyResponse>('/auth/totp/verify', {
|
2026-01-12 06:28:36 +00:00
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ code, is_backup_code: isBackupCode }),
|
2026-01-14 07:21:55 +00:00
|
|
|
credentials: 'include', // Required for TOTP session tracking
|
|
|
|
|
}, false);
|
|
|
|
|
|
2026-01-15 23:15:04 +00:00
|
|
|
if (response.token) {
|
|
|
|
|
tokenManager.setToken(response.token, response.expires_at ?? null);
|
2026-01-14 07:21:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
},
|
2026-01-12 06:28:36 +00:00
|
|
|
|
2026-01-20 15:54:11 +10:30
|
|
|
// Verify TOTP code with an mfa_token (used after OAuth callback when MFA is required)
|
|
|
|
|
verifyWithMfaToken: async (code: string, mfaToken: string, isBackupCode = false): Promise<TotpVerifyResponse> => {
|
|
|
|
|
const response = await request<TotpVerifyResponse>('/auth/totp/verify', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ code, mfa_token: mfaToken, is_backup_code: isBackupCode }),
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
}, false);
|
|
|
|
|
|
|
|
|
|
if (response.token) {
|
|
|
|
|
tokenManager.setToken(response.token, response.expires_at ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-12 06:28:36 +00:00
|
|
|
// Get TOTP status
|
|
|
|
|
status: () =>
|
|
|
|
|
request<TotpStatusResponse>('/auth/totp/status'),
|
|
|
|
|
|
2026-01-14 02:10:23 +00:00
|
|
|
// Disable TOTP - wrong password should not log user out
|
2026-01-12 06:28:36 +00:00
|
|
|
disable: (password: string) =>
|
|
|
|
|
request<{ message: string }>('/auth/totp/disable', {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
body: JSON.stringify({ password }),
|
2026-01-14 02:10:23 +00:00
|
|
|
}, true, { clearTokenOn401: false }),
|
2026-01-12 06:28:36 +00:00
|
|
|
|
2026-01-14 02:10:23 +00:00
|
|
|
// Regenerate backup codes - wrong password should not log user out
|
2026-01-12 06:28:36 +00:00
|
|
|
regenerateBackupCodes: (password: string) =>
|
|
|
|
|
request<{ backup_codes: string[] }>('/auth/totp/regenerate-backup-codes', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ password }),
|
2026-01-14 02:10:23 +00:00
|
|
|
}, true, { clearTokenOn401: false }),
|
2026-01-12 06:28:36 +00:00
|
|
|
},
|
2026-01-14 15:32:30 +00:00
|
|
|
|
|
|
|
|
webauthn: {
|
|
|
|
|
// Get WebAuthn status
|
|
|
|
|
status: () =>
|
|
|
|
|
request<WebAuthnStatusResponse>('/auth/webauthn/status'),
|
|
|
|
|
|
|
|
|
|
// List all passkeys for current user
|
|
|
|
|
listCredentials: () =>
|
|
|
|
|
request<WebAuthnCredentialsResponse>('/auth/webauthn/credentials'),
|
|
|
|
|
|
|
|
|
|
// Begin passkey registration (returns raw WebAuthn options)
|
|
|
|
|
beginRegistration: async (): Promise<Record<string, unknown>> => {
|
|
|
|
|
const response = await fetch(`${config.api.baseUrl}/auth/webauthn/register/begin`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Authorization': `Bearer ${tokenManager.getToken()}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new ApiError(
|
|
|
|
|
error.message || 'Failed to begin registration',
|
|
|
|
|
error.code || response.status,
|
|
|
|
|
error.error?.type || 'WEBAUTHN_ERROR',
|
|
|
|
|
error.error?.details || {}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Returns raw WebAuthn options (not wrapped in standard response)
|
|
|
|
|
return response.json();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Complete passkey registration
|
|
|
|
|
completeRegistration: (credential: Record<string, unknown>, name?: string) =>
|
|
|
|
|
request<{ message: string; credential_id: string }>('/auth/webauthn/register/complete', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ ...credential, name }),
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// Begin passkey login (returns raw WebAuthn options)
|
|
|
|
|
beginLogin: async (email: string): Promise<Record<string, unknown>> => {
|
|
|
|
|
const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/begin`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-01-16 11:35:21 +10:30
|
|
|
credentials: 'include', // Required for session cookie
|
2026-01-14 15:32:30 +00:00
|
|
|
body: JSON.stringify({ email }),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new ApiError(
|
|
|
|
|
error.message || 'No passkeys found for this account',
|
|
|
|
|
error.code || response.status,
|
|
|
|
|
error.error?.type || 'WEBAUTHN_ERROR',
|
|
|
|
|
error.error?.details || {}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Returns raw WebAuthn options (not wrapped in standard response)
|
|
|
|
|
return response.json();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Complete passkey login
|
|
|
|
|
completeLogin: async (assertion: Record<string, unknown>): Promise<WebAuthnLoginCompleteResponse> => {
|
2026-01-16 11:35:21 +10:30
|
|
|
const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/complete`, {
|
2026-01-14 15:32:30 +00:00
|
|
|
method: 'POST',
|
2026-01-16 11:35:21 +10:30
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
credentials: 'include', // Required for session cookie
|
2026-01-14 15:32:30 +00:00
|
|
|
body: JSON.stringify(assertion),
|
2026-01-16 11:35:21 +10:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const json: ApiResponse<WebAuthnLoginCompleteResponse> = await response.json();
|
|
|
|
|
|
|
|
|
|
if (!json.success) {
|
|
|
|
|
throw new ApiError(
|
|
|
|
|
json.message || 'Authentication failed',
|
|
|
|
|
json.code,
|
|
|
|
|
json.error?.type || 'AUTHENTICATION_ERROR',
|
|
|
|
|
json.error?.details || {}
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-14 15:32:30 +00:00
|
|
|
|
|
|
|
|
// Store token after successful passkey login
|
2026-01-16 11:35:21 +10:30
|
|
|
if (json.data?.token) {
|
|
|
|
|
tokenManager.setToken(json.data.token, json.data.expires_at ?? null);
|
2026-01-14 15:32:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 11:35:21 +10:30
|
|
|
return json.data as WebAuthnLoginCompleteResponse;
|
2026-01-14 15:32:30 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Rename a passkey
|
|
|
|
|
renameCredential: (credentialId: string, name: string) =>
|
|
|
|
|
request<{ message: string }>(`/auth/webauthn/credentials/${credentialId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// Delete a passkey
|
|
|
|
|
deleteCredential: (credentialId: string) =>
|
|
|
|
|
request<{ message: string }>(`/auth/webauthn/credentials/${credentialId}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
}),
|
|
|
|
|
},
|
2026-01-16 17:31:25 +10:30
|
|
|
|
|
|
|
|
policies: {
|
|
|
|
|
// Get organization security policy
|
|
|
|
|
getOrgPolicy: (orgId: string, requestConfig?: RequestConfig) =>
|
|
|
|
|
request<OrgPolicyResponse>(`/organizations/${orgId}/security-policy`, {}, true, requestConfig),
|
|
|
|
|
|
|
|
|
|
// Update organization security policy
|
|
|
|
|
updateOrgPolicy: (orgId: string, body: UpdateOrgPolicyDto, requestConfig?: RequestConfig) =>
|
|
|
|
|
request<OrgPolicyResponse>(`/organizations/${orgId}/security-policy`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
}, true, requestConfig),
|
|
|
|
|
|
|
|
|
|
// List organization compliance (paginated)
|
|
|
|
|
listOrgCompliance: (orgId: string, params: Record<string, string>, requestConfig?: RequestConfig) =>
|
|
|
|
|
request<OrgCompliancePage>(
|
|
|
|
|
`/organizations/${orgId}/mfa-compliance?${new URLSearchParams(params)}`,
|
|
|
|
|
{},
|
|
|
|
|
true,
|
|
|
|
|
requestConfig
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// Get current user's MFA compliance summary
|
|
|
|
|
getMyCompliance: () =>
|
|
|
|
|
request<MfaComplianceSummary>('/users/me/mfa-compliance'),
|
|
|
|
|
},
|
2026-01-20 15:54:11 +10:30
|
|
|
|
|
|
|
|
externalAuth: {
|
|
|
|
|
// Provider management (admin)
|
|
|
|
|
listProviders: () =>
|
|
|
|
|
request<ExternalProviderListResponse>('/auth/external/providers'),
|
|
|
|
|
|
|
|
|
|
getProviderConfig: (provider: ExternalProviderId) =>
|
|
|
|
|
request<ExternalProviderConfig | null>(`/auth/external/providers/${provider}/config`),
|
|
|
|
|
|
|
|
|
|
updateProviderConfig: (provider: ExternalProviderId, config: Partial<ExternalProviderConfig>) =>
|
|
|
|
|
request<void>(`/auth/external/providers/${provider}/config`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(config),
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
deleteProviderConfig: (provider: ExternalProviderId) =>
|
|
|
|
|
request<void>(`/auth/external/providers/${provider}/config`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// User account management
|
|
|
|
|
listLinkedAccounts: () =>
|
|
|
|
|
request<LinkedAccountsResponse>('/auth/external/linked-accounts'),
|
|
|
|
|
|
|
|
|
|
unlinkAccount: (provider: ExternalProviderId) =>
|
|
|
|
|
request<void>(`/auth/external/${provider}/unlink`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// OAuth flow initiation
|
|
|
|
|
initiateLogin: (provider: ExternalProviderId, state: string) => {
|
|
|
|
|
const params = new URLSearchParams({ state });
|
|
|
|
|
return request<OAuthAuthorizeResponse>(
|
|
|
|
|
`/auth/external/${provider}/authorize?${params.toString()}`,
|
|
|
|
|
{
|
|
|
|
|
method: 'GET',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
},
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
initiateRegister: (provider: ExternalProviderId, state: string) => {
|
|
|
|
|
const params = new URLSearchParams({ state });
|
|
|
|
|
return request<OAuthAuthorizeResponse>(
|
|
|
|
|
`/auth/external/${provider}/authorize?${params.toString()}`,
|
|
|
|
|
{
|
|
|
|
|
method: 'GET',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
},
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
initiateLink: (provider: ExternalProviderId, state: string) =>
|
|
|
|
|
request<OAuthAuthorizeResponse>(`/auth/external/${provider}/link`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ state }),
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// OAuth callback (called after redirect from provider)
|
|
|
|
|
handleCallback: (provider: ExternalProviderId, code: string, state: string) => {
|
|
|
|
|
const params = new URLSearchParams({ code, state });
|
|
|
|
|
return request<OAuthCallbackResponse>(
|
|
|
|
|
`/auth/external/${provider}/callback?${params.toString()}`,
|
|
|
|
|
{
|
|
|
|
|
method: 'GET',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
},
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-01-06 15:33:03 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-16 17:31:25 +10:30
|
|
|
// Policy types
|
|
|
|
|
export interface OrgPolicyResponse {
|
|
|
|
|
security_policy: {
|
|
|
|
|
organization_id: string;
|
|
|
|
|
mfa_policy_mode: string;
|
|
|
|
|
mfa_grace_period_days: number;
|
|
|
|
|
notify_days_before: number;
|
|
|
|
|
policy_version: number;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UpdateOrgPolicyDto {
|
|
|
|
|
mfa_policy_mode: string;
|
|
|
|
|
mfa_grace_period_days: number;
|
|
|
|
|
notify_days_before: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OrgCompliancePage {
|
|
|
|
|
members: OrgComplianceMember[];
|
|
|
|
|
count: number;
|
|
|
|
|
page: number;
|
|
|
|
|
page_size: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OrgComplianceMember {
|
|
|
|
|
user_id: string;
|
|
|
|
|
user_email: string;
|
|
|
|
|
user_name: string;
|
|
|
|
|
status: string;
|
|
|
|
|
deadline_at: string | null;
|
|
|
|
|
compliant_at: string | null;
|
|
|
|
|
last_notified_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 15:33:03 +00:00
|
|
|
export { ApiError };
|
2026-01-16 17:31:25 +10:30
|
|
|
|
|
|
|
|
// Reusable 403 error handler for API calls
|
|
|
|
|
// Shows a user-friendly toast message when access is denied
|
|
|
|
|
export function create403Handler(toastFn: (options: { title: string; description: string; variant: "destructive" }) => void) {
|
|
|
|
|
return (error: ApiError) => {
|
|
|
|
|
console.warn('[API] 403 Access Denied:', error.message);
|
|
|
|
|
toastFn({
|
|
|
|
|
title: "Access Denied",
|
|
|
|
|
description: "You don't have permission to view this section. Please contact your organization administrator.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
}
|