Files
gatehouse-ui/src/lib/api.ts
T

312 lines
8.4 KiB
TypeScript
Raw Normal View History

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-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;
}
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;
}
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()) {
tokenManager.clearToken();
return null;
}
}
return token;
},
setToken: (token: string, expiresAt: string): void => {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
},
clearToken: (): void => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
},
hasValidToken: (): boolean => {
return tokenManager.getToken() !== null;
},
};
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',
];
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-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-14 02:10:23 +00:00
const { clearTokenOn401 = 'auto' } = requestConfig;
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-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-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-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)
if (response.token && response.expires_at && !response.requires_totp) {
tokenManager.setToken(response.token, response.expires_at);
}
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
organizations: () => request<OrganizationsResponse>('/users/me/organizations'),
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);
// Store token after successful TOTP verification
if (response.token && response.expires_at) {
tokenManager.setToken(response.token, response.expires_at);
}
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-06 15:33:03 +00:00
};
export { ApiError };