Compare commits
4 Commits
a9a7216447
...
5d199c4c5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d199c4c5c | |||
| 5e6efdaf59 | |||
| 8b6aeb28b1 | |||
| 9733b82c9c |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.worktrees
|
||||||
|
.claude
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
341
docs/deployment-guide.md
Normal 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(生产必须)
|
||||||
|
|
||||||
|
### 方案 A:Caddy(推荐,自动 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
105
docs/oauth-setup-guide.md
Normal 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 App,callback 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` 页面点击对应按钮即可测试。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user