Logs a distinct db_viewer_accessed event (not just the earlier session_created "requested" event) when an admin's browser actually completes the hand-off into Adminer. Adds a password-confirmed account-deletion box to Settings, relying on the existing ON DELETE CASCADE foreign keys to clean up everything the account owns. Adds an admin-only "require password change" flag that get_current_user enforces server-side (403 on everything except /auth/me, /auth/change-password, /auth/logout) - meant for handing a demo account to someone with a known sample password. Co-Authored-By: Claude Sonnet 5 <[email protected]>
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import { API_BASE_URL } from "./config";
|
|
import type {
|
|
AlertDetailResponse,
|
|
AlertListFilters,
|
|
AlertResponse,
|
|
AlertUpdatePayload,
|
|
ApiErrorBody,
|
|
BanIpPayload,
|
|
CompanyCreatePayload,
|
|
CompanyResponse,
|
|
CompanyUpdatePayload,
|
|
ConfirmPasswordResetPayload,
|
|
ChangePasswordPayload,
|
|
DashboardAnalytics,
|
|
DbViewerSessionResponse,
|
|
DeleteAccountPayload,
|
|
DiscoverCompanyRequest,
|
|
DiscoveredCompanyProfile,
|
|
IpBan,
|
|
LogEntry,
|
|
LoginPayload,
|
|
MeResponse,
|
|
MonitorConfigurationResponse,
|
|
MonitorConfigurationUpdatePayload,
|
|
MonitoringRunResponse,
|
|
NotificationDestinationCreatePayload,
|
|
NotificationDestinationResponse,
|
|
NotificationDestinationUpdatePayload,
|
|
NotificationTestResult,
|
|
RegisterPayload,
|
|
ReportListItem,
|
|
ReportResponse,
|
|
RequestPasswordResetPayload,
|
|
ResendVerificationPayload,
|
|
SecurityEvent,
|
|
SetSystemSecretPayload,
|
|
SetUserApiKeyPayload,
|
|
SnapshotResponse,
|
|
SourceCreatePayload,
|
|
SourceResponse,
|
|
SourceTestResult,
|
|
SourceUpdatePayload,
|
|
SystemSecretStatus,
|
|
SystemStatus,
|
|
TokenResponse,
|
|
UnbanRequestPayload,
|
|
UnbanRequestRecord,
|
|
UserApiKeyStatus,
|
|
UserResponse,
|
|
VerifyEmailPayload,
|
|
} from "./types";
|
|
|
|
const ACCESS_TOKEN_KEY = "ciagent_access_token";
|
|
const REFRESH_TOKEN_KEY = "ciagent_refresh_token";
|
|
|
|
// Local-storage token persistence keeps this MVP's client simple to run
|
|
// entirely outside Docker/without a backend session store. Tokens are
|
|
// short-lived (see JWT_ACCESS_TOKEN_MINUTES) and refresh tokens rotate on
|
|
// use; see KNOWN_LIMITATIONS.md for the httpOnly-cookie hardening this
|
|
// would need before a real multi-user production deployment.
|
|
export function getAccessToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
|
}
|
|
|
|
export function getRefreshToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return window.localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
}
|
|
|
|
export function setTokens(accessToken: string, refreshToken: string): void {
|
|
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
|
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
|
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public body: ApiErrorBody | undefined,
|
|
) {
|
|
super(body?.detail ?? `Request failed with status ${status}`);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
interface RequestOptions extends RequestInit {
|
|
auth?: boolean;
|
|
}
|
|
|
|
// Access tokens are short-lived (JWT_ACCESS_TOKEN_MINUTES, 15min) while
|
|
// refresh tokens last days (JWT_REFRESH_TOKEN_DAYS, 7) - without this, any
|
|
// idle period past 15 minutes turned every authenticated request into a
|
|
// hard 401, which bounced the dashboard layout to /login while /login's own
|
|
// stale-cache "am I logged in?" check bounced right back, flickering
|
|
// between the two. A single in-flight refresh is shared across concurrent
|
|
// 401s so simultaneous requests don't each rotate (and invalidate) the
|
|
// refresh token out from under one another.
|
|
let refreshPromise: Promise<void> | null = null;
|
|
|
|
async function performRefresh(): Promise<void> {
|
|
const refreshToken = getRefreshToken();
|
|
if (!refreshToken) {
|
|
throw new ApiError(401, { detail: "No refresh token available" });
|
|
}
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
if (!response.ok) {
|
|
clearTokens();
|
|
throw new ApiError(response.status, undefined);
|
|
}
|
|
const tokens = (await response.json()) as TokenResponse;
|
|
setTokens(tokens.access_token, tokens.refresh_token);
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
|
|
const { auth = true, headers, ...rest } = options;
|
|
const finalHeaders = new Headers(headers);
|
|
finalHeaders.set("Content-Type", "application/json");
|
|
|
|
if (auth) {
|
|
const token = getAccessToken();
|
|
if (token) finalHeaders.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}${path}`, { ...rest, headers: finalHeaders });
|
|
|
|
if (response.status === 401 && auth && !isRetry && getRefreshToken()) {
|
|
try {
|
|
refreshPromise ??= performRefresh().finally(() => {
|
|
refreshPromise = null;
|
|
});
|
|
await refreshPromise;
|
|
return request<T>(path, options, true);
|
|
} catch {
|
|
// Refresh itself failed (refresh token also expired/revoked) - fall
|
|
// through and report the original 401 below, which is now a real,
|
|
// final "you're actually logged out" rather than a transient one.
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let body: ApiErrorBody | undefined;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
body = undefined;
|
|
}
|
|
throw new ApiError(response.status, body);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
export const api = {
|
|
systemStatus: () => request<SystemStatus>("/api/v1/system/status", { auth: false }),
|
|
|
|
systemLogs: () => request<LogEntry[]>("/api/v1/system/logs"),
|
|
|
|
listSystemSecrets: () => request<SystemSecretStatus[]>("/api/v1/system/secrets"),
|
|
|
|
createDbViewerSession: () =>
|
|
request<DbViewerSessionResponse>("/api/v1/db-viewer/session", { method: "POST" }),
|
|
|
|
setSystemSecret: (key: string, payload: SetSystemSecretPayload) =>
|
|
request<SystemSecretStatus>(`/api/v1/system/secrets/${key}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
register: (payload: RegisterPayload) =>
|
|
request<UserResponse>("/api/v1/auth/register", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
login: (payload: LoginPayload) =>
|
|
request<TokenResponse>("/api/v1/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
refresh: (refreshToken: string) =>
|
|
request<TokenResponse>("/api/v1/auth/refresh", {
|
|
method: "POST",
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
auth: false,
|
|
}),
|
|
|
|
logout: (refreshToken: string) =>
|
|
request<void>("/api/v1/auth/logout", {
|
|
method: "POST",
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
}),
|
|
|
|
me: () => request<MeResponse>("/api/v1/auth/me"),
|
|
|
|
deleteAccount: (payload: DeleteAccountPayload) =>
|
|
request<void>("/api/v1/auth/me", {
|
|
method: "DELETE",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
changePassword: (payload: ChangePasswordPayload) =>
|
|
request<TokenResponse>("/api/v1/auth/change-password", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
verifyEmail: (payload: VerifyEmailPayload) =>
|
|
request<void>("/api/v1/auth/verify-email", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
resendVerification: (payload: ResendVerificationPayload) =>
|
|
request<void>("/api/v1/auth/resend-verification", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
requestPasswordReset: (payload: RequestPasswordResetPayload) =>
|
|
request<void>("/api/v1/auth/request-password-reset", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
confirmPasswordReset: (payload: ConfirmPasswordResetPayload) =>
|
|
request<void>("/api/v1/auth/confirm-password-reset", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
securityEvents: () => request<SecurityEvent[]>("/api/v1/auth/security-events"),
|
|
|
|
submitUnbanRequest: (payload: UnbanRequestPayload) =>
|
|
request<void>("/api/v1/unban-requests", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
auth: false,
|
|
}),
|
|
|
|
listIpBans: () => request<IpBan[]>("/api/v1/admin/ip-bans"),
|
|
|
|
createIpBan: (payload: BanIpPayload) =>
|
|
request<IpBan>("/api/v1/admin/ip-bans", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteIpBan: (ipAddress: string) =>
|
|
request<void>(`/api/v1/admin/ip-bans/${encodeURIComponent(ipAddress)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
listUnbanRequests: () => request<UnbanRequestRecord[]>("/api/v1/admin/unban-requests"),
|
|
|
|
acceptUnbanRequest: (requestId: string) =>
|
|
request<void>(`/api/v1/admin/unban-requests/${requestId}/accept`, { method: "POST" }),
|
|
|
|
rejectUnbanRequest: (requestId: string) =>
|
|
request<void>(`/api/v1/admin/unban-requests/${requestId}`, { method: "DELETE" }),
|
|
|
|
listUserApiKeys: () => request<UserApiKeyStatus[]>("/api/v1/user-api-keys"),
|
|
|
|
setUserApiKey: (provider: string, payload: SetUserApiKeyPayload) =>
|
|
request<UserApiKeyStatus>(`/api/v1/user-api-keys/${provider}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
listCompanies: () => request<CompanyResponse[]>("/api/v1/companies"),
|
|
|
|
discoverCompany: (payload: DiscoverCompanyRequest) =>
|
|
request<DiscoveredCompanyProfile>("/api/v1/companies/discover", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
getCompany: (companyId: string) => request<CompanyResponse>(`/api/v1/companies/${companyId}`),
|
|
|
|
createCompany: (payload: CompanyCreatePayload) =>
|
|
request<CompanyResponse>("/api/v1/companies", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateCompany: (companyId: string, payload: CompanyUpdatePayload) =>
|
|
request<CompanyResponse>(`/api/v1/companies/${companyId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteCompany: (companyId: string) =>
|
|
request<void>(`/api/v1/companies/${companyId}`, { method: "DELETE" }),
|
|
|
|
pauseCompany: (companyId: string) =>
|
|
request<CompanyResponse>(`/api/v1/companies/${companyId}/pause`, { method: "POST" }),
|
|
|
|
resumeCompany: (companyId: string) =>
|
|
request<CompanyResponse>(`/api/v1/companies/${companyId}/resume`, { method: "POST" }),
|
|
|
|
updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) =>
|
|
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
listNotificationDestinations: () =>
|
|
request<NotificationDestinationResponse[]>("/api/v1/notification-destinations"),
|
|
|
|
createNotificationDestination: (payload: NotificationDestinationCreatePayload) =>
|
|
request<NotificationDestinationResponse>("/api/v1/notification-destinations", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateNotificationDestination: (
|
|
destinationId: string,
|
|
payload: NotificationDestinationUpdatePayload,
|
|
) =>
|
|
request<NotificationDestinationResponse>(`/api/v1/notification-destinations/${destinationId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteNotificationDestination: (destinationId: string) =>
|
|
request<void>(`/api/v1/notification-destinations/${destinationId}`, { method: "DELETE" }),
|
|
|
|
unlinkNotificationDestinationCompany: (destinationId: string, companyId: string) =>
|
|
request<void>(`/api/v1/notification-destinations/${destinationId}/companies/${companyId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
testNotificationDestination: (destinationId: string) =>
|
|
request<NotificationTestResult>(`/api/v1/notification-destinations/${destinationId}/test`, {
|
|
method: "POST",
|
|
}),
|
|
|
|
listAlerts: (filters: AlertListFilters = {}) => {
|
|
const params = new URLSearchParams();
|
|
if (filters.company_id) params.set("company_id", filters.company_id);
|
|
if (filters.severity) params.set("severity", filters.severity);
|
|
if (filters.read !== undefined) params.set("read", String(filters.read));
|
|
if (filters.resolved !== undefined) params.set("resolved", String(filters.resolved));
|
|
const query = params.toString();
|
|
return request<AlertResponse[]>(`/api/v1/alerts${query ? `?${query}` : ""}`);
|
|
},
|
|
|
|
getAlert: (alertId: string) => request<AlertDetailResponse>(`/api/v1/alerts/${alertId}`),
|
|
|
|
updateAlert: (alertId: string, payload: AlertUpdatePayload) =>
|
|
request<AlertResponse>(`/api/v1/alerts/${alertId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
markAlertRead: (alertId: string) =>
|
|
request<AlertResponse>(`/api/v1/alerts/${alertId}/read`, { method: "POST" }),
|
|
|
|
resolveAlert: (alertId: string) =>
|
|
request<AlertResponse>(`/api/v1/alerts/${alertId}/resolve`, { method: "POST" }),
|
|
|
|
getDashboardAnalytics: () => request<DashboardAnalytics>("/api/v1/dashboard/analytics"),
|
|
|
|
listSources: (companyId: string) =>
|
|
request<SourceResponse[]>(`/api/v1/companies/${companyId}/sources`),
|
|
|
|
listSnapshots: (companyId: string) =>
|
|
request<SnapshotResponse[]>(`/api/v1/companies/${companyId}/snapshots`),
|
|
|
|
createSource: (companyId: string, payload: SourceCreatePayload) =>
|
|
request<SourceResponse>(`/api/v1/companies/${companyId}/sources`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateSource: (sourceId: string, payload: SourceUpdatePayload) =>
|
|
request<SourceResponse>(`/api/v1/sources/${sourceId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteSource: (sourceId: string) =>
|
|
request<void>(`/api/v1/sources/${sourceId}`, { method: "DELETE" }),
|
|
|
|
testSource: (sourceId: string) =>
|
|
request<SourceTestResult>(`/api/v1/sources/${sourceId}/test`, { method: "POST" }),
|
|
|
|
runCompanyNow: (companyId: string) =>
|
|
request<MonitoringRunResponse>(`/api/v1/companies/${companyId}/run`, { method: "POST" }),
|
|
|
|
listCompanyRuns: (companyId: string) =>
|
|
request<MonitoringRunResponse[]>(`/api/v1/companies/${companyId}/runs`),
|
|
|
|
getRun: (runId: string) => request<MonitoringRunResponse>(`/api/v1/runs/${runId}`),
|
|
|
|
listReports: (companyId: string) =>
|
|
request<ReportListItem[]>(`/api/v1/companies/${companyId}/reports`),
|
|
|
|
generateReport: (companyId: string) =>
|
|
request<ReportResponse>(`/api/v1/companies/${companyId}/reports/generate`, {
|
|
method: "POST",
|
|
}),
|
|
|
|
getReport: (reportId: string) => request<ReportResponse>(`/api/v1/reports/${reportId}`),
|
|
|
|
getReportMarkdown: (reportId: string) =>
|
|
fetch(`${API_BASE_URL}/api/v1/reports/${reportId}/markdown`, {
|
|
headers: (() => {
|
|
const h = new Headers();
|
|
const token = getAccessToken();
|
|
if (token) h.set("Authorization", `Bearer ${token}`);
|
|
return h;
|
|
})(),
|
|
}).then((r) => r.text()),
|
|
};
|