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/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/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/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)/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)/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/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/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx new file mode 100644 index 00000000..71dc5cc8 --- /dev/null +++ b/frontend/app/(app)/settings/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function SettingsPage() { + redirect('/system') +} diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx new file mode 100644 index 00000000..e886b2a7 --- /dev/null +++ b/frontend/app/(app)/system/page.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +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 } from '@/lib/api/hooks' + +type StatusRowProps = { + label: string + value: string + status?: 'done' | 'pending' | 'readonly' +} + +function StatusRow({ label, value, status = 'readonly' }: StatusRowProps) { + const statusLabel = status === 'done' ? '已配置' : status === 'pending' ? '待配置' : '只读' + return ( +
+ {label} + + {value} + {statusLabel} + +
+ ) +} + + +export default function SystemSettingsPage() { + const config = useSystemConfig() + const { changePassword } = useAuth() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [savingPassword, setSavingPassword] = useState(false) + + 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 + } + + + return ( + +
+ + + 系统概览 + 用户先看结果,不需要先理解环境变量和部署参数。 + + +

应用环境

{config.data.app_env}

+

数据库

{config.data.database_kind}

+

镜像版本

{config.data.image_tag}

+

调试模式

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

+
+
+ +
+ + 任务与执行当前配置结果与待处理项。 + + + + + + + + + + + 浏览器与 Agent查看执行资源是否准备好。 + + + + + + + + + + + + AI 与人机协作展示模型与控制策略的当前状态。 + + + + + + + + + + 集成与密钥状态只显示是否准备好,不在浏览器中暴露实际密钥。 + + + + + + + +
+ + + 管理员账户账户密码属于安全边界,仍保留明确的人工确认表单。 + +
+ + 当前密码 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..04db630d 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' @@ -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 + 自动化与智能体 @@ -140,6 +140,10 @@ export function AppHeader({ {accountLabel} + router.push('/system')}> + + 系统设置 + 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 148599db..1551d12c 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -3,17 +3,17 @@ 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' 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' @@ -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() @@ -136,18 +143,21 @@ export function GlobalAgentDock({ } return ( - - - - + + + + 全局 Agent - - + + 当前上下文:{ROUTE_LABELS[pathname] ?? pathname}。读取可直接执行,写入操作先生成确认提案。 未明确指定 Workspace 时,仅在后端能解析出唯一授权范围时允许确认写操作。 - - + +
@@ -238,7 +248,7 @@ export function GlobalAgentDock({
-
-
+ + ) } 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/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..2e0b3073 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) @@ -716,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/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/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/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 4db59d73..c54a9ef9 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -4,6 +4,7 @@ import { Database, LayoutDashboard, PanelsTopLeft, + Settings2, ShieldCheck, Workflow, type LucideIcon, @@ -45,10 +46,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'], }, ], }, @@ -73,6 +74,11 @@ export const NAV_GROUPS: NavGroup[] = [ icon: ShieldCheck, match: ['/providers', '/control/actions'], }, + { + href: '/system', + label: '系统设置', + icon: Settings2, + }, ], }, ] @@ -86,14 +92,16 @@ export const ROUTE_LABELS: Record = { '/canvas': '节点工作流(兼容入口)', '/plugins': '插件中心', '/sources': '数据(兼容入口)', - '/schedules': '触发与调度', + '/schedules': '自动化与智能体', '/tasks': '工作项', '/records': '成果与数据', '/notifications': '通知', '/agents': '智能体', + '/operations-agents': '自动化与智能体', + '/system': '系统设置', '/skills': '技能', '/providers': '模型与连接', - '/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/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"] == [] 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() 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)