feat: add admin user status controls
This commit is contained in:
@@ -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 ?? ""),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<ApiResult<UsageSummary>> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }),
|
||||
adminBillingSummary: (): Promise<ApiResult<AdminBillingSummary>> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }),
|
||||
updateAdminUserStatus: (userId: string, status: AccessUserStatus): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }),
|
||||
performance: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" })
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -285,6 +285,16 @@ export interface AdminBillingSummary {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdminBillingMutationResponse {
|
||||
ok?: boolean;
|
||||
userId?: string;
|
||||
status?: AccessUserStatus;
|
||||
actor?: AuthUser;
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
|
||||
@@ -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<AdminBillingSummary | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const mutationError = ref<string | null>(null);
|
||||
const updatingUserId = ref<string | null>(null);
|
||||
|
||||
const totals = computed(() => summary.value?.totals ?? {});
|
||||
const users = computed(() => summary.value?.users ?? []);
|
||||
@@ -27,6 +29,28 @@ async function refresh(): Promise<void> {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function setUserStatus(userId: string | undefined, status: AccessUserStatus): Promise<void> {
|
||||
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 {
|
||||
<EmptyState v-else-if="error" title="管理员账务接口不可用" :description="error" />
|
||||
<EmptyState v-else-if="!summary" title="暂无用户账务" description="当前运行面未返回 admin billing summary。" />
|
||||
<template v-else>
|
||||
<p v-if="mutationError" class="form-error admin-action-error">{{ mutationError }}</p>
|
||||
<div class="overview-grid usage-metrics">
|
||||
<article class="metric-card">
|
||||
<span>用户</span>
|
||||
@@ -79,6 +104,7 @@ function formatDate(value: unknown): string {
|
||||
<th>API keys</th>
|
||||
<th>Reservations</th>
|
||||
<th>Last activity</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -108,6 +134,11 @@ function formatDate(value: unknown): string {
|
||||
<small v-else>none</small>
|
||||
</td>
|
||||
<td>{{ formatDate(item.usage?.lastUsedAt ?? item.apiKeys?.lastUsedAt ?? item.user?.createdAt) }}</td>
|
||||
<td>
|
||||
<button class="table-action" type="button" :disabled="!item.user?.id || updatingUserId === item.user?.id" @click="setUserStatus(item.user?.id, nextStatus(item.user?.status))">
|
||||
{{ updatingUserId === item.user?.id ? "处理中" : statusActionLabel(item.user?.status) }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user