feat(cloud-console): add user authentication and administration
This commit is contained in:
+129
-46
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CloudUser,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
PluginRecord,
|
||||
@@ -6,21 +7,24 @@ import type {
|
||||
TaskAttempt,
|
||||
TaskListResponse,
|
||||
TaskStatus,
|
||||
UserCreatePayload,
|
||||
UserListResponse,
|
||||
UserUpdatePayload,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||
| string
|
||||
| undefined;
|
||||
// Same-origin Docker deployments (CLOUD_CONSOLE_STATIC_DIR) serve this SPA
|
||||
// straight off the Cloud API, so without a build-time override the API lives
|
||||
// at whatever host the browser loaded the page from, not a hardcoded IP.
|
||||
export const API_BASE_URL = (
|
||||
configuredBaseUrl || window.location.origin
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
|
||||
const CSRF_COOKIE_NAME = "amcp_csrf";
|
||||
|
||||
export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid";
|
||||
export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid";
|
||||
|
||||
export class CloudApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -47,64 +51,109 @@ 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 = getStoredToken();
|
||||
if (!token) {
|
||||
throw new CloudApiError(401, "no bearer token stored");
|
||||
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",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...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: init.method || "GET",
|
||||
method,
|
||||
body: init.body ?? null,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
clearStoredToken();
|
||||
window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT));
|
||||
let detail = "token rejected by cloud api";
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") {
|
||||
detail = payload.detail;
|
||||
}
|
||||
} catch {
|
||||
// fall back to the default detail
|
||||
}
|
||||
throw new CloudApiError(response.status, detail);
|
||||
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) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") {
|
||||
message = payload.detail;
|
||||
} else if (payload.detail) {
|
||||
message = JSON.stringify(payload.detail);
|
||||
}
|
||||
} catch {
|
||||
message = await response.text().catch(() => message);
|
||||
}
|
||||
throw new CloudApiError(response.status, message);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
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;
|
||||
@@ -119,9 +168,7 @@ export function listTasks(options?: {
|
||||
}
|
||||
|
||||
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
||||
return request<TaskAttempt[]>(
|
||||
`/v1/tasks/${encodeURIComponent(taskId)}/attempts`,
|
||||
);
|
||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||
}
|
||||
|
||||
export function listDevices(): Promise<DeviceRecord[]> {
|
||||
@@ -136,11 +183,47 @@ export function listPlugins(): Promise<PluginRecord[]> {
|
||||
return request<PluginRecord[]>("/v1/plugins");
|
||||
}
|
||||
|
||||
export function registerPlugin(
|
||||
payload: PluginRegistrationPayload,
|
||||
): Promise<PluginRecord> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user