232 lines
9.2 KiB
TypeScript
232 lines
9.2 KiB
TypeScript
import {
|
|
AccessAction,
|
|
AccessRecord,
|
|
AdminExcelImportResult,
|
|
ApiResponse,
|
|
AuditLog,
|
|
BlacklistCreate,
|
|
BlacklistItem,
|
|
ChangePasswordRequest,
|
|
CurrentUser,
|
|
DeliveryStatus,
|
|
EmailDiagnosticsResult,
|
|
ExcelImportResult,
|
|
InsideVisitor,
|
|
LoginRequest,
|
|
PassDelivery,
|
|
PurposeCode,
|
|
Role,
|
|
SmsDiagnosticsResult,
|
|
StatsSummary,
|
|
PublicPass,
|
|
Team,
|
|
VisitRequestCreate,
|
|
VisitRequestResetResult,
|
|
VisitRequestView,
|
|
AdminUser,
|
|
Watcher1Settings,
|
|
Zone,
|
|
} from './types';
|
|
|
|
const BASE = '/api';
|
|
|
|
/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */
|
|
const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/'];
|
|
|
|
/**
|
|
* CSRF double-submit: the backend sets an XSRF-TOKEN cookie; echo it back in the
|
|
* X-XSRF-TOKEN header on state-changing requests. GET/HEAD need no token.
|
|
*/
|
|
function csrfHeaders(method?: string): Record<string, string> {
|
|
const m = (method ?? 'GET').toUpperCase();
|
|
if (m === 'GET' || m === 'HEAD') return {};
|
|
const c = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
|
return c ? { 'X-XSRF-TOKEN': decodeURIComponent(c[1]) } : {};
|
|
}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
credentials: 'include',
|
|
...init,
|
|
headers: { ...(init?.headers ?? {}), ...csrfHeaders(init?.method) },
|
|
});
|
|
// Session expired / not authenticated on a protected call → send to login.
|
|
if (
|
|
res.status === 401 &&
|
|
!NO_REDIRECT_ON_401.some((p) => path.startsWith(p)) &&
|
|
typeof window !== 'undefined' &&
|
|
!['/login', '/kiosk'].includes(window.location.pathname) &&
|
|
!window.location.pathname.startsWith('/pass/')
|
|
) {
|
|
window.location.assign('/login');
|
|
}
|
|
let body: ApiResponse<T> | null = null;
|
|
try {
|
|
body = (await res.json()) as ApiResponse<T>;
|
|
} catch {
|
|
// non-JSON (shouldn't happen with our envelope)
|
|
}
|
|
if (!res.ok || !body) {
|
|
throw new Error(body?.message || `HTTP ${res.status}`);
|
|
}
|
|
return body.data as T;
|
|
}
|
|
|
|
function jsonInit(method: string, payload: unknown): RequestInit {
|
|
return {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
};
|
|
}
|
|
|
|
// ===== Auth =====
|
|
export const login = (req: LoginRequest) =>
|
|
request<CurrentUser>('/auth/login', jsonInit('POST', req));
|
|
|
|
export const logout = () =>
|
|
request<string>('/auth/logout', { method: 'POST' });
|
|
|
|
export const getCurrentUser = () => request<CurrentUser>('/auth/me');
|
|
|
|
export const changePassword = (req: ChangePasswordRequest) =>
|
|
request<string>('/auth/change-password', jsonInit('POST', req));
|
|
|
|
// ===== Zones =====
|
|
export const listZones = () => request<Zone[]>('/zones');
|
|
export const listPurposeCodes = () => request<PurposeCode[]>('/purpose-codes');
|
|
|
|
// ===== Visit requests =====
|
|
export const listVisitRequests = () =>
|
|
request<VisitRequestView[]>('/visit-requests');
|
|
|
|
export const listPendingRequests = () =>
|
|
request<VisitRequestView[]>('/visit-requests/pending');
|
|
|
|
export const getVisitRequest = (id: number) =>
|
|
request<VisitRequestView>(`/visit-requests/${id}`);
|
|
|
|
export const createVisitRequest = (req: VisitRequestCreate) =>
|
|
request<VisitRequestView[]>('/visit-requests', jsonInit('POST', req));
|
|
|
|
export const cancelVisitRequest = (id: number) =>
|
|
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
|
|
|
|
export const deleteVisitRequest = (id: number) =>
|
|
request<{ deleted: boolean }>(`/visit-requests/${id}`, { method: 'DELETE' });
|
|
|
|
export const uploadVisitRequests = (file: File) => {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
return request<ExcelImportResult>('/visit-requests/upload', {
|
|
method: 'POST',
|
|
body: form,
|
|
});
|
|
};
|
|
|
|
// ===== Approvals =====
|
|
export const approveRequest = (id: number, comment?: string) =>
|
|
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
|
|
|
|
export const rejectRequest = (id: number, comment?: string) =>
|
|
request<VisitRequestView>(`/approvals/${id}/reject`, jsonInit('POST', { comment }));
|
|
|
|
// ===== Access console (check-in / out, currently inside) =====
|
|
export const searchApprovedForCheckIn = (q: string) =>
|
|
request<VisitRequestView[]>(`/access/search?q=${encodeURIComponent(q)}`);
|
|
|
|
export const checkIn = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
|
|
request<AccessAction>('/access/check-in', jsonInit('POST', payload));
|
|
|
|
export const checkOut = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
|
|
request<AccessAction>('/access/check-out', jsonInit('POST', payload));
|
|
|
|
export const listInside = () => request<InsideVisitor[]>('/access/inside');
|
|
export const listTodayAccess = () => request<AccessRecord[]>('/access/today');
|
|
|
|
// ===== Passes / badge =====
|
|
export const getPass = (id: number) => request<VisitRequestView>(`/passes/${id}`);
|
|
export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`;
|
|
|
|
// ===== Public pass + self-service kiosk (no login, token-based) =====
|
|
export const getPublicPass = (token: string) => request<PublicPass>(`/public/passes/${token}`);
|
|
export const publicPassQrUrl = (token: string) => `/api/public/passes/${token}/qr.png`;
|
|
export const publicCheckIn = (token: string) =>
|
|
request<AccessAction>(`/public/passes/${token}/check-in`, { method: 'POST' });
|
|
export const publicCheckOut = (token: string) =>
|
|
request<AccessAction>(`/public/passes/${token}/check-out`, { method: 'POST' });
|
|
|
|
// ===== Stats =====
|
|
export const getStatsSummary = () => request<StatsSummary>('/stats/summary');
|
|
|
|
// ===== Blacklist (ADMIN) =====
|
|
export const listBlacklist = () => request<BlacklistItem[]>('/blacklist');
|
|
export const addBlacklist = (req: BlacklistCreate) =>
|
|
request<BlacklistItem>('/blacklist', jsonInit('POST', req));
|
|
export const deleteBlacklist = (id: number) =>
|
|
request<string>(`/blacklist/${id}`, { method: 'DELETE' });
|
|
|
|
// ===== Reports =====
|
|
export const listReportVisits = (from: string, to: string) =>
|
|
request<VisitRequestView[]>(`/reports/visits?from=${from}&to=${to}`);
|
|
|
|
export const reportDownloadUrl = (from: string, to: string) =>
|
|
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
|
|
|
// ===== Audit log (ADMIN) =====
|
|
export const listAudit = () => request<AuditLog[]>('/admin/audit');
|
|
|
|
// ===== Pass delivery outbox (ADMIN) =====
|
|
export const listDeliveries = (status?: DeliveryStatus) =>
|
|
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
|
|
export const retryDelivery = (id: number) =>
|
|
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });
|
|
|
|
// ===== Admin management =====
|
|
export const listAdminPurposeCodes = () => request<PurposeCode[]>('/admin/purpose-codes');
|
|
export const createAdminPurposeCode = (payload: Omit<PurposeCode, 'id'>) =>
|
|
request<PurposeCode>('/admin/purpose-codes', jsonInit('POST', payload));
|
|
export const updateAdminPurposeCode = (id: number, payload: Omit<PurposeCode, 'id'>) =>
|
|
request<PurposeCode>(`/admin/purpose-codes/${id}`, jsonInit('PUT', payload));
|
|
export const getWatcher1Settings = () => request<Watcher1Settings>('/settings/watcher1');
|
|
export const updateWatcher1Settings = (payload: Watcher1Settings) =>
|
|
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
|
|
export const runSmsDiagnostics = () =>
|
|
request<SmsDiagnosticsResult>('/admin/sms/diagnostics', { method: 'POST' });
|
|
export const runEmailDiagnostics = () =>
|
|
request<EmailDiagnosticsResult>('/admin/email/diagnostics', { method: 'POST' });
|
|
export const resetVisitRequestTestData = (confirm: string) =>
|
|
request<VisitRequestResetResult>('/admin/visit-requests/reset-test-data', jsonInit('POST', { confirm }));
|
|
export const listAdminUsers = () => request<AdminUser[]>('/admin/users');
|
|
export const updateAdminUserRoles = (id: number, roles: Role[]) =>
|
|
request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles }));
|
|
export const listAdminTeams = () => request<Team[]>('/admin/teams');
|
|
export const createAdminTeam = (payload: Omit<Team, 'id'>) =>
|
|
request<Team>('/admin/teams', jsonInit('POST', payload));
|
|
export const updateAdminTeam = (id: number, payload: Omit<Team, 'id'>) =>
|
|
request<Team>(`/admin/teams/${id}`, jsonInit('PUT', payload));
|
|
export const updateAdminUserTeam = (id: number, teamId: number, applyDefaultRoles: boolean) =>
|
|
request<AdminUser>(`/admin/users/${id}/team`, jsonInit('PUT', { teamId, applyDefaultRoles }));
|
|
export const applyTeamDefaultRolesToUser = (id: number) =>
|
|
request<AdminUser>(`/admin/users/${id}/apply-team-default-roles`, { method: 'POST' });
|
|
export const applyTeamDefaultRolesToMembers = (teamId: number) =>
|
|
request<string>(`/admin/teams/${teamId}/apply-default-roles`, { method: 'POST' });
|
|
export const uploadAdminTeams = (file: File, dryRun: boolean) => {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
return request<AdminExcelImportResult>(`/admin/teams/upload?dryRun=${dryRun}`, {
|
|
method: 'POST',
|
|
body: form,
|
|
});
|
|
};
|
|
export const uploadAdminUserRoles = (file: File, dryRun: boolean) => {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
return request<AdminExcelImportResult>(`/admin/users/roles/upload?dryRun=${dryRun}`, {
|
|
method: 'POST',
|
|
body: form,
|
|
});
|
|
};
|
|
export const adminTeamTemplateUrl = () => '/api/admin/teams/template';
|
|
export const adminUserRolesTemplateUrl = () => '/api/admin/users/roles/template';
|