From 7823bb7a95316feb3842d603fa8eb6486a482143 Mon Sep 17 00:00:00 2001 From: lyon Date: Sun, 14 Jun 2026 11:04:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=20v03=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=20Web=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/access-control.ts | 85 ++++- internal/cloud/server.ts | 33 +- internal/cloud/user-billing-client.ts | 13 + .../cloud/user-billing-integration.test.ts | 77 ++++ internal/dev-entrypoint/cloud-web-routes.mjs | 16 + .../dev-entrypoint/cloud-web-routes.test.mjs | 39 +++ internal/dev-entrypoint/cloud-web-runtime.mjs | 37 +- web/hwlab-cloud-web/src/api/apiKeys.ts | 1 + web/hwlab-cloud-web/src/api/auth.ts | 6 + web/hwlab-cloud-web/src/api/usage.ts | 13 +- .../src/components/layout/AppShell.vue | 2 +- web/hwlab-cloud-web/src/router/index.ts | 1 + web/hwlab-cloud-web/src/stores/auth.ts | 36 +- web/hwlab-cloud-web/src/styles/workbench.css | 90 +++++ web/hwlab-cloud-web/src/types/index.ts | 38 +- web/hwlab-cloud-web/src/views/LoginView.vue | 12 +- .../src/views/RegisterView.vue | 57 +++ .../src/views/admin/UsersView.vue | 331 +++++++++++++----- .../src/views/user/ApiKeysView.vue | 140 +++++++- 19 files changed, 878 insertions(+), 149 deletions(-) create mode 100644 web/hwlab-cloud-web/src/views/RegisterView.vue diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index ef7770d9..0ec834da 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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 }); } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index cfff7325..6693d205 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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 { diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index ad315f76..dffb8343 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -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 }); }, diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index d5228d57..29f94a75 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -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, diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 40439a07..da9fa6ec 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -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; diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 72c9d3ab..c48fc867 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -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, diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 6894c3cf..6e9dc78b 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -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) { diff --git a/web/hwlab-cloud-web/src/api/apiKeys.ts b/web/hwlab-cloud-web/src/api/apiKeys.ts index 682375dd..43d69786 100644 --- a/web/hwlab-cloud-web/src/api/apiKeys.ts +++ b/web/hwlab-cloud-web/src/api/apiKeys.ts @@ -4,5 +4,6 @@ import type { ApiKeyRecord, ApiResult } from "@/types"; export const apiKeysAPI = { list: (): Promise> => fetchJson("/v1/api-keys", { timeoutMs: 12000, timeoutName: "api keys" }), create: (name: string): Promise> => fetchJson("/v1/api-keys", { method: "POST", body: JSON.stringify({ name }), timeoutMs: 12000, timeoutName: "create api key" }), + rename: (id: string, name: string): Promise> => fetchJson(`/v1/api-keys/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ name }), timeoutMs: 12000, timeoutName: "rename api key" }), revoke: (id: string): Promise> => fetchJson(`/v1/api-keys/${encodeURIComponent(id)}`, { method: "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "revoke api key" }) }; diff --git a/web/hwlab-cloud-web/src/api/auth.ts b/web/hwlab-cloud-web/src/api/auth.ts index 7962f7a6..452d5112 100644 --- a/web/hwlab-cloud-web/src/api/auth.ts +++ b/web/hwlab-cloud-web/src/api/auth.ts @@ -12,6 +12,12 @@ export const authAPI = { session: (): Promise> => fetchJson("/auth/session", { timeoutName: "auth session" }), bootstrap: (): Promise> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }), workspaceBootstrap: (projectId: string): Promise> => fetchJson(`/auth/workspace-bootstrap?projectId=${encodeURIComponent(projectId)}`, { timeoutName: "workspace bootstrap", timeoutMs: 8000 }), + register: (payload: { email: string; username: string; displayName?: string; password: string }): Promise> => fetchJson("/auth/register", { + method: "POST", + body: JSON.stringify(payload), + timeoutName: "auth register", + timeoutMs: 12000 + }), login: (username: string, password: string): Promise> => fetchJson("/auth/login", { method: "POST", body: JSON.stringify({ username, password }), diff --git a/web/hwlab-cloud-web/src/api/usage.ts b/web/hwlab-cloud-web/src/api/usage.ts index 76da08c8..8088a1cf 100644 --- a/web/hwlab-cloud-web/src/api/usage.ts +++ b/web/hwlab-cloud-web/src/api/usage.ts @@ -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> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }), adminBillingSummary: (): Promise> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }), + adminUsers: (params: { page?: number; pageSize?: number; search?: string; status?: string; role?: string } = {}): Promise> => { + 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> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}`, { timeoutMs: 12000, timeoutName: "admin user detail" }), + createAdminUser: (body: Record): Promise> => fetchJson("/v1/admin/billing/users", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "create admin user" }), + updateAdminUser: (userId: string, body: Record): Promise> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "update admin user" }), + adjustAdminCredits: (userId: string, body: Record): Promise> => 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> => 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/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index 64706c30..a2428c99 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -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); diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index b3f1e841..82b621cd 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -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" } }, diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index 9d799db4..fe7612ae 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -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("checking"); const session = ref(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 { + 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 { 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 { diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 52215bf5..fac45a52 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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; } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 536ee212..ab74ee58 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -319,7 +319,7 @@ export interface AdminAccessSummaryResponse { summary?: Record; export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown } export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array>; [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; redis?: Record }; + proxy?: Record; + 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; + 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; valuesRedacted?: boolean; diff --git a/web/hwlab-cloud-web/src/views/LoginView.vue b/web/hwlab-cloud-web/src/views/LoginView.vue index 60a89698..9a90ebf1 100644 --- a/web/hwlab-cloud-web/src/views/LoginView.vue +++ b/web/hwlab-cloud-web/src/views/LoginView.vue @@ -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 { + 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 { diff --git a/web/hwlab-cloud-web/src/views/RegisterView.vue b/web/hwlab-cloud-web/src/views/RegisterView.vue new file mode 100644 index 00000000..fb7d4842 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/RegisterView.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 090b43b0..3409f011 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -1,46 +1,158 @@