From f0346b099647f7db4b86464a6b045feba9a99b84 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:29 +0800 Subject: [PATCH 1/9] feat: simplify local administrator login --- .env.docker.example | 4 +- README.md | 9 +- backend/api/v1/identity.py | 59 +++++++- backend/config.py | 7 + backend/security/fleet_auth.py | 43 ++++-- backend/security/identity.py | 16 ++ backend/security/local_auth.py | 88 +++++++++++ docker-compose.yml | 2 +- docs/local-first-auth-PRD.md | 96 ++++++++++++ frontend/app/(app)/settings/page.tsx | 95 ++++++++++++ frontend/app/login/page.tsx | 138 +++++------------- frontend/components/auth/auth-provider.tsx | 62 ++++---- frontend/components/shell/app-header.tsx | 6 +- frontend/e2e/login.spec.mjs | 7 +- frontend/lib/api/endpoints.ts | 19 +++ frontend/lib/navigation.ts | 1 + .../scripts/check-login-theme-regressions.mjs | 8 +- scripts/install.sh | 7 +- tests/integration/test_auth_api.py | 67 +++++++++ tests/unit/security/test_local_auth.py | 17 +++ 20 files changed, 592 insertions(+), 159 deletions(-) create mode 100644 backend/security/local_auth.py create mode 100644 docs/local-first-auth-PRD.md create mode 100644 frontend/app/(app)/settings/page.tsx create mode 100644 tests/unit/security/test_local_auth.py diff --git a/.env.docker.example b/.env.docker.example index 2162f948..9584bbcd 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -7,8 +7,10 @@ FRONTEND_PORT=3010 API_PORT=8031 PUBLIC_URL=http://localhost:8031 -# Required. The installers generate all four values automatically. +# API transport token is required for non-localhost binds and Fleet/Agent/API +# integrations. The installer generates it automatically. API_AUTH_TOKEN= +# Optional legacy emergency/OIDC bootstrap token; local login does not use it. BOOTSTRAP_ADMIN_TOKEN= SECRET_KEY= CREDENTIAL_ENCRYPTION_KEY= diff --git a/README.md b/README.md index b93b5cfc..3c0589ed 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,13 @@ Invoke-WebRequest https://raw.githubusercontent.com/2233admin/opencli-Razormind/ | API 文档 | http://localhost:8031/docs | REST API 与集成调试 | | 内置浏览器 | http://localhost:6080 | 扫码或登录需要账号的平台 | -安装完成后,终端会打印: +安装完成后可以直接使用本地管理员账号登录: -- `BOOTSTRAP_ADMIN_TOKEN`:首次进入管理界面使用; -- `API_AUTH_TOKEN`:Fleet、Agent、API 和 MCP 访问使用。 +- 用户名:`admin` +- 密码:`admin` +- 登录后可在「账户设置」修改密码 -两者同时保存在安装目录的 `.env`。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。 +`API_AUTH_TOKEN` 仅由 Fleet、Agent、API 和 MCP 传输使用,自动保存在安装目录的 `.env`,不需要填入管理界面。不要公开 API 令牌、noVNC 或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。 ## 正常的研究流程 diff --git a/backend/api/v1/identity.py b/backend/api/v1/identity.py index 362ad6fe..613385bc 100644 --- a/backend/api/v1/identity.py +++ b/backend/api/v1/identity.py @@ -1,15 +1,70 @@ -"""Request identity endpoint.""" +"""Local administrator and request identity endpoints.""" from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from backend.config import get_settings from backend.schemas.common import ApiResponse from backend.security.identity import RequestIdentity, get_request_identity +from backend.security.local_auth import ( + hash_password, + issue_local_token, + persist_password_hash, + verify_password, +) + + +class LocalLoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=1, max_length=255) + + +class ChangePasswordRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=255) + new_password: str = Field(min_length=6, max_length=255) + router = APIRouter(prefix="/auth", tags=["auth"]) +@router.post("/login", response_model=ApiResponse[dict]) +async def local_login(body: LocalLoginRequest) -> ApiResponse: + settings = get_settings() + if body.username != settings.local_admin_username or not verify_password( + body.password, settings.local_admin_password_hash + ): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码错误") + + return ApiResponse.ok( + { + "access_token": issue_local_token(settings.local_admin_username, settings.secret_key), + "token_type": "bearer", + "using_default_password": verify_password( + "admin", settings.local_admin_password_hash + ), + } + ) + + +@router.post("/password", response_model=ApiResponse[dict]) +async def change_local_password( + body: ChangePasswordRequest, + identity: Annotated[RequestIdentity, Depends(get_request_identity)], +) -> ApiResponse: + if identity.auth_method != "local": + raise HTTPException(status.HTTP_403_FORBIDDEN, "仅本地管理员可以修改本地密码") + settings = get_settings() + if not verify_password(body.current_password, settings.local_admin_password_hash): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码错误") + if body.new_password == body.current_password: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "新密码不能与当前密码相同") + persist_password_hash(hash_password(body.new_password)) + get_settings.cache_clear() + return ApiResponse.ok({"message": "密码已更新"}) + + @router.get("/me", response_model=ApiResponse[dict]) async def read_identity( identity: Annotated[RequestIdentity, Depends(get_request_identity)], diff --git a/backend/config.py b/backend/config.py index 5b199923..46d835b3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -3,6 +3,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +from backend.security.local_auth import DEFAULT_LOCAL_ADMIN_PASSWORD_HASH + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -59,6 +61,11 @@ class Settings(BaseSettings): # guard only allows on a localhost bind. Env: API_AUTH_TOKEN. api_auth_token: str = "" + # Local-first account used by the NAS/server deployment. The password hash + # is persisted in .env after the user changes the default password. + local_admin_username: str = "admin" + local_admin_password_hash: str = DEFAULT_LOCAL_ADMIN_PASSWORD_HASH + # CLI channel binary allowlist (ADR-0005, audit P0-4). The cli channel is # an arbitrary-binary-execution surface, so it only runs binaries the # operator explicitly listed here. Comma-separated binary paths/names, diff --git a/backend/security/fleet_auth.py b/backend/security/fleet_auth.py index 94642385..df3e463e 100644 --- a/backend/security/fleet_auth.py +++ b/backend/security/fleet_auth.py @@ -67,6 +67,7 @@ from collections.abc import Sequence from urllib.parse import parse_qs +from jose import JWTError, jwt from starlette.datastructures import Headers from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send @@ -77,6 +78,10 @@ #: Path prefixes guarded by :class:`FleetAuthMiddleware`. PROTECTED_PREFIXES = ("/api", "/mcp") +# Local login is intentionally the only unauthenticated API route. Once the +# user has a local bearer session, the identity dependency authenticates it. +PUBLIC_PATHS = frozenset({"/api/v1/auth/login"}) + _LOCALHOST_HOSTS = frozenset({"localhost", "::1"}) @@ -120,6 +125,17 @@ def enforce_bind_guard(host: str, token: str) -> None: "local development." ) +def _is_local_session(credential: str) -> bool: + try: + claims = jwt.decode( + credential, + get_settings().secret_key, + algorithms=["HS256"], + ) + except JWTError: + return False + return claims.get("auth_method") == "local" and claims.get("sub") == "local-admin" + def _token_matches(candidate: str, token: str) -> bool: """Constant-time comparison of a caller-supplied credential against *token*.""" @@ -147,30 +163,34 @@ class FleetAuthMiddleware: /health exemption rationale. """ + def __init__(self, app: ASGIApp) -> None: self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] not in ("http", "websocket") or not scope["path"].startswith( - PROTECTED_PREFIXES + if ( + scope["type"] not in ("http", "websocket") + or not scope["path"].startswith(PROTECTED_PREFIXES) + or scope["path"] in PUBLIC_PATHS ): await self.app(scope, receive, send) return - # Read per request: get_settings() is lru_cached (cheap), but - # api/v1/system.py may cache_clear() it at runtime after a config - # patch, so don't freeze the token at middleware construction time. + # Read per request so a runtime configuration update is respected. token = get_settings().api_auth_token if not token: - # Dev posture: no token configured -> API open. Only reachable on - # a localhost bind thanks to enforce_bind_guard at startup. + # No fleet token configured: local deployments rely on the identity + # dependency and the bind guard limits this posture to localhost. await self.app(scope, receive, send) return if scope["type"] == "websocket": headers = Headers(scope=scope) - credential = _bearer_credential(headers) or _query_token(scope.get("query_string", b"")) - if credential and _token_matches(credential, token): + bearer = _bearer_credential(headers) + credential = bearer or _query_token(scope.get("query_string", b"")) + if _is_local_session(bearer) or ( + credential and _token_matches(credential, token) + ): await self.app(scope, receive, send) return await WebSocketClose(code=4401, reason="Invalid or missing API token")( @@ -179,8 +199,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return headers = Headers(scope=scope) - credential = headers.get("x-api-token", "") or _bearer_credential(headers) - if credential and _token_matches(credential, token): + bearer = _bearer_credential(headers) + credential = headers.get("x-api-token", "") or bearer + if _is_local_session(bearer) or (credential and _token_matches(credential, token)): await self.app(scope, receive, send) return diff --git a/backend/security/identity.py b/backend/security/identity.py index 6b1a0f8f..e7c3fa7c 100644 --- a/backend/security/identity.py +++ b/backend/security/identity.py @@ -19,14 +19,18 @@ class IdentitySettings: audience: str jwks_url: str = "" bootstrap_admin_token: str = "" + secret_key: str = "change-me-in-production" @classmethod def from_env(cls) -> IdentitySettings: + from backend.config import get_settings + return cls( issuer=os.getenv("OIDC_ISSUER", "").rstrip("/"), audience=os.getenv("OIDC_AUDIENCE", ""), jwks_url=os.getenv("OIDC_JWKS_URL", ""), bootstrap_admin_token=os.getenv("BOOTSTRAP_ADMIN_TOKEN", ""), + secret_key=get_settings().secret_key, ) @@ -129,6 +133,18 @@ async def get_request_identity(request: Request) -> RequestIdentity: "Bearer token required", headers={"WWW-Authenticate": "Bearer"}, ) + try: + local_claims = jwt.decode(token, resolved.secret_key, algorithms=["HS256"]) + except JWTError: + local_claims = None + if local_claims and local_claims.get("auth_method") == "local": + return RequestIdentity( + subject="local-admin", + name=local_claims.get("name") or "本地管理员", + username=local_claims.get("username"), + is_platform_admin=True, + auth_method="local", + ) if resolved.bootstrap_admin_token and hmac.compare_digest( token, resolved.bootstrap_admin_token ): diff --git a/backend/security/local_auth.py b/backend/security/local_auth.py new file mode 100644 index 00000000..428449b9 --- /dev/null +++ b/backend/security/local_auth.py @@ -0,0 +1,88 @@ +"""Local-first administrator password and session helpers.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import re +import secrets +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from jose import jwt + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + n, r, p = 16384, 8, 1 + digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=n, r=r, p=p) + encode = base64.urlsafe_b64encode + return f"scrypt${n}${r}${p}${encode(salt).decode()}${encode(digest).decode()}" + +DEFAULT_LOCAL_ADMIN_PASSWORD_HASH = hash_password("admin") + + +def verify_password(password: str, encoded: str) -> bool: + try: + scheme, n, r, p, salt_text, digest_text = encoded.split("$", 5) + if scheme != "scrypt": + return False + salt = base64.urlsafe_b64decode(salt_text.encode("ascii")) + expected = base64.urlsafe_b64decode(digest_text.encode("ascii")) + actual = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=int(n), + r=int(r), + p=int(p), + maxmem=64 * 1024 * 1024, + ) + return hmac.compare_digest(actual, expected) + except (ValueError, TypeError, UnicodeError): + return False + + +def issue_local_token(username: str, secret_key: str) -> str: + now = datetime.now(UTC) + return jwt.encode( + { + "sub": "local-admin", + "name": "本地管理员", + "username": username, + "is_platform_admin": True, + "auth_method": "local", + "iat": now, + "exp": now + timedelta(days=30), + }, + secret_key, + algorithm="HS256", + ) + + +def persist_password_hash(password_hash: str) -> None: + """Persist the local password in the deployment .env and current process.""" + + os.environ["LOCAL_ADMIN_PASSWORD_HASH"] = password_hash + path = os.environ.get("ENV_FILE_PATH") + if not path: + for candidate in (Path("/app/.env"), Path(__file__).resolve().parents[2] / ".env"): + if candidate.exists(): + path = str(candidate) + break + else: + path = str(Path(__file__).resolve().parents[2] / ".env") + + env_path = Path(path) + try: + content = env_path.read_text(encoding="utf-8") + except FileNotFoundError: + content = "" + new_line = f"LOCAL_ADMIN_PASSWORD_HASH={password_hash}" + pattern = r"^LOCAL_ADMIN_PASSWORD_HASH=.*$" + if re.search(pattern, content, re.MULTILINE): + content = re.sub(pattern, new_line, content, flags=re.MULTILINE) + else: + content = content.rstrip("\n") + f"\n{new_line}\n" + env_path.write_text(content, encoding="utf-8") diff --git a/docker-compose.yml b/docker-compose.yml index 84ef3f63..61589023 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -155,7 +155,7 @@ services: DEBUG: ${DEBUG:-false} SECRET_KEY: ${SECRET_KEY:-change-me-in-production} API_AUTH_TOKEN: ${API_AUTH_TOKEN:?Set API_AUTH_TOKEN in .env or run scripts/install.sh} - BOOTSTRAP_ADMIN_TOKEN: ${BOOTSTRAP_ADMIN_TOKEN:?Set BOOTSTRAP_ADMIN_TOKEN in .env or run scripts/install.sh} + BOOTSTRAP_ADMIN_TOKEN: ${BOOTSTRAP_ADMIN_TOKEN:-} OPENCLI_MCP_ALLOWED_HOSTS: ${OPENCLI_MCP_ALLOWED_HOSTS:-} OPENCLI_MCP_ALLOWED_ORIGINS: ${OPENCLI_MCP_ALLOWED_ORIGINS:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} diff --git a/docs/local-first-auth-PRD.md b/docs/local-first-auth-PRD.md new file mode 100644 index 00000000..33c69944 --- /dev/null +++ b/docs/local-first-auth-PRD.md @@ -0,0 +1,96 @@ +# 本地优先登录简化 PRD + +## 1. 背景 + +OpenCLI 是部署在 NAS 或自有服务器上的本地数据采集与编排工具,不是需要组织身份提供商才能使用的 SaaS 平台。 + +现有登录页要求用户填写管理员身份令牌,并可选填写 Fleet API 令牌,同时展示 OIDC、首次部署和紧急恢复概念。这些信息不是本地用户完成首次使用所必需的,直接阻断了“部署后打开即用”的主链路。 + +## 2. 产品目标 + +让本地部署用户完成以下最短路径: + +```text +部署 → 打开页面 → admin/admin 登录 → 配置采集 → 持续运行 +``` + +首次登录后仅通过轻量提示提醒用户修改密码,不要求用户理解令牌、组织登录或 Fleet 网络。 + +## 3. 范围 + +### 本期包含 + +- 本地管理员默认账号:`admin` +- 本地管理员默认密码:`admin` +- 登录页只展示用户名和密码 +- 登录成功后创建本地会话 +- 登录页提示默认密码可在设置中修改 +- 设置页提供修改本地管理员密码的入口 +- 修改密码后持久化到部署的 `.env` +- Fleet API 令牌、OIDC 和组织登录不再出现在本地登录页 +- 保留后端已有 OIDC / Fleet 能力作为后续高级部署能力,但不阻塞本地登录 + +### 本期不包含 + +- 组织成员管理 +- 邀请、找回密码、邮件验证 +- 多因素认证 +- 多租户权限模型改造 +- Fleet 网络配置向导 +- 公网 SaaS 登录体验 + +## 4. 用户流程 + +### 首次登录 + +1. 用户打开 `/login`。 +2. 页面预填用户名 `admin`,密码由用户填写;页面说明首次使用可使用 `admin / admin`。 +3. 登录成功后进入控制台。 +4. 控制台账户菜单或设置页显示“建议修改默认密码”提示。 +5. 用户可以稍后处理,不阻塞采集工具使用。 + +### 修改密码 + +1. 用户打开账户菜单中的“账户设置”。 +2. 输入当前密码、新密码和确认密码。 +3. 保存成功后当前会话继续有效,下次登录使用新密码。 + +## 5. 交互要求 + +- 登录卡片只保留用户名、密码和“登录”按钮。 +- 不出现“首次部署令牌”“管理员身份令牌”“Fleet API 令牌”“OIDC issuer”等部署术语。 +- “本地开发模式”仅在开发环境显示,不作为生产用户入口。 +- 密码修改失败时给出直接错误,不展示后端堆栈或配置细节。 +- 默认密码提示使用普通文案,不使用红色阻断式告警。 + +## 6. API 契约 + +### `POST /api/v1/auth/login` + +请求: + +```json +{"username":"admin","password":"admin"} +``` + +响应:返回 bearer access token、本地管理员身份和 `using_default_password` 标记。 + +### `POST /api/v1/auth/password` + +请求: + +```json +{"current_password":"admin","new_password":"new-password"} +``` + +要求当前请求携带本地管理员 bearer token。成功后持久化密码哈希。 + +## 7. 验收标准 + +- `/login` 不再渲染任何令牌输入框。 +- 使用 `admin / admin` 可在全新本地部署中登录。 +- 错误密码返回明确失败,不创建会话。 +- 修改密码后,旧密码不能登录,新密码可以登录。 +- 登录成功后的 API 请求不要求用户手工填写 Fleet API 令牌。 +- 登录页 E2E 和后端认证测试覆盖以上行为。 +- OIDC / Fleet 后端代码仍可独立工作,但不进入本地登录主流程。 diff --git a/frontend/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx new file mode 100644 index 00000000..991bd2c4 --- /dev/null +++ b/frontend/app/(app)/settings/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' + +import { useAuth } from '@/components/auth/auth-provider' +import { PageContainer } from '@/components/shell/page-container' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' + +export default function SettingsPage() { + const { changePassword } = useAuth() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [saving, setSaving] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (newPassword !== confirmPassword) { + toast.error('两次输入的新密码不一致') + return + } + setSaving(true) + try { + await changePassword(currentPassword, newPassword) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + toast.success('密码已更新') + } catch (error) { + toast.error(error instanceof Error ? error.message : '密码更新失败') + } finally { + setSaving(false) + } + } + + return ( + + + + 修改密码 + 首次使用可以将默认密码 admin 修改为自己的密码。 + + +
+ + + 当前密码 + setCurrentPassword(event.target.value)} + required + /> + + + 新密码 + setNewPassword(event.target.value)} + required + /> + 至少 6 个字符。 + + + 确认新密码 + setConfirmPassword(event.target.value)} + required + /> + + + +
+
+
+
+ ) +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 16447936..576cc3d0 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Droplets, Grid3X3, KeyRound, LoaderCircle, ShieldCheck, SquareTerminal } from 'lucide-react' +import { Droplets, Grid3X3, LoaderCircle, SquareTerminal } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { useRouter, useSearchParams } from 'next/navigation' import { Suspense, useEffect, useState } from 'react' @@ -21,7 +21,6 @@ import { } from '@/components/ui/card' import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' -import { Separator } from '@/components/ui/separator' import RevealText from '@/components/ui/smoothui/reveal-text' import { sanitizeReturnTo } from '@/lib/auth/oidc' @@ -132,17 +131,10 @@ function LoginBackground({ theme, reduceMotion }: { theme: LoginBackdrop; reduce function LoginForm() { const router = useRouter() const searchParams = useSearchParams() - const { - status, - oidcEnabled, - developmentLoginEnabled, - signInWithOidc, - signInWithBootstrap, - enterDevelopmentMode, - } = useAuth() - const [identityToken, setIdentityToken] = useState('') - const [fleetToken, setFleetToken] = useState('') - const [submitting, setSubmitting] = useState<'oidc' | 'bootstrap' | 'development' | null>(null) + const { status, developmentLoginEnabled, signInWithPassword, enterDevelopmentMode } = useAuth() + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('') + const [submitting, setSubmitting] = useState<'password' | 'development' | null>(null) const [reduceMotion, setReduceMotion] = useState(true) const [backdrop, setBackdrop] = useState('liquid') const [headlineWord, setHeadlineWord] = useState(0) @@ -173,27 +165,19 @@ function LoginForm() { return () => window.clearInterval(interval) }, [reduceMotion]) - const optionalFleetToken = fleetToken.trim() || undefined - - async function startOidcLogin() { - setSubmitting('oidc') - try { - await signInWithOidc(returnTo, optionalFleetToken) - } catch (error) { - toast.error(error instanceof Error ? error.message : '无法启动 OIDC 登录') - setSubmitting(null) - } - } - - async function handleBootstrapLogin(event: React.FormEvent) { + async function handlePasswordLogin(event: React.FormEvent) { event.preventDefault() - setSubmitting('bootstrap') + setSubmitting('password') try { - await signInWithBootstrap(identityToken, optionalFleetToken) - toast.success('管理员身份验证成功') + const usingDefaultPassword = await signInWithPassword(username, password) + toast.success( + usingDefaultPassword + ? '登录成功。你可以在账户设置中修改默认密码。' + : '登录成功', + ) router.replace(returnTo) } catch (error) { - toast.error(error instanceof Error ? error.message : '身份验证失败') + toast.error(error instanceof Error ? error.message : '用户名或密码错误') setSubmitting(null) } } @@ -201,7 +185,7 @@ function LoginForm() { function handleDevelopmentLogin() { setSubmitting('development') try { - enterDevelopmentMode(optionalFleetToken) + enterDevelopmentMode() window.setTimeout(() => router.replace(returnTo), reduceMotion ? 0 : 285) } catch (error) { toast.error(error instanceof Error ? error.message : '无法进入本地开发模式') @@ -213,9 +197,7 @@ function LoginForm() { @@ -257,11 +239,7 @@ function LoginForm() {
{Array.from('OPENCLI').map((character, index) => ( - + {character} ))} @@ -329,68 +307,33 @@ function LoginForm() { 登录控制台 - 使用组织账号登录;Bootstrap Admin 仅用于首次部署和紧急恢复。 + 本地部署直接登录。首次使用可使用默认账号 admin / admin。 - - {oidcEnabled ? ( - - ) : ( -
- 当前未配置组织登录。请配置 OIDC issuer、client ID 和授权端点。 -
- )} - -
- - 紧急管理员访问 - -
- -
+ + - 管理员身份令牌 + 用户名 setIdentityToken(event.target.value)} - autoComplete="off" + id="local-username" + autoComplete="username" + value={username} + onChange={(event) => setUsername(event.target.value)} + required /> - 验证成功后仅保存在当前标签页会话中。 - Fleet API 令牌(可选) + 密码 setFleetToken(event.target.value)} - autoComplete="off" + autoComplete="current-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required /> - - 后端启用 Fleet Auth 时填写;留空沿用部署配置或浏览器中已有值。 - + 登录后可以在账户设置中修改密码。 @@ -398,22 +341,21 @@ function LoginForm() { {developmentLoginEnabled ? ( diff --git a/frontend/components/auth/auth-provider.tsx b/frontend/components/auth/auth-provider.tsx index d00ee1b9..74cd01a2 100644 --- a/frontend/components/auth/auth-provider.tsx +++ b/frontend/components/auth/auth-provider.tsx @@ -2,9 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getCurrentIdentity } from '@/lib/api/endpoints' +import { changeLocalPassword, getCurrentIdentity, loginWithPassword } from '@/lib/api/endpoints' import { AUTH_REQUIRED_EVENT } from '@/lib/api/auth-events' -import { setApiAuthToken } from '@/lib/api/auth-token' import { getOidcManager, isOidcConfigured, oidcReturnTo, sanitizeReturnTo } from '@/lib/auth/oidc' import { clearIdentityToken, @@ -22,10 +21,11 @@ type AuthContextValue = { identity: AuthIdentity | null oidcEnabled: boolean developmentLoginEnabled: boolean - signInWithOidc: (returnTo?: string, fleetToken?: string) => Promise + signInWithOidc: (returnTo?: string) => Promise + signInWithPassword: (username: string, password: string) => Promise + changePassword: (currentPassword: string, newPassword: string) => Promise completeOidcSignIn: () => Promise - signInWithBootstrap: (identityToken: string, fleetToken?: string) => Promise - enterDevelopmentMode: (fleetToken?: string) => void + enterDevelopmentMode: () => void signOut: () => Promise } @@ -135,13 +135,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) }, [becomeAnonymous, developmentLoginEnabled]) - const signInWithOidc = useCallback(async (returnTo = '/studio', fleetToken?: string) => { + const signInWithOidc = useCallback(async (returnTo = '/studio') => { const manager = getOidcManager() if (!manager) throw new Error('OIDC 登录尚未配置') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) await manager.signinRedirect({ state: { returnTo: sanitizeReturnTo(returnTo) } }) }, []) + const signInWithPassword = useCallback( + async (username: string, password: string) => { + const result = await loginWithPassword(username, password) + await acceptIdentityToken(result.access_token) + persistBootstrapIdentityToken(result.access_token) + return result.using_default_password + }, + [acceptIdentityToken], + ) + + const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { + await changeLocalPassword(currentPassword, newPassword) + }, []) + const completeOidcSignIn = useCallback(async () => { const manager = getOidcManager() if (!manager) throw new Error('OIDC 登录尚未配置') @@ -151,28 +164,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return oidcReturnTo(user) }, [acceptIdentityToken]) - const signInWithBootstrap = useCallback( - async (identityToken: string, fleetToken?: string) => { - const trimmed = identityToken.trim() - if (!trimmed) throw new Error('请输入管理员身份令牌') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) - await acceptIdentityToken(trimmed) - persistBootstrapIdentityToken(trimmed) - }, - [acceptIdentityToken], - ) - - const enterDevelopmentMode = useCallback( - (fleetToken?: string) => { - if (!developmentLoginEnabled) throw new Error('本地开发模式不可用') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) - clearIdentityToken() - setDevelopmentSession(true) - setIdentity(DEVELOPMENT_IDENTITY) - setStatus('authenticated') - }, - [developmentLoginEnabled], - ) + const enterDevelopmentMode = useCallback(() => { + if (!developmentLoginEnabled) throw new Error('本地开发模式不可用') + clearIdentityToken() + setDevelopmentSession(true) + setIdentity(DEVELOPMENT_IDENTITY) + setStatus('authenticated') + }, [developmentLoginEnabled]) const signOut = useCallback(async () => { const manager = getOidcManager() @@ -193,19 +191,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { oidcEnabled, developmentLoginEnabled, signInWithOidc, + signInWithPassword, + changePassword, completeOidcSignIn, - signInWithBootstrap, enterDevelopmentMode, signOut, }), [ + changePassword, completeOidcSignIn, developmentLoginEnabled, enterDevelopmentMode, identity, oidcEnabled, - signInWithBootstrap, signInWithOidc, + signInWithPassword, signOut, status, ], diff --git a/frontend/components/shell/app-header.tsx b/frontend/components/shell/app-header.tsx index 2f2b5455..cb5a02d5 100644 --- a/frontend/components/shell/app-header.tsx +++ b/frontend/components/shell/app-header.tsx @@ -1,7 +1,7 @@ 'use client' import Link from 'next/link' -import { Bot, LogOut, Search } from 'lucide-react' +import { Bot, LogOut, Search, Settings } from 'lucide-react' import { usePathname, useRouter } from 'next/navigation' import { Fragment } from 'react' @@ -140,6 +140,10 @@ export function AppHeader({ {accountLabel} + router.push('/settings')}> + + 账户设置 + diff --git a/frontend/e2e/login.spec.mjs b/frontend/e2e/login.spec.mjs index f44344b7..90a5da0b 100644 --- a/frontend/e2e/login.spec.mjs +++ b/frontend/e2e/login.spec.mjs @@ -1,7 +1,10 @@ import { expect, test } from '@playwright/test' -test('login page renders its administrator credentials form', async ({ page }) => { +test('login page renders its local administrator credentials form', async ({ page }) => { await page.goto('/login') await expect(page.getByText('登录控制台')).toBeVisible() - await expect(page.getByLabel('管理员身份令牌')).toBeVisible() + await expect(page.getByLabel('用户名')).toHaveValue('admin') + await expect(page.getByLabel('密码')).toBeVisible() + await expect(page.getByLabel('管理员身份令牌')).toHaveCount(0) + await expect(page.getByLabel('Fleet API 令牌(可选)')).toHaveCount(0) }) diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts index cfe9742d..6355f248 100644 --- a/frontend/lib/api/endpoints.ts +++ b/frontend/lib/api/endpoints.ts @@ -91,6 +91,25 @@ export const resetWorkspaceSettings = () => export const getCurrentIdentity = () => apiClient.get>('/auth/me').then((r) => r.data.data) +export const loginWithPassword = (username: string, password: string) => + apiClient + .post< + ApiResponse<{ + access_token: string + token_type: 'bearer' + using_default_password: boolean + }> + >('/auth/login', { username, password }) + .then((r) => r.data.data) + +export const changeLocalPassword = (currentPassword: string, newPassword: string) => + apiClient + .post>('/auth/password', { + current_password: currentPassword, + new_password: newPassword, + }) + .then((r) => r.data.data) + export const listMyWorkspaces = () => apiClient.get>('/workspaces').then((r) => r.data.data) diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 4db59d73..70cd34ec 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -96,4 +96,5 @@ export const ROUTE_LABELS: Record = { '/nodes': '执行资源', '/workers': 'Worker', '/control/actions': '控制与审计', + '/settings': '账户设置', } diff --git a/frontend/scripts/check-login-theme-regressions.mjs b/frontend/scripts/check-login-theme-regressions.mjs index 40eddf7a..793eb2ce 100644 --- a/frontend/scripts/check-login-theme-regressions.mjs +++ b/frontend/scripts/check-login-theme-regressions.mjs @@ -14,13 +14,15 @@ test('login keeps the liquid, terminal, and pixel theme switcher', async () => { assert.match(login, / { +test('login uses local credentials and keeps the reduced-motion fallback', async () => { const login = await read('app/login/page.tsx') - assert.match(login, /signInWithOidc/) - assert.match(login, /signInWithBootstrap/) + assert.match(login, /signInWithPassword/) assert.match(login, /enterDevelopmentMode/) assert.match(login, /prefers-reduced-motion: reduce/) + assert.doesNotMatch(login, /signInWithBootstrap/) + assert.doesNotMatch(login, /管理员身份令牌/) + assert.doesNotMatch(login, /Fleet API 令牌/) }) test('auth defaults return to the project list instead of a contextless workflow', async () => { diff --git a/scripts/install.sh b/scripts/install.sh index a79c9a36..5a308589 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -61,14 +61,12 @@ replace_env() { } api_token="$(random_hex 32)" -bootstrap_token="$(random_hex 32)" credential_encryption_key="$(random_fernet)" if [ -z "$credential_encryption_key" ]; then echo "Failed to generate CREDENTIAL_ENCRYPTION_KEY" >&2 exit 1 fi replace_env API_AUTH_TOKEN "$api_token" -replace_env BOOTSTRAP_ADMIN_TOKEN "$bootstrap_token" replace_env SECRET_KEY "$(random_hex 32)" replace_env CREDENTIAL_ENCRYPTION_KEY "$credential_encryption_key" chmod 600 "$INSTALL_DIR/.env" @@ -91,6 +89,5 @@ done printf '\nOpenCLI Admin %s is ready.\n' "$VERSION" printf 'URL: http://localhost:%s\n' "${FRONTEND_PORT:-3010}" -printf 'BOOTSTRAP_ADMIN_TOKEN: %s\n' "$bootstrap_token" -printf 'API_AUTH_TOKEN: %s\n' "$api_token" -printf 'Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in %s/.env\n' "$INSTALL_DIR" +printf 'Local login: admin / admin (change it later in Account Settings)\n' +printf 'API_AUTH_TOKEN is generated for Fleet/Agent/API transport and stored in %s/.env\n' "$INSTALL_DIR" diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 226fc3e6..d6a62f73 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -6,6 +6,8 @@ change is visible immediately and undone automatically after each test. """ +import secrets + import pytest from backend.config import get_settings @@ -15,6 +17,7 @@ get_request_identity, identity_dependency, ) +from backend.security.local_auth import DEFAULT_LOCAL_ADMIN_PASSWORD_HASH TOKEN = "fleet-test-token" @@ -138,3 +141,67 @@ async def test_health_exempt_and_leaks_nothing(client, auth_enabled): response = await client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} + + +@pytest.mark.asyncio +async def test_local_admin_login_uses_simple_credentials(client, auth_enabled): + response = await client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + assert response.status_code == 200 + payload = response.json()["data"] + assert payload["token_type"] == "bearer" + assert payload["using_default_password"] is True + + identity = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {payload['access_token']}"}, + ) + assert identity.status_code == 200 + assert identity.json()["data"]["auth_method"] == "local" + + +@pytest.mark.asyncio +async def test_local_admin_login_rejects_wrong_password(client, auth_disabled): + wrong_password = f"wrong-{secrets.token_hex(8)}" + response = await client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": wrong_password}, + ) + assert response.status_code == 401 + + + +@pytest.mark.asyncio +async def test_local_admin_can_change_password(client, auth_disabled, monkeypatch, tmp_path): + new_password = f"local-{secrets.token_hex(8)}" + monkeypatch.setenv("ENV_FILE_PATH", str(tmp_path / ".env")) + monkeypatch.setenv("LOCAL_ADMIN_PASSWORD_HASH", DEFAULT_LOCAL_ADMIN_PASSWORD_HASH) + get_settings.cache_clear() + try: + login = await client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + token = login.json()["data"]["access_token"] + changed = await client.post( + "/api/v1/auth/password", + headers={"Authorization": f"Bearer {token}"}, + json={"current_password": "admin", "new_password": new_password}, + ) + assert changed.status_code == 200 + assert ( + await client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + ).status_code == 401 + assert ( + await client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": new_password}, + ) + ).status_code == 200 + finally: + get_settings.cache_clear() diff --git a/tests/unit/security/test_local_auth.py b/tests/unit/security/test_local_auth.py new file mode 100644 index 00000000..f7ec4532 --- /dev/null +++ b/tests/unit/security/test_local_auth.py @@ -0,0 +1,17 @@ +from backend.security.local_auth import ( + DEFAULT_LOCAL_ADMIN_PASSWORD_HASH, + hash_password, + verify_password, +) + + +def test_default_password_hash_accepts_admin_only(): + assert verify_password("admin", DEFAULT_LOCAL_ADMIN_PASSWORD_HASH) + assert not verify_password("wrong", DEFAULT_LOCAL_ADMIN_PASSWORD_HASH) + + +def test_password_hash_round_trip(): + encoded = hash_password("new-local-password") + assert encoded.startswith("scrypt$") + assert verify_password("new-local-password", encoded) + assert not verify_password("admin", encoded) From 3b60ae5f2e4ced2a8c20b46f4de4f9b9fefd8052 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:51:06 +0800 Subject: [PATCH 2/9] feat: unify automation and agent workspace --- frontend/app/(app)/dashboard/page.tsx | 2 +- frontend/app/(app)/schedules/page.tsx | 74 +----------------------- frontend/components/shell/app-header.tsx | 12 ++-- frontend/components/shell/route-tabs.tsx | 2 +- frontend/lib/navigation.ts | 10 ++-- 5 files changed, 15 insertions(+), 85 deletions(-) diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx index f8a71fa6..782176a5 100644 --- a/frontend/app/(app)/dashboard/page.tsx +++ b/frontend/app/(app)/dashboard/page.tsx @@ -639,7 +639,7 @@ export default function DashboardPage() {
- +
diff --git a/frontend/app/(app)/schedules/page.tsx b/frontend/app/(app)/schedules/page.tsx index e039394c..ccc3dc7c 100644 --- a/frontend/app/(app)/schedules/page.tsx +++ b/frontend/app/(app)/schedules/page.tsx @@ -1,75 +1,5 @@ -'use client' - -import { useSchedules } from '@/lib/api/hooks' -import { formatDateTime, formatRelative } from '@/lib/format' -import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states' -import { PageContainer } from '@/components/shell/page-container' -import { AUTOMATION_TABS, RouteTabs } from '@/components/shell/route-tabs' -import { StatusBadge } from '@/components/shell/status-badge' -import { Badge } from '@/components/ui/badge' -import { Card } from '@/components/ui/card' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' +import { redirect } from 'next/navigation' export default function SchedulesPage() { - const { data, isLoading, isError, error } = useSchedules() - const schedules = data?.data ?? [] - - return ( - } - > - {isLoading ? ( - - ) : isError ? ( - - ) : schedules.length === 0 ? ( - - ) : ( - - - - - 名称 - Cron 表达式 - 类型 - 状态 - 上次运行 - 下次运行 - - - - {schedules.map((s) => ( - - {s.name} - - - {s.cron_expression} - - - - {s.is_one_time ? '一次性' : '周期'} - - - - - {formatRelative(s.last_run_at)} - {formatDateTime(s.next_run_at)} - - ))} - -
-
- )} -
- ) + redirect('/operations-agents') } diff --git a/frontend/components/shell/app-header.tsx b/frontend/components/shell/app-header.tsx index cb5a02d5..2265d537 100644 --- a/frontend/components/shell/app-header.tsx +++ b/frontend/components/shell/app-header.tsx @@ -39,10 +39,8 @@ function resolveLabels(pathname: string): string[] { return match ? [ROUTE_LABELS[match]] : [] } export function AppHeader({ - onOpenAgent, onOpenCommand, }: { - onOpenAgent?: () => void onOpenCommand?: () => void }) { const pathname = usePathname() @@ -91,17 +89,19 @@ export function AppHeader({ variant="outline" size="sm" className="hidden gap-2 sm:flex" - onClick={onOpenAgent} + nativeButton={false} + render={} > - Agent + 自动化与智能体 diff --git a/frontend/components/shell/route-tabs.tsx b/frontend/components/shell/route-tabs.tsx index 3d274242..2ca33ef9 100644 --- a/frontend/components/shell/route-tabs.tsx +++ b/frontend/components/shell/route-tabs.tsx @@ -62,7 +62,7 @@ export const ACTION_CENTER_TABS: RouteTab[] = [ ] export const AUTOMATION_TABS: RouteTab[] = [ - { href: '/schedules', label: '调度' }, + { href: '/operations-agents', label: '自动化与智能体' }, { href: '/agents', label: 'Agent' }, { href: '/skills', label: '技能' }, ] diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 70cd34ec..5f4e0d18 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -45,10 +45,10 @@ export const NAV_GROUPS: NavGroup[] = [ { href: '/studio', label: '项目', icon: PanelsTopLeft, match: ['/studio', '/canvas'] }, { href: '/plugins', label: '插件中心', icon: Blocks }, { - href: '/schedules', - label: '自动化与 Agent', + href: '/operations-agents', + label: '自动化与智能体', icon: Workflow, - match: ['/schedules', '/agents', '/skills'], + match: ['/operations-agents', '/schedules'], }, ], }, @@ -86,14 +86,14 @@ export const ROUTE_LABELS: Record = { '/canvas': '节点工作流(兼容入口)', '/plugins': '插件中心', '/sources': '数据(兼容入口)', - '/schedules': '触发与调度', + '/schedules': '自动化与智能体', '/tasks': '工作项', '/records': '成果与数据', '/notifications': '通知', '/agents': '智能体', + '/operations-agents': '自动化与智能体', '/skills': '技能', '/providers': '模型与连接', - '/nodes': '执行资源', '/workers': 'Worker', '/control/actions': '控制与审计', '/settings': '账户设置', From b55c8b7d840f2353dca5bfe5b3f19fb3d1b2d408 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:57:53 +0800 Subject: [PATCH 3/9] fix: provision local admin workspace access --- backend/api/v1/workspaces.py | 48 ++++++++++++++++++- frontend/app/(app)/operations-agents/page.tsx | 4 +- tests/integration/test_local_workspace_api.py | 23 +++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 tests/integration/test_local_workspace_api.py diff --git a/backend/api/v1/workspaces.py b/backend/api/v1/workspaces.py index 17969488..79a0bded 100644 --- a/backend/api/v1/workspaces.py +++ b/backend/api/v1/workspaces.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.database import get_db -from backend.models.identity import User, Workspace, WorkspaceMembership, WorkspaceRole +from backend.models.identity import Team, User, Workspace, WorkspaceMembership, WorkspaceRole from backend.models.workflow import Project from backend.schemas.common import ApiResponse from backend.schemas.workflow_asset import ProjectRead @@ -54,6 +54,51 @@ async def _get_or_create_user( raise HTTPException(status.HTTP_409_CONFLICT, "Disabled user cannot join a Workspace") return user +async def _ensure_local_admin_workspace( + db: AsyncSession, + identity: RequestIdentity, +) -> None: + if identity.auth_method != "local": + return + + user = await db.scalar(select(User).where(User.subject == identity.subject)) + if user is None: + user = User( + subject=identity.subject, + display_name=identity.name or "本地管理员", + ) + db.add(user) + await db.flush() + + workspace = await db.scalar(select(Workspace).where(Workspace.slug == "opencli-default")) + if workspace is None: + workspace = Workspace(name="OpenCLI 工作区", slug="opencli-default") + db.add(workspace) + await db.flush() + + membership = await db.scalar( + select(WorkspaceMembership) + .where(WorkspaceMembership.workspace_id == workspace.id) + .where(WorkspaceMembership.user_id == user.id) + ) + if membership is None: + db.add( + WorkspaceMembership( + workspace_id=workspace.id, + user_id=user.id, + role=WorkspaceRole.ADMIN, + ) + ) + + team = await db.scalar( + select(Team) + .where(Team.workspace_id == workspace.id) + .where(Team.slug == "default") + ) + if team is None: + db.add(Team(workspace_id=workspace.id, name="默认团队", slug="default")) + await db.flush() + @router.get( "/governance/workspaces", @@ -63,6 +108,7 @@ async def list_accessible_workspaces( identity: RequestIdentity = Depends(get_request_identity), db: AsyncSession = Depends(get_db), ) -> ApiResponse: + await _ensure_local_admin_workspace(db, identity) rows = ( ( await db.execute( diff --git a/frontend/app/(app)/operations-agents/page.tsx b/frontend/app/(app)/operations-agents/page.tsx index 2476c970..a359cfbf 100644 --- a/frontend/app/(app)/operations-agents/page.tsx +++ b/frontend/app/(app)/operations-agents/page.tsx @@ -6,7 +6,7 @@ import { ArrowUp, Bell, Bot, CalendarClock, ChevronDown, CircleDot, Cloud, Code2 import { toast } from 'sonner' import AgentAvatar from '@/components/smoothui/agent-avatar' -import { useAutomations, useCreateAutomation, useMyWorkspaces, useOperationsAgentActivity, useOperationsAgentDraft, useOperationsAgents, useOperationsAgentVersions, usePatchAutomation, usePublishOperationsAgentVersion, useStartOperationsAgentRun, useUpdateOperationsAgentDraft } from '@/lib/api/hooks' +import { useAutomations, useCreateAutomation, useGovernedWorkspaces, useOperationsAgentActivity, useOperationsAgentDraft, useOperationsAgents, useOperationsAgentVersions, usePatchAutomation, usePublishOperationsAgentVersion, useStartOperationsAgentRun, useUpdateOperationsAgentDraft } from '@/lib/api/hooks' import type { Automation, OperationsAgent, OperationsAgentMode } from '@/lib/api/types' import { cn } from '@/lib/utils' import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states' @@ -175,7 +175,7 @@ function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: Op } export default function OperationsAgentsPage() { - const workspaces = useMyWorkspaces() + const workspaces = useGovernedWorkspaces() const [workspaceId, setWorkspaceId] = useState(null) const [view, setView] = useState<'automations' | 'agents'>('automations') const automations = useAutomations(workspaceId) diff --git a/tests/integration/test_local_workspace_api.py b/tests/integration/test_local_workspace_api.py new file mode 100644 index 00000000..cef51c81 --- /dev/null +++ b/tests/integration/test_local_workspace_api.py @@ -0,0 +1,23 @@ +import pytest + + +@pytest.mark.asyncio +async def test_local_admin_gets_default_workspace_membership(client): + login = await client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + token = login.json()["data"]["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + workspaces = await client.get("/api/v1/governance/workspaces", headers=headers) + assert workspaces.status_code == 200 + workspace = workspaces.json()["data"][0] + assert workspace["slug"] == "opencli-default" + + automations = await client.get( + f"/api/v1/workspaces/{workspace['id']}/automations", + headers=headers, + ) + assert automations.status_code == 200 + assert automations.json()["data"] == [] From e6c500d006d541df69ae188d41ee0c68d07b3772 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:13:55 +0800 Subject: [PATCH 4/9] feat: add system settings and floating agent window --- frontend/app/(app)/system/page.tsx | 87 +++++++++++++++++++ .../components/shell/global-agent-dock.tsx | 35 ++++---- frontend/lib/api/hooks.ts | 13 +++ frontend/lib/navigation.ts | 7 ++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 frontend/app/(app)/system/page.tsx diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx new file mode 100644 index 00000000..2fc0c80e --- /dev/null +++ b/frontend/app/(app)/system/page.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' +import { BACKEND_HINT, ErrorState, LoadingState } from '@/components/shell/data-states' +import { PageContainer } from '@/components/shell/page-container' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +export default function SystemSettingsPage() { + const config = useSystemConfig() + const updateConfig = useUpdateSystemConfig() + const [collectionMode, setCollectionMode] = useState<'local' | 'agent'>('local') + + useEffect(() => { + if (config.data) setCollectionMode(config.data.collection_mode) + }, [config.data]) + + async function saveCollectionMode() { + try { + await updateConfig.mutateAsync({ collection_mode: collectionMode }) + toast.success('系统设置已保存') + } catch (error) { + toast.error(error instanceof Error ? error.message : '系统设置保存失败') + } + } + + if (config.isLoading) return + if (config.isError || !config.data) { + return + } + + return ( + +
+ + + 执行模式 + 决定采集任务由当前服务执行,还是交给远程 Agent。 + + + + + + + + + + 当前运行环境 + 当前部署读取到的基础运行信息。 + + +
+ 任务执行器 + {config.data.task_executor} +
+
+ 镜像版本 + {config.data.image_tag} +
+

+ 模型连接、浏览器节点、自动化与账户密码分别在对应设置页管理。 +

+
+
+
+
+ ) +} diff --git a/frontend/components/shell/global-agent-dock.tsx b/frontend/components/shell/global-agent-dock.tsx index 148599db..d4d777cc 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -8,12 +8,12 @@ import { FormEvent, KeyboardEvent, useState } from 'react' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet' + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Textarea } from '@/components/ui/textarea' import { apiClient } from '@/lib/api/client' import type { ApiResponse } from '@/lib/api/types' @@ -136,18 +136,21 @@ export function GlobalAgentDock({ } return ( - - - - + + + + 全局 Agent - - + + 当前上下文:{ROUTE_LABELS[pathname] ?? pathname}。读取可直接执行,写入操作先生成确认提案。 未明确指定 Workspace 时,仅在后端能解析出唯一授权范围时允许确认写操作。 - - + +
@@ -238,7 +241,7 @@ export function GlobalAgentDock({
-
-
+ + ) } diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts index e69bed3a..a26dbd1b 100644 --- a/frontend/lib/api/hooks.ts +++ b/frontend/lib/api/hooks.ts @@ -18,6 +18,19 @@ export function useMyWorkspaces() { return useQuery({ queryKey: ['workspaces'], queryFn: api.listMyWorkspaces }) } +export function useSystemConfig() { + return useQuery({ queryKey: ['system-config'], queryFn: api.getSystemConfig }) +} + +export function useUpdateSystemConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: Parameters[0]) => + api.updateSystemConfig(data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-config'] }), + }) +} + export function useWorkspaceProjects(workspaceId: string | null) { return useQuery({ queryKey: ['workspace-projects', workspaceId], diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 5f4e0d18..14d82625 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -4,6 +4,7 @@ import { Database, LayoutDashboard, PanelsTopLeft, + Settings2, ShieldCheck, Workflow, type LucideIcon, @@ -73,6 +74,11 @@ export const NAV_GROUPS: NavGroup[] = [ icon: ShieldCheck, match: ['/providers', '/control/actions'], }, + { + href: '/system', + label: '系统设置', + icon: Settings2, + }, ], }, ] @@ -92,6 +98,7 @@ export const ROUTE_LABELS: Record = { '/notifications': '通知', '/agents': '智能体', '/operations-agents': '自动化与智能体', + '/system': '系统设置', '/skills': '技能', '/providers': '模型与连接', '/workers': 'Worker', From a8cdb1680363af5e7cbdab72d908d1e3b3808f91 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:37:39 +0800 Subject: [PATCH 5/9] refactor: consolidate account settings into system settings --- frontend/app/(app)/settings/page.tsx | 94 +----------------------- frontend/app/(app)/system/page.tsx | 86 +++++++++++++++++++++- frontend/components/shell/app-header.tsx | 4 +- frontend/lib/navigation.ts | 2 +- 4 files changed, 88 insertions(+), 98 deletions(-) diff --git a/frontend/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx index 991bd2c4..71dc5cc8 100644 --- a/frontend/app/(app)/settings/page.tsx +++ b/frontend/app/(app)/settings/page.tsx @@ -1,95 +1,5 @@ -'use client' - -import { useState } from 'react' -import { toast } from 'sonner' - -import { useAuth } from '@/components/auth/auth-provider' -import { PageContainer } from '@/components/shell/page-container' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' +import { redirect } from 'next/navigation' export default function SettingsPage() { - const { changePassword } = useAuth() - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [saving, setSaving] = useState(false) - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault() - if (newPassword !== confirmPassword) { - toast.error('两次输入的新密码不一致') - return - } - setSaving(true) - try { - await changePassword(currentPassword, newPassword) - setCurrentPassword('') - setNewPassword('') - setConfirmPassword('') - toast.success('密码已更新') - } catch (error) { - toast.error(error instanceof Error ? error.message : '密码更新失败') - } finally { - setSaving(false) - } - } - - return ( - - - - 修改密码 - 首次使用可以将默认密码 admin 修改为自己的密码。 - - -
- - - 当前密码 - setCurrentPassword(event.target.value)} - required - /> - - - 新密码 - setNewPassword(event.target.value)} - required - /> - 至少 6 个字符。 - - - 确认新密码 - setConfirmPassword(event.target.value)} - required - /> - - - -
-
-
-
- ) + redirect('/system') } diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index 2fc0c80e..e7420727 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -3,16 +3,24 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' +import { useAuth } from '@/components/auth/auth-provider' import { BACKEND_HINT, ErrorState, LoadingState } from '@/components/shell/data-states' import { PageContainer } from '@/components/shell/page-container' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' export default function SystemSettingsPage() { const config = useSystemConfig() const updateConfig = useUpdateSystemConfig() + const { changePassword } = useAuth() const [collectionMode, setCollectionMode] = useState<'local' | 'agent'>('local') + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [savingPassword, setSavingPassword] = useState(false) useEffect(() => { if (config.data) setCollectionMode(config.data.collection_mode) @@ -27,6 +35,26 @@ export default function SystemSettingsPage() { } } + async function savePassword(event: React.FormEvent) { + event.preventDefault() + if (newPassword !== confirmPassword) { + toast.error('两次输入的新密码不一致') + return + } + setSavingPassword(true) + try { + await changePassword(currentPassword, newPassword) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + toast.success('密码已更新') + } catch (error) { + toast.error(error instanceof Error ? error.message : '密码更新失败') + } finally { + setSavingPassword(false) + } + } + if (config.isLoading) return if (config.isError || !config.data) { return @@ -36,7 +64,7 @@ export default function SystemSettingsPage() {
@@ -77,10 +105,62 @@ export default function SystemSettingsPage() { {config.data.image_tag}

- 模型连接、浏览器节点、自动化与账户密码分别在对应设置页管理。 + 模型连接、浏览器节点和自动化分别在对应功能页管理。

+ + + + 管理员账户 + 在这里修改本地管理员密码,不再单独跳转到账户设置页面。 + + +
+ + + 当前密码 + setCurrentPassword(event.target.value)} + required + /> + + + 新密码 + setNewPassword(event.target.value)} + required + /> + 至少 6 个字符。 + + + 确认新密码 + setConfirmPassword(event.target.value)} + required + /> + + + +
+
+
) diff --git a/frontend/components/shell/app-header.tsx b/frontend/components/shell/app-header.tsx index 2265d537..04db630d 100644 --- a/frontend/components/shell/app-header.tsx +++ b/frontend/components/shell/app-header.tsx @@ -140,9 +140,9 @@ export function AppHeader({ {accountLabel} - router.push('/settings')}> + router.push('/system')}> - 账户设置 + 系统设置 diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 14d82625..c54a9ef9 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -103,5 +103,5 @@ export const ROUTE_LABELS: Record = { '/providers': '模型与连接', '/workers': 'Worker', '/control/actions': '控制与审计', - '/settings': '账户设置', + '/settings': '系统设置', } From 6156b9d601407a715da60a2230086c125d7d73af Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:50:33 +0800 Subject: [PATCH 6/9] feat: expand system settings center --- backend/api/v1/system.py | 103 ++++++---- frontend/app/(app)/system/page.tsx | 197 +++++++++++--------- frontend/lib/api/endpoints.ts | 5 +- frontend/lib/api/types.ts | 21 +++ tests/integration/test_system_config_api.py | 38 ++++ 5 files changed, 241 insertions(+), 123 deletions(-) create mode 100644 tests/integration/test_system_config_api.py diff --git a/backend/api/v1/system.py b/backend/api/v1/system.py index 06d0d0f4..2ef79885 100644 --- a/backend/api/v1/system.py +++ b/backend/api/v1/system.py @@ -1,16 +1,20 @@ -"""System configuration endpoint.""" +"""System-level configuration and deployment status endpoints.""" + +from __future__ import annotations import os import re +from typing import Literal -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter +from pydantic import BaseModel, Field from backend.config import get_settings from backend.schemas.common import ApiResponse router = APIRouter(prefix="/system", tags=["system"]) + def _resolve_env_path() -> str: if explicit := os.environ.get("ENV_FILE_PATH"): return explicit @@ -20,15 +24,14 @@ def _resolve_env_path() -> str: ]: if os.path.exists(candidate): return candidate - # Fallback: project root .env (will be created if missing) return os.path.join(os.path.dirname(__file__), "..", "..", "..", ".env") def _update_env_file(key: str, value: str) -> None: path = _resolve_env_path() try: - with open(path) as f: - content = f.read() + with open(path, encoding="utf-8") as env_file: + content = env_file.read() except FileNotFoundError: content = "" new_line = f"{key}={value}" @@ -37,44 +40,72 @@ def _update_env_file(key: str, value: str) -> None: content = re.sub(pattern, new_line, content, flags=re.MULTILINE) else: content = content.rstrip("\n") + f"\n{new_line}\n" - with open(path, "w") as f: - f.write(content) + with open(path, "w", encoding="utf-8") as env_file: + env_file.write(content) class ConfigPatch(BaseModel): - collection_mode: str | None = None + collection_mode: Literal["local", "agent"] | None = None + collection_orchestrator: Literal["admin", "iii"] | None = None + local_max_concurrent_pipelines: int | None = Field(default=None, ge=1, le=64) + opencli_timeout: int | None = Field(default=None, ge=1, le=3600) + default_timezone: str | None = Field(default=None, min_length=1, max_length=64) + public_url: str | None = Field(default=None, max_length=2048) + fleet_network_provider: Literal["lan", "netbird", "wireguard", "ssh", "custom"] | None = None + netbird_mode: Literal["off", "host", "docker"] | None = None + opencli_cdp_endpoint: str | None = Field(default=None, min_length=1, max_length=2048) + agent_pool_endpoints: str | None = Field(default=None, max_length=8192) + llm_request_timeout_seconds: int | None = Field(default=None, ge=1, le=3600) + llm_max_concurrency: int | None = Field(default=None, ge=1, le=64) + control_mode: Literal["advisory", "automatic"] | None = None + control_kill_switch: bool | None = None + + +def _system_payload() -> dict: + settings = get_settings() + return { + "app_name": settings.app_name, + "app_env": settings.app_env, + "debug": settings.debug, + "collection_mode": settings.collection_mode, + "collection_orchestrator": settings.collection_orchestrator, + "task_executor": settings.task_executor, + "local_max_concurrent_pipelines": settings.local_max_concurrent_pipelines, + "opencli_timeout": settings.opencli_timeout, + "default_timezone": settings.default_timezone, + "public_url": settings.public_url, + "fleet_network_provider": settings.fleet_network_provider, + "netbird_mode": settings.netbird_mode, + "opencli_cdp_endpoint": settings.opencli_cdp_endpoint, + "agent_pool_endpoints": settings.cdp_endpoints, + "llm_request_timeout_seconds": settings.llm_request_timeout_seconds, + "llm_max_concurrency": settings.llm_max_concurrency, + "control_mode": settings.control_mode, + "control_kill_switch": settings.control_kill_switch, + "image_tag": settings.image_tag, + "database_kind": "sqlite" if settings.is_sqlite else "postgresql", + "api_auth_configured": bool(settings.api_auth_token), + "oidc_configured": bool(os.getenv("OIDC_ISSUER") and os.getenv("OIDC_AUDIENCE")), + "smtp_configured": bool(settings.smtp_host and settings.smtp_from), + "credential_encryption_configured": bool(settings.credential_encryption_key), + } @router.get("/config", response_model=ApiResponse[dict]) async def get_config() -> ApiResponse: - s = get_settings() - return ApiResponse.ok( - { - "collection_mode": s.collection_mode, - "task_executor": s.task_executor, - "image_tag": s.image_tag, - } - ) + return ApiResponse.ok(_system_payload()) @router.patch("/config", response_model=ApiResponse[dict]) async def update_config(body: ConfigPatch) -> ApiResponse: - if body.collection_mode is not None: - if body.collection_mode not in ("local", "agent"): - raise HTTPException( - status_code=400, detail="collection_mode must be 'local' or 'agent'" - ) - _update_env_file("COLLECTION_MODE", body.collection_mode) - # Also update the process env var so pydantic-settings picks up the new value - # (env vars take priority over .env file in pydantic-settings v2) - os.environ["COLLECTION_MODE"] = body.collection_mode - get_settings.cache_clear() - - s = get_settings() - return ApiResponse.ok( - { - "collection_mode": s.collection_mode, - "task_executor": s.task_executor, - "image_tag": s.image_tag, - } - ) + updates = body.model_dump(exclude_none=True) + if not updates: + return ApiResponse.ok(_system_payload()) + + for key, value in updates.items(): + env_key = key.upper() + env_value = str(value).lower() if isinstance(value, bool) else str(value) + _update_env_file(env_key, env_value) + os.environ[env_key] = env_value + get_settings.cache_clear() + return ApiResponse.ok(_system_payload()) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index e7420727..5af9c536 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -6,29 +6,77 @@ import { toast } from 'sonner' import { useAuth } from '@/components/auth/auth-provider' import { BACKEND_HINT, ErrorState, LoadingState } from '@/components/shell/data-states' import { PageContainer } from '@/components/shell/page-container' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' +import type { SystemConfig } from '@/lib/api/types' + +function configuredLabel(value: boolean) { + return value ? '已配置' : '未配置' +} + +function StatusBadge({ configured }: { configured: boolean }) { + return {configuredLabel(configured)} +} export default function SystemSettingsPage() { const config = useSystemConfig() const updateConfig = useUpdateSystemConfig() const { changePassword } = useAuth() - const [collectionMode, setCollectionMode] = useState<'local' | 'agent'>('local') + const [form, setForm] = useState>({ + collection_mode: 'local', + collection_orchestrator: 'admin', + local_max_concurrent_pipelines: 8, + opencli_timeout: 120, + default_timezone: 'UTC', + public_url: '', + fleet_network_provider: 'lan', + netbird_mode: 'off', + opencli_cdp_endpoint: 'http://localhost:9222', + agent_pool_endpoints: [], + llm_request_timeout_seconds: 120, + llm_max_concurrency: 4, + control_mode: 'advisory', + control_kill_switch: false, + }) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [savingPassword, setSavingPassword] = useState(false) useEffect(() => { - if (config.data) setCollectionMode(config.data.collection_mode) + if (!config.data) return + setForm({ + collection_mode: config.data.collection_mode, + collection_orchestrator: config.data.collection_orchestrator, + local_max_concurrent_pipelines: config.data.local_max_concurrent_pipelines, + opencli_timeout: config.data.opencli_timeout, + default_timezone: config.data.default_timezone, + public_url: config.data.public_url, + fleet_network_provider: config.data.fleet_network_provider, + netbird_mode: config.data.netbird_mode, + opencli_cdp_endpoint: config.data.opencli_cdp_endpoint, + agent_pool_endpoints: config.data.agent_pool_endpoints, + llm_request_timeout_seconds: config.data.llm_request_timeout_seconds, + llm_max_concurrency: config.data.llm_max_concurrency, + control_mode: config.data.control_mode, + control_kill_switch: config.data.control_kill_switch, + }) }, [config.data]) - async function saveCollectionMode() { + function setField(key: K, value: (typeof form)[K]) { + setForm((current) => ({ ...current, [key]: value })) + } + + async function saveSystemConfig() { try { - await updateConfig.mutateAsync({ collection_mode: collectionMode }) + await updateConfig.mutateAsync({ + ...form, + agent_pool_endpoints: form.agent_pool_endpoints.join(','), + }) toast.success('系统设置已保存') } catch (error) { toast.error(error instanceof Error ? error.message : '系统设置保存失败') @@ -64,100 +112,77 @@ export default function SystemSettingsPage() { void saveSystemConfig()} disabled={updateConfig.isPending}>{updateConfig.isPending ? '保存中…' : '保存全部设置'}} > -
+
- 执行模式 - 决定采集任务由当前服务执行,还是交给远程 Agent。 + 系统概览 + 先确认当前部署状态,再调整下面的运行参数。 - - - + +

应用环境

{config.data.app_env}

+

数据库

{config.data.database_kind}

+

镜像版本

{config.data.image_tag}

+

调试模式

{config.data.debug ? '开启' : '关闭'}

- - - 当前运行环境 - 当前部署读取到的基础运行信息。 - - -
- 任务执行器 - {config.data.task_executor} -
-
- 镜像版本 - {config.data.image_tag} -
-

- 模型连接、浏览器节点和自动化分别在对应功能页管理。 -

-
-
+
+ + 任务与执行控制采集任务如何运行,以及单机资源上限。 + + + + + + + + - - - 管理员账户 - 在这里修改本地管理员密码,不再单独跳转到账户设置页面。 - + + 浏览器与 Agent管理本地 Chrome、远程节点与 Fleet 网络方式。 + + + + + + + + + + + AI 与人机协作限制模型调用资源,并设置 Agent 控制策略。 + + + + + + + + + + 集成与密钥状态只显示配置状态,不在浏览器中暴露实际密钥。 + +
API / Fleet 访问令牌
+
凭证加密密钥
+
OIDC 组织登录
+
SMTP 通知
+
+
+
+ + + 管理员账户系统级设置的一部分:在这里修改本地管理员密码。
- - 当前密码 - setCurrentPassword(event.target.value)} - required - /> - - - 新密码 - setNewPassword(event.target.value)} - required - /> - 至少 6 个字符。 - - - 确认新密码 - setConfirmPassword(event.target.value)} - required - /> - + 当前密码 setCurrentPassword(event.target.value)} required /> + 新密码 setNewPassword(event.target.value)} required />至少 6 个字符。 + 确认新密码 setConfirmPassword(event.target.value)} required /> - +
diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts index 6355f248..2e0b3073 100644 --- a/frontend/lib/api/endpoints.ts +++ b/frontend/lib/api/endpoints.ts @@ -735,8 +735,11 @@ export const getHealth = () => export const getSystemConfig = () => apiClient.get>('/system/config').then((r) => r.data.data) +type SystemConfigPatch = Partial> & { + agent_pool_endpoints?: string +} -export const updateSystemConfig = (data: Partial) => +export const updateSystemConfig = (data: SystemConfigPatch) => apiClient.patch>('/system/config', data).then((r) => r.data.data) export const getWsAgentStatus = () => diff --git a/frontend/lib/api/types.ts b/frontend/lib/api/types.ts index d9963e3d..cafb2a90 100644 --- a/frontend/lib/api/types.ts +++ b/frontend/lib/api/types.ts @@ -422,9 +422,30 @@ export interface EdgeNodeEvent { } export interface SystemConfig { + app_name: string + app_env: string + debug: boolean collection_mode: 'local' | 'agent' + collection_orchestrator: 'admin' | 'iii' task_executor: 'local' | 'celery' + local_max_concurrent_pipelines: number + opencli_timeout: number + default_timezone: string + public_url: string + fleet_network_provider: 'lan' | 'netbird' | 'wireguard' | 'ssh' | 'custom' + netbird_mode: 'off' | 'host' | 'docker' + opencli_cdp_endpoint: string + agent_pool_endpoints: string[] + llm_request_timeout_seconds: number + llm_max_concurrency: number + control_mode: 'advisory' | 'automatic' + control_kill_switch: boolean image_tag: string + database_kind: string + api_auth_configured: boolean + oidc_configured: boolean + smtp_configured: boolean + credential_encryption_configured: boolean } export interface NodeStats { diff --git a/tests/integration/test_system_config_api.py b/tests/integration/test_system_config_api.py new file mode 100644 index 00000000..6d43f9f7 --- /dev/null +++ b/tests/integration/test_system_config_api.py @@ -0,0 +1,38 @@ +import pytest + +from backend.config import get_settings + + +@pytest.mark.asyncio +async def test_system_config_exposes_runtime_sections(client): + response = await client.get("/api/v1/system/config") + assert response.status_code == 200 + data = response.json()["data"] + assert data["collection_mode"] == "local" + assert "agent_pool_endpoints" in data + assert "credential_encryption_configured" in data + assert "control_kill_switch" in data + + +@pytest.mark.asyncio +async def test_system_config_updates_safe_runtime_fields(client, monkeypatch, tmp_path): + monkeypatch.setenv("ENV_FILE_PATH", str(tmp_path / ".env")) + get_settings.cache_clear() + try: + response = await client.patch( + "/api/v1/system/config", + json={ + "collection_mode": "agent", + "local_max_concurrent_pipelines": 12, + "default_timezone": "Asia/Shanghai", + "control_kill_switch": True, + }, + ) + assert response.status_code == 200 + data = response.json()["data"] + assert data["collection_mode"] == "agent" + assert data["local_max_concurrent_pipelines"] == 12 + assert data["default_timezone"] == "Asia/Shanghai" + assert data["control_kill_switch"] is True + finally: + get_settings.cache_clear() From bfb17f689442ce0d869e4fbb35d0ea04db3e4906 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:52:49 +0800 Subject: [PATCH 7/9] feat: add system module control links --- frontend/app/(app)/system/page.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index 5af9c536..6c730ebc 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import Link from 'next/link' import { toast } from 'sonner' import { useAuth } from '@/components/auth/auth-provider' @@ -173,6 +174,25 @@ export default function SystemSettingsPage() {
+ + 系统模块从这里进入各个系统级管理区域,不再把配置散落在不同入口。 + + {[ + ['/operations-agents', '自动化与智能体', '安排任务、选择 Agent 和查看活动'], + ['/nodes', '执行资源', '管理浏览器节点与远程 Agent'], + ['/providers', '模型与连接', '配置模型供应商和运行时连接'], + ['/notifications', '通知与交付', '配置消息渠道和通知规则'], + ['/control/actions', '控制与审计', '查看系统建议、执行记录和控制状态'], + ['/inbox', '任务与日志', '处理失败任务、待确认操作和通知'], + ].map(([href, title, description]) => ( + + {title} + {description} + + ))} + + + 管理员账户系统级设置的一部分:在这里修改本地管理员密码。 From fa1f9c8f1e5c2d024f1095c9b337e7a52bb90ad9 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:08:30 +0800 Subject: [PATCH 8/9] feat: make system settings agent-driven --- frontend/app/(app)/system/page.tsx | 168 ++++++------------ frontend/components/shell/app-shell.tsx | 17 +- .../components/shell/global-agent-dock.tsx | 9 +- 3 files changed, 81 insertions(+), 113 deletions(-) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index 6c730ebc..f0c4b1fb 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import Link from 'next/link' import { toast } from 'sonner' @@ -12,78 +12,39 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' -import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' -import type { SystemConfig } from '@/lib/api/types' +import { useSystemConfig } from '@/lib/api/hooks' -function configuredLabel(value: boolean) { - return value ? '已配置' : '未配置' +type StatusRowProps = { + label: string + value: string + status?: 'done' | 'pending' | 'readonly' } -function StatusBadge({ configured }: { configured: boolean }) { - return {configuredLabel(configured)} +function StatusRow({ label, value, status = 'readonly' }: StatusRowProps) { + const statusLabel = status === 'done' ? '已配置' : status === 'pending' ? '待配置' : '只读' + return ( +
+ {label} + + {value} + {statusLabel} + +
+ ) +} + +function requestAgent(prompt: string) { + window.dispatchEvent(new CustomEvent('open-global-agent', { detail: { prompt } })) } export default function SystemSettingsPage() { const config = useSystemConfig() - const updateConfig = useUpdateSystemConfig() const { changePassword } = useAuth() - const [form, setForm] = useState>({ - collection_mode: 'local', - collection_orchestrator: 'admin', - local_max_concurrent_pipelines: 8, - opencli_timeout: 120, - default_timezone: 'UTC', - public_url: '', - fleet_network_provider: 'lan', - netbird_mode: 'off', - opencli_cdp_endpoint: 'http://localhost:9222', - agent_pool_endpoints: [], - llm_request_timeout_seconds: 120, - llm_max_concurrency: 4, - control_mode: 'advisory', - control_kill_switch: false, - }) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [savingPassword, setSavingPassword] = useState(false) - useEffect(() => { - if (!config.data) return - setForm({ - collection_mode: config.data.collection_mode, - collection_orchestrator: config.data.collection_orchestrator, - local_max_concurrent_pipelines: config.data.local_max_concurrent_pipelines, - opencli_timeout: config.data.opencli_timeout, - default_timezone: config.data.default_timezone, - public_url: config.data.public_url, - fleet_network_provider: config.data.fleet_network_provider, - netbird_mode: config.data.netbird_mode, - opencli_cdp_endpoint: config.data.opencli_cdp_endpoint, - agent_pool_endpoints: config.data.agent_pool_endpoints, - llm_request_timeout_seconds: config.data.llm_request_timeout_seconds, - llm_max_concurrency: config.data.llm_max_concurrency, - control_mode: config.data.control_mode, - control_kill_switch: config.data.control_kill_switch, - }) - }, [config.data]) - - function setField(key: K, value: (typeof form)[K]) { - setForm((current) => ({ ...current, [key]: value })) - } - - async function saveSystemConfig() { - try { - await updateConfig.mutateAsync({ - ...form, - agent_pool_endpoints: form.agent_pool_endpoints.join(','), - }) - toast.success('系统设置已保存') - } catch (error) { - toast.error(error instanceof Error ? error.message : '系统设置保存失败') - } - } - async function savePassword(event: React.FormEvent) { event.preventDefault() if (newPassword !== confirmPassword) { @@ -109,18 +70,22 @@ export default function SystemSettingsPage() { return } + const runtimePrompt = '请检查当前 OpenCLI 的任务执行模式、调度器、并发数、超时和时区;列出未配置项,并在我确认后完成必要配置。' + const agentPrompt = '请检查浏览器节点、Agent Pool、CDP 地址和 Fleet 网络;告诉我哪些节点可用、哪些配置缺失,并在我确认后修复。' + const collaborationPrompt = '请检查模型连接、AI 并发限制、控制策略和全局暂停开关;用人能看懂的方式说明当前状态,并给出需要我确认的变更。' + return ( void saveSystemConfig()} disabled={updateConfig.isPending}>{updateConfig.isPending ? '保存中…' : '保存全部设置'}} + description="这里主要展示系统状态。需要变更时,让 Agent 读取上下文并协助配置。" + actions={} >
系统概览 - 先确认当前部署状态,再调整下面的运行参数。 + 用户先看结果,不需要先理解环境变量和部署参数。

应用环境

{config.data.app_env}

@@ -132,69 +97,54 @@ export default function SystemSettingsPage() {
- 任务与执行控制采集任务如何运行,以及单机资源上限。 - - - - - - + 任务与执行当前配置结果与待处理项。 + + + + + + + - 浏览器与 Agent管理本地 Chrome、远程节点与 Fleet 网络方式。 - - - - - - + 浏览器与 Agent查看执行资源是否准备好,具体修改交给 Agent。 + + + + + + +
- AI 与人机协作限制模型调用资源,并设置 Agent 控制策略。 - - - - - + AI 与人机协作模型与控制策略由 Agent 解释,人只确认有影响的变更。 + + + + + + - 集成与密钥状态只显示配置状态,不在浏览器中暴露实际密钥。 - -
API / Fleet 访问令牌
-
凭证加密密钥
-
OIDC 组织登录
-
SMTP 通知
+ 集成与密钥状态只显示是否准备好,不在浏览器中暴露实际密钥。 + + + + + +
- 系统模块从这里进入各个系统级管理区域,不再把配置散落在不同入口。 - - {[ - ['/operations-agents', '自动化与智能体', '安排任务、选择 Agent 和查看活动'], - ['/nodes', '执行资源', '管理浏览器节点与远程 Agent'], - ['/providers', '模型与连接', '配置模型供应商和运行时连接'], - ['/notifications', '通知与交付', '配置消息渠道和通知规则'], - ['/control/actions', '控制与审计', '查看系统建议、执行记录和控制状态'], - ['/inbox', '任务与日志', '处理失败任务、待确认操作和通知'], - ].map(([href, title, description]) => ( - - {title} - {description} - - ))} - - - - - 管理员账户系统级设置的一部分:在这里修改本地管理员密码。 + 管理员账户账户密码属于安全边界,仍保留明确的人工确认表单。
diff --git a/frontend/components/shell/app-shell.tsx b/frontend/components/shell/app-shell.tsx index 5d5c7730..d56885b0 100644 --- a/frontend/components/shell/app-shell.tsx +++ b/frontend/components/shell/app-shell.tsx @@ -1,24 +1,34 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { AppRouteTransition } from '@/components/motion/app-route-transition' import { AppHeader } from '@/components/shell/app-header' import { AppSidebar } from '@/components/shell/app-sidebar' import { CommandPalette } from '@/components/shell/command-palette' +import { GlobalAgentBubble } from '@/components/shell/global-agent-bubble' import { GlobalAgentDock } from '@/components/shell/global-agent-dock' import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' export function AppShell({ children }: { children: React.ReactNode }) { const [commandOpen, setCommandOpen] = useState(false) const [agentOpen, setAgentOpen] = useState(false) + const [agentPrompt, setAgentPrompt] = useState('') + useEffect(() => { + function openAgent(event: Event) { + const prompt = (event as CustomEvent<{ prompt?: string }>).detail?.prompt?.trim() ?? '' + setAgentPrompt(prompt) + setAgentOpen(true) + } + window.addEventListener('open-global-agent', openAgent) + return () => window.removeEventListener('open-global-agent', openAgent) + }, []) return ( setAgentOpen(true)} onOpenCommand={() => setCommandOpen(true)} />
@@ -26,7 +36,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
- + { setAgentPrompt(''); setAgentOpen(true) }} /> +
) } diff --git a/frontend/components/shell/global-agent-dock.tsx b/frontend/components/shell/global-agent-dock.tsx index d4d777cc..1551d12c 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { Bot, Check, Loader2, Send, ShieldCheck, X } from 'lucide-react' import { usePathname } from 'next/navigation' -import { FormEvent, KeyboardEvent, useState } from 'react' +import { FormEvent, KeyboardEvent, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' @@ -43,9 +43,11 @@ type AgentReply = { export function GlobalAgentDock({ open, onOpenChange, + initialPrompt = '', }: { open: boolean onOpenChange: (open: boolean) => void + initialPrompt?: string }) { const pathname = usePathname() const queryClient = useQueryClient() @@ -55,6 +57,11 @@ export function GlobalAgentDock({ const [error, setError] = useState(null) const [sending, setSending] = useState(false) const [confirming, setConfirming] = useState(false) + useEffect(() => { + if (open && initialPrompt) { + setInput(initialPrompt) + } + }, [initialPrompt, open]) async function sendMessage(event?: FormEvent) { event?.preventDefault() From 78d133aeb1fa63658a55b480a6803c0680734deb Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:17:49 +0800 Subject: [PATCH 9/9] refactor: keep agent actions inside the floating chat --- frontend/app/(app)/system/page.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index f0c4b1fb..e886b2a7 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -33,9 +33,6 @@ function StatusRow({ label, value, status = 'readonly' }: StatusRowProps) { ) } -function requestAgent(prompt: string) { - window.dispatchEvent(new CustomEvent('open-global-agent', { detail: { prompt } })) -} export default function SystemSettingsPage() { const config = useSystemConfig() @@ -70,16 +67,12 @@ export default function SystemSettingsPage() { return } - const runtimePrompt = '请检查当前 OpenCLI 的任务执行模式、调度器、并发数、超时和时区;列出未配置项,并在我确认后完成必要配置。' - const agentPrompt = '请检查浏览器节点、Agent Pool、CDP 地址和 Fleet 网络;告诉我哪些节点可用、哪些配置缺失,并在我确认后修复。' - const collaborationPrompt = '请检查模型连接、AI 并发限制、控制策略和全局暂停开关;用人能看懂的方式说明当前状态,并给出需要我确认的变更。' return ( requestAgent('请完整检查这套 OpenCLI 部署的系统设置,告诉我哪些已完成、哪些未完成。')}>让 Agent 检查全部} + description="查看整套 OpenCLI 部署的状态、完成项与待处理项。" >
@@ -104,30 +97,28 @@ export default function SystemSettingsPage() { - - 浏览器与 Agent查看执行资源是否准备好,具体修改交给 Agent。 + 浏览器与 Agent查看执行资源是否准备好。 -
+
- AI 与人机协作模型与控制策略由 Agent 解释,人只确认有影响的变更。 + AI 与人机协作展示模型与控制策略的当前状态。 - @@ -138,7 +129,6 @@ export default function SystemSettingsPage() { -