diff --git a/config/hwlab-access-control/nav-profiles.yaml b/config/hwlab-access-control/nav-profiles.yaml new file mode 100644 index 00000000..15ec6180 --- /dev/null +++ b/config/hwlab-access-control/nav-profiles.yaml @@ -0,0 +1,16 @@ +apiVersion: hwlab.dev/v0alpha1 +kind: HwlabNavProfiles +metadata: + name: d601-v03-nav-profiles + verificationIssue: pikasTech/HWLAB#2187 +spec: + profiles: + - id: admin-full + displayName: Admin full access + allowedIds: + - "*" + - id: code-mdtodo-only + displayName: Code and MDTODO only + allowedIds: + - workbench.code + - project.mdtodo diff --git a/config/hwlab-access-control/users.d601-v03.yaml b/config/hwlab-access-control/users.d601-v03.yaml new file mode 100644 index 00000000..f2d96c24 --- /dev/null +++ b/config/hwlab-access-control/users.d601-v03.yaml @@ -0,0 +1,26 @@ +apiVersion: hwlab.dev/v0alpha1 +kind: HwlabAccessControlUsers +metadata: + name: d601-v03-users + verificationIssue: pikasTech/HWLAB#2187 +spec: + users: + - id: usr_v03_admin + username: admin + displayName: HWLAB v0.3 Admin + role: admin + status: active + navProfile: admin-full + passwordHashRef: + env: HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH + secretRef: hwlab-v03-bootstrap-admin/password-hash + - id: usr_v03_huiggao + username: huiggao@163.com + displayName: huiggao@163.com + email: huiggao@163.com + role: user + status: active + navProfile: code-mdtodo-only + passwordHashRef: + env: HWLAB_PRESET_USER_HUIGGAO_PASSWORD_HASH + secretRef: hwlab-v03-preset-users/huiggao-password-hash diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 49a92d39..1cb90363 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -325,6 +325,9 @@ lanes: artifactCatalog: deploy/artifact-catalog.v03.json runtimePath: runtime-v03 imageTagMode: full + accessControl: + usersRef: config/hwlab-access-control/users.d601-v03.yaml#spec.users + navProfilesRef: config/hwlab-access-control/nav-profiles.yaml#spec.profiles workbench: traceTimeline: autoExpandRunning: false diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 44602d98..d42e3cb2 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -37,6 +37,9 @@ const AUTH_METHOD_WEB_SESSION = "web-session"; const AUTH_METHOD_LEGACY_LOCAL_SESSION = "legacy-local-session"; const AUTH_METHOD_USER_BILLING_API_KEY = "user-billing-api-key"; const AUTH_METHOD_USER_BILLING_SESSION = "user-billing-session"; +const ACCESS_NAV_ALL = "*"; +const ACCESS_DEFAULT_ADMIN_PROFILE = "admin-full"; +const ACCESS_DEFAULT_AUTHENTICATED_PROFILE = "authenticated-full"; const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, @@ -238,6 +241,7 @@ class AccessController { actor: publicActor(auth.actor), session: auth.session, authMethod: publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)), + access: auth.access ?? this.accessClaimsForActor(auth.actor), contractVersion: "user-access-v1" }); } @@ -568,6 +572,30 @@ class AccessController { for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user); } + async syncPresetUsers() { + const users = presetAccessUsersFromEnv(this.env); + for (const spec of users) { + const existingById = await this.store.getUserById?.(spec.id) ?? null; + const existing = existingById ?? await this.store.findUserByUsername?.(spec.username) ?? null; + const passwordHash = passwordHashForPresetUser(spec, this.env) || existing?.passwordHash || null; + if (!passwordHash && !existing) continue; + const user = await this.store.createUser({ + id: spec.id, + username: spec.username, + displayName: spec.displayName || spec.username, + role: spec.role === "admin" ? "admin" : "user", + status: spec.status === "disabled" ? "disabled" : "active", + passwordHash, + email: spec.email || null, + now: this.now() + }); + await this.syncUserAdminTuple(user, user); + if (user?.role === "admin") { + for (const toolId of HWLAB_TOOL_IDS) await this.grantAccessTuple({ userId: user.id, relation: "can_use", object: openFgaObject("tool", toolId), admin: user }); + } + } + } + async grantAccessTuple({ userId, relation, object, admin }) { const result = await this.openFga.writeTuple({ userId, relation, object }); if (result.ok !== false) await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() }); @@ -580,28 +608,67 @@ class AccessController { return result; } + withAccessClaims(auth = {}) { + if (!auth?.ok && !auth?.actor) return auth; + const access = this.accessClaimsForActor(auth.actor); + return { ...auth, access }; + } + + accessClaimsForActor(actor) { + const authenticated = Boolean(actor); + const spec = presetAccessUserForActor(actor, this.env); + const profileId = spec?.navProfile || (actor?.role === "admin" ? ACCESS_DEFAULT_ADMIN_PROFILE : ACCESS_DEFAULT_AUTHENTICATED_PROFILE); + const profile = navProfileByIdFromEnv(this.env, profileId); + const allowedIds = uniqueTextList(profile?.allowedIds ?? (actor?.role === "admin" || authenticated ? [ACCESS_NAV_ALL] : [])); + return { nav: { profileId, allowedIds, valuesRedacted: true } }; + } + + navAccessAllowed(actor, navId) { + const id = textOr(navId, ""); + if (!id) return true; + const allowedIds = this.accessClaimsForActor(actor).nav.allowedIds; + return allowedIds.includes(ACCESS_NAV_ALL) || allowedIds.includes(id); + } + + async requireNavAccess(request, response, navId) { + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return null; + } + if (!this.navAccessAllowed(auth.actor, navId)) { + sendJson(response, 403, { + ...errorPayload("nav_access_denied", "Navigation access is denied for this route", 403), + navId: textOr(navId, ""), + access: auth.access ?? this.accessClaimsForActor(auth.actor) + }); + return null; + } + return auth; + } + async authenticate(request, { required = this.required } = {}) { await this.ensureBootstrap(); const bearerToken = bearerTokenFromRequest(request); if (bearerToken && this.userBilling?.configured) { const delegated = await this.authenticateUserBillingToken(bearerToken); - if (delegated.ok || !isApiKeySecret(bearerToken)) return delegated; + if (delegated.ok || !isApiKeySecret(bearerToken)) return this.withAccessClaims(delegated); } const apiKeySecret = apiKeyFromRequest(request); - if (apiKeySecret) return this.authenticateApiKey(apiKeySecret); + if (apiKeySecret) return this.withAccessClaims(await this.authenticateApiKey(apiKeySecret)); const token = sessionCookieFromRequest(request); 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); + return this.withAccessClaims(await 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); } await this.store.touchSession(session.id, this.now()); - return { ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_SESSION }; + return this.withAccessClaims({ ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_SESSION }); } async withAuthOtelDependencySpan(name, otelContext, parentSpanId, attributes, fn, classifyResult = null) { @@ -911,7 +978,7 @@ class AccessController { const { token, session } = await this.issueWebSessionForActor(user); setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env); mark(200, "local_session"); - return sendJson(response, 200, sessionResponseFromAuth({ actor: user, session: publicSession({ ...session, user }), authMethod: AUTH_METHOD_WEB_SESSION })); + return sendJson(response, 200, sessionResponseFromAuth(this.withAccessClaims({ actor: user, session: publicSession({ ...session, user }), authMethod: AUTH_METHOD_WEB_SESSION }))); } catch (error) { httpStatus = Number(error?.statusCode ?? httpStatus ?? 500); errorCode = textOr(error?.code, "auth_login_error"); @@ -1011,13 +1078,13 @@ class AccessController { "db.sql.table": "sessions", "auth.operation": "issue_web_session" }, () => this.issueWebSessionForActor(auth.actor)); - const localAuth = { + const localAuth = this.withAccessClaims({ ok: true, actor: auth.actor, session: publicSession({ ...localSession.session, user: auth.actor }), authMethod: AUTH_METHOD_WEB_SESSION, userBilling: auth.userBilling ? { active: true, principal: auth.userBilling.principal, valuesRedacted: true } : undefined - }; + }); setSessionCookie(response, localSession.token, SESSION_MAX_AGE_SECONDS, request, this.env); return { ok: true, auth: localAuth, token: localSession.token, upstreamTokenPrefix: redactedTokenPrefix(upstreamToken), status: login.status, retryCount: login.retryCount, transientObserved: login.transientObserved, valuesRedacted: true }; } catch (error) { @@ -1092,20 +1159,23 @@ class AccessController { passwordHash, now: this.now() }); + await this.syncPresetUsers(); await this.syncExistingAdminTuples(); await this.ensureBootstrapAdminApiKey(); return; } - if (!passwordHash) return; - await this.store.createUser({ - id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", - username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin", - displayName: this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin", - role: "admin", - status: "active", - passwordHash, - now: this.now() - }); + if (passwordHash) { + await this.store.createUser({ + id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", + username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin", + displayName: this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin", + role: "admin", + status: "active", + passwordHash, + now: this.now() + }); + } + await this.syncPresetUsers(); await this.syncExistingAdminTuples(); await this.ensureBootstrapAdminApiKey(); } @@ -1641,6 +1711,7 @@ function canonicalPrincipalFromAuth(auth = {}) { sessionKind: authenticated ? sessionKindForAuthMethod(authMethod) : null, actor: authenticated ? publicActor(actor) : null, capabilities: capabilitySummaryForPrincipal({ actor, authMethod, userBilling: auth.userBilling }), + access: auth.access ?? defaultAccessClaimsForPrincipal({ actor }), valuesRedacted: true }; } @@ -1679,6 +1750,55 @@ function capabilitySummaryForPrincipal({ actor = null, authMethod = null, userBi admin: admin ? "available" : "forbidden" }; } +function defaultAccessClaimsForPrincipal({ actor = null } = {}) { + const authenticated = Boolean(actor); + return { + nav: { + profileId: actor?.role === "admin" ? ACCESS_DEFAULT_ADMIN_PROFILE : ACCESS_DEFAULT_AUTHENTICATED_PROFILE, + allowedIds: authenticated ? [ACCESS_NAV_ALL] : [], + valuesRedacted: true + } + }; +} +function presetAccessUsersFromEnv(env = process.env) { + return parseJson(textOr(env.HWLAB_ACCESS_CONTROL_USERS_JSON, "[]"), []) + .filter((item) => item && typeof item === "object" && !Array.isArray(item)) + .map((item) => ({ + id: textOr(item.id, ""), + username: textOr(item.username, ""), + displayName: textOr(item.displayName, item.username ?? ""), + email: textOr(item.email, ""), + role: item.role === "admin" ? "admin" : "user", + status: item.status === "disabled" ? "disabled" : "active", + navProfile: textOr(item.navProfile, ""), + passwordHashEnv: textOr(item.passwordHashEnv, "") + })) + .filter((item) => item.id && item.username); +} +function presetAccessUserForActor(actor, env = process.env) { + if (!actor) return null; + const id = textOr(actor.id, ""); + const username = textOr(actor.username, ""); + return presetAccessUsersFromEnv(env).find((item) => (id && item.id === id) || (username && item.username === username)) ?? null; +} +function passwordHashForPresetUser(user, env = process.env) { + const envName = textOr(user?.passwordHashEnv, ""); + return envName ? textOr(env[envName], "") : ""; +} +function navProfilesFromEnv(env = process.env) { + const profiles = parseJson(textOr(env.HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON, "[]"), []); + return Array.isArray(profiles) ? profiles.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : []; +} +function navProfileByIdFromEnv(env = process.env, profileId) { + const id = textOr(profileId, ""); + const found = navProfilesFromEnv(env).find((profile) => textOr(profile.id, "") === id) ?? null; + if (!found) return null; + return { id, allowedIds: uniqueTextList(found.allowedIds) }; +} +function uniqueTextList(value) { + if (!Array.isArray(value)) return []; + return [...new Set(value.map((item) => textOr(item, "")).filter(Boolean))]; +} function userBillingActor(principal = {}) { const id = textOr(principal.userId, ""); const username = textOr(principal.username, "") || localUserBillingUsername(id); diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index b31c067e..84d0b3cf 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -489,6 +489,9 @@ async function handleRpcHttpRequest(request, response, options) { } async function handleRestAdapter(request, response, url, options) { + const navId = navIdForRestPath(url.pathname, request.method || "GET"); + if (navId && typeof options.accessController?.requireNavAccess === "function" && !(await options.accessController.requireNavAccess(request, response, navId))) return; + if (url.pathname === "/v1/web-performance" && request.method === "POST") { await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore, env: options.env }); return; @@ -946,6 +949,28 @@ async function handleRestAdapter(request, response, url, options) { sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } +function navIdForRestPath(pathname, method = "GET") { + const verb = String(method || "GET").toUpperCase(); + if (pathname === "/v1/usage/summary") return "user.usage"; + if (pathname === "/v1/billing/summary" || pathname === "/v1/redeem" || pathname === "/v1/subscription/summary" || pathname === "/v1/payments/summary") return "user.billing"; + if (pathname.startsWith("/v1/admin/billing/")) return "admin.billing"; + if (pathname === "/v1/skills" || pathname.startsWith("/v1/skills/")) return "system.skills"; + if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo"; + if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys"; + if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings"; + if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code"; + if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code"; + if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles"; + if (pathname === "/v1/provider-profiles" && verb === "GET") return "workbench.code"; + if (pathname.startsWith("/v1/admin/access")) return "admin.access"; + if (pathname.startsWith("/v1/admin/users")) return "admin.users"; + if (pathname.startsWith("/v1/admin/")) return "admin.access"; + if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs") return "admin.hwpodGroups"; + if (pathname === "/v1/web-performance/metrics" || pathname === "/v1/web-performance/summary" || pathname === "/v1/live-builds") return "system.performance"; + if (pathname === "/v1/diagnostics/gate") return "system.gate"; + return ""; +} + async function handleHwpodNodeOpsHttp(request, response, options) { if (request.method === "GET") { sendJson(response, 200, { diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 797e9d64..461593fc 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -327,6 +327,94 @@ async function readJsonIfPresent(relativePath, fallback = null) { } } +async function attachAccessControlEnv(deploy) { + const byProfile = {}; + for (const [profile, laneConfig] of Object.entries(deploy?.lanes ?? {})) { + if (!isRuntimeLane(profile)) continue; + const accessControl = laneConfig?.accessControl; + if (!accessControl || typeof accessControl !== "object" || Array.isArray(accessControl)) continue; + const users = await readConfigRef(accessControl.usersRef, `${profile}.accessControl.usersRef`); + const navProfiles = await readConfigRef(accessControl.navProfilesRef, `${profile}.accessControl.navProfilesRef`); + const env = accessControlEnvFromConfig({ users, navProfiles }); + if (Object.keys(env).length > 0) byProfile[profile] = { "hwlab-cloud-api": env }; + } + Object.defineProperty(deploy, "__accessControlEnvByProfile", { value: byProfile, enumerable: false, configurable: true }); +} + +async function readConfigRef(ref, label) { + assert.equal(typeof ref, "string", `${label} must be a file reference`); + const [filePath, fragment = ""] = ref.split("#"); + assert.ok(filePath, `${label} must include a file path`); + const root = await readJson(filePath); + return configFragment(root, fragment || "", label); +} + +function configFragment(value, fragment, label) { + const pathText = String(fragment ?? "").replace(/^\//u, ""); + if (!pathText) return value; + return pathText.split(/[./]/u).filter(Boolean).reduce((current, segment) => { + assert.ok(current && typeof current === "object" && Object.hasOwn(current, segment), `${label} fragment is missing ${segment}`); + return current[segment]; + }, value); +} + +function accessControlEnvFromConfig({ users, navProfiles }) { + assert.ok(Array.isArray(users), "accessControl usersRef must resolve to an array"); + assert.ok(Array.isArray(navProfiles), "accessControl navProfilesRef must resolve to an array"); + const env = {}; + const renderedUsers = users.map((user, index) => normalizeAccessControlUser(user, index, env)); + const renderedProfiles = navProfiles.map(normalizeNavProfile); + env.HWLAB_ACCESS_CONTROL_USERS_JSON = JSON.stringify(renderedUsers); + env.HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON = JSON.stringify(renderedProfiles); + return env; +} + +function normalizeAccessControlUser(user, index, env) { + assert.ok(user && typeof user === "object" && !Array.isArray(user), `accessControl user ${index} must be an object`); + const passwordHashRef = user.passwordHashRef && typeof user.passwordHashRef === "object" && !Array.isArray(user.passwordHashRef) ? user.passwordHashRef : null; + const passwordHashEnv = configString(user.passwordHashEnv) || configString(passwordHashRef?.env); + const secretRef = configString(passwordHashRef?.secretRef) || secretRefFromParts(passwordHashRef); + if (passwordHashEnv && secretRef) env[passwordHashEnv] = `secretRef:${secretRef}`; + return { + id: requiredAccessConfigString(user.id, `accessControl user ${index}.id`), + username: requiredAccessConfigString(user.username, `accessControl user ${index}.username`), + displayName: configString(user.displayName) || configString(user.username), + email: configString(user.email) || null, + role: configString(user.role) === "admin" ? "admin" : "user", + status: configString(user.status) === "disabled" ? "disabled" : "active", + navProfile: requiredAccessConfigString(user.navProfile, `accessControl user ${index}.navProfile`), + passwordHashEnv: passwordHashEnv || null + }; +} + +function normalizeNavProfile(profile, index) { + assert.ok(profile && typeof profile === "object" && !Array.isArray(profile), `accessControl nav profile ${index} must be an object`); + const allowedIds = Array.isArray(profile.allowedIds) ? profile.allowedIds.map(configString).filter(Boolean) : []; + assert.ok(allowedIds.length > 0, `accessControl nav profile ${index}.allowedIds must not be empty`); + return { + id: requiredAccessConfigString(profile.id, `accessControl nav profile ${index}.id`), + displayName: configString(profile.displayName) || configString(profile.id), + allowedIds + }; +} + +function secretRefFromParts(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + const sourceRef = configString(value.sourceRef ?? value.secretName); + const targetKey = configString(value.targetKey ?? value.key); + return sourceRef && targetKey ? `${sourceRef}/${targetKey}` : ""; +} + +function configString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function requiredAccessConfigString(value, label) { + const text = configString(value); + assert.ok(text, `${label} is required`); + return text; +} + async function gitValue(args) { const result = await execFileAsync("git", args, { cwd: repoRoot, timeout: 10000, maxBuffer: 1024 * 1024 }); return result.stdout.trim(); @@ -997,6 +1085,7 @@ function deployServicesForProfile(deploy, profile) { copy.env = mergeEnvMaps( copy.env, serviceDeclarationEnvForProfile(deploy, profile, service.serviceId), + accessControlEnvForProfile(deploy, profile, service.serviceId), runtimeStoreEnvForProfile(deploy, profile, service.serviceId), workbenchRuntimeClientEnvForProfile(deploy, profile, service.serviceId), workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, service.serviceId), @@ -1014,6 +1103,7 @@ function deployServicesForProfile(deploy, profile) { serviceId: override.serviceId, env: mergeEnvMaps( serviceDeclarationEnvForProfile(deploy, profile, override.serviceId), + accessControlEnvForProfile(deploy, profile, override.serviceId), runtimeStoreEnvForProfile(deploy, profile, override.serviceId), workbenchRuntimeClientEnvForProfile(deploy, profile, override.serviceId), workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, override.serviceId), @@ -1031,6 +1121,10 @@ function deployServicesForProfile(deploy, profile) { return services; } +function accessControlEnvForProfile(deploy, profile, serviceId) { + return deploy?.__accessControlEnvByProfile?.[profile]?.[serviceId] ?? {}; +} + function serviceDeclarationEnvForProfile(deploy, profile, serviceId) { if (!isRuntimeLane(profile)) return {}; const env = deploy?.lanes?.[profile]?.serviceDeclarations?.[serviceId]?.env; @@ -5498,6 +5592,7 @@ async function plannedFiles(args) { defaultV02RuntimeEndpoint, defaultSourceRepo }); + await attachAccessControlEnv(deploy); const source = await resolveSourceRevision(args.sourceRevision); source.imageTag = imageTagForSource(args, source); let artifactCatalog = await readJsonIfPresent(args.catalogPath, null); diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index 4787c932..8fd4b405 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -18,16 +18,32 @@ const app = useAppStore(); const workbench = useWorkbenchStore(); const diagnosticsOpen = ref(false); +interface NavItem { + name: string; + label: string; + path: string; + icon: string; + navId: string; +} + +interface NavSection { + title: string; + items: NavItem[]; +} + const navSections = [ - { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D" }] }, - { title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M" }] }, - { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K" }, { name: "Usage", label: "用量", path: "/usage", icon: "U" }, { name: "Billing", label: "充值", path: "/billing", icon: "$" }] }, - { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R" }] }, - { title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G" }, { name: "Performance", label: "性能", path: "/performance", icon: "M" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S" }, { name: "Settings", label: "设置", path: "/settings", icon: "T" }, { name: "Help", label: "帮助", path: "/help", icon: "?" }] } -]; + { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] }, + { title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] }, + { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] }, + { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] }, + { title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G", navId: "system.gate" }, { name: "Performance", label: "性能", path: "/performance", icon: "M", navId: "system.performance" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S", navId: "system.skills" }, { name: "Settings", label: "设置", path: "/settings", icon: "T", navId: "system.settings" }, { name: "Help", label: "帮助", path: "/help", icon: "?", navId: "system.help" }] } +] satisfies NavSection[]; const shellless = computed(() => route.name === "Login" || route.name === "Register" || route.name === "NotFound"); const showWorkbenchDiagnostics = computed(() => route.meta.section === "workbench"); +const visibleNavSections = computed(() => navSections + .map((section) => ({ ...section, items: section.items.filter((item) => auth.canAccessNav(item.navId)) })) + .filter((section) => section.items.length > 0)); watch(showWorkbenchDiagnostics, (visible) => { if (!visible) diagnosticsOpen.value = false; @@ -43,7 +59,7 @@ async function go(path: string): Promise { } } -function isNavItemActive(item: { name: string }): boolean { +function isNavItemActive(item: NavItem): boolean { return route.name === item.name || (item.name === "CodeWorkbench" && route.meta.section === "workbench"); } @@ -62,7 +78,7 @@ function isNavItemActive(item: { name: string }): boolean {