230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
import type {
|
|
CloudUser,
|
|
DeviceRecord,
|
|
HostRecord,
|
|
PluginRecord,
|
|
PluginRegistrationPayload,
|
|
TaskAttempt,
|
|
TaskListResponse,
|
|
TaskStatus,
|
|
UserCreatePayload,
|
|
UserListResponse,
|
|
UserUpdatePayload,
|
|
} from "./types";
|
|
|
|
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
|
| string
|
|
| undefined;
|
|
|
|
export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replace(
|
|
/\/$/,
|
|
"",
|
|
);
|
|
|
|
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
|
|
const CSRF_COOKIE_NAME = "amcp_csrf";
|
|
|
|
export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid";
|
|
|
|
export class CloudApiError extends Error {
|
|
readonly status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.name = "CloudApiError";
|
|
}
|
|
}
|
|
|
|
export function getStoredToken(): string | null {
|
|
try {
|
|
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function storeToken(token: string): void {
|
|
sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
|
|
}
|
|
|
|
export function clearStoredToken(): void {
|
|
sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function hasTokenMode(): boolean {
|
|
return getStoredToken() !== null;
|
|
}
|
|
|
|
interface RequestInitLike {
|
|
method?: string;
|
|
body?: string | null;
|
|
headers?: Record<string, string>;
|
|
allowAnonymous?: boolean;
|
|
sessionOnly?: boolean;
|
|
}
|
|
|
|
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
|
|
const token = init.sessionOnly ? null : getStoredToken();
|
|
if (!init.allowAnonymous && !token && !init.sessionOnly) {
|
|
// Session mode is permitted, so a missing token is not itself an error.
|
|
}
|
|
const method = init.method || "GET";
|
|
const headers: Record<string, string> = {
|
|
Accept: "application/json",
|
|
...init.headers,
|
|
};
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`;
|
|
} else if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
const csrfToken = getCookie(CSRF_COOKIE_NAME);
|
|
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
|
}
|
|
if (init.body !== undefined && init.body !== null) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
method,
|
|
body: init.body ?? null,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
if (response.status === 401) {
|
|
if (token) clearStoredToken();
|
|
window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT));
|
|
throw new CloudApiError(401, await responseDetail(response, "authentication expired"));
|
|
}
|
|
if (!response.ok) {
|
|
throw new CloudApiError(
|
|
response.status,
|
|
await responseDetail(response, `${response.status} ${response.statusText}`),
|
|
);
|
|
}
|
|
if (response.status === 204) return undefined as T;
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function responseDetail(response: Response, fallback: string): Promise<string> {
|
|
try {
|
|
const payload = (await response.json()) as { detail?: unknown };
|
|
if (typeof payload.detail === "string") return payload.detail;
|
|
if (payload.detail) return JSON.stringify(payload.detail);
|
|
} catch {
|
|
// Preserve the fallback for empty or non-JSON responses.
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function getCookie(name: string): string | null {
|
|
const prefix = `${encodeURIComponent(name)}=`;
|
|
for (const part of document.cookie.split(";")) {
|
|
const value = part.trim();
|
|
if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function login(username: string, password: string): Promise<CloudUser> {
|
|
return request<CloudUser>("/v1/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ username, password }),
|
|
allowAnonymous: true,
|
|
sessionOnly: true,
|
|
});
|
|
}
|
|
|
|
export function getCurrentUser(): Promise<CloudUser> {
|
|
return request<CloudUser>("/v1/auth/me", { sessionOnly: true });
|
|
}
|
|
|
|
export function logout(): Promise<void> {
|
|
return request<void>("/v1/auth/logout", { method: "POST", sessionOnly: true });
|
|
}
|
|
|
|
export function changePassword(
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
): Promise<void> {
|
|
return request<void>("/v1/auth/password", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
current_password: currentPassword,
|
|
new_password: newPassword,
|
|
}),
|
|
sessionOnly: true,
|
|
});
|
|
}
|
|
|
|
export function listTasks(options?: {
|
|
status?: TaskStatus;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<TaskListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options?.status) params.set("status", options.status);
|
|
params.set("limit", String(options?.limit ?? 50));
|
|
params.set("offset", String(options?.offset ?? 0));
|
|
const query = params.toString();
|
|
return request<TaskListResponse>(`/v1/tasks${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
|
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
|
}
|
|
|
|
export function listDevices(): Promise<DeviceRecord[]> {
|
|
return request<DeviceRecord[]>("/v1/devices");
|
|
}
|
|
|
|
export function listHosts(): Promise<HostRecord[]> {
|
|
return request<HostRecord[]>("/v1/hosts");
|
|
}
|
|
|
|
export function listPlugins(): Promise<PluginRecord[]> {
|
|
return request<PluginRecord[]>("/v1/plugins");
|
|
}
|
|
|
|
export function registerPlugin(payload: PluginRegistrationPayload): Promise<PluginRecord> {
|
|
return request<PluginRecord>("/v1/plugins", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function listUsers(options?: {
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<UserListResponse> {
|
|
const params = new URLSearchParams({
|
|
limit: String(options?.limit ?? 50),
|
|
offset: String(options?.offset ?? 0),
|
|
});
|
|
return request<UserListResponse>(`/v1/users?${params.toString()}`);
|
|
}
|
|
|
|
export function createUser(payload: UserCreatePayload): Promise<CloudUser> {
|
|
return request<CloudUser>("/v1/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function updateUser(userId: string, payload: UserUpdatePayload): Promise<CloudUser> {
|
|
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
|
|
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
}
|
|
|
|
export function revokeUserSessions(userId: string): Promise<void> {
|
|
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|