feat: OA 统一门户初始化 — LLDAP 认证 + 共享 JWT + 用户管理
- OA 聚合导航首页(站点卡片) - LLDAP 统一认证登录/登出 - 共享 JWT(tlyq_session, domain=.tlyq.ai)实现跨站点免登录 - 用户管理:创建/删除用户、角色分配、密码修改 - 深色/浅色主题切换 - 邮件通知(163 企业邮箱)
This commit is contained in:
commit
0351dba617
|
|
@ -0,0 +1,11 @@
|
|||
# OA 门户环境变量
|
||||
LDAP_URL=ldap://localhost:3890
|
||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
JWT_SECRET=change-me-same-across-all-sites
|
||||
COOKIE_DOMAIN=
|
||||
NODE_ENV=development
|
||||
SMTP_HOST=smtphz.qiye.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=gxp@qx002575.com
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=gxp@qx002575.com
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-05-14
|
||||
|
||||
- [新增] 管理员权限接入 LLDAP `lldap_admin` 组:`isLldapAdmin()` 通过 LDAP 查询组成员决定管理权限,不再硬编码 `username === 'admin'`;将用户加入该组即可获得 OA 管理权限
|
||||
- [优化] `/api/auth/me` 响应增加 `isAdmin` 字段,前端复用判断权限
|
||||
- [修复] 部署后 Cloudflare 缓存旧 HTML 引用已删除 chunk 导致 Application Error:middleware + nginx 双层 `Cache-Control: no-cache` 防缓存,部署脚本 `docker compose restart` 确保容器重启
|
||||
- [修复] deploy-ai.sh 对无 Dockerfile 站点(OA)容器不重启,Next.js 进程仍使用旧 `.next`:`docker compose up -d` 后追加 `docker compose restart`
|
||||
- [新增] `nginx-proxy-ai/conf.d/oa-ai.conf` 纳入版本控制,新服务器可直接部署
|
||||
- [优化] 统一顶栏 Header 组件:提取 `Header.tsx`(服务端)+ `HeaderUI.tsx`(客户端复用),三处页面(首页/用户管理/个人信息)共用同一顶栏,退出按钮、displayName、返回链接风格完全一致
|
||||
- [优化] 个人信息页"修改密码"改为折叠按钮,点击后展开表单;新增"确认新密码"字段 + 两次输入一致性校验
|
||||
- [优化] 门户首页欢迎语和右上角个人信息优先显示 displayName(显示名),回退 username
|
||||
- [优化] 门户退出按钮改为 SVG 门+箭头图标,暗色/亮色模式均清晰可见,hover 高亮
|
||||
- [优化] 创建用户弹窗(未填邮箱时)新增显示用户名和显示名,"复制密码"改为"复制信息",复制内容与邮件一致(含用户名、密码、登录地址、安全提示)
|
||||
- [修复] deploy-ai.sh 中 OA 的 localhost→域名 sed 替换在 macOS BSD sed 下静默失败,导致生产环境 OA 导航链接指向 localhost:改用 `sed $SED_I_BACKUP`,同时 page.tsx 新增 `siteUrl()` 自动根据 `NODE_ENV` 构造域名
|
||||
- [修复] OA 容器缺少 SMTP 环境变量,创建用户填邮箱时邮件发送静默失败:docker-compose.yml 添加 SMTP_HOST/PORT/USER/PASS/FROM
|
||||
- [修复] OA 创建用户报 401:`LDAP_ADMIN_PASS` 回退默认值与服务器 LLDAP 实际密码不匹配 → 改为 `docker exec lldap printenv` 动态获取
|
||||
- [修复] assets/issue 缺少 LDAP_URL/LDAP_BASE_DN,容器内回退 localhost 不可达 → docker-compose.yml 添加
|
||||
- [修复] assets/issue 缺少 COOKIE_DOMAIN,JWT_SECRET 不一致,跨站点免登录失效 → 统一硬编码 JWT_SECRET + COOKIE_DOMAIN
|
||||
- [修复] assets/issue 新用户 SSO 免登录自动创建:`getCurrentUser()` 增加 `INSERT OR IGNORE` 逻辑
|
||||
- [修复] assets/issue Docker 镜像原生模块 musl/glibc 不兼容导致登录 API 500 → Dockerfile 改为 `npm install` 在 COPY standalone 之后 + `npm rebuild better-sqlite3` + 删除 musl 变体
|
||||
- [修复] deploy-ai.sh `--build` 在无 Dockerfile 站点(OA)失败导致容器未重启 → 改为检测 Dockerfile 存在才加 `--build`
|
||||
- [部署] deploy-ai.sh 新增构建后清理 musl 残留 + 根据 Dockerfile 存在与否自动决定是否 `--build`
|
||||
- [调整] 所有站点 docker-compose.yml 统一 JWT_SECRET=oa-shared-jwt-secret-tlyq-2026、COOKIE_DOMAIN=.tlyq.ai
|
||||
|
||||
## 2026-05-12
|
||||
|
||||
- [新增] 修改密码增加复杂度要求:至少 8 位,大写/小写/数字/特殊字符 4 选 3,前端实时显示密码强度指示器
|
||||
- [修复] OA 容器只挂载单个 .db 文件导致 SQLite WAL 不共享,权限管理中已删除用户仍可见:改为挂载整个数据目录,WAL 文件实时同步
|
||||
- [新增] 创建用户支持邮件发送密码:新增邮箱输入框(选填),填写后通过 nodemailer 将用户名和密码发送至指定邮箱,未填写则弹窗显示(原流程)
|
||||
- [新增] `src/lib/email.ts` 邮件发送工具(163 企业邮箱 SMTP,HTML + 纯文本双版本)
|
||||
- [调整] 用户管理页标签重命名:「用户管理」→「删除用户」,「角色管理」→「权限管理」
|
||||
- [调整] 邮件提示修改密码只能通过 OA 统一门户,不能在子站点修改
|
||||
- [部署] OA 站点部署至 txjp 服务器,绑定域名 oa.tlyq.ai
|
||||
- [部署] Cloudflare Origin Certificate 部署(覆盖 *.tlyq.ai,15 年有效期)
|
||||
- [修复] OA 用户管理/角色管理加载缓慢:容器缺少 assets/issue 数据库卷挂载,LLDAP 缺少 sqlite3
|
||||
- [修复] OA 容器重启后 nginx DNS 缓存旧 IP 导致 502:重启 nginx 解决
|
||||
- [修复] www-ai 容器未启动导致 nginx 重载失败,连锁影响 OA 配置不生效
|
||||
- [调整] 所有站点 nginx 改为运行时 DNS 解析(resolver 127.0.0.11),容器离线不再阻塞 nginx 重载
|
||||
- [调整] OA API 路径改用环境变量(ASSETS_DB_PATH / ISSUE_DB_PATH),生产使用 Docker volume 路径
|
||||
- [调整] sso.tlyq.ai 301 重定向至 oa.tlyq.ai
|
||||
- [调整] assets/issue/oa JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026
|
||||
- [调整] assets/issue docker-compose 移除 AUTHELIA_URL,添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
|
||||
- [调整] LLDAP admin 密码同步为 3Vm!Y!@RCiPs
|
||||
|
||||
## 2026-05-11
|
||||
|
||||
- [新增] OA 统一门户项目创建:Next.js 框架、LLDAP 认证、共享 JWT 跨站点免登录
|
||||
- [新增] 门户首页:6 站点卡片聚合导航(资产管理/工单跟踪/官网/云平台/Token工厂/代码仓库)
|
||||
- [新增] 登录/退出功能:直连 LLDAP 认证,签发 tlyq_session 共享 JWT
|
||||
- [新增] 个人信息页:账户信息展示 + 会话有效期 + 修改密码(docker exec 调 lldap_set_password)
|
||||
- [新增] 用户管理(admin 专属):创建用户/用户列表/角色管理三 tab
|
||||
- [新增] 创建用户自动生成 12 位复杂密码,弹窗显示 + 一键复制
|
||||
- [新增] 创建用户自动同步至 assets-ai/issue-ai,支持角色预设
|
||||
- [新增] 创建用户角色下拉从各站点实时获取可用角色列表
|
||||
- [新增] 用户列表含删除功能(LLDAP + 各站点 SQLite 同步删除)
|
||||
- [新增] 角色管理:按站点修改用户角色,待保存机制 + 批量提交 + 确认弹窗
|
||||
- [新增] 深色/浅色主题切换(CSS 变量 + localStorage 持久化,同 assets/issue 风格)
|
||||
- [新增] 统一顶栏:主题按钮 + 用户头像 + 个人信息链接 + 图标退出
|
||||
- [新增] 站点卡片 hover 域名提示 + 动画效果
|
||||
- [新增] CLAUDE.md / README.md / CHANGELOG.md
|
||||
- [调整] 登录逻辑 v2.1:LLDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户
|
||||
- [调整] LLDAP 删除用户后,assets/issue 实时检测并踢出已登录用户
|
||||
- [调整] 会话有效期显示精确到秒
|
||||
- [优化] 用户管理页与首页顶栏样式统一(主题按钮/头像/退出图标)
|
||||
- [优化] 密码特殊字符通过 base64 编码传输,避免 shell 转义问题
|
||||
- [优化] 站点卡片统一高度,移动端适配
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
# CLAUDE.md — oa.tlyq.ai 统一门户
|
||||
|
||||
## 项目概述
|
||||
|
||||
oa-ai 是基于 Next.js 的 OA 统一门户,域名 `oa.tlyq.ai`。提供站点聚合导航、LLDAP 统一认证入口、用户管理(创建/删除/角色分配)、密码修改功能。各子站点(assets/issue)各自直连 LLDAP 认证,通过共享 JWT(`tlyq_session` cookie, domain=.tlyq.ai)实现跨站点免登录。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 站点域名 | `oa.tlyq.ai` |
|
||||
| 服务器 | txjp(IP: 43.133.38.210) |
|
||||
| 代码路径 | `/root/docker/oa-ai/` |
|
||||
| 本地端口 | 6179 |
|
||||
| 容器名 | `oa-ai` |
|
||||
| 默认账号 | `admin` / `admin123`(LLDAP 统一管理) |
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
npm run dev # 本地开发(端口 6179)
|
||||
npm run build # 生产构建
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/middleware.ts` | 全局路由守卫:检查 `tlyq_session` cookie,未认证跳转 /login |
|
||||
| `src/lib/jwt.ts` | 共享 JWT 签发/验证(HS256,与 assets/issue 共用密钥和格式) |
|
||||
| `src/lib/ldap.ts` | LLDAP 认证(ldapAuth)+ 密码修改(ldapChangePassword)+ 用户存在性检查 |
|
||||
| `src/lib/email.ts` | 邮件发送(nodemailer,163 企业邮箱,创建用户时发送凭证) |
|
||||
| `src/app/page.tsx` | 门户首页:站点卡片导航(核心系统 + 其他站点) |
|
||||
| `src/app/login/page.tsx` | 登录页(LLDAP 认证) |
|
||||
| `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) |
|
||||
| `src/app/admin/create-user/page.tsx` | 用户管理页(创建/删除/角色管理,仅 admin 可见) |
|
||||
| `src/app/api/auth/login/route.ts` | 登录 API(OA 仅 LLDAP 认证,无本地 DB) |
|
||||
| `src/app/api/auth/logout/route.ts` | 退出 API(清除 tlyq_session) |
|
||||
| `src/app/api/auth/change-password/route.ts` | 修改密码(docker exec 调 lldap_set_password) |
|
||||
| `src/app/api/admin/create-user/route.ts` | 创建用户(SQLite 写 LLDAP + 自动同步站点 + 角色设置) |
|
||||
| `src/app/api/admin/users/route.ts` | 用户列表/删除(GET/DELETE) |
|
||||
| `src/app/api/admin/user-roles/route.ts` | 角色查询/修改(GET/PUT) |
|
||||
| `src/components/ThemeToggle.tsx` | 深色/浅色主题切换按钮 |
|
||||
| `src/app/profile/change-password-form.tsx` | 修改密码客户端组件 |
|
||||
| `src/app/admin/create-user/role-manager.tsx` | 角色管理组件(待保存机制 + 批量提交) |
|
||||
|
||||
---
|
||||
|
||||
## 认证机制
|
||||
|
||||
OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证:
|
||||
|
||||
1. 用户登录 → LDAP bind 验证 → 签发 `tlyq_session` JWT(7 天)
|
||||
2. 中间件检查 `tlyq_session` → 有效则设置 `session` cookie → 放行
|
||||
3. 退出清除 `tlyq_session`
|
||||
|
||||
跨站点共享:assets/issue 各自中间件优先检查 `tlyq_session`,有效则免登录。
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 本地与云端差异
|
||||
|
||||
| 环境变量 | 本地开发 | 云服务器(txjp) |
|
||||
|---------|---------|----------------|
|
||||
| `LDAP_URL` | `ldap://localhost:3890` | `ldap://lldap:3890` |
|
||||
| `LDAP_BASE_DN` | `dc=tlyq,dc=ai` | 同 |
|
||||
| `LDAP_ADMIN_DN` | `uid=admin,ou=people,dc=tlyq,dc=ai` | 同 |
|
||||
| `LDAP_ADMIN_PASS` | `admin123` | LLDAP admin 密码 |
|
||||
| `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) |
|
||||
| `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` |
|
||||
| `SMTP_HOST` | `smtphz.qiye.163.com` | 163 企业邮箱 |
|
||||
| `SMTP_PORT` | `465` | SSL 端口 |
|
||||
| `SMTP_USER` | `gxp@qx002575.com` | 发件邮箱 |
|
||||
| `SMTP_PASS` | 见 .env | 邮箱密码 |
|
||||
| `SMTP_FROM` | `gxp@qx002575.com` | 发件人地址 |
|
||||
|
||||
### `.env` 示例
|
||||
|
||||
```bash
|
||||
LDAP_URL=ldap://localhost:3890
|
||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||
LDAP_ADMIN_PASS=admin123
|
||||
JWT_SECRET=dev-secret-key-local
|
||||
COOKIE_DOMAIN=
|
||||
NODE_ENV=development
|
||||
SMTP_HOST=smtphz.qiye.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=gxp@qx002575.com
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=gxp@qx002575.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```
|
||||
txjp 服务器
|
||||
├── oa-ai(容器) ← Next.js standalone,监听 3000
|
||||
├── nginx-ai ← 反向代理 oa.tlyq.ai → oa-ai:3000
|
||||
└── webnet(external) ← 共享网络
|
||||
```
|
||||
|
||||
生产环境需要 OA 容器能访问 Docker socket(`/var/run/docker.sock`)以执行 `docker exec lldap` 修改密码和创建用户。
|
||||
|
||||
---
|
||||
|
||||
## 主题切换
|
||||
|
||||
支持深色/浅色双主题:
|
||||
- `layout.tsx` 注入 `<script>` 读取 localStorage/系统偏好设置 `dark` class
|
||||
- CSS 变量 `var(--bg)`、`var(--text)` 等响应主题变化
|
||||
- `ThemeToggle` 组件:`document.documentElement.classList.toggle('dark')` + localStorage 持久化
|
||||
- 配色对齐 assets/issue(slate/blue)
|
||||
|
||||
---
|
||||
|
||||
## 站点 URL 规则
|
||||
|
||||
源码中站点跳转 URL 均使用 localhost,部署时 `deploy-ai.sh` 通过 `sed` 自动替换为生产域名。
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
|
||||
- **新增页面**:在 `src/app/` 下创建,中间件自动检查认证
|
||||
- **API 路由**:`/api/auth/` 公开,`/api/admin/` 需 admin 权限
|
||||
- **admin 校验**:`verifySharedJwt(token).username === 'admin'`
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### OA 502 Bad Gateway
|
||||
|
||||
**常见原因**:nginx DNS 缓存旧 IP。OA 容器重启后 IP 变化,nginx 仍向旧 IP 发送请求。
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
docker restart nginx-ai
|
||||
```
|
||||
|
||||
### 用户管理/角色管理一直加载
|
||||
|
||||
**原因**:OA 容器未挂载 assets/issue 的 SQLite 数据库文件,或 LLDAP 容器缺少 `sqlite3`。
|
||||
|
||||
**检查**:
|
||||
```bash
|
||||
docker exec oa-ai ls /data/other-sites/assets/ # 应有 assets.db, assets.db-wal, assets.db-shm
|
||||
docker exec oa-ai ls /data/other-sites/issue/ # 应有 issue.db, issue.db-wal, issue.db-shm
|
||||
docker exec lldap which sqlite3 # 应有 /usr/bin/sqlite3
|
||||
```
|
||||
|
||||
**LLDAP 容器 sqlite3 安装**(每次容器重建后需重新安装):
|
||||
```bash
|
||||
docker exec lldap apk add --no-cache sqlite
|
||||
```
|
||||
|
||||
### 生产部署关键点
|
||||
|
||||
- **Docker socket**:OA 需 `/var/run/docker.sock` 挂载(用于 `docker exec lldap` 创建用户和改密)
|
||||
- **数据库卷**:OA 需挂载 assets/issue 的 Docker volume **整个数据目录**(非单个文件),确保 SQLite WAL 文件(`.db-wal`/`.db-shm`)共享,否则权限管理页面无法实时反映子站点用户变更:
|
||||
- `/var/lib/docker/volumes/assets-ai_assets-data/_data` → `/data/other-sites/assets`
|
||||
- `/var/lib/docker/volumes/issue-ai_issue-data/_data` → `/data/other-sites/issue`
|
||||
- **WAL 共享**:OA 容器必须挂载整个目录而非单个 `.db` 文件。挂载单个文件会导致 WAL 文件独立,sqlite3 CLI 读取过期数据。参见 `docker-compose.yml` 中的 volumes 配置
|
||||
- **JWT 密钥**:三站点必须一致(`JWT_SECRET=oa-shared-jwt-secret-tlyq-2026`)
|
||||
- **COOKIE_DOMAIN**:生产必须设为 `.tlyq.ai`
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# OA 统一门户
|
||||
|
||||
基于 Next.js 的 OA 统一门户,域名 `oa.tlyq.ai`。提供站点聚合导航、LLDAP 统一认证、用户管理、密码修改。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 分类 | 技术 |
|
||||
|------|------|
|
||||
| 框架 | Next.js 15 + TypeScript |
|
||||
| UI | React 19(inline CSS + CSS 变量主题) |
|
||||
| 认证 | LLDAP(ldapts)+ 共享 JWT(HS256) |
|
||||
| 部署 | Docker + nginx 反向代理 |
|
||||
|
||||
## 功能
|
||||
|
||||
- 站点聚合导航(核心系统 + 其他站点,新标签页打开)
|
||||
- LLDAP 统一认证入口(与 assets/issue 共享 JWT 免登录)
|
||||
- 个人信息(账户信息 + 修改密码,docker exec 调 LLDAP)
|
||||
- 用户管理(仅 admin 可见):
|
||||
- 创建用户(自动生成 12 位密码 + 弹窗显示 + 一键复制)
|
||||
- 用户列表(含删除,LLDAP + 各站点同步清理)
|
||||
- 角色管理(各站点角色批量修改,待保存机制)
|
||||
- 深色/浅色主题切换(同 assets/issue 风格)
|
||||
- LLDAP 删除用户后,各站点下次操作自动踢出
|
||||
|
||||
## 页面结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `/login` | 登录页 |
|
||||
| `/` | 门户首页(站点卡片 + 导航) |
|
||||
| `/profile` | 个人信息(账户信息 + 修改密码) |
|
||||
| `/admin/create-user` | 用户管理(仅 admin,含创建/删除/角色管理) |
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 确保 LLDAP 已启动(端口 3890)
|
||||
brew services start lldap # 或 docker compose up
|
||||
|
||||
npm install
|
||||
npm run dev # http://localhost:6179
|
||||
```
|
||||
|
||||
默认管理员:`admin` / `admin123`(LLDAP 统一管理)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [OA 设计文档](../docs/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤
|
||||
- [OA UI 设计](../docs/OA-UI-DESIGN.md) — 页面设计、配色方案
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# 本地开发不使用 Docker,直接用 npm run dev
|
||||
# 此文件仅用于 docker compose 配置完整性
|
||||
services:
|
||||
oa-ai:
|
||||
ports:
|
||||
- "6179:3000"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
services:
|
||||
oa-ai:
|
||||
build: .
|
||||
container_name: oa-ai
|
||||
ports:
|
||||
- "6179:3000"
|
||||
environment:
|
||||
- LDAP_URL=ldap://lldap:3890
|
||||
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
|
||||
- COOKIE_DOMAIN=.tlyq.ai
|
||||
- NODE_ENV=production
|
||||
- TZ=Asia/Shanghai
|
||||
- ASSETS_DB_PATH=/data/other-sites/assets/assets.db
|
||||
- ISSUE_DB_PATH=/data/other-sites/issue/issue.db
|
||||
- SMTP_HOST=smtphz.qiye.163.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_USER=gxp@qx002575.com
|
||||
- SMTP_PASS=qhQcTaR6rAzCnHQk
|
||||
- SMTP_FROM=gxp@qx002575.com
|
||||
volumes:
|
||||
- ./.next:/app/.next
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 挂载整个数据目录(非单个文件),确保 SQLite WAL 文件共享
|
||||
- /var/lib/docker/volumes/assets-ai_assets-data/_data:/data/other-sites/assets
|
||||
- /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue
|
||||
networks:
|
||||
- webnet
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
webnet:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from 'next'
|
||||
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
export default config
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "oa-ai",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 6179",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"ldapts": "^6.0.0",
|
||||
"next": "^15.0.0",
|
||||
"nodemailer": "^8.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import HeaderUI from '@/components/HeaderUI'
|
||||
import RoleManager from './role-manager'
|
||||
|
||||
interface Role { name: string; display_name: string }
|
||||
interface LdapUser { username: string; email: string; displayName: string; createdAt: string }
|
||||
export default function AdminUsersPage() {
|
||||
const [tab, setTab] = useState<'create' | 'manage' | 'roles'>('create')
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [assetsRole, setAssetsRole] = useState('viewer')
|
||||
const [issueRole, setIssueRole] = useState('viewer')
|
||||
const [assetsRoles, setAssetsRoles] = useState<Role[]>([])
|
||||
const [issueRoles, setIssueRoles] = useState<Role[]>([])
|
||||
const [users, setUsers] = useState<LdapUser[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [loginUser, setLoginUser] = useState('')
|
||||
const [loginDisplayName, setLoginDisplayName] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
// 密码弹窗
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [generatedPwd, setGeneratedPwd] = useState('')
|
||||
const [pwdUser, setPwdUser] = useState('')
|
||||
const [pwdName, setPwdName] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
try {
|
||||
const [rolesR, usersR] = await Promise.all([
|
||||
fetch('/api/admin/roles').then(r => r.json()),
|
||||
fetch('/api/admin/users').then(r => r.json()),
|
||||
])
|
||||
if (rolesR.assets?.length) setAssetsRoles(rolesR.assets)
|
||||
if (rolesR.issue?.length) setIssueRoles(rolesR.issue)
|
||||
if (usersR.users?.length) setUsers(usersR.users)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const fetchLoginUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
const d = await res.json()
|
||||
if (d.user) {
|
||||
setLoginUser(d.user.username || '')
|
||||
setLoginDisplayName(d.user.displayName || d.user.username || '')
|
||||
setIsAdmin(d.user.isAdmin || false)
|
||||
}
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchRoles(); fetchLoginUser() }, [fetchRoles, fetchLoginUser])
|
||||
|
||||
async function refreshUsers() {
|
||||
try {
|
||||
const u = await fetch('/api/admin/users').then(r => r.json())
|
||||
if (u.users?.length) setUsers(u.users)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setResult(null); setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/create-user', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, displayName, assetsRole, issueRole, email: email || undefined }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (res.ok) {
|
||||
if (d.password) {
|
||||
setGeneratedPwd(d.password); setPwdUser(username); setPwdName(displayName || username); setShowPwd(true); setCopied(false)
|
||||
}
|
||||
setUsername(''); setDisplayName(''); setEmail('')
|
||||
refreshUsers()
|
||||
}
|
||||
setResult({ ok: res.ok, msg: d.message || d.error || '操作完成' })
|
||||
} catch { setResult({ ok: false, msg: '网络错误' }) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const text = `您好,${pwdName}:
|
||||
|
||||
您的 OA 统一门户账号已创建,请使用以下信息登录:
|
||||
|
||||
用户名:${pwdUser}
|
||||
密 码:${generatedPwd}
|
||||
|
||||
登录地址:https://oa.tlyq.ai
|
||||
|
||||
⚠ 请注意:
|
||||
修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。
|
||||
请登录 OA 后在个人资料页修改密码。
|
||||
|
||||
请在首次登录后及时修改密码。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
如有疑问,请联系管理员:gxp@qx002575.com`
|
||||
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch {}
|
||||
}
|
||||
|
||||
async function handleDelete(target: string) {
|
||||
if (!confirm(`确定删除用户「${target}」?\n\n将从 LLDAP 及所有站点中永久删除,不可撤销。`)) return
|
||||
setDeleting(target)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: target }),
|
||||
})
|
||||
const d = await res.json()
|
||||
setResult({ ok: res.ok, msg: res.ok ? `已删除 ${target}` : (d.error || '删除失败') })
|
||||
if (res.ok) setUsers(users.filter(u => u.username !== target))
|
||||
} catch { setResult({ ok: false, msg: '网络错误' }) }
|
||||
finally { setDeleting(null) }
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 42, padding: '0 12px',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
||||
}
|
||||
const tabStyle = (t: string): React.CSSProperties => ({
|
||||
padding: '10px 20px', border: 'none', background: tab === t ? 'var(--bg-card)' : 'transparent',
|
||||
color: tab === t ? '#2563eb' : 'var(--text-secondary)', cursor: 'pointer',
|
||||
fontSize: 14, fontWeight: tab === t ? 600 : 400,
|
||||
borderBottom: tab === t ? '2px solid #2563eb' : '2px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<HeaderUI displayName={loginDisplayName || loginUser} isAdmin={isAdmin} backLabel="用户管理" />
|
||||
|
||||
{/* 密码弹窗 */}
|
||||
{showPwd && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }} onClick={() => setShowPwd(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: '36px 40px', boxShadow: '0 20px 60px rgba(0,0,0,0.2)', textAlign: 'center', maxWidth: 420, width: '90%', border: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔑</div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px', color: 'var(--text)' }}>用户创建成功</h2>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 16px' }}>
|
||||
{pwdName !== pwdUser ? `${pwdName}(${pwdUser})` : pwdUser}
|
||||
</p>
|
||||
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 20px', fontSize: 22, fontWeight: 700, fontFamily: 'monospace', letterSpacing: '0.05em', color: '#2563eb', marginBottom: 8, wordBreak: 'break-all', userSelect: 'all' }}>{generatedPwd}</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: '0 0 20px' }}>初始密码,请妥善保存</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={handleCopy} style={{ flex: 1, height: 42, borderRadius: 8, border: 'none', cursor: 'pointer', background: copied ? '#f0fdf4' : '#2563eb', color: copied ? '#16a34a' : '#fff', fontSize: 14, fontWeight: 500, transition: 'all 0.2s' }}>{copied ? '✓ 已复制' : '复制信息'}</button>
|
||||
<button onClick={() => setShowPwd(false)} style={{ flex: 1, height: 42, borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-card)', color: 'var(--text-secondary)', fontSize: 14, cursor: 'pointer' }}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 60, paddingBottom: 60 }}>
|
||||
<div style={{ width: 680 }}>
|
||||
|
||||
{/* Tab */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 0 }}>
|
||||
<button onClick={() => { setTab('create'); setResult(null) }} style={tabStyle('create')}>创建用户</button>
|
||||
<button onClick={() => { setTab('manage'); setResult(null) }} style={tabStyle('manage')}>删除用户</button>
|
||||
<button onClick={() => { setTab('roles'); setResult(null) }} style={tabStyle('roles')}>权限管理</button>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderTop: 'none', borderRadius: '0 0 12px 12px', padding: '28px 30px', boxShadow: '0 1px 3px rgba(0,0,0,0.06)' }}>
|
||||
|
||||
{result && (
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, marginBottom: 18, fontSize: 13, background: result.ok ? '#f0fdf4' : '#fef2f2', color: result.ok ? '#16a34a' : '#dc2626' }}>
|
||||
{result.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== 创建用户 ====== */}
|
||||
{tab === 'create' && (
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>用户名</div>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="英文用户名" required style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>显示名</div>
|
||||
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="可选" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>邮箱地址 <span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}>(选填,填写后将密码发送至此邮箱)</span></div>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="user@example.com" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16, marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 12 }}>各站点角色</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>资产管理</div>
|
||||
<select value={assetsRole} onChange={e => setAssetsRole(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', appearance: 'auto' as any }}>
|
||||
{assetsRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>工单跟踪</div>
|
||||
<select value={issueRole} onChange={e => setIssueRole(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', appearance: 'auto' as any }}>
|
||||
{issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} style={{
|
||||
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb', color: '#fff',
|
||||
border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}>{loading ? '创建中...' : '创建用户'}</button>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 12, textAlign: 'center' }}>密码自动生成,创建成功后弹窗显示</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ====== 用户管理 ====== */}
|
||||
{tab === 'manage' && (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>LLDAP 不支持禁用用户,如需停用请删除。已登录用户在删除后下次操作自动退出。</p>
|
||||
{users.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>加载中...</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{users.map(u => (
|
||||
<div key={u.username} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{u.username}</span>
|
||||
{u.displayName !== u.username && <span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 8 }}>{u.displayName}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.createdAt?.substring(0, 10)}</span>
|
||||
{(u.username === 'admin' || u.username === 'localadmin') ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', padding: '2px 10px', background: 'var(--bg-hover)', borderRadius: 10 }}>系统保留</span>
|
||||
) : (
|
||||
<button onClick={() => handleDelete(u.username)} disabled={deleting === u.username} style={{
|
||||
padding: '4px 14px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: deleting === u.username ? '#fecaca' : '#fef2f2',
|
||||
color: deleting === u.username ? '#fca5a5' : '#dc2626',
|
||||
fontSize: 12, fontWeight: 500,
|
||||
}}>{deleting === u.username ? '...' : '删除'}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== 角色管理 ====== */}
|
||||
{tab === 'roles' && (
|
||||
<RoleManager
|
||||
inputStyle={inputStyle}
|
||||
setResult={setResult}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface SiteUser { username: string; display_name: string; role: string }
|
||||
interface RoleData {
|
||||
assetsUsers: SiteUser[]; issueUsers: SiteUser[]
|
||||
assetsRoles: string[]; issueRoles: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
inputStyle: React.CSSProperties
|
||||
setResult: (r: { ok: boolean; msg: string } | null) => void
|
||||
}
|
||||
|
||||
export default function RoleManager({ inputStyle, setResult }: Props) {
|
||||
const [roleData, setRoleData] = useState<RoleData | null>(null)
|
||||
const [pending, setPending] = useState<Record<string, { site: string; newRole: string }>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchRoleData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/user-roles')
|
||||
const d = await res.json()
|
||||
if (d.users) setRoleData({
|
||||
assetsUsers: d.users.assets || [],
|
||||
issueUsers: d.users.issue || [],
|
||||
assetsRoles: d.assetsRoles || [],
|
||||
issueRoles: d.issueRoles || [],
|
||||
})
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchRoleData() }, [fetchRoleData])
|
||||
|
||||
function handleSelect(site: string, username: string, newRole: string, originalRole: string) {
|
||||
if (newRole === originalRole) {
|
||||
// 改回原值,清除 pending
|
||||
const next = { ...pending }
|
||||
delete next[`${site}:${username}`]
|
||||
setPending(next)
|
||||
} else {
|
||||
setPending({ ...pending, [`${site}:${username}`]: { site, newRole } })
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentRole(site: string, username: string, originalRole: string): string {
|
||||
const key = `${site}:${username}`
|
||||
return pending[key]?.newRole || originalRole
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(pending).length === 0) return
|
||||
if (!confirm(`确定保存 ${Object.keys(pending).length} 项角色修改?`)) return
|
||||
|
||||
setSaving(true)
|
||||
let ok = 0; let fail = 0
|
||||
for (const [key, { site, newRole }] of Object.entries(pending)) {
|
||||
const username = key.split(':')[1]
|
||||
try {
|
||||
const res = await fetch('/api/admin/user-roles', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, site, role: newRole }),
|
||||
})
|
||||
if (res.ok) ok++; else fail++
|
||||
} catch { fail++ }
|
||||
}
|
||||
|
||||
setResult({ ok: fail === 0, msg: `已保存:${ok} 项成功${fail > 0 ? `,${fail} 项失败` : ''}` })
|
||||
setPending({})
|
||||
if (fail === 0) fetchRoleData()
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>加载中...</p>
|
||||
|
||||
// 合并两个站点用户
|
||||
const userMap = new Map<string, { displayName: string; assetsRole: string; issueRole: string }>()
|
||||
roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, assetsRole: u.role, issueRole: '—' }))
|
||||
roleData.issueUsers.forEach(u => {
|
||||
const e = userMap.get(u.username)
|
||||
if (e) e.issueRole = u.role
|
||||
else userMap.set(u.username, { displayName: u.display_name, assetsRole: '—', issueRole: u.role })
|
||||
})
|
||||
const users = Array.from(userMap.entries())
|
||||
const changed = Object.keys(pending).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
|
||||
修改角色后点击「保存修改」统一提交
|
||||
{changed > 0 && <span style={{ color: '#d97706', fontWeight: 600, marginLeft: 8 }}>({changed} 项待保存)</span>}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={changed === 0 || saving}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 8, border: 'none', cursor: changed > 0 ? 'pointer' : 'not-allowed',
|
||||
background: changed > 0 ? '#d97706' : 'var(--bg-hover)',
|
||||
color: changed > 0 ? '#fff' : 'var(--text-muted)',
|
||||
fontSize: 13, fontWeight: 500, transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12, padding: '0 0 10px', borderBottom: '2px solid var(--border)', fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||
<div>用户名</div><div>显示名</div><div>资产管理</div><div>工单跟踪</div>
|
||||
</div>
|
||||
|
||||
{users.map(([uname, info]) => (
|
||||
<div key={uname} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12, alignItems: 'center', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{uname}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{info.displayName}</div>
|
||||
{/* 资产管理 */}
|
||||
<RoleCell
|
||||
site="assets" username={uname} originalRole={info.assetsRole}
|
||||
roles={roleData.assetsRoles} pending={pending}
|
||||
inputStyle={inputStyle} onSelect={handleSelect} getCurrentRole={getCurrentRole}
|
||||
/>
|
||||
{/* 工单跟踪 */}
|
||||
<RoleCell
|
||||
site="issue" username={uname} originalRole={info.issueRole}
|
||||
roles={roleData.issueRoles} pending={pending}
|
||||
inputStyle={inputStyle} onSelect={handleSelect} getCurrentRole={getCurrentRole}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleCell({ site, username, originalRole, roles, pending, inputStyle, onSelect, getCurrentRole }: {
|
||||
site: string; username: string; originalRole: string; roles: string[]
|
||||
pending: Record<string, { site: string; newRole: string }>
|
||||
inputStyle: React.CSSProperties
|
||||
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
|
||||
getCurrentRole: (site: string, username: string, originalRole: string) => string
|
||||
}) {
|
||||
const isReserved = username === 'admin' || username === 'localadmin'
|
||||
const key = `${site}:${username}`
|
||||
const changed = !!pending[key]
|
||||
const currentRole = getCurrentRole(site, username, originalRole)
|
||||
|
||||
if (isReserved) {
|
||||
return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span>
|
||||
}
|
||||
if (originalRole === '—' && !changed) {
|
||||
return <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>未同步</span>
|
||||
}
|
||||
return (
|
||||
<select
|
||||
value={currentRole}
|
||||
onChange={e => onSelect(site, username, e.target.value, originalRole)}
|
||||
style={{
|
||||
...inputStyle, height: 34, fontSize: 12, cursor: 'pointer', appearance: 'auto' as any,
|
||||
width: 'auto', minWidth: 110,
|
||||
border: changed ? '1px solid #d97706' : '1px solid var(--border)',
|
||||
background: changed ? 'rgba(217,119,6,0.06)' : 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
{roles.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import { sendCredentialsEmail } from '@/lib/email'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
function generatePassword(): string {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const lower = 'abcdefghjkmnpqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
const special = '!@#$%&*'
|
||||
const all = upper + lower + digits + special
|
||||
const crypto = globalThis.crypto
|
||||
const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length]
|
||||
// 确保每种类型至少一个,其余随机填充到 12 位
|
||||
let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special)
|
||||
for (let i = 4; i < 12; i++) pwd += pick(all)
|
||||
// 打乱顺序
|
||||
return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('')
|
||||
}
|
||||
|
||||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${siteUrl}/api/internal/roles`, {
|
||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await res.json()
|
||||
return (data.roles || []).map((r: { name: string }) => r.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${siteUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 直接更新站点 SQLite 数据库中的用户角色
|
||||
function setRoleSQL(dbPath: string, username: string, role: string): string {
|
||||
return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"`
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
const session = verifySharedJwt(token)
|
||||
if (!session || !(await isLldapAdmin(session.username))) {
|
||||
return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { username, displayName, assetsRole, issueRole, email } = await request.json()
|
||||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
||||
if (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 })
|
||||
|
||||
const password = generatePassword()
|
||||
|
||||
// 从各站点实时获取可用角色列表
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer'
|
||||
const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer'
|
||||
|
||||
const safeName = (displayName || username).replace(/'/g, "'\\''")
|
||||
const safeUser = username.replace(/'/g, "'\\''")
|
||||
const lldapEmail = `${username}@tlyq.ai`
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
const userUuid = crypto.randomUUID()
|
||||
|
||||
// 1. LLDAP SQLite 插入用户
|
||||
const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');`
|
||||
await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 })
|
||||
|
||||
// 2. 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置)
|
||||
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
|
||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
||||
|
||||
// 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题
|
||||
const b64Pass = Buffer.from(password).toString('base64')
|
||||
await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 })
|
||||
const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'`
|
||||
await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 })
|
||||
|
||||
// 3. 自动登录各站点触发用户同步
|
||||
const [assetsOk, issueOk] = await Promise.all([
|
||||
syncToSite('http://localhost:6177', username, password),
|
||||
syncToSite('http://localhost:6176', username, password),
|
||||
])
|
||||
|
||||
// 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值)
|
||||
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
|
||||
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
|
||||
const roleResults = { assets: false, issue: false }
|
||||
if (assetsOk) {
|
||||
try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {}
|
||||
}
|
||||
if (issueOk) {
|
||||
try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {}
|
||||
}
|
||||
|
||||
// 5. 如果提供了邮箱,发送凭证邮件
|
||||
let emailSent = false
|
||||
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
try {
|
||||
await sendCredentialsEmail(email, username, password, displayName || username)
|
||||
emailSent = true
|
||||
} catch (e) {
|
||||
console.error('发送邮件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
password: emailSent ? undefined : password,
|
||||
synced: { assets: assetsOk, issue: issueOk },
|
||||
roles: { assets: ar, issue: ir, applied: roleResults },
|
||||
emailSent,
|
||||
message: emailSent
|
||||
? `用户已创建,密码已发送至 ${email}`
|
||||
: '用户已创建并同步至所有站点',
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
async function fetchRoles(url: string): Promise<{ name: string; display_name: string }[]> {
|
||||
try {
|
||||
const res = await fetch(`${url}/api/internal/roles`, {
|
||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.roles || []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
const session = verifySharedJwt(token)
|
||||
if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
return NextResponse.json({ assets: assetsRoles, issue: issueRoles })
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${siteUrl}/api/internal/roles`, {
|
||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await res.json()
|
||||
return (data.roles || []).map((r: { name: string }) => r.name)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function queryDb(dbPath: string, sql: string): Promise<string> {
|
||||
return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '')
|
||||
}
|
||||
|
||||
async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> {
|
||||
const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;')
|
||||
return out.trim().split('\n').filter(Boolean).map(line => {
|
||||
const [username, display_name, role] = line.split('|')
|
||||
return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' }
|
||||
})
|
||||
}
|
||||
|
||||
async function checkAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return false
|
||||
const session = verifySharedJwt(token)
|
||||
return session ? isLldapAdmin(session.username) : false
|
||||
}
|
||||
|
||||
// GET — 列出各站点用户及其角色
|
||||
export async function GET() {
|
||||
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
const [assetsUsers, issueUsers] = await Promise.all([
|
||||
getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles),
|
||||
getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
assetsRoles,
|
||||
issueRoles,
|
||||
users: { assets: assetsUsers, issue: issueUsers },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT — 更新用户角色
|
||||
export async function PUT(request: Request) {
|
||||
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const { username, site, role } = await request.json()
|
||||
if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
|
||||
if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 })
|
||||
|
||||
const dbPath = site === 'assets'
|
||||
? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db')
|
||||
: (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db')
|
||||
|
||||
// 验证角色合法性
|
||||
const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`)
|
||||
if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 })
|
||||
|
||||
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now') WHERE username='${username}';"`, { timeout: 3000 })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: '更新失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
function checkAdmin() {
|
||||
return async () => {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return false
|
||||
const session = verifySharedJwt(token)
|
||||
return session ? isLldapAdmin(session.username) : false
|
||||
}
|
||||
}
|
||||
|
||||
// GET — 列出 LLDAP 中所有用户
|
||||
export async function GET() {
|
||||
const isAdmin = await checkAdmin()()
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
const users = stdout.trim().split('\n').filter(Boolean).map(line => {
|
||||
const [user_id, email, display_name, creation_date] = line.split('|')
|
||||
return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date }
|
||||
})
|
||||
return NextResponse.json({ users })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE — 删除用户(LLDAP + 各站点)
|
||||
export async function DELETE(request: Request) {
|
||||
const isAdmin = await checkAdmin()()
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const { username } = await request.json()
|
||||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
||||
if (username === 'admin' || username === 'localadmin') {
|
||||
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeUser = username.replace(/'/g, "''")
|
||||
|
||||
// 删除 LLDAP 用户
|
||||
const lldapSQL = `DELETE FROM users WHERE user_id='${safeUser}';`
|
||||
await execAsync(
|
||||
`docker exec lldap /bin/sh -c "cat > /tmp/del.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/del.sql"`,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// 删除各站点本地用户
|
||||
const results: Record<string, boolean> = {}
|
||||
for (const [site, dbPath] of Object.entries({
|
||||
assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db',
|
||||
issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db',
|
||||
})) {
|
||||
try {
|
||||
await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 })
|
||||
results[site] = true
|
||||
} catch { results[site] = false }
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, deleted: results })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: '删除失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const session = verifySharedJwt(token)
|
||||
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
||||
|
||||
const { currentPassword, newPassword } = await request.json()
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: '请输入当前密码和新密码' }, { status: 400 })
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 })
|
||||
}
|
||||
// 密码复杂度:大写/小写/数字/特殊字符 4选3
|
||||
const hasUpper = /[A-Z]/.test(newPassword)
|
||||
const hasLower = /[a-z]/.test(newPassword)
|
||||
const hasDigit = /[0-9]/.test(newPassword)
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(newPassword)
|
||||
const complexityScore = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
||||
if (complexityScore < 3) {
|
||||
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置)
|
||||
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
|
||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
||||
|
||||
const safeUser = session.username.replace(/'/g, "'\\''")
|
||||
const safePass = newPassword.replace(/'/g, "'\\''")
|
||||
const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'`
|
||||
|
||||
const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 })
|
||||
if (stderr && !stderr.includes('Successfully')) {
|
||||
return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '修改失败'
|
||||
if (msg.includes('command not found') || msg.includes('No such container')) {
|
||||
return NextResponse.json({ error: '密码服务不可用' }, { status: 503 })
|
||||
}
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
||||
import { ldapAuth } from '@/lib/ldap'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { username, password } = await request.json()
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await ldapAuth(username, password)
|
||||
if (!result.success) {
|
||||
if (result.unreachable) {
|
||||
return NextResponse.json({ error: '认证服务暂时不可用,请稍后再试' }, { status: 503 })
|
||||
}
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = signSharedJwt({ username: result.username!, displayName: result.displayName! })
|
||||
const cfg = sharedCookieConfig()
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(cfg.name, token, cfg)
|
||||
|
||||
return NextResponse.json({
|
||||
user: { username: result.username, displayName: result.displayName },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: '登录失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://localhost:6179'))
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const payload = verifySharedJwt(token)
|
||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
||||
|
||||
const admin = await isLldapAdmin(payload.username)
|
||||
|
||||
return NextResponse.json({
|
||||
user: { username: payload.username, displayName: payload.displayName, isAdmin: admin },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
export const metadata = { title: 'OA 统一门户' }
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
(function() {
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.classList.add(t);
|
||||
else if (window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark');
|
||||
} catch(e) {}
|
||||
})();
|
||||
`}} />
|
||||
<style>{`
|
||||
:root {
|
||||
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
|
||||
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
|
||||
}
|
||||
.dark {
|
||||
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
|
||||
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body style={{
|
||||
margin: 0,
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text)',
|
||||
fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
transition: 'background 0.3s, color 0.3s',
|
||||
}}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json()
|
||||
setError(d.error || '登录失败')
|
||||
return
|
||||
}
|
||||
window.location.href = '/'
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 42, padding: '0 12px',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
||||
<div style={{
|
||||
width: 400, background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12,
|
||||
padding: '40px 36px', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{ width: 44, height: 44, margin: '0 auto 14px', background: '#2563eb', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#fff' }}>◎</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', margin: 0 }}>统一门户</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>TLYQ.IDENTITY</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>账号</div>
|
||||
<input type="text" placeholder="LDAP 用户名" value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>密码</div>
|
||||
<input type="password" placeholder="输入密码" value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
{error && <p style={{ color: '#dc2626', fontSize: 13, marginBottom: 12, padding: '8px 12px', background: '#fef2f2', borderRadius: 8 }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{
|
||||
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb',
|
||||
color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}>
|
||||
{loading ? '验证中...' : '登 录'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
通过 <span style={{ color: '#2563eb', fontWeight: 500 }}>LLDAP</span> 统一身份认证
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
function siteUrl(url: string, domain: string): string {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return `https://${domain}`
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const CORE_SITES = [
|
||||
{ name: '资产管理', url: 'http://localhost:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
|
||||
{ name: '工单跟踪', url: 'http://localhost:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
|
||||
]
|
||||
|
||||
const OTHER_SITES = [
|
||||
{ name: '官网', url: 'http://localhost:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
||||
{ name: '云平台', url: 'http://localhost:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
|
||||
{ name: 'Token 工厂', url: 'http://localhost:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
|
||||
{ name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' },
|
||||
]
|
||||
|
||||
const COLORS: Record<string, { light: string; tag: string }> = {
|
||||
'#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' },
|
||||
'#7c3aed': { light: 'rgba(124,58,237,0.08)', tag: '#7c3aed' },
|
||||
'#059669': { light: 'rgba(5,150,105,0.08)', tag: '#059669' },
|
||||
'#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' },
|
||||
'#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' },
|
||||
'#db2777': { light: 'rgba(219,39,119,0.08)', tag: '#db2777' },
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const cookieStore = await cookies()
|
||||
const sessionCookie = cookieStore.get('session')?.value
|
||||
let username = ''
|
||||
if (sessionCookie) {
|
||||
try { username = JSON.parse(sessionCookie).username || '' } catch { }
|
||||
}
|
||||
if (!username) redirect('/login')
|
||||
|
||||
const tlyqToken = cookieStore.get('tlyq_session')?.value
|
||||
let displayName = username
|
||||
if (tlyqToken) {
|
||||
const shared = verifySharedJwt(tlyqToken)
|
||||
if (shared && shared.displayName && shared.displayName !== shared.username) {
|
||||
displayName = shared.displayName
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<Header />
|
||||
|
||||
<div style={{ maxWidth: 1160, margin: '0 auto', padding: '32px 28px 60px' }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 }}>tlyq.ai / OA PORTAL</div>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)', margin: 0 }}>欢迎回来,{displayName}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px', paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>核心系统</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{CORE_SITES.map(site => {
|
||||
const c = COLORS[site.dot]
|
||||
return (
|
||||
<a key={site.name} href={siteUrl(site.url, site.domain)} target="_blank" rel="noopener noreferrer" className="sc" style={{
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, padding: 22, textDecoration: 'none', minHeight: 140,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)', transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: site.dot, flexShrink: 0 }}></div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', flex: 1 }}>{site.name}</div>
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: c.light, color: c.tag, fontWeight: 500, letterSpacing: '0.03em' }}>{site.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, flex: 1 }}>{site.desc}</div>
|
||||
<div className="ch" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 'auto', paddingTop: 8, opacity: 0, transform: 'translateY(4px)', transition: 'all 0.25s ease' }}>{site.domain} →</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '36px 0 12px', paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>其他站点</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{OTHER_SITES.map(site => {
|
||||
const c = COLORS[site.dot]
|
||||
return (
|
||||
<a key={site.name} href={siteUrl(site.url, site.domain)} target="_blank" rel="noopener noreferrer" className="sc" style={{
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, padding: 22, textDecoration: 'none', minHeight: 140,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)', transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: site.dot, flexShrink: 0 }}></div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', flex: 1 }}>{site.name}</div>
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: c.light, color: c.tag, fontWeight: 500, letterSpacing: '0.03em' }}>{site.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, flex: 1 }}>{site.desc}</div>
|
||||
<div className="ch" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 'auto', paddingTop: 8, opacity: 0, transform: 'translateY(4px)', transition: 'all 0.25s ease' }}>{site.domain} →</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<style>{`.sc:hover { border-color: #2563eb !important; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04) !important; transform: translateY(-1px); } .sc:hover .ch { opacity: 1 !important; transform: translateY(0) !important; } .lo:hover { background: var(--bg-hover) !important; color: var(--text) !important; border-color: var(--text-muted) !important; }`}</style>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
interface PasswordStrength {
|
||||
minLength: boolean
|
||||
hasUpper: boolean
|
||||
hasLower: boolean
|
||||
hasDigit: boolean
|
||||
hasSpecial: boolean
|
||||
score: number // 满足的复杂度项数 (3/4 required)
|
||||
}
|
||||
|
||||
function checkPassword(pw: string): PasswordStrength {
|
||||
const minLength = pw.length >= 8
|
||||
const hasUpper = /[A-Z]/.test(pw)
|
||||
const hasLower = /[a-z]/.test(pw)
|
||||
const hasDigit = /[0-9]/.test(pw)
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(pw)
|
||||
const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
||||
return { minLength, hasUpper, hasLower, hasDigit, hasSpecial, score }
|
||||
}
|
||||
|
||||
const checks: { key: keyof PasswordStrength; label: string }[] = [
|
||||
{ key: 'minLength', label: '至少 8 位字符' },
|
||||
{ key: 'hasUpper', label: '包含大写字母' },
|
||||
{ key: 'hasLower', label: '包含小写字母' },
|
||||
{ key: 'hasDigit', label: '包含数字' },
|
||||
{ key: 'hasSpecial', label: '包含特殊字符' },
|
||||
]
|
||||
|
||||
export default function ChangePasswordForm() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const strength = useMemo(() => checkPassword(newPassword), [newPassword])
|
||||
const isStrong = strength.minLength && strength.score >= 3
|
||||
const passwordMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setMessage('')
|
||||
setError('')
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(d.error || '修改失败')
|
||||
return
|
||||
}
|
||||
setMessage('密码已更新,所有站点同步生效')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 42, padding: '0 12px',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 28,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
{!open ? (
|
||||
<button onClick={() => setOpen(true)} style={{
|
||||
width: '100%', height: 42, background: '#7c3aed', color: '#fff',
|
||||
border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: 'pointer',
|
||||
}}>修改密码</button>
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, margin: '0 0 18px', color: 'var(--text)' }}>修改密码</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>当前密码</div>
|
||||
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>新密码</div>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="至少 8 位,大写/小写/数字/特殊字符 4 选 3"
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
{newPassword.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 2px' }}>
|
||||
需满足至少 8 位 + 以下 3 项:
|
||||
</p>
|
||||
{checks.map(c => {
|
||||
const ok = c.key === 'minLength' ? strength.minLength : strength[c.key]
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', width: 16, height: 16, borderRadius: '50%',
|
||||
background: ok ? '#dcfce7' : 'var(--bg-hover)',
|
||||
color: ok ? '#16a34a' : 'var(--text-muted)',
|
||||
alignItems: 'center', justifyContent: 'center', fontSize: 10, fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{ok ? '✓' : '—'}
|
||||
</span>
|
||||
<span style={{ color: ok ? '#16a34a' : 'var(--text-muted)' }}>{c.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p style={{ fontSize: 11, marginTop: 4, color: isStrong ? '#16a34a' : '#d97706' }}>
|
||||
{isStrong ? '密码强度符合要求' : `已满足 ${strength.score} 项(至少 3 项)+ ${strength.minLength ? '长度符合' : '至少 8 位'}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>确认新密码</div>
|
||||
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} placeholder="再次输入新密码"
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = passwordMismatch ? '#dc2626' : 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={{ ...inputStyle, borderColor: passwordMismatch ? '#dc2626' : undefined }} />
|
||||
{passwordMismatch && <p style={{ color: '#dc2626', fontSize: 12, marginTop: 4 }}>两次输入的密码不一致</p>}
|
||||
</div>
|
||||
{error && <p style={{ color: '#dc2626', fontSize: 13, padding: '8px 12px', background: '#fef2f2', borderRadius: 8, marginBottom: 14 }}>{error}</p>}
|
||||
{message && <p style={{ color: '#16a34a', fontSize: 13, padding: '8px 12px', background: '#f0fdf4', borderRadius: 8, marginBottom: 14 }}>{message}</p>}
|
||||
<button type="submit" disabled={loading || !isStrong || passwordMismatch} style={{
|
||||
width: '100%', height: 42, background: loading || !isStrong || passwordMismatch ? 'var(--bg-hover)' : '#7c3aed',
|
||||
color: loading || !isStrong || passwordMismatch ? 'var(--text-muted)' : '#fff', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: loading || !isStrong || passwordMismatch ? 'not-allowed' : 'pointer',
|
||||
}}>
|
||||
{loading ? '修改中...' : passwordMismatch ? '请先确认密码一致' : !isStrong ? '请先满足密码复杂度要求' : '确认修改'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-muted)', marginTop: 12 }}>密码通过 LLDAP 统一管理,修改后全站生效</p>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import Header from '@/components/Header'
|
||||
import ChangePasswordForm from './change-password-form'
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) redirect('/login')
|
||||
|
||||
const session = verifySharedJwt(token)
|
||||
if (!session) redirect('/login')
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<Header backLabel="个人信息" />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 56 }}>
|
||||
<div style={{ width: 480, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 28, boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, margin: '0 0 18px', color: 'var(--text)' }}>账户信息</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 3 }}>用户名</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{session.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 3 }}>显示名</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{session.displayName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 3 }}>认证方式</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>LLDAP 统一认证</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 3 }}>会话有效期</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{(() => { const d = new Date(session.exp * 1000); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` })()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChangePasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function SettingsPage() {
|
||||
redirect('/profile')
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import HeaderUI from './HeaderUI'
|
||||
|
||||
export default async function Header({ backLabel }: { backLabel?: string }) {
|
||||
const cookieStore = await cookies()
|
||||
const sessionCookie = cookieStore.get('session')?.value
|
||||
let username = ''
|
||||
if (sessionCookie) {
|
||||
try { username = JSON.parse(sessionCookie).username || '' } catch { }
|
||||
}
|
||||
|
||||
const tlyqToken = cookieStore.get('tlyq_session')?.value
|
||||
let displayName = username
|
||||
if (tlyqToken) {
|
||||
const shared = verifySharedJwt(tlyqToken)
|
||||
if (shared && shared.displayName && shared.displayName !== shared.username) {
|
||||
displayName = shared.displayName
|
||||
}
|
||||
}
|
||||
|
||||
const admin = username ? await isLldapAdmin(username) : false
|
||||
|
||||
return <HeaderUI displayName={displayName} isAdmin={admin} backLabel={backLabel} />
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
|
||||
interface Props {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
backLabel?: string
|
||||
}
|
||||
|
||||
export default function HeaderUI({ displayName, isAdmin, backLabel }: Props) {
|
||||
return (
|
||||
<header style={{
|
||||
position: 'sticky', top: 0, zIndex: 50, height: 56,
|
||||
background: 'var(--bg-card)', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1160, height: '100%', margin: '0 auto', padding: '0 28px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{/* 左侧 */}
|
||||
{backLabel ? (
|
||||
<a href="/" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||
<span>←</span>
|
||||
<div style={{ width: 26, height: 26, background: '#2563eb', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: '#fff' }}>◎</div>
|
||||
<span style={{ fontWeight: 700, color: 'var(--text)' }}>{backLabel}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 18, fontWeight: 700, color: 'var(--text)' }}>
|
||||
<div style={{ width: 30, height: 30, background: '#2563eb', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: '#fff' }}>◎</div>
|
||||
OA 统一门户
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isAdmin && (
|
||||
<a href="/admin/create-user" style={{ fontSize: 13, color: 'var(--text-secondary)', textDecoration: 'none', marginRight: 8 }}>用户管理</a>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<div style={{ width: 1, height: 24, background: 'var(--border)', margin: '0 4px' }}></div>
|
||||
<a href="/profile" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '4px 14px 4px 4px',
|
||||
border: '1px solid var(--border)', borderRadius: 20, textDecoration: 'none', background: 'var(--bg-card)',
|
||||
}}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: '50%', background: '#2563eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: '#fff', fontWeight: 600 }}>{displayName.charAt(0).toUpperCase()}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text)' }}>{displayName}</span>
|
||||
</a>
|
||||
<form action="/api/auth/logout" method="POST" style={{ display: 'inline' }}>
|
||||
<button type="submit" className="lo" title="退出登录" style={{
|
||||
width: 36, height: 36, borderRadius: 8, border: '1px solid var(--border)',
|
||||
background: 'var(--bg-card)', cursor: 'pointer', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', color: 'var(--text-secondary)',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [dark, setDark] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains('dark'))
|
||||
}, [])
|
||||
|
||||
function toggle() {
|
||||
const next = !dark
|
||||
setDark(next)
|
||||
document.documentElement.classList.toggle('dark', next)
|
||||
localStorage.setItem('theme', next ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="theme-toggle-btn"
|
||||
title={dark ? '切换到亮色模式' : '切换到暗色模式'}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 8, border: 'none',
|
||||
background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{dark ? '☀' : '☾'}
|
||||
</button>
|
||||
<style>{`.theme-toggle-btn:hover { background: var(--bg-hover) !important; }`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtphz.qiye.163.com',
|
||||
port: Number(process.env.SMTP_PORT) || 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'gxp@qx002575.com',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
})
|
||||
|
||||
export async function sendCredentialsEmail(
|
||||
to: string,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
) {
|
||||
const name = displayName || username
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#f5f7fa;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fa;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
|
||||
<tr><td style="background:#2563eb;padding:28px 32px;text-align:center;">
|
||||
<div style="font-size:18px;font-weight:700;color:#fff;">🔒 OA 统一门户</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:32px;">
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#334155;">您好,<strong>${name}</strong>:</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#475569;line-height:1.7;">您的 OA 统一门户账号已创建,请使用以下信息登录:</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
|
||||
<tr><td style="padding:9px 16px;width:80px;font-size:13px;color:#64748b;">用户名</td><td style="padding:9px 16px;font-size:14px;font-weight:600;color:#0f172a;">${username}</td></tr>
|
||||
<tr><td style="padding:9px 16px;font-size:13px;color:#64748b;border-top:1px solid #e2e8f0;">密 码</td><td style="padding:9px 16px;font-size:14px;font-weight:700;font-family:monospace;color:#2563eb;border-top:1px solid #e2e8f0;letter-spacing:0.05em;">${password}</td></tr>
|
||||
</table>
|
||||
<p style="margin:20px 0 0;font-size:14px;color:#475569;">登录地址:<a href="https://oa.tlyq.ai" style="color:#2563eb;text-decoration:none;">https://oa.tlyq.ai</a></p>
|
||||
<div style="margin-top:24px;padding:14px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;">
|
||||
<p style="margin:0;font-size:13px;color:#92400e;line-height:1.6;"><strong>⚠ 请注意:</strong></p>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;">修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。请登录 OA 后在个人资料页修改。</p>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;">请在首次登录后及时修改密码。</p>
|
||||
</div>
|
||||
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8;">此邮件由系统自动发送,请勿回复。<br>如有疑问,请联系管理员:gxp@qx002575.com</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const text = `您好,${name}:
|
||||
|
||||
您的 OA 统一门户账号已创建,请使用以下信息登录:
|
||||
|
||||
用户名:${username}
|
||||
密 码:${password}
|
||||
|
||||
登录地址:https://oa.tlyq.ai
|
||||
|
||||
⚠ 请注意:
|
||||
修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。
|
||||
请登录 OA 后在个人资料页修改密码。
|
||||
|
||||
请在首次登录后及时修改密码。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
如有疑问,请联系管理员:gxp@qx002575.com`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || 'gxp@qx002575.com',
|
||||
to,
|
||||
subject: '您的 OA 统一门户账号已创建',
|
||||
text,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import crypto from 'crypto'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
|
||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
|
||||
|
||||
export interface SharedSession {
|
||||
username: string
|
||||
displayName: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64url(str: string): string {
|
||||
return Buffer.from(str).toString('base64url')
|
||||
}
|
||||
|
||||
export function signSharedJwt(
|
||||
payload: { username: string; displayName: string },
|
||||
expiresIn: number = 7 * 24 * 60 * 60
|
||||
): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const body = { ...payload, iat: now, exp: now + expiresIn }
|
||||
const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
|
||||
const signingInput = segments.join('.')
|
||||
segments.push(
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
|
||||
)
|
||||
return segments.join('.')
|
||||
}
|
||||
|
||||
export function verifySharedJwt(token: string): SharedSession | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const signingInput = parts.slice(0, 2).join('.')
|
||||
const expectedSig = crypto.createHmac('sha256', JWT_SECRET)
|
||||
.update(signingInput).digest('base64url')
|
||||
if (parts[2] !== expectedSig) return null
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||
return {
|
||||
username: payload.username,
|
||||
displayName: payload.displayName,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
||||
return {
|
||||
name: 'tlyq_session',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: COOKIE_DOMAIN,
|
||||
path: '/',
|
||||
maxAge,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Client, InvalidCredentialsError } from 'ldapts'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
||||
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
||||
|
||||
// 运行时从 LLDAP 容器动态获取 admin 密码
|
||||
function getLdapAdminPassword(): string {
|
||||
try {
|
||||
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
|
||||
{ timeout: 3000 }).toString().trim()
|
||||
} catch { return 'admin123' }
|
||||
}
|
||||
|
||||
// 检查用户是否属于 lldap_admin 组(用于管理员权限判断)
|
||||
export async function isLldapAdmin(username: string): Promise<boolean> {
|
||||
if (username === 'admin') return true // 默认 admin 永远是管理员
|
||||
const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}`
|
||||
const adminPass = getLdapAdminPassword()
|
||||
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||
|
||||
try {
|
||||
await client.bind(adminDn, adminPass)
|
||||
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
|
||||
const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, {
|
||||
scope: 'sub',
|
||||
filter: `(&(cn=lldap_admin)(member=${userDn}))`,
|
||||
timeLimit: 3,
|
||||
})
|
||||
return searchEntries.length > 0
|
||||
} catch {
|
||||
return false // LLDAP 不可达 → 保守拒绝,非 admin 不放行
|
||||
} finally {
|
||||
await client.unbind()
|
||||
}
|
||||
}
|
||||
|
||||
export interface LdapResult {
|
||||
success: boolean
|
||||
unreachable: boolean
|
||||
username?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export async function ldapAuth(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<LdapResult> {
|
||||
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
|
||||
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||
|
||||
try {
|
||||
await client.bind(userDn, password)
|
||||
try {
|
||||
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||
scope: 'sub',
|
||||
filter: `(uid=${username})`,
|
||||
attributes: ['displayName'],
|
||||
timeLimit: 3,
|
||||
})
|
||||
const displayName = (searchEntries[0] as any)?.displayName || username
|
||||
return { success: true, unreachable: false, username, displayName }
|
||||
} catch {
|
||||
return { success: true, unreachable: false, username, displayName: username }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCredentialsError) {
|
||||
return { success: false, unreachable: false }
|
||||
}
|
||||
return { success: false, unreachable: true }
|
||||
} finally {
|
||||
await client.unbind()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (payload.length % 4) payload += '='
|
||||
return JSON.parse(atob(payload))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||||
if (!payload) return false
|
||||
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||||
}
|
||||
|
||||
function noCache(response: NextResponse) {
|
||||
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
return response
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// 登录/退出/API 路径放行(API 路由自行验证)
|
||||
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
// /admin 管理页面需要认证
|
||||
if (pathname.startsWith('/admin')) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
const payload = token ? decodeJwtPayload(token) : null
|
||||
if (!isValidPayload(payload)) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
return noCache(NextResponse.next())
|
||||
}
|
||||
|
||||
// 静态资源放行(已有路径哈希,允许缓存)
|
||||
if (pathname.startsWith('/_next/') || pathname === '/favicon.ico') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
const payload = token ? decodeJwtPayload(token) : null
|
||||
|
||||
if (isValidPayload(payload)) {
|
||||
const response = NextResponse.next()
|
||||
response.cookies.set('session', JSON.stringify({ username: payload!.username }), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
return noCache(response)
|
||||
}
|
||||
|
||||
return noCache(NextResponse.redirect(new URL('/login', request.url)))
|
||||
}
|
||||
|
||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"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": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue