Compare commits

..

4 Commits

Author SHA1 Message Date
5d199c4c5c fix: 添加数据库迁移补齐 User API Key 字段
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:36:10 +08:00
5e6efdaf59 fix: Docker 构建改用 npm 替代 pnpm + 补全 OAuth/Redis 环境变量
- Dockerfile 全部改为 npm install + 全局 tsc,解决 pnpm 符号链接问题
- docker-compose 添加 Redis 服务、OAuth 环境变量透传、web 端口改为可配置
- MCP URL 改用 window.location.origin 适配反向代理
- tsconfig 添加 paths 映射解决 Docker 内模块引用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:24:52 +08:00
8b6aeb28b1 feat: 支持 OAuth 无密码用户设置密码和查看 API Key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:54 +08:00
9733b82c9c feat: 支持 OAuth 无密码用户设置密码和查看 API Key
- 新增 POST /auth/set-password 端点(仅限无密码用户)
- /auth/me 返回 hasPassword 字段
- SettingsDialog:无密码用户显示"设置密码"表单(无需当前密码)
- API Key reveal/copy:无密码时引导用户先设置密码
- 中英双语 i18n 支持

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:46 +08:00
18 changed files with 738 additions and 121 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.worktrees
.claude
.git
*.md
!README.md

View File

@@ -3,11 +3,15 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
redis:
ports:
- "6379:6379"
server: server:
build: build:
context: . context: .
dockerfile: packages/server/Dockerfile dockerfile: packages/server/Dockerfile
target: deps target: build
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
command: > command: >
@@ -24,6 +28,7 @@ services:
- ./prisma:/app/prisma - ./prisma:/app/prisma
environment: environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
JWT_SECRET: dev-secret JWT_SECRET: dev-secret
JWT_REFRESH_SECRET: dev-refresh-secret JWT_REFRESH_SECRET: dev-refresh-secret
SERVER_PORT: "3000" SERVER_PORT: "3000"
@@ -33,7 +38,7 @@ services:
build: build:
context: . context: .
dockerfile: packages/mcp/Dockerfile dockerfile: packages/mcp/Dockerfile
target: deps target: build
command: > command: >
sh -c " sh -c "
npx prisma generate --schema=prisma/schema.prisma && npx prisma generate --schema=prisma/schema.prisma &&
@@ -48,6 +53,7 @@ services:
- ./prisma:/app/prisma - ./prisma:/app/prisma
environment: environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
MCP_PORT: "3001" MCP_PORT: "3001"
NODE_ENV: development NODE_ENV: development

View File

@@ -15,21 +15,42 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
server: server:
build: build:
context: . context: .
dockerfile: packages/server/Dockerfile dockerfile: packages/server/Dockerfile
environment: environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:-change-me-in-production} JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production}
API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
OAUTH_CALLBACK_BASE_URL: ${OAUTH_CALLBACK_BASE_URL:-}
FRONTEND_URL: ${FRONTEND_URL:-}
SERVER_PORT: "3000" SERVER_PORT: "3000"
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
mcp: mcp:
build: build:
@@ -37,22 +58,26 @@ services:
dockerfile: packages/mcp/Dockerfile dockerfile: packages/mcp/Dockerfile
environment: environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
MCP_PORT: "3001" MCP_PORT: "3001"
ports: ports:
- "3001:3001" - "3001:3001"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
web: web:
build: build:
context: . context: .
dockerfile: packages/web/Dockerfile dockerfile: packages/web/Dockerfile
ports: ports:
- "80:80" - "${WEB_PORT:-8088}:80"
depends_on: depends_on:
- server - server
- mcp - mcp
volumes: volumes:
pgdata: pgdata:
redisdata:

341
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,341 @@
# AgentFox 生产环境部署指南
## 架构概览
```
┌──────────────────────────────────┐
│ Docker Network │
用户请求 │ │
────────▶ ┌──────┴──────┐ │
│ Nginx │ /api/* │
│ (Web) │──────────▶┌─────────┐ │
│ :80 │ │ Server │ │
│ │ /mcp/* │ (API) │──┐ │
│ SPA 静态 │──────▶┌───┤ :3000 │ │ │
│ 资源服务 │ │ └─────────┘ │ │
└─────────────┘ │ │ │
│ ┌─────────┐ │ │
└──▶│ MCP │ │ │
│ :3001 │──┤ │
└─────────┘ │ │
▼ │
┌────────────┐ ┌───────┐│
│ PostgreSQL │ │ Redis ││
│ :5432 │ │ :6379 ││
└────────────┘ └───────┘│
└──────────────────────────────────┘
```
| 服务 | 镜像 | 端口 | 用途 |
|------|------|------|------|
| **web** | nginx:alpine | 80 | 前端 SPA + 反向代理(`/api/` → server`/mcp/` → mcp |
| **server** | node:20-alpine | 3000 | Express API认证、项目管理、OpenAPI 导入解析 |
| **mcp** | node:20-alpine | 3001 | MCP 协议服务:为 LLM 提供 API 文档查询工具 |
| **postgres** | postgres:16-alpine | 5432 | 主数据库:用户、项目、模块、端点数据 |
| **redis** | redis:7-alpine | 6379 | 会话缓存、速率限制 |
**请求流向**:用户浏览器 → Nginx(:80) → 静态 SPA 或反代到 Server/MCP → PostgreSQL + Redis
---
## 前置要求
- Docker Engine 24+ 和 Docker Compose V2
- 至少 2GB 内存、10GB 磁盘空间
- 一个可访问的域名(用于 HTTPS 和 OAuth 回调)
---
## 第一步:获取代码
```bash
git clone <repo-url> agent-fox
cd agent-fox
```
---
## 第二步:配置环境变量
```bash
cp .env.example .env
```
编辑 `.env` 文件:
### 必填项
```bash
# 先生成 3 个随机密钥
openssl rand -hex 32 # → JWT_SECRET
openssl rand -hex 32 # → JWT_REFRESH_SECRET
openssl rand -hex 32 # → API_KEY_ENCRYPTION_SECRET
```
```env
JWT_SECRET=<粘贴第1个值>
JWT_REFRESH_SECRET=<粘贴第2个值>
API_KEY_ENCRYPTION_SECRET=<粘贴第3个值>
```
> **警告**:这三个密钥一旦投入使用,切勿更换。更换 JWT_SECRET 会使所有已登录用户失效,更换 API_KEY_ENCRYPTION_SECRET 会使所有 API Key 不可用。
### 数据库(可选修改)
如需自定义数据库密码,同时修改 `docker-compose.yml``.env`
```env
DATABASE_URL=postgresql://myuser:MyStr0ngP@ss@postgres:5432/agentfox
```
对应修改 `docker-compose.yml` 中的 `POSTGRES_USER` / `POSTGRES_PASSWORD`
### OAuth 第三方登录(可选)
不配置则仅支持邮箱密码注册登录。配置方法详见 [OAuth 注册指南](./oauth-setup-guide.md)。
```env
# Google
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPx-xxx
# GitHub
GITHUB_CLIENT_ID=Ov23li...
GITHUB_CLIENT_SECRET=xxx
# Apple
APPLE_CLIENT_ID=com.example.agentfox
APPLE_TEAM_ID=xxx
APPLE_KEY_ID=xxx
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----
# 回调地址(必须与第三方平台注册的一致)
OAUTH_CALLBACK_BASE_URL=https://你的域名
FRONTEND_URL=https://你的域名
```
### 完整 `.env` 示例
```env
DATABASE_URL=postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL=redis://redis:6379
JWT_SECRET=e0c33a89e22b6ace23a1718d388e2b20f4735b5ab58c5cffe8d31c8fbedb6910
JWT_REFRESH_SECRET=28f886e7af5b704b4c45440bd9db41706bf996601f78dd05ca98c7dce358c833
API_KEY_ENCRYPTION_SECRET=5917b3fdc14565ede76b118d672dc98cdffb90f8ee16d88e5e6c61eb86e56eb8
SERVER_PORT=3000
MCP_PORT=3001
MCP_BASE_URL=http://mcp:3001
OAUTH_CALLBACK_BASE_URL=https://你的域名
FRONTEND_URL=https://你的域名
```
---
## 第三步:构建并启动
```bash
docker compose up --build -d
```
首次启动过程:
1. Docker 构建 4 个镜像(约 3-5 分钟)
2. PostgreSQL 和 Redis 率先启动并通过健康检查
3. Server 启动时自动执行 `prisma migrate deploy` 创建数据库表
4. MCP 和 Web 随后启动
### 验证
```bash
# 确认 5 个服务全部 running
docker compose ps
# 期望输出:
# NAME STATUS
# postgres Up (healthy)
# redis Up (healthy)
# server Up
# mcp Up
# web Up
```
```bash
# 验证 API 可达
curl http://localhost/api/health
# 验证前端可达
curl -s http://localhost | head -5
```
访问 `http://服务器IP` 即可使用。
---
## 第四步:配置 HTTPS生产必须
### 方案 ACaddy推荐自动 HTTPS
```bash
# 安装 Caddy
sudo apt install -y caddy # Debian/Ubuntu
# 或 brew install caddy # macOS
```
创建 `/etc/caddy/Caddyfile`
```
你的域名 {
reverse_proxy localhost:80
}
```
```bash
sudo systemctl restart caddy
```
Caddy 会自动申请和续期 Let's Encrypt 证书。
### 方案 B修改 Nginx 配置
将 SSL 证书挂载进 web 容器,修改 `packages/web/nginx.conf` 添加 443 监听。
### 方案 C云厂商负载均衡
将 HTTPS 终止放在云厂商 SLB/ALB 上,后端指向服务器的 80 端口。
---
## 生产加固清单
### 数据库安全
```yaml
# docker-compose.yml
postgres:
environment:
POSTGRES_USER: agentfox_prod
POSTGRES_PASSWORD: <使用 openssl rand -base64 24 生成>
ports: [] # 生产环境移除端口映射,仅内部访问
```
### Redis 安全
```yaml
redis:
command: redis-server --requirepass <密码>
ports: [] # 生产环境移除端口映射
```
对应更新环境变量:
```env
REDIS_URL=redis://:<密码>@redis:6379
```
### 资源限制
```yaml
# docker-compose.yml 各服务添加
server:
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
```
### 日志管理
```yaml
# 防止日志文件无限增长
services:
server:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
```
---
## 更新部署
```bash
# 拉取最新代码
git pull origin main
# 重新构建并重启(零停机:先构建再替换)
docker compose up --build -d
# 如果只更新了前端
docker compose build web && docker compose up -d web
# 如果只更新了后端
docker compose build server && docker compose up -d server
```
---
## 数据备份
### PostgreSQL
```bash
# 备份
docker compose exec postgres pg_dump -U agentfox agentfox > backup_$(date +%Y%m%d).sql
# 恢复
cat backup_20260403.sql | docker compose exec -T postgres psql -U agentfox agentfox
```
### 定时备份cron
```bash
# 每天凌晨 3 点备份
0 3 * * * cd /path/to/agent-fox && docker compose exec -T postgres pg_dump -U agentfox agentfox | gzip > /backups/agentfox_$(date +\%Y\%m\%d).sql.gz
```
### Redis
Redis 数据通过 `redisdata` volume 自动持久化RDB 快照)。
---
## 常用运维命令
```bash
# === 服务管理 ===
docker compose up -d # 启动
docker compose down # 停止
docker compose restart server # 重启单个服务
docker compose logs -f server # 查看日志(实时)
docker compose logs --tail 100 # 最近 100 行
# === 调试 ===
docker compose exec server sh # 进入 server 容器
docker compose exec postgres psql -U agentfox # 进入数据库
docker compose exec redis redis-cli # 进入 Redis
# === 数据库 ===
docker compose exec server npx prisma migrate deploy --schema=prisma/schema.prisma # 手动迁移
docker compose exec postgres psql -U agentfox -c "\dt" # 查看表
docker compose exec postgres psql -U agentfox -c "SELECT count(*) FROM \"User\"" # 查询用户数
# === 清理 ===
docker compose down -v # ⚠️ 停止并删除所有数据
docker system prune -f # 清理无用镜像/缓存
```
---
## 故障排查
| 现象 | 原因 & 解决 |
|------|------------|
| `docker compose up` 后 server 一直重启 | 查看 `docker compose logs server`,通常是数据库连接失败。确认 postgres 已 healthy |
| 访问页面显示 502 | server 或 mcp 未正常运行。`docker compose ps` 检查状态 |
| 登录报 500 | 检查 `JWT_SECRET` 是否已正确配置,`docker compose logs server` 查看详情 |
| OAuth 回调报错 | 确认 `.env``OAUTH_CALLBACK_BASE_URL` 与第三方平台注册的回调地址完全一致(协议+域名) |
| MCP 工具连接失败 | 1) 确认 API Key 正确 2) `docker compose logs mcp` 查看错误 3) 确认 MCP URL 格式为 `http(s)://域名/mcp/<projectId>` |
| Redis 连接失败 | `docker compose exec redis redis-cli ping` 应返回 PONG |
| 数据库迁移失败 | `docker compose exec server npx prisma migrate deploy --schema=prisma/schema.prisma` 手动执行并查看报错 |
| 磁盘空间不足 | `docker system prune -f` 清理无用镜像,检查 pg 数据量 |

105
docs/oauth-setup-guide.md Normal file
View File

@@ -0,0 +1,105 @@
# OAuth 第三方登录注册指南
## 1. Google OAuth
**前往**: [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
**步骤**:
1. 创建项目(或选已有项目)
2. 左侧菜单 → "OAuth consent screen" → 选 External → 填写应用名称AgentFox、用户支持邮箱
3. 左侧菜单 → "Credentials" → "Create Credentials" → "OAuth 2.0 Client ID"
4. 应用类型选 **Web application**
5. 名称填 `AgentFox Web`
6. "Authorized redirect URIs" 添加:
- 开发:`http://localhost:3000/api/auth/oauth/google/callback`
- 生产:`https://你的域名/api/auth/oauth/google/callback`
7. 创建后拿到 **Client ID****Client Secret**
**写入 `.env`**:
```
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPx-xxx
```
---
## 2. GitHub OAuth
**前往**: [GitHub Developer Settings](https://github.com/settings/developers)
**步骤**:
1. "OAuth Apps" → "New OAuth App"
2. 填写:
- Application name: `AgentFox`
- Homepage URL: `http://localhost:5173`(开发)
- Authorization callback URL: `http://localhost:3000/api/auth/oauth/github/callback`
3. 创建后拿到 **Client ID**,点击 "Generate a new client secret" 拿到 **Client Secret**
**写入 `.env`**:
```
GITHUB_CLIENT_ID=Ov23li...
GITHUB_CLIENT_SECRET=xxx
```
> 生产环境需要再创建一个 OAuth Appcallback URL 改为生产域名。
---
## 3. Apple Sign In
> 需要 **Apple Developer Program** 付费账号($99/年)。如果暂时没有,可以先跳过,按钮已在前端显示但会报错提示。
**前往**: [Apple Developer - Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources)
### 3a. 注册 App ID
1. "Identifiers" → "+" → 选 "App IDs" → "App"
2. Description: `AgentFox`
3. Bundle ID: `com.agentfox.web`Explicit
4. 勾选 "Sign In with Apple" → Continue → Register
### 3b. 创建 Services ID
1. "Identifiers" → "+" → 选 "Services IDs"
2. Description: `AgentFox Web Login`
3. Identifier: `com.agentfox.web.login` ← 这就是 **APPLE_CLIENT_ID**
4. 勾选 "Sign In with Apple" → Configure:
- Primary App ID: 选上面创建的 App ID
- Domains: `你的域名`(开发时用 ngrok
- Return URLs: `https://你的域名/api/auth/oauth/apple/callback`
5. Save → Continue → Register
### 3c. 创建 Key
1. "Keys" → "+" → 名称 `AgentFox Auth Key`
2. 勾选 "Sign In with Apple" → Configure → 选 Primary App ID → Save
3. Continue → Register → **下载 .p8 文件**(只能下载一次!)
4. 记下 **Key ID**
### 3d. 找到 Team ID
1. 右上角账户名 → "Membership details"
2. 记下 **Team ID**
**写入 `.env`**:
```
APPLE_CLIENT_ID=com.agentfox.web.login
APPLE_TEAM_ID=XXXXXXXXXX
APPLE_KEY_ID=XXXXXXXXXX
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT...这里是.p8文件内容...\n-----END PRIVATE KEY-----"
```
> Apple 回调必须 HTTPS。本地开发可以用 `ngrok http 3000` 获取临时 HTTPS 域名,然后设置 `OAUTH_CALLBACK_BASE_URL=https://xxx.ngrok.io`。
---
## 通用配置
`.env` 中还需要设置回调基础 URL
```
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
```
生产环境改为实际域名即可。
## 验证
配置好后启动 `pnpm dev:server` + `pnpm dev:web`,访问 `/login` 页面点击对应按钮即可测试。

View File

@@ -1,36 +1,35 @@
FROM node:20-alpine AS base FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate RUN npm install -g typescript@5
WORKDIR /app WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/shared/tsconfig.json packages/shared/
COPY packages/mcp/package.json packages/mcp/
COPY packages/mcp/tsconfig.json packages/mcp/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/ ./
COPY packages/shared/ packages/shared/
COPY packages/mcp/ packages/mcp/
COPY prisma/ prisma/
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
RUN npx prisma generate --schema=prisma/schema.prisma COPY prisma/ prisma/
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/mcp build
# shared: 安装依赖 + prisma generate
COPY packages/shared/package.json packages/shared/tsconfig.json packages/shared/
RUN cd packages/shared && npm install && npx prisma generate --schema=../../prisma/schema.prisma
# mcp: 安装依赖workspace:* → file: 引用)
COPY packages/mcp/package.json packages/mcp/tsconfig.json packages/mcp/
RUN cd packages/mcp && sed -i 's|"workspace:\*"|"file:../shared"|g' package.json && npm install
# 拷贝源码 + 编译
COPY packages/shared/src/ packages/shared/src/
COPY packages/mcp/src/ packages/mcp/src/
RUN tsc -p packages/shared/tsconfig.json
RUN tsc -p packages/mcp/tsconfig.json
# --- 精简运行时镜像 ---
FROM node:20-alpine AS runtime FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/ COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/mcp/dist ./packages/mcp/dist COPY --from=build /app/packages/mcp/dist ./packages/mcp/dist
COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
COPY --from=build /app/packages/mcp/package.json ./packages/mcp/ COPY --from=build /app/packages/mcp/package.json ./packages/mcp/
COPY --from=build /app/prisma ./prisma COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
WORKDIR /app/packages/mcp WORKDIR /app/packages/mcp
EXPOSE 3001 EXPOSE 3001

View File

@@ -2,7 +2,11 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"baseUrl": ".",
"paths": {
"@agent-fox/shared": ["../shared/dist"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,40 +1,39 @@
FROM node:20-alpine AS base FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate RUN npm install -g typescript@5
WORKDIR /app WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/shared/tsconfig.json packages/shared/
COPY packages/server/package.json packages/server/
COPY packages/server/tsconfig.json packages/server/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/ ./
COPY packages/shared/ packages/shared/
COPY packages/server/ packages/server/
COPY prisma/ prisma/
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
RUN npx prisma generate --schema=prisma/schema.prisma COPY prisma/ prisma/
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/server build
# shared: 安装依赖 + prisma generate
COPY packages/shared/package.json packages/shared/tsconfig.json packages/shared/
RUN cd packages/shared && npm install && npx prisma generate --schema=../../prisma/schema.prisma
# server: 安装依赖workspace:* → file: 引用)
COPY packages/server/package.json packages/server/tsconfig.json packages/server/
RUN cd packages/server && sed -i 's|"workspace:\*"|"file:../shared"|g' package.json && npm install
# 拷贝源码 + 编译
COPY packages/shared/src/ packages/shared/src/
COPY packages/server/src/ packages/server/src/
RUN tsc -p packages/shared/tsconfig.json
RUN tsc -p packages/server/tsconfig.json
# --- 精简运行时镜像 ---
FROM node:20-alpine AS runtime FROM node:20-alpine AS runtime
RUN npm install -g prisma@6
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/ COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/server/dist ./packages/server/dist COPY --from=build /app/packages/server/dist ./packages/server/dist
COPY --from=build /app/packages/server/node_modules ./packages/server/node_modules
COPY --from=build /app/packages/server/package.json ./packages/server/ COPY --from=build /app/packages/server/package.json ./packages/server/
COPY --from=build /app/packages/server/node_modules ./packages/server/node_modules
COPY --from=build /app/prisma ./prisma COPY --from=build /app/prisma ./prisma
COPY scripts/migrate-and-start.sh ./scripts/ COPY scripts/migrate-and-start.sh ./scripts/
RUN chmod +x scripts/migrate-and-start.sh RUN chmod +x scripts/migrate-and-start.sh
RUN npm install -g prisma@6
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "scripts/migrate-and-start.sh"] CMD ["sh", "scripts/migrate-and-start.sh"]

View File

@@ -90,6 +90,33 @@ router.post('/refresh', async (req, res) => {
} }
}); });
const setPasswordSchema = z.object({
password: z.string().min(8),
});
router.post('/set-password', requireAuth, async (req, res) => {
const parsed = setPasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
if (user.passwordHash) {
res.status(400).json({ success: false, error: { code: 'ALREADY_HAS_PASSWORD', message: 'Password already set. Use change-password instead.' } });
return;
}
const passwordHash = await hashPassword(parsed.data.password);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
res.json({ success: true, data: { message: 'Password set successfully' } });
});
const changePasswordSchema = z.object({ const changePasswordSchema = z.object({
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string().min(8), newPassword: z.string().min(8),
@@ -143,13 +170,14 @@ router.put('/profile', requireAuth, async (req, res) => {
router.get('/me', requireAuth, async (req, res) => { router.get('/me', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: req.user!.userId }, where: { id: req.user!.userId },
select: { id: true, email: true, name: true, avatarUrl: true }, select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true },
}); });
if (!user) { if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }); res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return; return;
} }
res.json({ success: true, data: user }); const { passwordHash, ...rest } = user;
res.json({ success: true, data: { ...rest, hasPassword: !!passwordHash } });
}); });
// --- API Key Management --- // --- API Key Management ---

View File

@@ -122,7 +122,7 @@ export async function parseOpenApiDocument(input: string | object): Promise<Pars
if (typeof input === 'string' && input.startsWith('http')) { if (typeof input === 'string' && input.startsWith('http')) {
const res = await fetch(input); const res = await fetch(input);
if (!res.ok) throw new Error(`Failed to fetch spec from URL: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`Failed to fetch spec from URL: ${res.status} ${res.statusText}`);
specInput = await res.json(); specInput = await res.json() as object;
} }
// Bundle resolves all $refs into a single document, then dereference inlines them // Bundle resolves all $refs into a single document, then dereference inlines them

View File

@@ -2,7 +2,11 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"baseUrl": ".",
"paths": {
"@agent-fox/shared": ["../shared/dist"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,14 +1,16 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app/packages/web
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY packages/web/package.json ./
COPY packages/web/package.json packages/web/ RUN npm install
RUN pnpm install --frozen-lockfile --filter @agent-fox/web...
COPY packages/web/ packages/web/ COPY packages/web/src/ ./src/
COPY tsconfig.base.json ./ COPY packages/web/index.html ./
RUN pnpm --filter @agent-fox/web build COPY packages/web/vite.config.ts ./
COPY packages/web/tsconfig.json ./
COPY tsconfig.base.json /app/
RUN npx vite build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/packages/web/dist /usr/share/nginx/html COPY --from=build /app/packages/web/dist /usr/share/nginx/html

View File

@@ -108,6 +108,29 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
} }
}; };
const handleSetPassword = async () => {
if (newPassword !== confirmPassword) {
setPasswordMsg({ type: 'error', text: t('dashboard.settings.passwordMismatch') });
return;
}
setPasswordLoading(true);
setPasswordMsg(null);
try {
await apiFetch('/auth/set-password', {
method: 'POST', body: JSON.stringify({ password: newPassword }),
});
setPasswordMsg({ type: 'success', text: t('dashboard.settings.passwordSet') });
updateUser({ hasPassword: true });
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setPasswordMsg(null), 3000);
} catch (err) {
setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to set password' });
} finally {
setPasswordLoading(false);
}
};
// API Key handlers // API Key handlers
const handleGenerateKey = async () => { const handleGenerateKey = async () => {
setKeyLoading(true); setKeyLoading(true);
@@ -139,6 +162,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
} }
}; };
const hasPassword = user?.hasPassword !== false;
const handleVerifyAndAction = async () => { const handleVerifyAndAction = async () => {
setVerifyLoading(true); setVerifyLoading(true);
setVerifyError(''); setVerifyError('');
@@ -307,9 +332,10 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
</button> </button>
</div> </div>
{/* Password prompt inline */}
{showPasswordPrompt && ( {showPasswordPrompt && (
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in"> <div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
{hasPassword ? (
<>
<p className="text-[13px] text-text-secondary"> <p className="text-[13px] text-text-secondary">
{t('dashboard.settings.passwordPrompt', { {t('dashboard.settings.passwordPrompt', {
action: showPasswordPrompt === 'copy' action: showPasswordPrompt === 'copy'
@@ -333,6 +359,24 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
</button> </button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button> <button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div> </div>
</>
) : (
<>
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
<div className="flex gap-2">
<button
onClick={() => {
setShowPasswordPrompt(null);
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
}}
className="btn-primary text-[13px] py-1.5"
>
{t('dashboard.settings.setPasswordAction')}
</button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</>
)}
</div> </div>
)} )}
@@ -358,7 +402,9 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
</section> </section>
{/* Password */} {/* Password */}
<section className="border-t border-border-default pt-5"> <section id="set-password-section" className="border-t border-border-default pt-5">
{hasPassword ? (
<>
<p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p> <p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p>
<p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p> <p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p>
<div className="space-y-3"> <div className="space-y-3">
@@ -390,6 +436,38 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
{passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')} {passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')}
</button> </button>
</div> </div>
</>
) : (
<>
<p className="section-title">{t('dashboard.settings.setPasswordTitle')}</p>
<p className="section-desc mb-4">{t('dashboard.settings.setPasswordDesc')}</p>
<div className="space-y-3">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
</div>
{passwordMsg && (
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
</svg>
{passwordMsg.text}
</div>
)}
<button
onClick={handleSetPassword}
disabled={passwordLoading || !newPassword || newPassword.length < 8 || newPassword !== confirmPassword}
className="btn-primary"
>
{passwordLoading ? t('dashboard.settings.settingPassword') : t('dashboard.settings.setPassword')}
</button>
</div>
</>
)}
</section> </section>
</div> </div>
</dialog> </dialog>

View File

@@ -1,7 +1,7 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api'; import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
type User = { id: string; email: string; name: string }; type User = { id: string; email: string; name: string; hasPassword?: boolean };
type AuthContextType = { type AuthContextType = {
user: User | null; user: User | null;

View File

@@ -375,6 +375,13 @@ const en = {
'dashboard.settings.enterCurrentPassword': 'Enter current password', 'dashboard.settings.enterCurrentPassword': 'Enter current password',
'dashboard.settings.atLeast8Chars': 'At least 8 characters', 'dashboard.settings.atLeast8Chars': 'At least 8 characters',
'dashboard.settings.confirmNewPassword': 'Confirm new password', 'dashboard.settings.confirmNewPassword': 'Confirm new password',
'dashboard.settings.setPasswordTitle': 'Set Password',
'dashboard.settings.setPasswordDesc': 'You signed in with a third-party account. Set a password to reveal or copy your API key.',
'dashboard.settings.setPassword': 'Set Password',
'dashboard.settings.settingPassword': 'Setting...',
'dashboard.settings.passwordSet': 'Password set successfully',
'dashboard.settings.setPasswordToReveal': 'Set a password first to reveal your API key.',
'dashboard.settings.setPasswordAction': 'Set Password',
}; };
export default en; export default en;

View File

@@ -377,6 +377,13 @@ const zh: Record<TranslationKey, string> = {
'dashboard.settings.enterCurrentPassword': '输入当前密码', 'dashboard.settings.enterCurrentPassword': '输入当前密码',
'dashboard.settings.atLeast8Chars': '至少 8 个字符', 'dashboard.settings.atLeast8Chars': '至少 8 个字符',
'dashboard.settings.confirmNewPassword': '确认新密码', 'dashboard.settings.confirmNewPassword': '确认新密码',
'dashboard.settings.setPasswordTitle': '设置密码',
'dashboard.settings.setPasswordDesc': '您通过第三方账号登录,设置密码后可以查看或复制 API Key。',
'dashboard.settings.setPassword': '设置密码',
'dashboard.settings.settingPassword': '设置中...',
'dashboard.settings.passwordSet': '密码设置成功',
'dashboard.settings.setPasswordToReveal': '请先设置密码才能查看 API Key。',
'dashboard.settings.setPasswordAction': '设置密码',
}; };
export default zh; export default zh;

View File

@@ -10,8 +10,7 @@ export default function McpIntegration({ project }: { project: Project }) {
const [copied, setCopied] = useState<string | null>(null); const [copied, setCopied] = useState<string | null>(null);
const { onOpenSettings } = useLayoutContext(); const { onOpenSettings } = useLayoutContext();
const { t } = useI18n(); const { t } = useI18n();
const mcpHost = window.location.hostname; const mcpUrl = `${window.location.origin}/mcp/${project.id}`;
const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`;
const { data: keyStatus } = useQuery({ const { data: keyStatus } = useQuery({
queryKey: ['api-key-status'], queryKey: ['api-key-status'],

View File

@@ -0,0 +1,7 @@
-- AlterTable: Add API key fields to User
ALTER TABLE "User" ADD COLUMN "apiKeyHash" TEXT;
ALTER TABLE "User" ADD COLUMN "apiKeyEncrypted" TEXT;
ALTER TABLE "User" ADD COLUMN "apiKeyPrefix" TEXT;
-- AlterTable: Remove apiKeyHash from Project (moved to User)
ALTER TABLE "Project" DROP COLUMN "apiKeyHash";