Merge pull request #1190 from pikasTech/feat/issue-1188-r2-web-user-management

feat: 补齐 v03 用户管理 Web parity
This commit is contained in:
Lyon
2026-06-14 11:06:27 +08:00
committed by GitHub
19 changed files with 878 additions and 149 deletions
+84 -1
View File
@@ -199,6 +199,11 @@ class AccessController {
return;
}
if (request.method === "POST" && url.pathname === "/auth/register") {
await this.handleUserBillingRegister(request, response);
return;
}
if (request.method === "POST" && url.pathname === "/auth/login") {
await this.handleLogin(request, response);
return;
@@ -580,6 +585,9 @@ class AccessController {
if (!token) {
return required ? errorPayload("auth_required", "Authentication is required", 401) : { ok: true, actor: null };
}
if (token.startsWith("hws_") && this.userBilling?.configured) {
return this.authenticateUserBillingToken(token);
}
const session = await this.store.findSessionByTokenHash(sha256(token), this.now());
if (!session?.user || session.user.status !== "active") {
return errorPayload("auth_session_invalid", "Session is missing, expired, revoked, or disabled", 401);
@@ -797,6 +805,8 @@ class AccessController {
async handleLogin(request, response) {
const body = await jsonBody(request);
const userBilling = await this.userBillingLogin(body, request, response);
if (userBilling?.handled) return;
const user = await this.store.findUserByUsername(requiredText(body.username, "username"));
if (!user || user.status !== "active" || !verifyPassword(user.passwordHash, requiredText(body.password, "password"))) {
return sendJson(response, 401, errorPayload("invalid_credentials", "Username or password is invalid", 401));
@@ -818,9 +828,82 @@ class AccessController {
});
}
async handleUserBillingRegister(request, response) {
if (!this.userBilling?.configured || typeof this.userBilling.register !== "function" || typeof this.userBilling.login !== "function") {
return sendJson(response, 503, errorPayload("user_billing_not_configured", "HWLAB user-billing registration is not configured", 503));
}
const body = await jsonBody(request);
const result = await this.userBilling.register({
email: textOr(body.email, ""),
username: textOr(body.username, ""),
displayName: textOr(body.displayName, ""),
password: requiredText(body.password, "password")
});
if (!result.ok) {
return sendJson(response, result.status ?? 502, errorPayload(result.error?.code ?? "user_billing_register_failed", result.error?.message ?? "user-billing registration failed", result.status ?? 502));
}
const login = await this.issueUserBillingWebSession({ username: textOr(body.username, body.email), password: requiredText(body.password, "password") }, request, response);
if (!login.ok) {
return sendJson(response, login.status ?? 502, errorPayload(login.error?.code ?? "user_billing_login_failed", login.error?.message ?? "registration completed but login failed", login.status ?? 502));
}
return sendJson(response, 201, {
created: true,
authenticated: true,
actor: publicActor(login.auth.actor),
user: publicActor(login.auth.actor),
session: login.auth.session,
authMethod: AUTH_METHOD_USER_BILLING_SESSION,
initialCredits: result.body?.initialCredits ?? null,
contractVersion: "user-billing-web-session-v1",
valuesRedacted: true
});
}
async userBillingLogin(body, request, response) {
if (!this.userBilling?.configured || typeof this.userBilling.login !== "function") return { handled: false };
const username = textOr(body.username ?? body.email, "");
if (!username || !body.password) return { handled: false };
const result = await this.issueUserBillingWebSession({ username, password: requiredText(body.password, "password") }, request, response);
if (!result.ok) {
if (result.status === 401) return { handled: false };
return {
handled: true,
response: sendJson(response, result.status ?? 502, errorPayload(result.error?.code ?? "user_billing_login_failed", result.error?.message ?? "user-billing login failed", result.status ?? 502))
};
}
sendJson(response, 200, {
authenticated: true,
actor: publicActor(result.auth.actor),
user: publicActor(result.auth.actor),
session: result.auth.session,
authMethod: AUTH_METHOD_USER_BILLING_SESSION,
contractVersion: "user-billing-web-session-v1",
valuesRedacted: true
});
return { handled: true };
}
async issueUserBillingWebSession({ username, password }, request, response) {
const login = await this.userBilling.login({ username, email: username, password });
if (!login.ok || !login.body?.token) {
return { ok: false, status: login.status, error: login.error };
}
const token = textOr(login.body.token, "");
const auth = await this.authenticateUserBillingToken(token);
if (!auth.ok) {
return { ok: false, status: auth.status, error: auth.error };
}
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env);
return { ok: true, auth, token };
}
async handleLogout(request, response) {
const token = sessionCookieFromRequest(request);
if (token) await this.store.revokeSessionByTokenHash(sha256(token), this.now());
if (token?.startsWith("hws_") && this.userBilling?.configured && typeof this.userBilling.logout === "function") {
await this.userBilling.logout(token);
} else if (token) {
await this.store.revokeSessionByTokenHash(sha256(token), this.now());
}
clearSessionCookie(response, request, this.env);
return sendJson(response, 200, { authenticated: false, loggedOut: true });
}
+29 -4
View File
@@ -77,6 +77,7 @@ import {
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
const HWLAB_WEB_SESSION_COOKIE = "hwlab_session";
function runtimeEnvironment(env = process.env) {
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
@@ -382,7 +383,7 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/auth/session" || url.pathname === "/v1/auth/login" || url.pathname === "/v1/auth/logout") {
if (url.pathname === "/v1/auth/session" || url.pathname === "/v1/auth/login" || url.pathname === "/v1/auth/logout" || url.pathname === "/v1/auth/register") {
const authUrl = new URL(url.href);
authUrl.pathname = url.pathname.replace(/^\/v1\/auth/u, "/auth");
await options.accessController.handleAuthRoute(request, response, authUrl);
@@ -1217,7 +1218,7 @@ async function handleAdminBillingUserStatusHttp(request, response, userId, optio
}
async function maybeHandleUserBillingApiKeysHttp(request, response, url, options) {
const token = bearerTokenFromRequest(request);
const token = userBillingTokenFromRequest(request);
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
if (!token || !client?.configured) return false;
const auth = await options.accessController.authenticate(request, { required: true });
@@ -1260,7 +1261,7 @@ async function maybeHandleUserBillingApiKeysHttp(request, response, url, options
}
async function handleUserBillingProfileHttp(request, response, options) {
const token = bearerTokenFromRequest(request);
const token = userBillingTokenFromRequest(request);
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
const auth = await options.accessController.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
@@ -1277,7 +1278,7 @@ async function handleUserBillingProfileHttp(request, response, options) {
}
async function handleUserBillingPasswordHttp(request, response, options) {
const token = bearerTokenFromRequest(request);
const token = userBillingTokenFromRequest(request);
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
const auth = await options.accessController.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
@@ -1332,6 +1333,30 @@ function bearerTokenFromRequest(request) {
return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
}
function userBillingTokenFromRequest(request) {
const bearer = bearerTokenFromRequest(request);
if (bearer) return bearer;
const cookieToken = cookieValueFromRequest(request, HWLAB_WEB_SESSION_COOKIE);
return cookieToken.startsWith("hws_") ? cookieToken : "";
}
function cookieValueFromRequest(request, name) {
const target = String(name ?? "");
if (!target) return "";
for (const part of String(getHeader(request, "cookie") ?? "").split(";")) {
const trimmed = part.trim();
const index = trimmed.indexOf("=");
if (index <= 0) continue;
if (trimmed.slice(0, index) !== target) continue;
try {
return decodeURIComponent(trimmed.slice(index + 1));
} catch {
return trimmed.slice(index + 1);
}
}
return "";
}
async function readJsonObject(request, limitBytes) {
const body = await readBody(request, limitBytes);
try {
+13
View File
@@ -57,6 +57,10 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
return requestJson(path, { method: "POST", body });
}
async function publicPost(path, body = {}) {
return requestJson(path, { method: "POST", body });
}
async function patch(path, body = {}, options = {}) {
return requestJson(path, { method: "PATCH", body, ...options });
}
@@ -68,6 +72,15 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
return {
configured: Boolean(baseUrl),
baseUrl,
async register(body = {}) {
return publicPost("/v1/auth/register", body);
},
async login(body = {}) {
return publicPost("/v1/auth/login", body);
},
async logout(token) {
return requestJson("/v1/auth/logout", { method: "POST", body: {}, bearerToken: token });
},
async introspect(token) {
return post("/internal/auth/introspect", { token });
},
@@ -518,6 +518,83 @@ test("cloud api proxies R1 user-billing API key and profile self-service routes"
}
});
test("cloud api bridges user-billing register/login into HttpOnly web session cookie", async () => {
const calls = [];
const sessionToken = "hws_user_billing_web_session_secret";
const userBillingClient = {
configured: true,
async register(body = {}) {
calls.push({ op: "register", body });
assert.equal(body.email, "new-user@hwlab.local");
assert.equal(body.username, "new-user");
assert.equal(body.password, "new-password-1188");
return { ok: true, status: 201, body: { user: { id: "usr_web_new", email: body.email, username: body.username, role: "user", status: "active" }, initialCredits: 0 } };
},
async login(body = {}) {
calls.push({ op: "login", username: body.username, email: body.email, passwordSeen: Boolean(body.password) });
assert.equal(body.password, "new-password-1188");
return { ok: true, status: 200, body: { token: sessionToken, tokenType: "Bearer", user: { id: "usr_web_new", email: "new-user@hwlab.local", username: "new-user", role: "user", status: "active" } } };
},
async introspect(token) {
calls.push({ op: "introspect", tokenPrefix: token.slice(0, 8) });
assert.equal(token, sessionToken);
return {
ok: true,
status: 200,
body: {
active: true,
principal: {
userId: "usr_web_new",
email: "new-user@hwlab.local",
username: "new-user",
role: "user",
scopes: ["session"],
authType: "session"
}
}
};
},
async listApiKeys(token) {
calls.push({ op: "listApiKeys", tokenPrefix: token.slice(0, 8) });
assert.equal(token, sessionToken);
return { ok: true, status: 200, body: { contractVersion: "user-api-key-v1", keys: [{ id: "key_web_session", name: "Web session key", prefix: "hwl_web" }], count: 1 } };
}
};
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, userBillingClient });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const register = await fetch(`http://127.0.0.1:${port}/auth/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "new-user@hwlab.local", username: "new-user", displayName: "New User", password: "new-password-1188" })
});
assert.equal(register.status, 201);
const cookie = register.headers.get("set-cookie")?.split(";")[0] ?? "";
assert.match(cookie, /^hwlab_session=hws_/u);
const body = await register.json();
assert.equal(body.authMethod, "user-billing-session");
assert.equal(JSON.stringify(body).includes(sessionToken), false);
const session = await fetch(`http://127.0.0.1:${port}/auth/session`, { headers: { cookie } });
assert.equal(session.status, 200);
const sessionBody = await session.json();
assert.equal(sessionBody.authMethod, "user-billing-session");
assert.equal(sessionBody.actor.id, "usr_web_new");
assert.equal(JSON.stringify(sessionBody).includes(sessionToken), false);
const keys = await fetch(`http://127.0.0.1:${port}/v1/api-keys`, { headers: { cookie } });
assert.equal(keys.status, 200);
const keysBody = await keys.json();
assert.equal(keysBody.proxy.source, "hwlab-user-billing");
assert.equal(keysBody.keys[0].id, "key_web_session");
assert.equal(JSON.stringify(keysBody).includes(sessionToken), false);
assert.deepEqual(calls.map((call) => call.op), ["register", "login", "introspect", "introspect", "introspect", "listApiKeys"]);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api reports auth_required before user-billing profile/password proxy config errors", async () => {
const userBillingClient = {
configured: true,
@@ -2,6 +2,7 @@ const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap", "/auth/workspace-bootstrap"]);
const POST_PROXY_ROUTES = new Set([
"/auth/login",
"/auth/register",
"/auth/logout",
"/json-rpc",
"/v1/agent/chat",
@@ -18,6 +19,7 @@ const PUBLIC_PROXY_ROUTES = new Set([
"GET /auth/bootstrap",
"GET /auth/workspace-bootstrap",
"POST /auth/login",
"POST /auth/register",
"POST /auth/logout",
"POST /v1/agent/chat",
"POST /v1/agent/chat/cancel",
@@ -54,13 +56,16 @@ export function isCloudApiProxyRoute(method, pathname) {
isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) ||
isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) ||
isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) ||
isAdminBillingWriteProxyRoute(normalizedMethod, normalizedPath) ||
normalizedPath.startsWith("/v1/rpc/") ||
normalizedPath.startsWith("/v1/agent/sessions/") ||
isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath);
}
if (normalizedMethod === "PATCH" || normalizedMethod === "PUT") {
return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) ||
isAdminBillingWriteProxyRoute(normalizedMethod, normalizedPath) ||
isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) ||
isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) ||
isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath) ||
isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath);
}
@@ -97,11 +102,22 @@ function isProviderProfileManagementProxyRoute(method, pathname) {
function isApiKeyWriteProxyRoute(method, pathname) {
if (pathname === "/v1/api-keys" && method === "POST") return true;
if (!pathname.startsWith("/v1/api-keys/")) return false;
if (method === "PATCH") return true;
if (method === "DELETE") return true;
if (method !== "POST") return false;
return pathname.endsWith("/regenerate");
}
function isAdminBillingWriteProxyRoute(method, pathname) {
const parts = pathname.split("/").filter(Boolean);
if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "admin" || parts[2] !== "billing" || parts[3] !== "users") return false;
if (method === "POST" && parts.length === 4) return true;
if (method === "PATCH" && parts.length === 5) return true;
if (method === "PATCH" && parts.length === 6 && parts[5] === "status") return true;
if (method === "POST" && parts.length === 7 && parts[5] === "credits" && parts[6] === "adjust") return true;
return false;
}
function isWorkbenchWorkspaceWriteProxyRoute(method, pathname) {
const parts = pathname.split("/").filter(Boolean);
if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "workbench" || parts[2] !== "workspace") return false;
@@ -67,6 +67,12 @@ test("cloud web exposes auth bootstrap as a public auth route", () => {
publicRoute: true,
routeKey: "GET /auth/workspace-bootstrap"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/auth/register"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "POST /auth/register"
});
});
test("cloud web proxies authenticated API key management write routes", () => {
@@ -82,6 +88,12 @@ test("cloud web proxies authenticated API key management write routes", () => {
publicRoute: false,
routeKey: "POST /v1/api-keys/key_1/regenerate"
});
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/api-keys/key_1"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "PATCH /v1/api-keys/key_1"
});
assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/api-keys/key_1"), {
proxy: true,
authRequired: true,
@@ -90,6 +102,33 @@ test("cloud web proxies authenticated API key management write routes", () => {
});
});
test("cloud web proxies authenticated admin billing user management write routes", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/billing/users"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "POST /v1/admin/billing/users"
});
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/admin/billing/users/usr_alice"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "PATCH /v1/admin/billing/users/usr_alice"
});
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/admin/billing/users/usr_alice/status"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "PATCH /v1/admin/billing/users/usr_alice/status"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/billing/users/usr_alice/credits/adjust"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "POST /v1/admin/billing/users/usr_alice/credits/adjust"
});
});
test("cloud web proxies authenticated Admin Access write routes", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/access/check"), {
proxy: true,
+6 -31
View File
@@ -15,8 +15,6 @@ const readOnlyRpcMethods = new Set([
]);
const gzipAsync = promisify(gzip);
const GZIP_MIN_BYTES = 1024;
const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
const CLIENT_DISCONNECT_ERROR_CODES = new Set([
"ECONNRESET",
@@ -193,6 +191,10 @@ export async function handleCloudWebAuth(options) {
await proxyCloudApi(options);
return true;
}
if (url.pathname === "/auth/register" && request.method === "POST") {
await proxyCloudApi(options);
return true;
}
if (url.pathname === "/auth/logout" && request.method === "POST") {
await proxyCloudApi(options);
return true;
@@ -246,35 +248,8 @@ async function handleWorkspaceBootstrap(options) {
}
async function resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
const config = webAuthConfig();
if (config.mode !== "auto" || hasCookieHeader(request.headers.cookie)) {
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (session.ok && session.body?.authenticated === true) return withCookieHeader(request, session);
if (config.mode !== "auto") return withCookieHeader(request, session);
}
const login = await requestCloudApiJson({
request,
method: "POST",
pathname: "/auth/login",
body: JSON.stringify({ username: config.username, password: config.password }),
cloudApiBaseUrl,
cloudApiProxyTimeoutMs
});
return withCookieHeader(request, login);
}
function webAuthConfig() {
return {
mode: authConfigValue("HWLAB_CLOUD_WEB_AUTH_MODE", "auto"),
username: authConfigValue("HWLAB_CLOUD_WEB_AUTH_USERNAME", DEFAULT_AUTH_USERNAME),
password: authConfigValue("HWLAB_CLOUD_WEB_AUTH_PASSWORD", DEFAULT_AUTH_PASSWORD)
};
}
function authConfigValue(name, fallback) {
const value = process.env[name];
return typeof value === "string" && value.trim() ? value.trim() : fallback;
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
return withCookieHeader(request, session);
}
function authConfigValueFromSearch(value, fallback) {
+1
View File
@@ -4,5 +4,6 @@ import type { ApiKeyRecord, ApiResult } from "@/types";
export const apiKeysAPI = {
list: (): Promise<ApiResult<{ keys?: ApiKeyRecord[] }>> => fetchJson("/v1/api-keys", { timeoutMs: 12000, timeoutName: "api keys" }),
create: (name: string): Promise<ApiResult<{ key?: ApiKeyRecord; secret?: string }>> => fetchJson("/v1/api-keys", { method: "POST", body: JSON.stringify({ name }), timeoutMs: 12000, timeoutName: "create api key" }),
rename: (id: string, name: string): Promise<ApiResult<{ key?: ApiKeyRecord }>> => fetchJson(`/v1/api-keys/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ name }), timeoutMs: 12000, timeoutName: "rename api key" }),
revoke: (id: string): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/api-keys/${encodeURIComponent(id)}`, { method: "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "revoke api key" })
};
+6
View File
@@ -12,6 +12,12 @@ export const authAPI = {
session: (): Promise<ApiResult<AuthSession>> => fetchJson("/auth/session", { timeoutName: "auth session" }),
bootstrap: (): Promise<ApiResult<AuthSession>> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }),
workspaceBootstrap: (projectId: string): Promise<ApiResult<WorkspaceBootstrapResponse>> => fetchJson(`/auth/workspace-bootstrap?projectId=${encodeURIComponent(projectId)}`, { timeoutName: "workspace bootstrap", timeoutMs: 8000 }),
register: (payload: { email: string; username: string; displayName?: string; password: string }): Promise<ApiResult<AuthSession & { initialCredits?: number | null }>> => fetchJson("/auth/register", {
method: "POST",
body: JSON.stringify(payload),
timeoutName: "auth register",
timeoutMs: 12000
}),
login: (username: string, password: string): Promise<ApiResult<AuthSession>> => fetchJson("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
+12 -1
View File
@@ -1,9 +1,20 @@
import { fetchJson } from "./client";
import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingSummary, ApiResult, UsageSummary } from "@/types";
import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, 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" }),
adminUsers: (params: { page?: number; pageSize?: number; search?: string; status?: string; role?: string } = {}): Promise<ApiResult<AdminBillingUsersResponse>> => {
const query = new URLSearchParams({ page: String(params.page ?? 1), pageSize: String(params.pageSize ?? 25) });
if (params.search) query.set("search", params.search);
if (params.status) query.set("status", params.status);
if (params.role) query.set("role", params.role);
return fetchJson(`/v1/admin/billing/users?${query.toString()}`, { timeoutMs: 12000, timeoutName: "admin users" });
},
adminUser: (userId: string): Promise<ApiResult<AdminBillingUserDetailResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}`, { timeoutMs: 12000, timeoutName: "admin user detail" }),
createAdminUser: (body: Record<string, unknown>): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson("/v1/admin/billing/users", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "create admin user" }),
updateAdminUser: (userId: string, body: Record<string, unknown>): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "update admin user" }),
adjustAdminCredits: (userId: string, body: Record<string, unknown>): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/credits/adjust`, { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "adjust admin credits" }),
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" })
};
@@ -16,7 +16,7 @@ const navSections = [
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate" }, { name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] }
];
const shellless = computed(() => route.name === "Login" || route.name === "NotFound");
const shellless = computed(() => route.name === "Login" || route.name === "Register" || route.name === "NotFound");
function go(path: string): void {
void router.push(path);
+1
View File
@@ -4,6 +4,7 @@ import { installRouterGuards } from "./guards";
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/workbench" },
{ path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } },
{ path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } },
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, title: "平台概览", section: "user" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: () => import("@/views/workbench/CodeWorkbenchView.vue"), meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } },
+20 -16
View File
@@ -5,9 +5,6 @@ import { publishWorkspaceBootstrap, resolveWorkspaceBootstrap } from "@/composab
import type { AuthSession, AuthState, AuthUser } from "@/types";
import { DEFAULT_WORKBENCH_PROJECT_ID, asRecord, nonEmptyString, normalizeWorkbenchProjectId } from "@/utils";
const DEFAULT_USERNAME = "admin";
const DEFAULT_PASSWORD = "hwlab2026";
export const useAuthStore = defineStore("auth", () => {
const authState = ref<AuthState>("checking");
const session = ref<AuthSession | null>(null);
@@ -32,18 +29,6 @@ export const useAuthStore = defineStore("auth", () => {
document.body.dataset.authState = "authenticated";
return;
}
if (mode === "auto" && current.status === 404) {
const username = nonEmptyString(config?.username) ?? DEFAULT_USERNAME;
const password = nonEmptyString(config?.password) ?? DEFAULT_PASSWORD;
const response = await authAPI.login(username, password);
if (response.ok && response.data?.authenticated === true) {
session.value = sessionFromPayload(response.data);
authState.value = "authenticated";
checked.value = true;
document.body.dataset.authState = "authenticated";
return;
}
}
session.value = null;
authState.value = "login";
checked.value = true;
@@ -64,6 +49,25 @@ export const useAuthStore = defineStore("auth", () => {
document.body.dataset.authState = "authenticated";
}
async function register(payload: { email: string; username: string; displayName?: string; password: string }): Promise<void> {
error.value = null;
const response = await authAPI.register({
email: payload.email.trim(),
username: payload.username.trim(),
displayName: payload.displayName?.trim() ?? "",
password: payload.password
});
if (!response.ok || response.data?.authenticated !== true) {
error.value = response.error || "注册失败,请检查邮箱、用户名和密码。";
authState.value = "login";
return;
}
session.value = sessionFromPayload(response.data);
authState.value = "authenticated";
checked.value = true;
document.body.dataset.authState = "authenticated";
}
async function logout(): Promise<void> {
await authAPI.logout();
session.value = null;
@@ -71,7 +75,7 @@ export const useAuthStore = defineStore("auth", () => {
document.body.dataset.authState = "login";
}
return { authState, checked, session, error, isAuthenticated, user, isAdmin, bootstrap, login, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
return { authState, checked, session, error, isAuthenticated, user, isAdmin, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
});
function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string | null }): AuthSession {
@@ -159,6 +159,17 @@
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.register-card {
width: min(520px, 100%);
}
.auth-link {
color: #0e7490;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.login-card label,
.settings-grid label {
display: grid;
@@ -1177,6 +1188,82 @@
margin: 0;
}
.inline-form-row,
.inline-edit-row,
.admin-users-filters,
.pagination-row {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.inline-form-row .input,
.admin-users-filters .input {
min-width: 180px;
flex: 1;
}
.secret-once-box,
.detail-subpanel {
display: grid;
min-width: 0;
gap: 10px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #f8fafc;
padding: 12px;
}
.secret-once-box code {
overflow-wrap: anywhere;
color: #0f766e;
}
.admin-users-layout,
.admin-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.85fr);
gap: 12px;
}
.admin-users-list-panel,
.admin-user-create-panel,
.admin-user-detail-panel,
.admin-users-toolbar {
display: grid;
min-width: 0;
gap: 12px;
padding: 14px;
}
.form-grid {
display: grid;
min-width: 0;
gap: 10px;
}
.form-grid.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid label {
display: grid;
gap: 6px;
color: #475569;
font-size: 13px;
font-weight: 650;
}
.span-2 {
grid-column: 1 / -1;
}
.admin-billing-table tr[data-selected="true"] td {
background: #f0fdfa;
}
.table-action {
min-width: 64px;
border: 1px solid #cbd5e1;
@@ -1607,12 +1694,15 @@
.overview-grid,
.settings-grid,
.usage-panels,
.admin-users-layout,
.admin-detail-grid,
.system-summary-grid,
.system-split-grid {
grid-template-columns: 1fr;
}
.field-grid,
.form-grid.two-col,
.compact-form-grid {
grid-template-columns: 1fr;
}
+37 -1
View File
@@ -319,7 +319,7 @@ export interface AdminAccessSummaryResponse { summary?: Record<string, unknown>;
export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown }
export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface AdminAccessMutationResponse { ok?: boolean; user?: AuthUser; [key: string]: unknown }
export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; revokedAt?: string | null }
export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; lastUsedAt?: string | null; revokedAt?: string | null; displaySecret?: string | null }
export interface UsageServiceSummary { serviceId: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string }
export interface UsageLedgerRow { id: string; kind?: string; reason?: string; deltaCredits?: number; balanceAfter?: number; createdAt?: string }
export interface UsageReservationRow { reservationId: string; serviceId?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string }
@@ -359,10 +359,46 @@ export interface AdminBillingSummary {
[key: string]: unknown;
}
export interface AdminBillingUsersResponse {
status?: string;
contractVersion?: string;
count?: number;
total?: number;
users?: AdminBillingUserSummary[];
pagination?: { page?: number; pageSize?: number; total?: number; totalPages?: number };
state?: { stateless?: boolean; stateAuthority?: string; database?: Record<string, unknown>; redis?: Record<string, unknown> };
proxy?: Record<string, unknown>;
valuesRedacted?: boolean;
[key: string]: unknown;
}
export interface AdminBillingUserDetail {
summary?: AdminBillingUserSummary;
ledger?: { count?: number; rows?: UsageLedgerRow[] };
apiKeys?: { count?: number; keys?: ApiKeyRecord[] };
usageBy?: UsageServiceSummary[];
reservations?: { count?: number; rows?: UsageReservationRow[]; active?: UsageReservationRow[] };
[key: string]: unknown;
}
export interface AdminBillingUserDetailResponse {
contractVersion?: string;
detail?: AdminBillingUserDetail;
actor?: AuthUser;
proxy?: Record<string, unknown>;
valuesRedacted?: boolean;
[key: string]: unknown;
}
export interface AdminBillingMutationResponse {
ok?: boolean;
userId?: string;
status?: AccessUserStatus;
user?: AuthUser;
detail?: AdminBillingUserDetail;
balance?: number;
created?: boolean;
updated?: boolean;
actor?: AuthUser;
proxy?: Record<string, unknown>;
valuesRedacted?: boolean;
+8 -4
View File
@@ -6,11 +6,14 @@ import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const username = ref("admin");
const username = ref("");
const password = ref("");
const pending = ref(false);
async function submit(): Promise<void> {
pending.value = true;
await auth.login(username.value, password.value);
pending.value = false;
if (auth.isAuthenticated) await router.replace(nextPath());
}
@@ -24,11 +27,12 @@ function nextPath(): string {
<section class="login-shell">
<form class="login-card" @submit.prevent="submit">
<div class="brand-lockup centered"><span class="brand-mark">HW</span><strong>HWLAB Cloud Web</strong></div>
<label>账号<input v-model="username" class="input" autocomplete="username" /></label>
<label>账号或邮箱<input v-model="username" class="input" autocomplete="username" /></label>
<label>密码<input v-model="password" class="input" type="password" autocomplete="current-password" /></label>
<p v-if="auth.error" class="form-error">{{ auth.error }}</p>
<button class="btn btn-primary" type="submit">登录</button>
<p class="login-note">Web session 使用服务器签发的 hwlab_session cookie浏览器不保存 provider Secret AgentRun token</p>
<button class="btn btn-primary" type="submit" :disabled="pending">{{ pending ? "登录中" : "登录" }}</button>
<p class="login-note">Web session 使用服务器签发的 hwlab_session cookie浏览器不保存 user-billing tokenprovider Secret AgentRun token</p>
<RouterLink class="auth-link" to="/register">创建 HWLAB 账号</RouterLink>
</form>
</section>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const email = ref("");
const username = ref("");
const displayName = ref("");
const password = ref("");
const confirmPassword = ref("");
const pending = ref(false);
const localError = ref<string | null>(null);
const canSubmit = computed(() => email.value.trim() && username.value.trim() && password.value.length >= 10 && password.value === confirmPassword.value);
async function submit(): Promise<void> {
localError.value = null;
if (password.value.length < 10) {
localError.value = "密码至少需要 10 个字符。";
return;
}
if (password.value !== confirmPassword.value) {
localError.value = "两次输入的密码不一致。";
return;
}
pending.value = true;
await auth.register({ email: email.value, username: username.value, displayName: displayName.value, password: password.value });
pending.value = false;
if (auth.isAuthenticated) await router.replace(nextPath());
}
function nextPath(): string {
const redirect = route.query.redirect;
return typeof redirect === "string" && redirect.startsWith("/") && !redirect.startsWith("//") ? redirect : "/workbench";
}
</script>
<template>
<section class="login-shell">
<form class="login-card register-card" @submit.prevent="submit">
<div class="brand-lockup centered"><span class="brand-mark">HW</span><strong>注册 HWLAB Cloud Web</strong></div>
<label>邮箱<input v-model="email" class="input" type="email" autocomplete="email" /></label>
<label>用户名<input v-model="username" class="input" autocomplete="username" /></label>
<label>显示名<input v-model="displayName" class="input" autocomplete="name" /></label>
<label>密码<input v-model="password" class="input" type="password" autocomplete="new-password" /></label>
<label>确认密码<input v-model="confirmPassword" class="input" type="password" autocomplete="new-password" /></label>
<p v-if="localError || auth.error" class="form-error">{{ localError || auth.error }}</p>
<button class="btn btn-primary" type="submit" :disabled="pending || !canSubmit">{{ pending ? "注册中" : "注册并登录" }}</button>
<p class="login-note">注册账号由 hwlab-user-billing 写入 PK01 PostgreSQL浏览器仅持有 HttpOnly Web session cookie</p>
<RouterLink class="auth-link" to="/login">已有账号返回登录</RouterLink>
</form>
</section>
</template>
+247 -84
View File
@@ -1,46 +1,158 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { usageAPI } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import type { AccessUserStatus, AdminBillingSummary } from "@/types";
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AuthUser } from "@/types";
const summary = ref<AdminBillingSummary | null>(null);
const users = ref<AdminBillingUserSummary[]>([]);
const detail = ref<AdminBillingUserDetail | null>(null);
const selectedUserId = ref<string | null>(null);
const loading = ref(true);
const detailLoading = ref(false);
const error = ref<string | null>(null);
const mutationError = ref<string | null>(null);
const updatingUserId = ref<string | null>(null);
const pending = ref<string | null>(null);
const page = ref(1);
const pageSize = ref(25);
const total = ref(0);
const stateAuthority = ref("pk01-postgres");
const totals = computed(() => summary.value?.totals ?? {});
const users = computed(() => summary.value?.users ?? []);
const filters = reactive({ search: "", status: "", role: "" });
const createForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 });
const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active" });
const creditForm = reactive({ deltaCredits: 0, reason: "admin_adjust", idempotencyKey: "" });
const selectedSummary = computed(() => detail.value?.summary ?? users.value.find((item) => item.user?.id === selectedUserId.value) ?? null);
const selectedUser = computed(() => selectedSummary.value?.user ?? null);
const ledgerRows = computed(() => detail.value?.ledger?.rows ?? []);
const apiKeys = computed(() => detail.value?.apiKeys?.keys ?? []);
const reservations = computed(() => detail.value?.reservations?.rows ?? detail.value?.reservations?.active ?? selectedSummary.value?.reservations?.active ?? []);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
onMounted(refresh);
async function refresh(): Promise<void> {
loading.value = true;
const response = await usageAPI.adminBillingSummary();
const response = await usageAPI.adminUsers({ page: page.value, pageSize: pageSize.value, search: filters.search.trim(), status: filters.status, role: filters.role });
if (!response.ok) {
error.value = response.error ?? `HTTP ${response.status}`;
summary.value = null;
users.value = [];
total.value = 0;
} else {
summary.value = response.data;
const payload = response.data ?? {};
users.value = payload.users ?? [];
total.value = Number(payload.total ?? payload.count ?? users.value.length);
stateAuthority.value = String(payload.state?.stateAuthority ?? "pk01-postgres");
error.value = null;
if (!selectedUserId.value && users.value[0]?.user?.id) await selectUser(users.value[0].user.id);
}
loading.value = false;
}
async function setUserStatus(userId: string | undefined, status: AccessUserStatus): Promise<void> {
if (!userId || updatingUserId.value) return;
updatingUserId.value = userId;
async function selectUser(userId: string | undefined): Promise<void> {
if (!userId) return;
selectedUserId.value = userId;
detailLoading.value = true;
mutationError.value = null;
const response = await usageAPI.updateAdminUserStatus(userId, status);
const response = await usageAPI.adminUser(userId);
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
detail.value = null;
} else {
detail.value = response.data?.detail ?? null;
fillEditForm(detail.value?.summary?.user ?? users.value.find((item) => item.user?.id === userId)?.user ?? null);
}
detailLoading.value = false;
}
function fillEditForm(user: (AuthUser & { email?: string }) | null | undefined): void {
editForm.email = user?.email ?? "";
editForm.username = user?.username ?? "";
editForm.displayName = user?.displayName ?? "";
editForm.password = "";
editForm.role = user?.role === "admin" ? "admin" : "user";
editForm.status = String(user?.status ?? "active");
}
async function createUser(): Promise<void> {
pending.value = "create";
mutationError.value = null;
const response = await usageAPI.createAdminUser({ ...createForm, initialCredits: Number(createForm.initialCredits || 0) });
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
return;
}
createForm.email = "";
createForm.username = "";
createForm.displayName = "";
createForm.password = "";
createForm.initialCredits = 0;
await refresh();
const userId = response.data?.user?.id;
if (userId) await selectUser(userId);
}
async function saveUser(): Promise<void> {
const userId = selectedUser.value?.id;
if (!userId) return;
pending.value = `save:${userId}`;
mutationError.value = null;
const body: Record<string, unknown> = {
email: editForm.email.trim(),
username: editForm.username.trim(),
displayName: editForm.displayName.trim(),
role: editForm.role,
status: editForm.status
};
if (editForm.password) body.password = editForm.password;
const response = await usageAPI.updateAdminUser(userId, body);
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
updatingUserId.value = null;
return;
}
await refresh();
updatingUserId.value = null;
await selectUser(userId);
}
async function setUserStatus(userId: string | undefined, status: AccessUserStatus): Promise<void> {
if (!userId) return;
pending.value = `status:${userId}`;
mutationError.value = null;
const response = await usageAPI.updateAdminUserStatus(userId, status);
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
return;
}
await refresh();
await selectUser(userId);
}
async function adjustCredits(): Promise<void> {
const userId = selectedUser.value?.id;
if (!userId) return;
pending.value = `credits:${userId}`;
mutationError.value = null;
const body: Record<string, unknown> = { deltaCredits: Number(creditForm.deltaCredits), reason: creditForm.reason.trim() || "admin_adjust" };
if (creditForm.idempotencyKey.trim()) body.idempotencyKey = creditForm.idempotencyKey.trim();
const response = await usageAPI.adjustAdminCredits(userId, body);
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
return;
}
creditForm.deltaCredits = 0;
creditForm.idempotencyKey = "";
await refresh();
await selectUser(userId);
}
async function nextPage(delta: number): Promise<void> {
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
await refresh();
}
function nextStatus(status: unknown): AccessUserStatus {
@@ -57,6 +169,12 @@ function formatNumber(value: unknown): string {
return new Intl.NumberFormat("zh-CN").format(number);
}
function formatSignedCredits(value: unknown): string {
const number = Number(value ?? 0);
const sign = number > 0 ? "+" : "";
return `${sign}${formatNumber(number)}`;
}
function formatDate(value: unknown): string {
const text = String(value ?? "").trim();
if (!text) return "-";
@@ -68,81 +186,126 @@ function formatDate(value: unknown): string {
<template>
<section class="route-stack">
<PageHeader eyebrow="Admin" title="用户与账务" description="来自 PK01 PostgreSQL ledger 的用户余额、用量和 reservation 概览。" />
<EmptyState v-if="loading" title="正在加载用户账务" description="正在通过 cloud-api 代理读取 user-billing admin summary。" />
<EmptyState v-else-if="error" title="管理员账务接口不可用" :description="error" />
<EmptyState v-else-if="!summary" title="暂无用户账务" description="当前运行面未返回 admin billing summary。" />
<PageHeader eyebrow="Admin" title="用户与账务" description="Sub2API-style 用户管理工作面;用户、余额、API key、ledger 和 reservation 均来自 PK01 user-billing authority。" />
<EmptyState v-if="loading" title="正在加载用户" description="正在通过 Cloud API admin 代理读取 user-billing 用户列表。" />
<EmptyState v-else-if="error" title="管理员用户接口不可用" :description="error" />
<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>
<strong>{{ formatNumber(totals.users ?? summary.count) }}</strong>
</article>
<article class="metric-card">
<span>平台 credit</span>
<strong>{{ formatNumber(totals.availableCredits) }}</strong>
</article>
<article class="metric-card">
<span>活跃预留</span>
<strong>{{ formatNumber(totals.activeReservations) }}</strong>
</article>
<section class="data-panel admin-users-toolbar">
<header class="panel-header">
<h2>用户查询</h2>
<small>{{ stateAuthority }} / {{ formatNumber(total) }} users</small>
</header>
<div class="admin-users-filters">
<input v-model="filters.search" class="input" placeholder="搜索用户名、邮箱或 user id" @keyup.enter="refresh" />
<select v-model="filters.status" class="input" @change="refresh"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select>
<select v-model="filters.role" class="input" @change="refresh"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select>
<button class="btn btn-secondary" type="button" @click="refresh">刷新</button>
</div>
</section>
<div class="admin-users-layout">
<section class="data-panel admin-users-list-panel">
<header class="panel-header">
<h2>用户列表</h2>
<small>page {{ page }} / {{ totalPages }}</small>
</header>
<table v-if="users.length" class="data-table usage-table admin-billing-table">
<thead><tr><th>User</th><th>Status</th><th>Credits</th><th>Usage</th><th>Keys</th><th>Action</th></tr></thead>
<tbody>
<tr v-for="item in users" :key="item.user?.id ?? item.user?.username" :data-selected="selectedUserId === item.user?.id">
<td>
<button class="link-button" type="button" @click="selectUser(item.user?.id)"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong></button>
<small>{{ item.user?.email || item.user?.username }} · {{ item.planId ?? "default" }}</small>
</td>
<td><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span></td>
<td><strong>{{ formatNumber(item.credits?.available) }}</strong><small>{{ formatNumber(item.credits?.balance) }} balance · {{ formatNumber(item.credits?.reserved) }} reserved</small></td>
<td><strong>{{ formatNumber(item.usage?.totalCredits) }}</strong><small>{{ formatNumber(item.usage?.recordCount) }} records</small></td>
<td><strong>{{ formatNumber(item.apiKeys?.activeCount) }}</strong><small>{{ formatNumber(item.apiKeys?.totalCount) }} total</small></td>
<td><button class="table-action" type="button" :disabled="pending === `status:${item.user?.id}`" @click="setUserStatus(item.user?.id, nextStatus(item.user?.status))">{{ statusActionLabel(item.user?.status) }}</button></td>
</tr>
</tbody>
</table>
<p v-else class="muted-box">没有匹配用户</p>
<footer class="pagination-row">
<button class="table-action" type="button" :disabled="page <= 1" @click="nextPage(-1)">上一页</button>
<button class="table-action" type="button" :disabled="page >= totalPages" @click="nextPage(1)">下一页</button>
</footer>
</section>
<section class="data-panel admin-user-create-panel">
<header class="panel-header"><h2>创建用户</h2><small>写入 user-billing</small></header>
<div class="form-grid two-col">
<label>邮箱<input v-model="createForm.email" class="input" type="email" /></label>
<label>用户名<input v-model="createForm.username" class="input" /></label>
<label>显示名<input v-model="createForm.displayName" class="input" /></label>
<label>初始 credit<input v-model.number="createForm.initialCredits" class="input" type="number" /></label>
<label>角色<select v-model="createForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label>
<label>状态<select v-model="createForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label>
<label class="span-2">初始密码<input v-model="createForm.password" class="input" type="password" autocomplete="new-password" /></label>
</div>
<button class="btn btn-primary" type="button" :disabled="pending === 'create'" @click="createUser">{{ pending === "create" ? "创建中" : "创建用户" }}</button>
</section>
</div>
<section class="data-panel usage-panel admin-billing-panel">
<section class="data-panel admin-user-detail-panel">
<header class="panel-header">
<h2>用户账务</h2>
<small>{{ summary.state?.stateAuthority ?? "pk01-postgres" }} / {{ summary.state?.redis?.role ?? "cache-only" }}</small>
<h2>{{ selectedUser?.displayName || selectedUser?.username || "用户详情" }}</h2>
<span class="status-pill" :data-status="selectedUser?.status || (detailLoading ? 'pending' : 'disabled')">{{ detailLoading ? "loading" : selectedUser?.role || "未选择" }}</span>
</header>
<table v-if="users.length" class="data-table usage-table admin-billing-table">
<thead>
<tr>
<th>User</th>
<th>Status</th>
<th>Credits</th>
<th>Usage</th>
<th>API keys</th>
<th>Reservations</th>
<th>Last activity</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :key="item.user?.id ?? item.user?.username">
<td>
<strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong>
<small>{{ item.user?.username }} · {{ item.planId ?? "default" }}</small>
</td>
<td>
<span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span>
</td>
<td>
<strong>{{ formatNumber(item.credits?.available) }}</strong>
<small>{{ formatNumber(item.credits?.balance) }} balance · {{ formatNumber(item.credits?.reserved) }} reserved</small>
</td>
<td>
<strong>{{ formatNumber(item.usage?.totalCredits) }}</strong>
<small>{{ formatNumber(item.usage?.recordCount) }} records · {{ formatNumber(item.usage?.totalQuantity) }} units</small>
</td>
<td>
<strong>{{ formatNumber(item.apiKeys?.activeCount) }}</strong>
<small>{{ formatNumber(item.apiKeys?.totalCount) }} total</small>
</td>
<td>
<strong>{{ formatNumber(item.reservations?.activeCount) }}</strong>
<small v-if="item.reservations?.active?.length"><code>{{ item.reservations.active[0]?.serviceId }}</code></small>
<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>
<p v-else class="muted-box">暂无用户记录</p>
<p v-if="!selectedUser" class="muted-box">选择一个用户查看详情</p>
<template v-else>
<div class="overview-grid usage-metrics">
<article class="metric-card"><span>Available</span><strong>{{ formatNumber(selectedSummary?.credits?.available) }}</strong></article>
<article class="metric-card"><span>Usage credits</span><strong>{{ formatNumber(selectedSummary?.usage?.totalCredits) }}</strong></article>
<article class="metric-card"><span>Active keys</span><strong>{{ formatNumber(selectedSummary?.apiKeys?.activeCount) }}</strong></article>
</div>
<div class="admin-detail-grid">
<section class="detail-subpanel">
<h3>资料与权限</h3>
<div class="form-grid two-col">
<label>邮箱<input v-model="editForm.email" class="input" /></label>
<label>用户名<input v-model="editForm.username" class="input" /></label>
<label>显示名<input v-model="editForm.displayName" class="input" /></label>
<label>重置密码<input v-model="editForm.password" class="input" type="password" autocomplete="new-password" placeholder="留空则不变" /></label>
<label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label>
<label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label>
</div>
<button class="btn btn-primary" type="button" :disabled="pending === `save:${selectedUser?.id}`" @click="saveUser">{{ pending === `save:${selectedUser?.id}` ? "保存中" : "保存资料" }}</button>
</section>
<section class="detail-subpanel">
<h3>Credit 调整</h3>
<div class="form-grid">
<label>Delta credits<input v-model.number="creditForm.deltaCredits" class="input" type="number" /></label>
<label>原因<input v-model="creditForm.reason" class="input" /></label>
<label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input" placeholder="可选" /></label>
</div>
<button class="btn btn-secondary" type="button" :disabled="pending === `credits:${selectedUser?.id}`" @click="adjustCredits">{{ pending === `credits:${selectedUser?.id}` ? "提交中" : "调整余额" }}</button>
</section>
</div>
<div class="usage-panels">
<section class="detail-subpanel">
<h3>API keys</h3>
<table v-if="apiKeys.length" class="data-table usage-table"><tbody><tr v-for="key in apiKeys" :key="key.id"><td>{{ key.name || key.id }}<small>{{ key.id }}</small></td><td><code>{{ key.prefix }}</code></td><td><span class="status-pill" :data-status="key.revokedAt ? 'disabled' : 'active'">{{ key.revokedAt ? "revoked" : "active" }}</span></td></tr></tbody></table>
<p v-else class="muted-box">暂无 API key</p>
</section>
<section class="detail-subpanel">
<h3>Ledger</h3>
<table v-if="ledgerRows.length" class="data-table usage-table"><tbody><tr v-for="row in ledgerRows" :key="row.id"><td>{{ formatDate(row.createdAt) }}<small>{{ row.kind }} / {{ row.reason }}</small></td><td>{{ formatSignedCredits(row.deltaCredits) }}</td><td>{{ formatNumber(row.balanceAfter) }}</td></tr></tbody></table>
<p v-else class="muted-box">暂无 ledger</p>
</section>
</div>
<section class="detail-subpanel">
<h3>Reservations</h3>
<table v-if="reservations.length" class="data-table usage-table"><tbody><tr v-for="item in reservations" :key="item.reservationId"><td><code>{{ item.reservationId }}</code></td><td><code>{{ item.serviceId }}</code></td><td>{{ formatNumber(item.estimatedCredits) }}</td><td>{{ item.status }}</td><td>{{ formatDate(item.expiresAt) }}</td></tr></tbody></table>
<p v-else class="muted-box">没有 active reservation</p>
</section>
</template>
</section>
</template>
</section>
@@ -1,23 +1,151 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { apiKeysAPI } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import type { ApiKeyRecord } from "@/types";
const keys = ref<ApiKeyRecord[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
onMounted(async () => {
const mutationError = ref<string | null>(null);
const newKeyName = ref("HWLAB CLI key");
const createdSecret = ref<string | null>(null);
const pending = ref<string | null>(null);
const editingId = ref<string | null>(null);
const editingName = ref("");
const activeKeys = computed(() => keys.value.filter((key) => !key.revokedAt));
onMounted(refresh);
async function refresh(): Promise<void> {
loading.value = true;
const response = await apiKeysAPI.list();
keys.value = response.data?.keys ?? [];
error.value = response.ok ? null : response.error;
});
loading.value = false;
}
async function createKey(): Promise<void> {
pending.value = "create";
mutationError.value = null;
createdSecret.value = null;
const response = await apiKeysAPI.create(newKeyName.value.trim() || "HWLAB API key");
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
return;
}
const payload = response.data ?? {};
createdSecret.value = payload.secret ?? payload.key?.displaySecret ?? null;
await refresh();
}
function startRename(key: ApiKeyRecord): void {
editingId.value = key.id;
editingName.value = key.name || "";
}
async function saveRename(key: ApiKeyRecord): Promise<void> {
if (!editingId.value) return;
pending.value = `rename:${key.id}`;
mutationError.value = null;
const response = await apiKeysAPI.rename(key.id, editingName.value.trim() || key.name || key.id);
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
return;
}
editingId.value = null;
editingName.value = "";
await refresh();
}
async function revokeKey(key: ApiKeyRecord): Promise<void> {
if (!window.confirm(`撤销 API key ${key.name || key.id}`)) return;
pending.value = `revoke:${key.id}`;
mutationError.value = null;
const response = await apiKeysAPI.revoke(key.id);
pending.value = null;
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
return;
}
await refresh();
}
function formatDate(value: unknown): string {
const text = String(value ?? "").trim();
if (!text) return "-";
const date = new Date(text);
if (Number.isNaN(date.getTime())) return text;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
</script>
<template>
<section class="route-stack">
<PageHeader eyebrow="User" title="API Keys" description="用户 API key 管理入口;浏览器日常请求仍使用 Web session。" />
<EmptyState v-if="keys.length === 0" title="API Key 页面骨架已就绪" :description="error || '等待 Cloud API /v1/api-keys 接口返回真实数据。'" />
<table v-else class="data-table"><tbody><tr v-for="key in keys" :key="key.id"><td>{{ key.name || key.id }}</td><td>{{ key.prefix }}</td></tr></tbody></table>
<PageHeader eyebrow="User" title="API Keys" description="API key 供 CLI、AgentRun/HWPOD 调用使用;浏览器请求仍使用 HttpOnly Web session。" />
<EmptyState v-if="loading" title="正在加载 API keys" description="正在读取当前用户的 user-billing API key。" />
<EmptyState v-else-if="error" title="API key 接口不可用" :description="error" />
<template v-else>
<section class="data-panel api-key-create-panel">
<header class="panel-header">
<h2>创建 API key</h2>
<small>{{ activeKeys.length }} active / {{ keys.length }} total</small>
</header>
<div class="inline-form-row">
<input v-model="newKeyName" class="input" placeholder="API key 名称" />
<button class="btn btn-primary" type="button" :disabled="pending === 'create'" @click="createKey">{{ pending === "create" ? "创建中" : "创建" }}</button>
</div>
<p v-if="mutationError" class="form-error">{{ mutationError }}</p>
<div v-if="createdSecret" class="secret-once-box">
<strong> key 只显示一次</strong>
<code>{{ createdSecret }}</code>
<button class="table-action" type="button" @click="createdSecret = null">已保存</button>
</div>
</section>
<section class="data-panel usage-panel">
<header class="panel-header">
<h2>当前 API keys</h2>
<small>prefix 和时间来自 PK01 user-billing authority</small>
</header>
<table v-if="keys.length" class="data-table usage-table api-key-table">
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Status</th>
<th>Created</th>
<th>Last used</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="key in keys" :key="key.id">
<td>
<div v-if="editingId === key.id" class="inline-edit-row">
<input v-model="editingName" class="input" />
<button class="table-action" type="button" :disabled="pending === `rename:${key.id}`" @click="saveRename(key)">保存</button>
</div>
<strong v-else>{{ key.name || key.id }}</strong>
<small>{{ key.id }}</small>
</td>
<td><code>{{ key.prefix || "-" }}</code></td>
<td><span class="status-pill" :data-status="key.revokedAt ? 'disabled' : 'active'">{{ key.revokedAt ? "revoked" : "active" }}</span></td>
<td>{{ formatDate(key.createdAt) }}</td>
<td>{{ formatDate(key.lastUsedAt) }}</td>
<td>
<button v-if="!key.revokedAt" class="table-action" type="button" @click="startRename(key)">重命名</button>
<button v-if="!key.revokedAt" class="table-action danger" type="button" :disabled="pending === `revoke:${key.id}`" @click="revokeKey(key)">撤销</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="muted-box">当前用户还没有 API key</p>
</section>
</template>
</section>
</template>