feat(cloud-console): add user authentication and administration

This commit is contained in:
2026-07-13 17:54:53 +08:00
parent 035b177128
commit cdef630e67
35 changed files with 4126 additions and 113 deletions
+129 -46
View File
@@ -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",
});
}