From 2138e1cc0da96d1556a9c78b62fa3721fa64b4a9 Mon Sep 17 00:00:00 2001 From: imtim Date: Fri, 17 Apr 2026 16:27:18 +0800 Subject: [PATCH] first commit --- .dockerignore | 10 + .gitignore | 47 +++ .qwen/settings.json | 18 + .qwen/settings.json.orig | 7 + Dockerfile | 47 +++ PLAN.md | 355 ++++++++++++++++++ README.md | 155 ++++++++ backend/.env.example | 18 + backend/package.json | 29 ++ .../20260417065810_init/migration.sql | 19 + backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 19 + backend/src/config/database.js | 5 + backend/src/config/index.js | 21 ++ backend/src/controllers/auth.controller.js | 99 +++++ backend/src/index.js | 49 +++ backend/src/middleware/auth.js | 32 ++ backend/src/routes/auth.routes.js | 15 + backend/src/routes/health.routes.js | 13 + backend/src/services/auth.service.js | 109 ++++++ docker-compose.yml | 28 ++ docker/nginx.conf | 54 +++ docker/start.sh | 12 + docs/casdoor-setup.md | 204 ++++++++++ frontend/.gitignore | 41 ++ frontend/README.md | 36 ++ frontend/eslint.config.mjs | 18 + frontend/next.config.ts | 13 + frontend/package.json | 34 ++ frontend/postcss.config.mjs | 7 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/src/app/api/auth/callback/route.ts | 25 ++ frontend/src/app/api/auth/login/route.ts | 12 + frontend/src/app/api/auth/logout/route.ts | 12 + frontend/src/app/api/auth/user/route.ts | 29 ++ frontend/src/app/dashboard/page.tsx | 75 ++++ frontend/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend/src/app/globals.css | 34 ++ frontend/src/app/layout.tsx | 21 ++ frontend/src/app/login/page.tsx | 53 +++ frontend/src/app/page.tsx | 77 ++++ frontend/src/components/header.tsx | 88 +++++ frontend/src/components/layout.tsx | 19 + frontend/src/components/protected-route.tsx | 30 ++ frontend/src/lib/api.ts | 39 ++ frontend/src/lib/auth-store.ts | 55 +++ frontend/tsconfig.json | 34 ++ 51 files changed, 2125 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .qwen/settings.json create mode 100644 .qwen/settings.json.orig create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20260417065810_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/config/database.js create mode 100644 backend/src/config/index.js create mode 100644 backend/src/controllers/auth.controller.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/auth.routes.js create mode 100644 backend/src/routes/health.routes.js create mode 100644 backend/src/services/auth.service.js create mode 100644 docker-compose.yml create mode 100644 docker/nginx.conf create mode 100755 docker/start.sh create mode 100644 docs/casdoor-setup.md create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/src/app/api/auth/callback/route.ts create mode 100644 frontend/src/app/api/auth/login/route.ts create mode 100644 frontend/src/app/api/auth/logout/route.ts create mode 100644 frontend/src/app/api/auth/user/route.ts create mode 100644 frontend/src/app/dashboard/page.tsx create mode 100644 frontend/src/app/favicon.ico create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/header.tsx create mode 100644 frontend/src/components/layout.tsx create mode 100644 frontend/src/components/protected-route.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth-store.ts create mode 100644 frontend/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02b6c6f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +.next +.git +.gitignore +.env +.env.local +.env.*.local +Dockerfile +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..168ff9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules/ +package-lock.json + +# Environment +.env +.env.local +.env.*.local + +# Build output +.next/ +dist/ +build/ + +# Database +*.db +prisma/dev.db +prisma/dev.db-journal + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Docker (optional) +casdoor-data/ + +# Testing +coverage/ +.nyc_output/ + +# Misc +*.tgz +.turbo/ diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..8b2a36e --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install)", + "Bash(npx *)", + "Bash(rm *)", + "Bash(chmod *)", + "Bash(npm run *)", + "Bash(sleep *)", + "Bash(curl *)", + "Bash(pkill *)", + "Bash(timeout *)", + "Bash(nohup *)", + "Bash(ss *)" + ] + }, + "$version": 3 +} \ No newline at end of file diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig new file mode 100644 index 0000000..0b86683 --- /dev/null +++ b/.qwen/settings.json.orig @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install)" + ] + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..186ed04 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# 阶段1: 构建前端 +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# 阶段2: 构建后端 +FROM node:20-alpine AS backend-builder +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci +COPY backend/ ./ +RUN npx prisma generate + +# 阶段3: 生产环境 +FROM node:20-alpine AS production +WORKDIR /app + +# 安装nginx和sqlite +RUN apk add --no-cache nginx sqlite + +# 复制后端 +COPY --from=backend-builder /app/backend ./backend +RUN cd backend && npm ci --only=production + +# 复制前端构建 +COPY --from=frontend-builder /app/frontend/.next ./frontend/.next +COPY --from=frontend-builder /app/frontend/package*.json ./frontend/ +COPY --from=frontend-builder /app/frontend/public ./frontend/public +COPY --from=frontend-builder /app/frontend/next.config.ts ./frontend/ +COPY --from=frontend-builder /app/frontend/tsconfig.json ./frontend/ +RUN cd frontend && npm ci --only=production + +# 复制nginx配置 +COPY docker/nginx.conf /etc/nginx/nginx.conf + +# 复制启动脚本 +COPY docker/start.sh /app/start.sh +RUN chmod +x /app/start.sh + +# 暴露端口 +EXPOSE 3000 + +# 启动服务 +CMD ["/app/start.sh"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..5a8dffa --- /dev/null +++ b/PLAN.md @@ -0,0 +1,355 @@ +# 门户登录基座应用 - 开发计划 + +## 项目概述 + +建立一个门户登录的基座应用,提供基础的用户认证功能,采用前后端分离架构,最终打包为单个Docker镜像部署。 + +--- + +## 技术栈 + +### 后端 +- **框架**: Node.js + Koa (ES Modules) +- **ORM**: Prisma v5.22 +- **数据库**: SQLite +- **认证**: OAuth 2.0 / OIDC (对接 Casdoor v3.18.0) +- **JWT**: jsonwebtoken v9 + +### 前端 +- **框架**: Next.js 16 (App Router) +- **React**: v19 +- **TypeScript**: v5 +- **UI组件库**: Radix UI + Lucide Icons + TailwindCSS v4 +- **状态管理**: Zustand v5 +- **HTTP客户端**: Axios + +### 部署 +- **容器化**: Docker (多阶段构建) +- **反向代理**: Nginx +- **认证服务**: Casdoor v3.18.0 (本地Docker部署) + +--- + +## 项目结构 + +``` +base-imtim/ +├── backend/ # Koa后端服务 +│ ├── prisma/ # Prisma ORM配置 +│ │ ├── migrations/ # 数据库迁移历史 +│ │ └── schema.prisma # 数据模型定义 +│ └── src/ +│ ├── config/ # 配置文件 +│ ├── controllers/ # 控制器 +│ ├── middleware/ # 中间件(认证、日志等) +│ ├── routes/ # 路由定义 +│ ├── services/ # 业务逻辑 +│ └── index.js # 入口文件 +├── frontend/ # Next.js前端应用 +│ └── src/ +│ ├── app/ # App Router页面 +│ │ ├── api/ # API代理路由 +│ │ ├── dashboard/ # 仪表板(需认证) +│ │ ├── login/ # 登录页 +│ │ └── page.tsx # 首页 +│ ├── components/ # React组件 +│ │ ├── header.tsx # 导航栏 +│ │ ├── layout.tsx # 布局组件 +│ │ └── protected-route.tsx # 路由守卫 +│ └── lib/ +│ ├── api.ts # HTTP客户端 +│ └── auth-store.ts # 认证状态管理 +├── docker/ # Docker配置 +│ ├── Dockerfile # 多阶段构建定义 +│ ├── nginx.conf # Nginx反向代理配置 +│ └── start.sh # 容器启动脚本 +├── docs/ # 文档 +│ └── casdoor-setup.md # Casdoor配置手册 +├── .gitignore # Git忽略规则 +├── docker-compose.yml # 开发环境编排 +├── PLAN.md # 本文件 +└── README.md +``` + +--- + +## 功能模块 + +### 1. 后端 (Koa) + +#### 1.1 核心功能 +- [x] 项目初始化 (Koa + 中间件) +- [x] 数据库配置 (SQLite + Prisma) +- [x] 用户模型设计 + - `id`: UUID + - `username`: 用户名 + - `email`: 邮箱 + - `avatar`: 头像URL + - `casdoorId`: Casdoor用户ID + - `createdAt`: 创建时间 + - `updatedAt`: 更新时间 + +#### 1.2 认证模块 +- [x] Casdoor OAuth 2.0 集成 + - `/api/auth/login` - 生成Casdoor登录链接 + - `/api/auth/callback` - 处理Casdoor回调 + - `/api/auth/logout` - 登出 + - `/api/auth/user` - 获取当前用户信息 +- [x] JWT Token管理 +- [x] 认证中间件 + +#### 1.3 API路由 +``` +POST /api/auth/login # 获取登录URL +GET /api/auth/callback # OAuth回调 +POST /api/auth/logout # 登出 +GET /api/auth/user # 获取用户信息 (需认证) +GET /api/health # 健康检查 +``` + +#### 1.4 中间件 +- [x] 认证中间件 (验证JWT) +- [x] 错误处理中间件 +- [x] 日志中间件 +- [x] CORS中间件 + +### 2. 前端 (Next.js) + +#### 2.1 页面结构 +- [x] 首页 (`/`) +- [x] 登录页 (`/login`) +- [x] 仪表板 (`/dashboard`) - 登录后可见 +- [x] API代理路由 (`/api/*`) + +#### 2.2 核心组件 +- [x] Layout (布局组件) + - Header (导航栏 + 用户菜单) + - Footer (页脚) +- [x] LoginButton (登录按钮) +- [x] UserMenu (用户菜单 - Radix Dropdown) +- [x] ProtectedRoute (受保护路由) + +#### 2.3 功能实现 +- [x] 与后端API对接 (Axios) +- [x] 认证状态管理 (Zustand + localStorage) +- [x] 路由守卫 (未登录跳转) +- [x] 响应式设计 (TailwindCSS v4) +- [x] OAuth回调Token处理 + +--- + +## 开发步骤 + +### Phase 1: 项目初始化 ✅ + +#### 1.1 后端初始化 +```bash +cd backend +npm init -y +npm install koa koa-router koa-bodyparser koa-cors +npm install @prisma/client prisma +npm install jsonwebtoken axios dotenv +``` + +初始化Prisma: +```bash +npx prisma init +``` + +#### 1.2 前端初始化 +```bash +npx create-next-app@latest frontend --typescript --tailwind --app --src-dir +cd frontend +npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu +npm install @radix-ui/react-avatar @radix-ui/react-separator +npm install axios zustand lucide-react +``` + +### Phase 2: 数据库与认证 ✅ + +#### 2.1 数据库模型 +`backend/prisma/schema.prisma`: +```prisma +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +model User { + id String @id @default(uuid()) + username String @unique + email String? @unique + avatar String? + casdoorId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +运行迁移: +```bash +npx prisma migrate dev +npx prisma generate +``` + +#### 2.2 Casdoor集成 +环境变量配置 (`backend/.env`): +```env +CASDOOR_SERVER_URL=http://localhost:8000 +CASDOOR_CLIENT_ID=your-client-id +CASDOOR_CLIENT_SECRET=your-client-secret +CASDOOR_ORG_NAME=imtim +CASDOOR_APP_NAME=base-imtim +CASDOOR_REDIRECT_URI=http://localhost:3000/api/auth/callback +JWT_SECRET=your-jwt-secret-key +APP_PORT=3001 +``` + +### Phase 3: 后端API开发 ✅ + +#### 3.1 认证流程实现 + +``` +1. 用户访问 /login → 点击登录 +2. 前端请求后端获取Casdoor登录URL +3. 用户跳转到Casdoor登录 +4. 登录成功后Casdoor回调 /api/auth/callback +5. Next.js API路由重定向到后端处理 +6. 后端用code换取token,获取用户信息 +7. 创建/更新本地用户记录 +8. 生成JWT token +9. 重定向到前端dashboard,携带token +10. 前端存储token到localStorage +``` + +#### 3.2 核心服务 + +`backend/src/services/auth.service.js`: +- `getLoginUrl()` - 生成Casdoor授权URL +- `handleCallback(code, redirectUri)` - 处理OAuth回调 +- `verifyToken(token)` - 验证JWT + +### Phase 4: 前端开发 ✅ + +#### 4.1 认证状态管理 + +`frontend/src/lib/auth-store.ts`: +```typescript +import { create } from 'zustand'; + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + login: (token: string, user: User) => void; + logout: () => void; + initialize: () => void; +} +``` + +#### 4.2 API代理路由 + +`frontend/src/app/api/auth/*/route.ts`: +- `/api/auth/login` - POST - 获取登录URL +- `/api/auth/callback` - GET - OAuth回调重定向 +- `/api/auth/user` - GET - 获取用户信息 +- `/api/auth/logout` - POST - 登出 + +#### 4.3 受保护路由 + +`frontend/src/app/dashboard/page.tsx`: +- 使用 Suspense 包裹 useSearchParams +- Token从URL参数传递到API请求 +- 成功后存储到localStorage + +### Phase 5: Docker化部署 ✅ + +#### 5.1 Dockerfile (多阶段构建) + +```dockerfile +# 阶段1: 构建前端 +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# 阶段2: 构建后端 +FROM node:20-alpine AS backend-builder +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci +COPY backend/ ./ +RUN npx prisma generate + +# 阶段3: 生产环境 +FROM node:20-alpine AS production +WORKDIR /app +RUN apk add --no-cache nginx + +# 复制后端和前端构建 +COPY --from=backend-builder /app/backend ./backend +COPY --from=frontend-builder /app/frontend/.next ./frontend/.next + +# 安装生产依赖、配置nginx、启动脚本 +# ... +``` + +#### 5.2 Nginx配置 + +反向代理配置: +- `/` → Next.js (3002) +- `/api/` → Koa (3001) + +--- + +## 开发状态 + +| 阶段 | 任务 | 状态 | +|------|------|------| +| Phase 1 | 项目初始化 | ✅ 已完成 | +| Phase 2 | 数据库与认证配置 | ✅ 已完成 | +| Phase 3 | 后端API开发 | ✅ 已完成 | +| Phase 4 | 前端开发 | ✅ 已完成 | +| Phase 5 | Docker化部署 | ✅ 已完成 | +| **总计** | | **✅ 基座功能已完成** | + +--- + +## 注意事项 + +1. **Casdoor版本**: 确保使用v3.18.0,API可能与其他版本不兼容 +2. **回调URL**: Casdoor配置的重定向URL必须为 `http://localhost:3000/api/auth/callback` +3. **Casdoor用户信息端点**: v3.x 使用 `/api/get-account` 而非 `/api/userinfo` +4. **网络配置**: Docker容器间通信需要使用服务名而非localhost +5. **数据持久化**: SQLite数据库文件需要挂载volume以防数据丢失 +6. **useSearchParams**: 必须包裹在 Suspense 边界内 + +--- + +## 后续扩展 + +- [ ] 角色权限管理 (RBAC) +- [ ] 多租户支持 +- [ ] 第三方登录 (GitHub, Google等) +- [ ] 用户资料编辑 +- [ ] 审计日志 +- [ ] API限流 +- [ ] 监控与告警 +- [ ] 国际化 (i18n) + +--- + +## 参考资源 + +- [Koa文档](https://koajs.com/) +- [Next.js文档](https://nextjs.org/docs) +- [Radix UI文档](https://www.radix-ui.com/) +- [Casdoor文档](https://casdoor.org/docs/overview) +- [Prisma文档](https://www.prisma.io/docs) +- [Docker文档](https://docs.docker.com/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab25919 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# IMTIM Portal + +企业级门户登录基座应用 + +## 技术栈 + +- **后端**: Node.js + Koa (ES Modules) +- **前端**: Next.js 16 (App Router) + React 19 + TypeScript +- **UI组件库**: Radix UI + Lucide Icons + TailwindCSS v4 +- **数据库**: SQLite + Prisma ORM +- **认证**: Casdoor v3.18.0 (OAuth 2.0) +- **状态管理**: Zustand +- **部署**: Docker (多阶段构建) + Nginx + +## 快速开始 + +### 前置要求 + +- Node.js >= 20 +- Casdoor v3.18.0 (本地Docker部署) + +### 开发环境 + +1. **配置后端** +```bash +cd backend +cp .env.example .env # 编辑Casdoor凭据 +npm install +npx prisma migrate dev +npx prisma generate +npm run dev # 启动于 http://localhost:3001 +``` + +2. **启动前端** +```bash +cd frontend +npm install +npm run dev # 启动于 http://localhost:3000 +``` + +3. **访问应用** + - 前端: http://localhost:3000 + - 后端API: http://localhost:3001 + +### Docker 部署 + +```bash +docker build -t imtim-portal:latest . + +docker run -d \ + --name imtim-portal \ + -p 3000:3000 \ + -e CASDOOR_SERVER_URL=http://your-casdoor:8000 \ + -e CASDOOR_CLIENT_ID=xxx \ + -e CASDOOR_CLIENT_SECRET=xxx \ + -e JWT_SECRET=xxx \ + imtim-portal:latest +``` + +## 项目结构 + +``` +base-imtim/ +├── backend/ # Koa后端服务 +│ ├── prisma/ +│ │ ├── migrations/ # 数据库迁移 +│ │ └── schema.prisma # 数据模型 +│ └── src/ +│ ├── config/ # 配置文件 +│ ├── controllers/ # 控制器 +│ ├── middleware/ # 中间件(认证等) +│ ├── routes/ # 路由 +│ ├── services/ # 业务逻辑 +│ └── index.js # 入口文件 +├── frontend/ # Next.js前端应用 +│ └── src/ +│ ├── app/ # App Router页面 +│ │ ├── api/ # API代理路由 +│ │ ├── dashboard/ # 仪表板(需认证) +│ │ ├── login/ # 登录页 +│ │ └── page.tsx # 首页 +│ ├── components/ # React组件 +│ │ ├── header.tsx # 导航栏 +│ │ ├── layout.tsx # 布局组件 +│ │ └── protected-route.tsx +│ └── lib/ +│ ├── api.ts # HTTP客户端 +│ └── auth-store.ts # 认证状态管理 +├── docker/ # Docker配置 +│ ├── Dockerfile # 多阶段构建 +│ ├── nginx.conf # 反向代理 +│ └── start.sh # 启动脚本 +├── docs/ # 文档 +│ └── casdoor-setup.md # Casdoor配置手册 +├── docker-compose.yml # 开发环境编排 +├── PLAN.md # 开发计划 +└── README.md +``` + +## API文档 + +| 方法 | 路径 | 描述 | 认证 | +|------|------|------|------| +| POST | /api/auth/login | 获取Casdoor登录URL | 无 | +| GET | /api/auth/callback | OAuth回调处理 | 无 | +| POST | /api/auth/logout | 登出 | 需要Token | +| GET | /api/auth/user | 获取当前用户信息 | 需要Token | +| GET | /api/health | 健康检查 | 无 | + +## 环境变量 + +### 后端 (backend/.env) + +| 变量 | 描述 | 默认值 | +|------|------|--------| +| CASDOOR_SERVER_URL | Casdoor服务器URL | http://localhost:8000 | +| CASDOOR_CLIENT_ID | 客户端ID | - | +| CASDOOR_CLIENT_SECRET | 客户端密钥 | - | +| CASDOOR_ORG_NAME | 组织名称 | imtim | +| CASDOOR_APP_NAME | 应用名称 | base-imtim | +| CASDOOR_REDIRECT_URI | OAuth回调URL | http://localhost:3000/api/auth/callback | +| JWT_SECRET | JWT密钥 | - | +| APP_PORT | 后端端口 | 3001 | +| FRONTEND_URL | 前端URL | http://localhost:3000 | + +### 前端 (frontend/.env.local) + +| 变量 | 描述 | 默认值 | +|------|------|--------| +| NEXT_PUBLIC_API_URL | 后端API地址 | http://localhost:3001/api | +| BACKEND_URL | 后端服务器URL | http://localhost:3001 | +| FRONTEND_URL | 前端服务器URL | http://localhost:3000 | + +## 认证流程 + +``` +1. 用户访问 /login → 点击登录按钮 +2. 前端请求 /api/auth/login → 获取Casdoor授权URL +3. 用户跳转到Casdoor登录页面 +4. 登录成功后Casdoor回调 /api/auth/callback?code=xxx +5. Next.js API路由重定向到后端 /api/auth/callback +6. 后端用code换取token,获取用户信息 +7. 创建/更新本地用户,生成JWT +8. 重定向到 /dashboard?token=xxx +9. 前端存储token到localStorage,后续请求自动携带 +``` + +## 文档 + +- [开发计划](PLAN.md) +- [Casdoor配置手册](docs/casdoor-setup.md) + +## License + +MIT diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..396729d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# Casdoor配置 +CASDOOR_SERVER_URL=http://localhost:8000 +CASDOOR_CLIENT_ID=your-client-id +CASDOOR_CLIENT_SECRET=your-client-secret +CASDOOR_ORG_NAME=imtim +CASDOOR_APP_NAME=base-imtim +CASDOOR_REDIRECT_URI=http://localhost:3000/api/auth/callback + +# JWT配置 +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + +# 应用配置 +APP_PORT=3001 +NODE_ENV=development +FRONTEND_URL=http://localhost:3000 + +# 数据库 +DATABASE_URL=file:./prisma/dev.db diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..ba8b35d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "Koa backend for IMTIM Portal", + "main": "src/index.js", + "type": "module", + "scripts": { + "dev": "nodemon src/index.js", + "start": "node src/index.js", + "db:migrate": "prisma migrate dev", + "db:generate": "prisma generate", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^5.22.0", + "axios": "^1.15.0", + "casdoor-nodejs-sdk": "^1.27.0", + "dotenv": "^16.4.5", + "jsonwebtoken": "^9.0.2", + "koa": "^2.15.3", + "koa-bodyparser": "^4.4.1", + "koa-cors": "^0.0.16", + "koa-router": "^12.0.1" + }, + "devDependencies": { + "nodemon": "^3.1.7", + "prisma": "^5.22.0" + } +} diff --git a/backend/prisma/migrations/20260417065810_init/migration.sql b/backend/prisma/migrations/20260417065810_init/migration.sql new file mode 100644 index 0000000..b665353 --- /dev/null +++ b/backend/prisma/migrations/20260417065810_init/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "email" TEXT, + "avatar" TEXT, + "casdoorId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_casdoorId_key" ON "User"("casdoorId"); diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..4b9282a --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,19 @@ +// Prisma Schema +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +model User { + id String @id @default(uuid()) + username String @unique + email String? @unique + avatar String? + casdoorId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..4e54f7a --- /dev/null +++ b/backend/src/config/database.js @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/backend/src/config/index.js b/backend/src/config/index.js new file mode 100644 index 0000000..bb5012e --- /dev/null +++ b/backend/src/config/index.js @@ -0,0 +1,21 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +export const config = { + casdoor: { + serverUrl: process.env.CASDOOR_SERVER_URL, + clientId: process.env.CASDOOR_CLIENT_ID, + clientSecret: process.env.CASDOOR_CLIENT_SECRET, + orgName: process.env.CASDOOR_ORG_NAME, + appName: process.env.CASDOOR_APP_NAME, + }, + jwt: { + secret: process.env.JWT_SECRET, + expiresIn: '7d', + }, + app: { + port: parseInt(process.env.APP_PORT || '3001', 10), + env: process.env.NODE_ENV || 'development', + }, +}; diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js new file mode 100644 index 0000000..54ceb60 --- /dev/null +++ b/backend/src/controllers/auth.controller.js @@ -0,0 +1,99 @@ +import { getLoginUrl, handleCallback } from '../services/auth.service.js'; +import prisma from '../config/database.js'; + +/** + * POST /api/auth/login + * 获取 Casdoor 登录 URL + */ +export const login = (ctx) => { + const loginUrl = getLoginUrl(); + ctx.body = { + success: true, + data: { loginUrl }, + }; +}; + +/** + * GET /api/auth/callback + * 处理 Casdoor OAuth 回调 + */ +export const callback = async (ctx) => { + const { code } = ctx.query; + + if (!code) { + ctx.status = 400; + ctx.body = { + success: false, + message: 'Missing authorization code', + }; + return; + } + + try { + const redirectUri = process.env.CASDOOR_REDIRECT_URI || 'http://localhost:3000/api/auth/callback'; + const { user, token } = await handleCallback(code, redirectUri); + + // 重定向到前端仪表板,携带 token + ctx.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3000'}/dashboard?token=${token}`); + } catch (error) { + ctx.status = 401; + ctx.body = { + success: false, + message: error.message, + }; + } +}; + +/** + * POST /api/auth/logout + * 登出 + */ +export const logout = (ctx) => { + // JWT 是无状态的,客户端删除 token 即可 + ctx.body = { + success: true, + message: 'Logged out successfully', + }; +}; + +/** + * GET /api/auth/user + * 获取当前用户信息 + */ +export const getUser = async (ctx) => { + try { + const userId = ctx.state.user.userId; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + email: true, + avatar: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + ctx.status = 404; + ctx.body = { + success: false, + message: 'User not found', + }; + return; + } + + ctx.body = { + success: true, + data: user, + }; + } catch (error) { + ctx.status = 500; + ctx.body = { + success: false, + message: 'Failed to fetch user info', + }; + } +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..8622495 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,49 @@ +import Koa from 'koa'; +import bodyParser from 'koa-bodyparser'; +import cors from 'koa-cors'; +import { config } from './config/index.js'; +import authRoutes from './routes/auth.routes.js'; +import healthRoutes from './routes/health.routes.js'; + +const app = new Koa(); + +// 中间件 +app.use(cors()); +app.use(bodyParser()); + +// 日志中间件 +app.use(async (ctx, next) => { + const start = Date.now(); + await next(); + const ms = Date.now() - start; + console.log(`${ctx.method} ${ctx.path} - ${ms}ms`); +}); + +// 错误处理中间件 +app.use(async (ctx, next) => { + try { + await next(); + } catch (error) { + console.error('Server error:', error); + ctx.status = error.status || 500; + ctx.body = { + success: false, + message: error.message || 'Internal Server Error', + }; + } +}); + +// 路由 +app.use(authRoutes.routes()); +app.use(authRoutes.allowedMethods()); +app.use(healthRoutes.routes()); +app.use(healthRoutes.allowedMethods()); + +// 启动服务器 +const PORT = config.app.port; +app.listen(PORT, () => { + console.log(`🚀 Backend server running on http://localhost:${PORT}`); + console.log(`📊 Environment: ${config.app.env}`); +}); + +export default app; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..9b3e62e --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,32 @@ +import { verifyToken } from '../services/auth.service.js'; + +/** + * 认证中间件 - 验证 JWT token + */ +export const authMiddleware = async (ctx, next) => { + const authHeader = ctx.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + ctx.status = 401; + ctx.body = { + success: false, + message: 'Unauthorized: No token provided', + }; + return; + } + + const token = authHeader.split(' ')[1]; + const decoded = verifyToken(token); + + if (!decoded) { + ctx.status = 401; + ctx.body = { + success: false, + message: 'Unauthorized: Invalid token', + }; + return; + } + + ctx.state.user = decoded; + await next(); +}; diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js new file mode 100644 index 0000000..5e2ddd7 --- /dev/null +++ b/backend/src/routes/auth.routes.js @@ -0,0 +1,15 @@ +import Router from 'koa-router'; +import { login, callback, logout, getUser } from '../controllers/auth.controller.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = new Router({ prefix: '/api/auth' }); + +// 公开路由 +router.post('/login', login); +router.get('/callback', callback); +router.post('/logout', logout); + +// 需要认证的路由 +router.get('/user', authMiddleware, getUser); + +export default router; diff --git a/backend/src/routes/health.routes.js b/backend/src/routes/health.routes.js new file mode 100644 index 0000000..0eb6e68 --- /dev/null +++ b/backend/src/routes/health.routes.js @@ -0,0 +1,13 @@ +import Router from 'koa-router'; + +const router = new Router({ prefix: '/api' }); + +router.get('/health', (ctx) => { + ctx.body = { + success: true, + message: 'OK', + timestamp: new Date().toISOString(), + }; +}); + +export default router; diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js new file mode 100644 index 0000000..baf1ff4 --- /dev/null +++ b/backend/src/services/auth.service.js @@ -0,0 +1,109 @@ +import axios from 'axios'; +import jwt from 'jsonwebtoken'; +import { config } from '../config/index.js'; +import prisma from '../config/database.js'; + +/** + * 生成 Casdoor 登录 URL + */ +export const getLoginUrl = () => { + const { serverUrl, clientId, appName, orgName } = config.casdoor; + // Casdoor回调到前端3000端口,middleware代理转发到后端 + const redirectUri = process.env.CASDOOR_REDIRECT_URI || 'http://localhost:3000/api/auth/callback'; + const state = 'imtim-portal'; + + return `${serverUrl}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read&state=${state}&org=${orgName}&app=${appName}`; +}; + +/** + * 处理 Casdoor OAuth 回调 + */ +export const handleCallback = async (code, redirectUri) => { + try { + // 用 code 换取 token + const tokenResponse = await axios.post( + `${config.casdoor.serverUrl}/api/login/oauth/access_token`, + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: config.casdoor.clientId, + client_secret: config.casdoor.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + const { access_token } = tokenResponse.data; + + if (!access_token) { + console.error('Token response:', tokenResponse.data); + throw new Error('Failed to get access token'); + } + + // 获取用户信息 - Casdoor v3.x 使用 /api/get-account + const userResponse = await axios.get( + `${config.casdoor.serverUrl}/api/get-account`, + { + headers: { + 'Authorization': `Bearer ${access_token}`, + }, + } + ); + + console.log('Casdoor get-account response:', JSON.stringify(userResponse.data, null, 2)); + + const userInfo = userResponse.data.data; + + if (!userInfo || !userInfo.id) { + console.error('User info missing. Got:', userInfo); + throw new Error('Failed to get user info'); + } + + // 创建或更新本地用户 + const user = await prisma.user.upsert({ + where: { casdoorId: userInfo.id }, + update: { + username: userInfo.name || userInfo.displayName, + email: userInfo.email, + avatar: userInfo.avatar, + }, + create: { + username: userInfo.name || userInfo.displayName || 'user', + email: userInfo.email || null, + casdoorId: userInfo.id, + avatar: userInfo.avatar || null, + }, + }); + + // 生成 JWT + const jwtToken = jwt.sign( + { + userId: user.id, + username: user.username, + casdoorId: user.casdoorId, + }, + config.jwt.secret, + { expiresIn: config.jwt.expiresIn } + ); + + return { user, token: jwtToken }; + } catch (error) { + console.error('OAuth callback error:', error.response?.data || error.message); + throw new Error(`Authentication failed: ${error.message}`); + } +}; + +/** + * 验证 JWT token + */ +export const verifyToken = (token) => { + try { + return jwt.verify(token, config.jwt.secret); + } catch (error) { + return null; + } +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a9e7023 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + casdoor: + image: casbin/casdoor:v3.18.0 + container_name: casdoor + ports: + - "8000:8000" + environment: + - driverName=sqlite3 + - dataSourceName=casdoor.db + volumes: + - ./casdoor-data:/conf + restart: unless-stopped + + # 开发环境不需要单独启动app服务 + # app: + # build: . + # container_name: imtim-portal + # ports: + # - "3000:3000" + # environment: + # - CASDOOR_SERVER_URL=http://casdoor:8000 + # - CASDOOR_CLIENT_ID=your-client-id + # - CASDOOR_CLIENT_SECRET=your-client-secret + # - JWT_SECRET=your-jwt-secret + # depends_on: + # - casdoor diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..549302e --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,54 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + upstream frontend { + server 127.0.0.1:3002; # Next.js + } + + upstream backend { + server 127.0.0.1:3001; # Koa + } + + server { + listen 3000; + server_name localhost; + + # 前端路由 + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # API路由 + location /api/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/docker/start.sh b/docker/start.sh new file mode 100755 index 0000000..053969c --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +# 启动后端 +cd /app/backend +node src/index.js & + +# 启动前端 +cd /app/frontend +HOSTNAME=127.0.0.1 PORT=3002 node server.js & + +# 启动nginx +nginx -g 'daemon off;' diff --git a/docs/casdoor-setup.md b/docs/casdoor-setup.md new file mode 100644 index 0000000..0c3248d --- /dev/null +++ b/docs/casdoor-setup.md @@ -0,0 +1,204 @@ +# Casdoor v3.18.0 配置手册 + +本文档详细说明如何配置本地Docker部署的Casdoor实例,以便与base-imtim门户应用集成。 + +--- + +## 目录 + +1. [访问Casdoor管理界面](#1-访问casdoor管理界面) +2. [创建组织](#2-创建组织) +3. [创建应用](#3-创建应用) +4. [配置认证方式](#4-配置认证方式) +5. [配置到base-imtim](#5-配置到base-imtim) +6. [测试登录流程](#6-测试登录流程) +7. [常见问题排查](#7-常见问题排查) + +--- + +## 1. 访问Casdoor管理界面 + +### 1.1 确认Casdoor运行状态 + +```bash +docker ps | grep casdoor +``` + +### 1.2 访问管理后台 + +``` +http://localhost:8000 +``` + +### 1.3 登录 + +- **用户名**: `admin` +- **密码**: `123` (默认) + +> ⚠️ 首次登录后请修改密码 + +--- + +## 2. 创建组织 + +1. 左侧导航栏 → **组织管理** → **组织** +2. 点击 **添加** +3. 填写: + +| 字段 | 值 | +|------|------| +| 组织名称 | `imtim` | +| 显示名称 | `IMTIM Portal` | +| 密码类型 | `salt` | + +4. 点击 **保存** + +--- + +## 3. 创建应用 + +1. 左侧导航栏 → **应用管理** → **应用** +2. 点击 **添加** +3. 填写配置: + +### 基本信息 + +| 字段 | 值 | 说明 | +|------|------|------| +| 名称 | `base-imtim` | 应用唯一标识 | +| 显示名称 | `IMTIM Portal` | 界面显示名称 | +| 组织 | `imtim` | 选择刚创建的组织 | + +### 认证配置 (关键) + +| 字段 | 值 | 说明 | +|------|------|------| +| **重定向URL** | `http://localhost:3000/api/auth/callback` | **必须一致** | +| 令牌格式 | `JWT` | | +| 令牌有效期 | `168` | 7天(小时) | + +4. 点击 **保存** + +5. **复制凭据**: + - Client ID (如: `a129daf893d964c03bfb`) + - Client Secret (如: `c3ecaa0d0e3f315d0a1a164087c88cbdf8324f00`) + +--- + +## 4. 配置认证方式 + +### 4.1 创建测试用户 + +1. 左侧导航栏 → **用户管理** → **用户** +2. 点击 **添加** +3. 填写: + - 组织: `imtim` + - 用户名: 如 `imtim` + - 显示名称 + - 密码 + - 邮箱 (可选) +4. 点击 **保存** + +--- + +## 5. 配置到base-imtim + +### 5.1 创建后端配置文件 + +```bash +cd backend +cp .env.example .env +``` + +### 5.2 编辑 .env 文件 + +```env +# Casdoor配置 +CASDOOR_SERVER_URL=http://localhost:8000 +CASDOOR_CLIENT_ID=你的ClientID +CASDOOR_CLIENT_SECRET=你的ClientSecret +CASDOOR_ORG_NAME=imtim +CASDOOR_APP_NAME=base-imtim +CASDOOR_REDIRECT_URI=http://localhost:3000/api/auth/callback + +# JWT配置 +JWT_SECRET=your-super-secret-jwt-key + +# 应用配置 +APP_PORT=3001 +``` + +--- + +## 6. 测试登录流程 + +### 6.1 启动应用 + +```bash +# 后端 +cd backend && npm run dev + +# 前端 (新终端) +cd frontend && npm run dev +``` + +### 6.2 测试步骤 + +1. 访问 http://localhost:3000 +2. 点击登录按钮 +3. 跳转到Casdoor登录页面 +4. 使用测试用户登录 +5. 登录成功后应自动跳转到 /dashboard + +--- + +## 7. 常见问题排查 + +### 7.1 重定向URL不匹配 + +**错误**: `redirect URI mismatch` + +**解决**: 检查Casdoor应用配置中的重定向URL是否为 `http://localhost:3000/api/auth/callback` + +### 7.2 获取用户信息失败 + +**错误**: `Authentication failed: Failed to get user info` + +**解决**: Casdoor v3.x 使用 `/api/get-account` 而非 `/api/userinfo` + +### 7.3 Client ID或Secret错误 + +**错误**: `invalid_client` + +**解决**: 重新从Casdoor复制Client ID和Secret + +### 7.4 Code已使用 + +**错误**: `authorization code has been used` + +**说明**: OAuth code只能用一次,刷新页面重新登录 + +--- + +## 附录: Casdoor Docker部署 + +```yaml +version: '3.8' + +services: + casdoor: + image: casbin/casdoor:v3.18.0 + ports: + - "8000:8000" + environment: + - driverName=sqlite3 + - dataSourceName=casdoor.db + volumes: + - ./casdoor-data:/conf +``` + +--- + +**文档版本**: 1.1 +**最后更新**: 2026-04-16 +**适用Casdoor版本**: v3.18.0 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..fcf6b27 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://localhost:3001/api/:path*', + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f95003b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-separator": "^1.1.8", + "axios": "^1.15.0", + "http-proxy-middleware": "^3.0.5", + "lucide-react": "^1.8.0", + "next": "^16.2.4", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.4", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..2db8a8f --- /dev/null +++ b/frontend/src/app/api/auth/callback/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; + +// GET /api/auth/callback - 处理OAuth回调 +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + if (!code) { + return NextResponse.json( + { success: false, message: 'Missing authorization code' }, + { status: 400 } + ); + } + + // 重定向到后端处理 + const backendCallbackUrl = new URL('/api/auth/callback', BACKEND_URL); + backendCallbackUrl.searchParams.set('code', code); + if (state) backendCallbackUrl.searchParams.set('state', state); + + return NextResponse.redirect(backendCallbackUrl.toString()); +} diff --git a/frontend/src/app/api/auth/login/route.ts b/frontend/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..1de3f04 --- /dev/null +++ b/frontend/src/app/api/auth/login/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; + +// POST /api/auth/login - 获取登录URL +export async function POST() { + const res = await fetch(`${BACKEND_URL}/api/auth/login`, { + method: 'POST', + }); + const data = await res.json(); + return NextResponse.json(data); +} diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..2cbe03a --- /dev/null +++ b/frontend/src/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; + +// POST /api/auth/logout - 登出 +export async function POST() { + const res = await fetch(`${BACKEND_URL}/api/auth/logout`, { + method: 'POST', + }); + const data = await res.json(); + return NextResponse.json(data); +} diff --git a/frontend/src/app/api/auth/user/route.ts b/frontend/src/app/api/auth/user/route.ts new file mode 100644 index 0000000..0189443 --- /dev/null +++ b/frontend/src/app/api/auth/user/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; + +// GET /api/auth/user - 获取用户信息 +export async function GET(request: NextRequest) { + // 从 cookie 或 query 参数获取 token + const token = request.nextUrl.searchParams.get('token') || + request.cookies.get('token')?.value || + request.headers.get('authorization')?.replace('Bearer ', ''); + + if (!token) { + return NextResponse.json( + { success: false, message: 'No token provided' }, + { status: 401 } + ); + } + + const res = await fetch(`${BACKEND_URL}/api/auth/user`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..165217b --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; +import { getUserInfo } from '@/lib/api'; +import Layout from '@/components/layout'; + +function DashboardContent() { + const { login, initialize } = useAuthStore(); + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + const token = searchParams.get('token'); + if (token) { + const fetchUser = async () => { + try { + // 先用 URL 中的 token 直接请求用户信息 + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'}/auth/user`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + const user = result.data; + + // 成功后存入 localStorage + login(token, user); + router.replace('/dashboard'); + } catch (error) { + console.error('Failed to fetch user info:', error); + router.push('/login'); + } + }; + fetchUser(); + } + }, [searchParams, login, router]); + + return ( +
+

仪表板

+
+
+

欢迎

+

+ 您已成功登录 IMTIM Portal +

+
+
+

账户状态

+

已激活

+
+
+

系统信息

+

版本 1.0.0

+
+
+
+ ); +} + +export default function DashboardPage() { + return ( + + 加载中...}> + + + + ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..f1ee6bc --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,34 @@ +@import "tailwindcss"; + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --radius: 0.5rem; +} + +* { + border-color: var(--border); +} + +body { + background-color: var(--background); + color: var(--foreground); + font-family: system-ui, -apple-system, sans-serif; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..bd9c722 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'IMTIM Portal', + description: '企业级门户登录基座应用', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..60fe13a --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; +import { getLoginUrl } from '@/lib/api'; +import { LogIn } from 'lucide-react'; +import Layout from '@/components/layout'; + +export default function LoginPage() { + const { isAuthenticated } = useAuthStore(); + const router = useRouter(); + + useEffect(() => { + if (isAuthenticated) { + router.push('/dashboard'); + } + }, [isAuthenticated, router]); + + const handleLogin = async () => { + try { + const loginUrl = await getLoginUrl(); + window.location.href = loginUrl; + } catch (error) { + console.error('Failed to get login URL:', error); + } + }; + + return ( + +
+
+
+
+

登录

+

+ 使用 Casdoor 账号登录 IMTIM Portal +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..9f87232 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; +import { ArrowRight, Shield, Zap, Users } from 'lucide-react'; +import Layout from '@/components/layout'; + +export default function Home() { + const { isAuthenticated, initialize } = useAuthStore(); + const router = useRouter(); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + if (isAuthenticated) { + router.push('/dashboard'); + } + }, [isAuthenticated, router]); + + return ( + +
+ {/* Hero Section */} +
+
+

+ IMTIM Portal +

+

+ 企业级门户登录基座应用,安全、高效、易于扩展 +

+ +
+
+ + {/* Features Section */} +
+
+

核心特性

+
+
+ +

安全可靠

+

+ 基于 OAuth 2.0 的企业级认证,支持多因素认证 +

+
+
+ +

快速部署

+

+ Docker 一键部署,开箱即用,降低运维成本 +

+
+
+ +

易于扩展

+

+ 模块化架构设计,支持自定义业务逻辑和第三方集成 +

+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx new file mode 100644 index 0000000..68260af --- /dev/null +++ b/frontend/src/components/header.tsx @@ -0,0 +1,88 @@ +import Link from 'next/link'; +import { useAuthStore } from '@/lib/auth-store'; +import { getLoginUrl } from '@/lib/api'; +import { LogOut, User } from 'lucide-react'; +import * as Avatar from '@radix-ui/react-avatar'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; + +export default function Header() { + const { user, isAuthenticated, logout } = useAuthStore(); + + const handleLogin = async () => { + try { + const loginUrl = await getLoginUrl(); + window.location.href = loginUrl; + } catch (error) { + console.error('Failed to get login URL:', error); + } + }; + + const handleLogout = () => { + logout(); + window.location.href = '/'; + }; + + return ( +
+
+ + IMTIM Portal + + + +
+
+ ); +} diff --git a/frontend/src/components/layout.tsx b/frontend/src/components/layout.tsx new file mode 100644 index 0000000..02ab898 --- /dev/null +++ b/frontend/src/components/layout.tsx @@ -0,0 +1,19 @@ +import Header from '@/components/header'; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
{children}
+
+
+ © 2026 IMTIM Portal. All rights reserved. +
+
+
+ ); +} diff --git a/frontend/src/components/protected-route.tsx b/frontend/src/components/protected-route.tsx new file mode 100644 index 0000000..b3032ec --- /dev/null +++ b/frontend/src/components/protected-route.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, initialize } = useAuthStore(); + const router = useRouter(); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + if (!isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, router]); + + if (!isAuthenticated) { + return ( +
+
加载中...
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..f0d9628 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 请求拦截器 - 自动添加token +api.interceptors.request.use((config) => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; +}); + +// 获取登录URL +export const getLoginUrl = async () => { + const response = await api.post('/auth/login'); + return response.data.data.loginUrl; +}; + +// 获取用户信息 +export const getUserInfo = async () => { + const response = await api.get('/auth/user'); + return response.data.data; +}; + +// 登出 +export const logout = async () => { + const response = await api.post('/auth/logout'); + return response.data; +}; diff --git a/frontend/src/lib/auth-store.ts b/frontend/src/lib/auth-store.ts new file mode 100644 index 0000000..f828066 --- /dev/null +++ b/frontend/src/lib/auth-store.ts @@ -0,0 +1,55 @@ +'use client'; + +import { create } from 'zustand'; + +interface User { + id: string; + username: string; + email?: string; + avatar?: string; +} + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + login: (token: string, user: User) => void; + logout: () => void; + initialize: () => void; +} + +export const useAuthStore = create((set) => ({ + user: null, + token: null, + isAuthenticated: false, + + login: (token, user) => { + if (typeof window !== 'undefined') { + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(user)); + } + set({ user, token, isAuthenticated: true }); + }, + + logout: () => { + if (typeof window !== 'undefined') { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + set({ user: null, token: null, isAuthenticated: false }); + }, + + initialize: () => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('token'); + const userStr = localStorage.getItem('user'); + if (token && userStr) { + set({ + token, + user: JSON.parse(userStr), + isAuthenticated: true, + }); + } + } + }, +})); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +}