From a6b897a08fbde63a253f823e1b7dbcfa980c3b64 Mon Sep 17 00:00:00 2001 From: lyon Date: Sun, 14 Jun 2026 01:33:26 +0800 Subject: [PATCH] feat: add admin user status controls --- internal/cloud/server.ts | 65 ++++++++++++++++ internal/cloud/user-billing-client.ts | 3 + .../cloud/user-billing-integration.test.ts | 76 ++++++++++++++++++- web/hwlab-cloud-web/src/api/usage.ts | 3 +- web/hwlab-cloud-web/src/styles/workbench.css | 26 +++++++ web/hwlab-cloud-web/src/types/index.ts | 10 +++ .../src/views/admin/UsersView.vue | 33 +++++++- 7 files changed, 211 insertions(+), 5 deletions(-) diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 042a7760..9a9441a1 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -324,6 +324,12 @@ async function handleRestAdapter(request, response, url, options) { return; } + const adminBillingUserStatusMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)\/status$/u); + if (adminBillingUserStatusMatch && request.method === "PATCH") { + await handleAdminBillingUserStatusHttp(request, response, decodeURIComponent(adminBillingUserStatusMatch[1]), options); + return; + } + if (request.method === "GET" && url.pathname === "/v1") { const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe); const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options); @@ -1033,6 +1039,65 @@ async function handleAdminBillingSummaryHttp(request, response, url, options) { }); } +async function handleAdminBillingUserStatusHttp(request, response, userId, options) { + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return; + } + if (auth.actor?.role !== "admin") { + sendRestError(request, response, 403, "admin_required", "Only admin users can update user status", { + operation: "PATCH /v1/admin/billing/users/{userId}/status", + target: { type: "route", id: "/v1/admin/billing/users/{userId}/status" }, + result: "rejected" + }); + return; + } + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + if (!client?.configured || typeof client.updateAdminUserStatus !== "function") { + sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user status API is not configured", { + operation: "PATCH /v1/admin/billing/users/{userId}/status", + target: { type: "service", id: "hwlab-user-billing" } + }); + return; + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) { + sendJson(response, 400, parsed.error); + return; + } + const status = String(parsed.value?.status ?? "").trim().toLowerCase(); + if (!["active", "disabled", "pending"].includes(status)) { + sendRestError(request, response, 400, "invalid_status", "status must be active, disabled or pending", { + operation: "PATCH /v1/admin/billing/users/{userId}/status", + target: { type: "user", id: userId }, + result: "rejected" + }); + return; + } + const result = await client.updateAdminUserStatus(userId, status); + if (!result.ok) { + sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_admin_user_status_failed", result.error?.message ?? "user-billing admin user status request failed", { + operation: "PATCH /v1/admin/billing/users/{userId}/status", + target: { type: "service", id: "hwlab-user-billing" }, + reason: result.error?.code, + result: "failed" + }); + return; + } + sendJson(response, 200, { + ...(result.body ?? {}), + actor: adminActorSummary(auth.actor), + proxy: { + serviceId: CLOUD_API_SERVICE_ID, + route: "/v1/admin/billing/users/{userId}/status", + source: "hwlab-user-billing", + valuesRedacted: true + }, + valuesRedacted: true + }); +} + function adminActorSummary(actor) { return actor ? { id: String(actor.id ?? ""), diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index f1267189..9a6f73b3 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -76,6 +76,9 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } async adminBillingSummary({ limit = 50 } = {}) { const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" }); + }, + async updateAdminUserStatus(userId, status) { + return post("/internal/admin/users/status", { userId, status }); } }; } diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index 52dd6973..904363a2 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -183,10 +183,16 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa assert.equal(summary.proxy.source, "hwlab-user-billing"); assert.equal(JSON.stringify(summary).includes("hwl_user_billing_test_secret"), false); - const adminDenied = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: authHeader }); - assert.equal(adminDenied.status, 403); + const adminSummaryDenied = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: authHeader }); + assert.equal(adminSummaryDenied.status, 403); + const adminStatusDenied = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_user_billing_agent/status`, { + method: "PATCH", + headers: { "content-type": "application/json", ...authHeader }, + body: JSON.stringify({ status: "disabled" }) + }); + assert.equal(adminStatusDenied.status, 403); - assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary", "introspect"]); + assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary", "introspect", "introspect"]); assert.equal(JSON.stringify(result).includes("hwl_user_billing_test_secret"), false); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); @@ -274,3 +280,67 @@ test("cloud api proxies admin billing summary for web admins without exposing us await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } }); + +test("cloud api proxies admin user status updates for web admins", async () => { + const calls = []; + const userBillingClient = { + configured: true, + async updateAdminUserStatus(userId, status) { + calls.push({ op: "adminUserStatus", userId, status }); + assert.equal(userId, "usr_billing_admin_subject"); + assert.equal(status, "disabled"); + return { + ok: true, + status: 200, + body: { + userId, + status + } + }; + } + }; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127" + }, + userBillingClient + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "admin-password-1127" }) + }); + assert.equal(login.status, 200); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + assert.match(cookie, /^hwlab_session=/u); + + const invalid = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject/status`, { + method: "PATCH", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ status: "archived" }) + }); + assert.equal(invalid.status, 400); + + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject/status`, { + method: "PATCH", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ status: "disabled" }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.userId, "usr_billing_admin_subject"); + assert.equal(payload.status, "disabled"); + assert.equal(payload.proxy.source, "hwlab-user-billing"); + assert.equal(payload.proxy.route, "/v1/admin/billing/users/{userId}/status"); + assert.equal(payload.valuesRedacted, true); + assert.equal(JSON.stringify(payload).includes("admin-password-1127"), false); + assert.deepEqual(calls, [{ op: "adminUserStatus", userId: "usr_billing_admin_subject", status: "disabled" }]); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/web/hwlab-cloud-web/src/api/usage.ts b/web/hwlab-cloud-web/src/api/usage.ts index 45436fe2..76da08c8 100644 --- a/web/hwlab-cloud-web/src/api/usage.ts +++ b/web/hwlab-cloud-web/src/api/usage.ts @@ -1,8 +1,9 @@ import { fetchJson } from "./client"; -import type { AdminBillingSummary, ApiResult, UsageSummary } from "@/types"; +import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingSummary, ApiResult, UsageSummary } from "@/types"; export const usageAPI = { summary: (): Promise> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }), adminBillingSummary: (): Promise> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }), + updateAdminUserStatus: (userId: string, status: AccessUserStatus): Promise> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }), performance: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }) }; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 2be3315e..f54499f6 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -798,6 +798,32 @@ font-size: 12px; } +.admin-action-error { + margin: 0; +} + +.table-action { + min-width: 64px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #0f172a; + cursor: pointer; + font-size: 12px; + font-weight: 750; + padding: 7px 10px; +} + +.table-action:hover:not(:disabled) { + border-color: #0891b2; + color: #0c4a6e; +} + +.table-action:disabled { + cursor: wait; + opacity: 0.55; +} + .status-pill { display: inline-flex; align-items: center; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 85ae7045..1d42ff7f 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -285,6 +285,16 @@ export interface AdminBillingSummary { [key: string]: unknown; } +export interface AdminBillingMutationResponse { + ok?: boolean; + userId?: string; + status?: AccessUserStatus; + actor?: AuthUser; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + export interface Toast { id: string; type: "success" | "error" | "info" | "warning"; diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 3290d57b..090b43b0 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -3,11 +3,13 @@ import { computed, onMounted, ref } from "vue"; import { usageAPI } from "@/api"; import EmptyState from "@/components/common/EmptyState.vue"; import PageHeader from "@/components/common/PageHeader.vue"; -import type { AdminBillingSummary } from "@/types"; +import type { AccessUserStatus, AdminBillingSummary } from "@/types"; const summary = ref(null); const loading = ref(true); const error = ref(null); +const mutationError = ref(null); +const updatingUserId = ref(null); const totals = computed(() => summary.value?.totals ?? {}); const users = computed(() => summary.value?.users ?? []); @@ -27,6 +29,28 @@ async function refresh(): Promise { loading.value = false; } +async function setUserStatus(userId: string | undefined, status: AccessUserStatus): Promise { + if (!userId || updatingUserId.value) return; + updatingUserId.value = userId; + mutationError.value = null; + const response = await usageAPI.updateAdminUserStatus(userId, status); + if (!response.ok) { + mutationError.value = response.error ?? `HTTP ${response.status}`; + updatingUserId.value = null; + return; + } + await refresh(); + updatingUserId.value = null; +} + +function nextStatus(status: unknown): AccessUserStatus { + return String(status ?? "") === "disabled" ? "active" : "disabled"; +} + +function statusActionLabel(status: unknown): string { + return String(status ?? "") === "disabled" ? "启用" : "禁用"; +} + function formatNumber(value: unknown): string { const number = Number(value ?? 0); if (!Number.isFinite(number)) return "0"; @@ -49,6 +73,7 @@ function formatDate(value: unknown): string {