Merge pull request #1190 from pikasTech/feat/issue-1188-r2-web-user-management
feat: 补齐 v03 用户管理 Web parity
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" })
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 token、provider 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user