Compare commits

..

12 Commits
v0.1.0 ... main

Author SHA1 Message Date
gitadmin ba26ac97f5 feat: 用户管理 UI 全面统一 + LDAP 邮箱同步 + 角色/Key 页面对齐
- 新增用户详情页,支持编辑(状态切换/密码确认)、键盘导航
- Modal 增加 ESC/←→/Enter 键盘支持
- LDAP 邮箱三路径同步:密码登录、SSO 免登录、批量同步接口
- 角色权限页:删除改 Modal、按钮样式对齐
- API Key 页:副标题、状态切换、创建弹窗错误提示、成功提示颜色对齐
- 全站字体/颜色一致:text-sm、dark mode 补全
- TopBar 文字颜色对齐
2026-05-18 16:56:40 +08:00
gitadmin 4a97326955 docs: 添加 Git Tag 日期版本号规范 2026-05-18 14:58:32 +08:00
gitadmin 9364d56e00 fix: SSO 免登录和本地 JWT 路径现在也会更新 last_login_at
此前仅密码登录路径更新 last_login_at,导致通过 SSO 共享 JWT
或本地 JWT 访问系统的用户始终显示"从未登录"。
2026-05-18 14:49:12 +08:00
gitadmin 0a4dadc748 docs: 记录 docker-compose ALLOWED_API_KEYS 修复与 middleware Edge Runtime 修复 2026-05-15 15:21:38 +08:00
gitadmin 66dbd80587 fix: docker-compose 补全 ALLOWED_API_KEYS 环境变量
P2 部署后 middleware 需要 ALLOWED_API_KEYS 验证 API Key,但 docker-compose.yml
未将该变量传入容器,导致中间件返回 401,IP 查询再次失效。
2026-05-15 12:03:49 +08:00
gitadmin 3f3dfc1f19 fix: middleware 移除 better-sqlite3 依赖,修复 Edge Runtime 报错
middleware 运行在 Edge Runtime,不支持 Node.js 原生模块。
API Key 验证回归 ALLOWED_API_KEYS 环境变量检查,DB 验证由 route handler 负责。
2026-05-15 09:26:25 +08:00
gitadmin 69694d3fe9 docs: 记录 API Key 修复与中间件安全加固(v2026.05.14)
- [修复] api_keys 表补注册 issue-ai 使用的 API Key
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS → DB 两级
2026-05-15 09:13:51 +08:00
gitadmin 5d841a56a6 fix: 内置角色旧权限迁移不生效 — 移除 builtin skip
迁移循环中 `if (builtinNames.has(r.name)) continue` 导致 editor/viewer
内置角色的旧 assets:write 权限永远不会被迁移。移除该限制,让权限迁移
对所有角色生效(admin 因持有 * 不受影响)。
2026-05-14 17:49:09 +08:00
gitadmin dbc7600a59 feat: 权限细粒度拆分 + 前端按钮权限控制 + 角色管理优化
- 权限拆分:assets:write → assets:create + assets:import + assets:update
- 旧 assets:write 自动迁移(自定义角色),editor 默认权限同步更新
- API 层:create/import/update/batch/template 路由改用独立权限检查
- 前端:资产列表页/详情页按钮由权限驱动显隐(导入/模板/新增/编辑/删除)
- 新增/导入/编辑页面增加权限守卫,无权限重定向到资产列表
- 角色管理页权限选择列表同步新增三个权限选项
- 修复模板按钮链接指向错误的 404 页面
- editor/viewer 角色可编辑权限,仅 admin 强制同步默认值
- 三个内置角色均不可删除
- 部署到 txjp 服务器 (assets.tlyq.ai)
2026-05-14 17:38:34 +08:00
gitadmin 747fe293d5 feat: SSO 集成 — 共享 JWT + LDAP 认证 + 跨站点用户管理 API
- 新增 src/lib/jwt.ts:共享 JWT 签发/验证(与 OA 共用密钥)
- 新增 src/lib/ldap.ts:LDAP 认证与用户存在性检查
- 新增 src/app/api/internal/roles/route.ts:内部 API 供 OA 查询角色
- 重构 auth.ts:双路径认证(共享 JWT + 本地 JWT 回退)
- 重构 middleware.ts:SSO 优先 + 本地认证回退
- 更新 docker-compose.yml:挂载 docker.sock 用于运行时 LLDAP 密码获取
- 更新 next.config.ts:serverExternalPackages 添加 ldapts
- 更新 Dockerfile:生产依赖安装优化
2026-05-14 16:37:49 +08:00
gitadmin c6a92ed33a feat: SSO双路径认证 + 端口修正 5177→6177
- 中间件支持 X-Remote-User (SSO) + JWT 双路径
- lib/auth.ts 新增 getSession() 统一会话获取
- 所有 API 路由改用 getSession(),支持 SSO header 回退
- 退出登录同时清除 SSO cookie
- 侧边栏根据角色显示/隐藏系统设置
- layout.tsx 支持 SSO 用户自动创建
- package.json 端口 5177→6177
- 跨站点引用 localhost 端口全部修正
2026-05-09 17:14:36 +08:00
gitadmin 3e7f94b014 feat: 添加 .dockerignore 减小 Docker 构建上下文
排除 node_modules、.next、data 等目录,解决 Build Cache 膨胀问题。
2026-05-08 12:56:03 +08:00
49 changed files with 1074 additions and 308 deletions

32
.dockerignore Normal file
View File

@ -0,0 +1,32 @@
# 依赖 —— Dockerfile 内用 npm ci 安装
node_modules
# 构建产物 —— Dockerfile 内用 npm run build 生成
.next
# 运行时数据 —— volume 挂载
data
uploads
db-backups
# 开发工具
.claude
.playwright-mcp
.git
.gitignore
Dockerfile
docker-compose.yml
Caddyfile
.env
.env.*
.DS_Store
# 文档
*.md
docs
templates-docs
CHANGELOG.md
README.md
# 构建缓存
tsconfig.tsbuildinfo

View File

@ -4,6 +4,6 @@ NODE_ENV=development
# issue-ai API 配置(用于故障历史功能) # issue-ai API 配置(用于故障历史功能)
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置 # NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置
# 本地开发http://localhost:5176/tickets # 本地开发http://localhost:6176/tickets
# 云上生产https://issue.tlyq.ai/tickets # 云上生产https://issue.tlyq.ai/tickets
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets

View File

@ -1,5 +1,60 @@
# 变更日志 # 变更日志
## 2026-05-18
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
- [优化] 用户管理 UI 统一Modal 支持键盘导航ESC/←→/Enter、TopBar/Button 样式与 issue-ai 对齐
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
- [优化] 角色权限页面 UI 统一:删除改用 Modal 确认、按钮样式对齐
- [优化] API Key 页面 UI 统一:增加副标题、状态可点击切换、创建弹窗增加错误提示和默认权限说明、成功提示颜色对齐
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
- [修复] 用户管理"最后登录"时间不动态更新SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
## 2026-05-15
- [修复] docker-compose.yml 缺少 `ALLOWED_API_KEYS` 环境变量传入,导致 P2 部署后中间件返回 401issue-ai 调用 assets API 再次失效
- [修复] middleware 移除 `better-sqlite3` 依赖,修复 Edge Runtime 不支持 Node.js 原生模块的编译报错(`The edge runtime does not support Node.js 'fs' module`
- [新增] deploy-ai.sh 部署后自动验证 assets→issue API 连通性带重试机制3次/10s间隔与 issue→assets 方向对称
## 2026-05-14
- [新增] 权限细粒度拆分:`assets:write` → `assets:create`(新增设备)、`assets:import`(导入设备)、`assets:update`(编辑设备),支持独立授权
- [新增] API 模板下载路由增加 `assets:import` 权限检查(原仅校验登录态)
- [优化] 角色权限选择列表同步新增三个权限选项(新增资产/导入资产/编辑资产)
- [修复] 资产列表页、详情页按钮无权限控制:导入、模板、新增设备、编辑、删除、批量编辑按钮现在由用户权限驱动显隐
- [修复] 新增/导入/编辑资产页面无权限守卫:无权限用户直接访问会被重定向到资产列表
- [修复] 资产列表页模板按钮链接指向 404`/assets/template` → `/api/assets/template`
- [调整] editor/viewer 角色权限允许自定义编辑(`db-schema.ts` 仅强制同步 admin三个内置角色均不可删除
- [修复] issue-ai 调用 assets API 返回 401云端 `api_keys` 表中注册了 issue-ai 使用的 API Key统一 Key此前只存在 issue-ai 的 ALLOWED_API_KEYS 环境变量中assets-ai 的 api_keys 表缺失该记录)
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS快速路径→ api_keys 数据库表(回退)两级验证;无效 key 不再被放行,中间件层直接返回 401安全加固与 issue-ai 行为一致)
## 2026-05-12
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026三站点密钥一致
- [部署] docker-compose 移除 AUTHELIA_URL添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
- [部署] nginx 移除 auth_request恢复纯反向代理改为运行时 DNS 解析
- [新增] `src/lib/db-schema.ts`users 表新增 last_login_at / last_active_at 列
- [新增] `src/lib/auth.ts`getSession() 更新 last_active_at
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
## 2026-05-11
- [新增] `src/lib/ldap.ts``ldapUserExists()` 函数,检查 LLDAP 中用户是否存在admin bind 搜索,不可达时容错放行)
- [新增] `src/lib/jwt.ts`:共享 JWT 签发/验证(`tlyq_session` cookieHS256与 OA/issue 共用密钥)
- [调整] `src/lib/auth.ts``getSession()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
- [调整] `src/app/api/auth/login/route.ts`LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_assets``tlyq_session`
- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getSession()` 统一获取
- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_assets`,移除 SSO 代理路径,放行 `/api/internal/`
- [调整] `src/app/(app)/layout.tsx`:移除 SSO header 路径,统一从 `session` cookie 读取用户
- [新增] `src/app/api/internal/roles/route.ts`:内部 API返回站点可用角色列表INTERNAL_API_KEY 鉴权)
- [新增] `src/app/api/users/[id]/route.ts`admin/localadmin 用户禁止删除和修改角色
- [调整] `src/app/(app)/settings/users/page.tsx`admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户admin 角色,纯本地 BCrypt 认证)
## 2026-05-07 ## 2026-05-07
- [新增] 导出功能区分"导出选中"和"导出全部"两种模式,有选中时工具栏显示两个按钮独立操作 - [新增] 导出功能区分"导出选中"和"导出全部"两种模式,有选中时工具栏显示两个按钮独立操作

View File

@ -13,7 +13,7 @@ assets-ai 是基于 Next.js + SQLite 的 IT 设备资产管理系统CMDB
| 站点域名 | `assets.tlyq.ai` | | 站点域名 | `assets.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 | | 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/assets-ai/` | | 代码路径 | `/root/docker/assets-ai/` |
| 本地端口 | 5177 | | 本地端口 | 6177 |
| 容器名 | `assets-ai` | | 容器名 | `assets-ai` |
| 数据库 | SQLite`data/assets.db` | | 数据库 | SQLite`data/assets.db` |
| 默认账号 | `admin` / `admin123` | | 默认账号 | `admin` / `admin123` |
@ -86,11 +86,16 @@ npm run import # 导入设备数据
### 认证 ### 认证
登录逻辑v2.1LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
登录成功签发两个 cookie`session_assets`(本地 JWT24h+ `tlyq_session`(共享 JWT7 天domain=.tlyq.ai
中间件优先检查 `tlyq_session`,回退 `session_assets`。`getSession()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| POST | `/api/auth/login` | 登录username + password → JWT cookie24h 有效) | | POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退 |
| POST | `/api/auth/logout` | 登出 | | POST | `/api/auth/logout` | 登出(清除两个 cookie |
| GET | `/api/auth/me` | 当前用户信息 | | GET | `/api/auth/me` | 当前用户信息 |
| GET | `/api/internal/roles` | 内部 API返回角色列表x-internal-key 鉴权) |
### 资产 ### 资产
@ -129,8 +134,9 @@ npm run import # 导入设备数据
## 认证机制 ## 认证机制
- **Web UI**`middleware.ts` 检查 cookie 中的 JWT tokenpayload 含 `{ userId, username, role, iat, exp }` - **Web UIv2.1**`middleware.ts` 优先检查 `tlyq_session`(共享 JWTOA 统一签发)→ 回退 `session_assets`(本地 JWT。`getSession()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
- **API Key**`Bearer ak_<32位十六进制>`,存储时 SHA-256 hash验证时更新 `last_used_at`,权限由创建时指定的 `permissions` 数组控制 - **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP用于 LLDAP 故障时应急登录DB 预置admin 角色)
- **API Keyv2.2**`middleware.ts` 检查 `ALLOWED_API_KEYS` 环境变量(逗号分隔明文 key无效 key 在中间件层直接返回 401。注意middleware 运行在 Edge Runtime不能使用 `better-sqlite3`DB 级别的 key 验证由 route handler 中的 `auth.ts verifyApiKey()` 进行(查 `api_keys` 表 SHA-256 hash支持 `last_used_at` 追踪和 permissions 控制)。外部系统调用本系统 API 时key 必须注册在 `ALLOWED_API_KEYS`
--- ---
@ -140,9 +146,9 @@ npm run import # 导入设备数据
| 环境变量 | 本地开发 | 云服务器txjp | 说明 | | 环境变量 | 本地开发 | 云服务器txjp | 说明 |
|---------|---------|----------------|------| |---------|---------|----------------|------|
| `ISSUE_API_URL` | `http://localhost:5176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 | | `ISSUE_API_URL` | `http://localhost:6176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 |
| `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** | | `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** |
| `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:5176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) | | `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:6176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 | | `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 |
| `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 | | `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 |
| `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 | | `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 |
@ -154,9 +160,9 @@ npm run import # 导入设备数据
DATABASE_PATH=./data/assets.db DATABASE_PATH=./data/assets.db
JWT_SECRET=dev-secret-key-local JWT_SECRET=dev-secret-key-local
NODE_ENV=development NODE_ENV=development
ISSUE_API_URL=http://localhost:5176/api ISSUE_API_URL=http://localhost:6176/api
ISSUE_API_KEY=ak_<32字节十六进制> ISSUE_API_KEY=ak_<32字节十六进制>
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets
``` ```
--- ---
@ -218,7 +224,21 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证 - **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供 - **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间在中国时区UTC+8`new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` - **日期处理(时区规范)**:整个系统统一使用 UTC+8北京时间。两处必须遵守
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间在中国时区UTC+8`new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`
---
## Git Tag 规范
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.05.18`)。提交后打 tag 再推送:
```bash
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
```
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`
--- ---

View File

@ -8,13 +8,15 @@ RUN --mount=type=cache,target=/root/.npm,id=assets-npm \
COPY . . COPY . .
RUN npm run build RUN npm run build
# runner 阶段:与 builder 保持一致Alpine + musl确保 better-sqlite3 等原生模块兼容 # runner 阶段:使用 Debianglibc与 txjp 宿主机平台一致,避免原生模块 musl/glibc 不兼容
FROM node:20-alpine AS runner FROM node:20-slim AS runner
RUN apk add --no-cache python3 make g++
WORKDIR /app WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./ COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
RUN npm install --omit=dev && \
npm rebuild better-sqlite3 && \
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
node_modules/@next/swc-linux-x64-musl
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
RUN mkdir -p /app/data /app/uploads RUN mkdir -p /app/data /app/uploads

View File

@ -3,22 +3,31 @@ services:
build: . build: .
container_name: assets-ai container_name: assets-ai
ports: ports:
- "5177:3000" - "6177:3000"
volumes: volumes:
- assets-data:/app/data - assets-data:/app/data
- assets-uploads:/app/uploads - assets-uploads:/app/uploads
# .next 目录从主机挂载,主机上 npm run build 后直接生效 # .next 目录从主机挂载,主机上 npm run build 后直接生效
- ./.next:/app/.next - ./.next:/app/.next
# 运行时从 LLDAP 容器动态读取 admin 密码
- /var/run/docker.sock:/var/run/docker.sock
environment: environment:
- DATABASE_PATH=/app/data/assets.db - DATABASE_PATH=/app/data/assets.db
- JWT_SECRET=${ASSETS_JWT_SECRET:-change-me-in-production} - JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- NODE_ENV=production - NODE_ENV=production
- COOKIE_DOMAIN=.tlyq.ai
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
- LDAP_URL=ldap://lldap:3890
- LDAP_BASE_DN=dc=tlyq,dc=ai
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
# issue-ai API 地址(容器内使用 issue-ai 服务名) # issue-ai API 地址(容器内使用 issue-ai 服务名)
- ISSUE_API_URL=http://issue-ai:3000/api - ISSUE_API_URL=http://issue-ai:3000/api
# issue-ai API Key用于服务间认证 # issue-ai API Key用于服务间认证
- ISSUE_API_KEY=${ISSUE_API_KEY:-your-secret-api-key-change-in-production} - ISSUE_API_KEY=${ISSUE_API_KEY:-your-secret-api-key-change-in-production}
# 允许调用本系统的 API Key逗号分隔中间件验证用
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
# 故障历史跳转的工单系统地址(客户端使用) # 故障历史跳转的工单系统地址(客户端使用)
- NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets - NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
restart: unless-stopped restart: unless-stopped

View File

@ -4,6 +4,8 @@ const nextConfig: NextConfig = {
images: { unoptimized: true }, images: { unoptimized: true },
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
serverExternalPackages: ['better-sqlite3'], // better-sqlite3: 原生模块,必须 external
// ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
serverExternalPackages: ['better-sqlite3', 'ldapts'],
} }
export default nextConfig export default nextConfig

19
package-lock.json generated
View File

@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "^15.1.0", "next": "^15.1.0",
"react": "^19.0.0", "react": "^19.0.0",
@ -2861,6 +2862,18 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/ldapts": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz",
"integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==",
"license": "MIT",
"dependencies": {
"strict-event-emitter-types": "2.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -3840,6 +3853,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/strict-event-emitter-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"license": "ISC"
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",

View File

@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5177", "dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6177",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -14,6 +14,7 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "^15.1.0", "next": "^15.1.0",
"react": "^19.0.0", "react": "^19.0.0",

0
public/.gitkeep Normal file
View File

View File

@ -13,6 +13,15 @@ export default function EditAssetPage() {
const [asset, setAsset] = useState<Asset | null>(null) const [asset, setAsset] = useState<Asset | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
const perms: string[] = d.user?.permissions || []
if (!perms.includes('*') && !perms.includes('assets:update')) router.replace('/assets')
else setAuthorized(true)
}).catch(() => router.replace('/assets'))
}, [router])
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@ -35,6 +44,7 @@ export default function EditAssetPage() {
} finally { setSaving(false) } } finally { setSaving(false) }
} }
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div> if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
if (!asset) return null if (!asset) return null

View File

@ -16,6 +16,7 @@ export default function AssetDetailPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showDelete, setShowDelete] = useState(false) const [showDelete, setShowDelete] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@ -26,6 +27,12 @@ export default function AssetDetailPage() {
load().finally(() => setLoading(false)) load().finally(() => setLoading(false))
}, [params.id, router]) }, [params.id, router])
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
if (d.user?.permissions) setPermissions(d.user.permissions)
}).catch(() => {})
}, [])
async function handleDelete() { async function handleDelete() {
setDeleting(true) setDeleting(true)
try { try {
@ -48,8 +55,12 @@ export default function AssetDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(permissions.includes('*') || permissions.includes('assets:update')) && (
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} /></Button></Link> <Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} /></Button></Link>
)}
{(permissions.includes('*') || permissions.includes('assets:delete')) && (
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} /></Button> <Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} /></Button>
)}
</div> </div>
</div> </div>

View File

@ -1,9 +1,24 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import AssetImport from '@/components/assets/AssetImport' import AssetImport from '@/components/assets/AssetImport'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
export default function ImportPage() { export default function ImportPage() {
const router = useRouter()
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
const perms: string[] = d.user?.permissions || []
if (!perms.includes('*') && !perms.includes('assets:import')) router.replace('/assets')
else setAuthorized(true)
}).catch(() => router.replace('/assets'))
}, [router])
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
async function handleImport(file: File) { async function handleImport(file: File) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)

View File

@ -1,5 +1,5 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import AssetForm from '@/components/assets/AssetForm' import AssetForm from '@/components/assets/AssetForm'
@ -8,6 +8,15 @@ import { ArrowLeft } from 'lucide-react'
export default function NewAssetPage() { export default function NewAssetPage() {
const router = useRouter() const router = useRouter()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
const perms: string[] = d.user?.permissions || []
if (!perms.includes('*') && !perms.includes('assets:create')) router.replace('/assets')
else setAuthorized(true)
}).catch(() => router.replace('/assets'))
}, [router])
async function handleSubmit(data: Record<string, unknown>) { async function handleSubmit(data: Record<string, unknown>) {
setSaving(true) setSaving(true)
@ -22,6 +31,8 @@ export default function NewAssetPage() {
} finally { setSaving(false) } } finally { setSaving(false) }
} }
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -365,8 +365,13 @@ export default function AssetsPage() {
// 是否有任何列筛选激活 // 是否有任何列筛选激活
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0) const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
const canExportAll = permissions.includes('*') || permissions.includes('assets:export:all') const hasWildcard = permissions.includes('*')
const canExportSelected = permissions.includes('*') || permissions.includes('assets:export:selected') const canExportAll = hasWildcard || permissions.includes('assets:export:all')
const canExportSelected = hasWildcard || permissions.includes('assets:export:selected')
const canCreate = hasWildcard || permissions.includes('assets:create')
const canImport = hasWildcard || permissions.includes('assets:import')
const canUpdate = hasWildcard || permissions.includes('assets:update')
const canDelete = hasWildcard || permissions.includes('assets:delete')
return ( return (
<div> <div>
@ -379,20 +384,26 @@ export default function AssetsPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedIds.size > 0 && ( {canUpdate && selectedIds.size > 0 && (
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}> <Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
<Button variant="secondary" size="sm"> {selectedIds.size} </Button> <Button variant="secondary" size="sm"> {selectedIds.size} </Button>
</Link> </Link>
)} )}
{canImport && (
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} /></Button></Link> <Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} /></Button></Link>
<Link href="/assets/template"><Button variant="secondary" size="sm"><Download size={14} /></Button></Link> )}
{canImport && (
<a href="/api/assets/template" download><Button variant="secondary" size="sm"><Download size={14} /></Button></a>
)}
{canExportSelected && selectedIds.size > 0 && ( {canExportSelected && selectedIds.size > 0 && (
<Button variant="secondary" size="sm" onClick={() => { setExportMode('selected'); setExportModalOpen(true) }}><Download size={14} />({selectedIds.size})</Button> <Button variant="secondary" size="sm" onClick={() => { setExportMode('selected'); setExportModalOpen(true) }}><Download size={14} />({selectedIds.size})</Button>
)} )}
{canExportAll && ( {canExportAll && (
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button> <Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
)} )}
{canCreate && (
<Link href="/assets/new"><Button size="sm"><Plus size={14} /></Button></Link> <Link href="/assets/new"><Button size="sm"><Plus size={14} /></Button></Link>
)}
</div> </div>
</div> </div>
@ -555,14 +566,18 @@ export default function AssetsPage() {
className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"> className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Eye size={16} /> <Eye size={16} />
</Link> </Link>
{canUpdate && (
<Link href={`/assets/${row.id}/edit`} <Link href={`/assets/${row.id}/edit`}
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"> className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Edit size={16} /> <Edit size={16} />
</Link> </Link>
)}
{canDelete && (
<button onClick={() => setDeleteTarget(row.id)} <button onClick={() => setDeleteTarget(row.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"> className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -1,22 +1,27 @@
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
import { cookies, headers } from 'next/headers' import { cookies } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth'
import AppShell from '@/components/layout/AppShell' import AppShell from '@/components/layout/AppShell'
export default async function AppLayout({ children }: { children: React.ReactNode }) { export default async function AppLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies() const cookieStore = await cookies()
const headersList = await headers()
const originalPath = headersList.get('x-original-pathname') || ''
const loginUrl = '/login' + (originalPath ? `?redirect=${encodeURIComponent(originalPath)}` : '')
const token = cookieStore.get('session_assets')?.value // 从 middleware 设置的 session cookie 获取用户名
if (!token) redirect(loginUrl) const sessionCookie = cookieStore.get('session')?.value
const payload = verifyJwt(token) let username = ''
if (!payload) redirect(loginUrl) if (sessionCookie) {
const user = db.prepare('SELECT display_name, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as { display_name: string; role: string } | undefined try { username = JSON.parse(sessionCookie).username || '' } catch { }
if (!user) redirect(loginUrl) }
return <AppShell user={user}>{children}</AppShell> if (!username) redirect('/login')
// 从数据库获取用户完整信息
const user = db.prepare(
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; username: string; display_name: string; role: string } | undefined
if (!user) redirect('/login')
return <AppShell user={{ display_name: user.display_name, role: user.role }}>{children}</AppShell>
} }

View File

@ -21,6 +21,7 @@ export default function ApiKeysPage() {
const [newKey, setNewKey] = useState<string | null>(null) const [newKey, setNewKey] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ApiKeyItem | null>(null) const [deleteTarget, setDeleteTarget] = useState<ApiKeyItem | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [error, setError] = useState('')
async function fetchKeys() { async function fetchKeys() {
const res = await fetch('/api/api-keys') const res = await fetch('/api/api-keys')
@ -31,20 +32,28 @@ export default function ApiKeysPage() {
async function handleCreate() { async function handleCreate() {
if (!name.trim()) return if (!name.trim()) return
setSaving(true) setSaving(true); setError('')
try { try {
const res = await fetch('/api/api-keys', { const res = await fetch('/api/api-keys', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }), body: JSON.stringify({ name: name.trim() }),
}) })
if (res.ok) {
const data = await res.json() const data = await res.json()
if (!res.ok) { setError(data.error || '创建失败'); return }
setNewKey(data.key) setNewKey(data.key)
setCreateOpen(false) setCreateOpen(false)
setName('') setName('')
fetchKeys() fetchKeys()
} catch { setError('创建失败') }
finally { setSaving(false) }
} }
} finally { setSaving(false) }
async function toggleActive(k: ApiKeyItem) {
await fetch(`/api/api-keys/${k.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: k.is_active ? 0 : 1 }),
})
fetchKeys()
} }
async function handleDelete() { async function handleDelete() {
@ -67,19 +76,22 @@ export default function ApiKeysPage() {
const perms: string[] = JSON.parse(r.permissions) const perms: string[] = JSON.parse(r.permissions)
return <div className="flex flex-wrap gap-1">{perms.map(p => <Badge key={p} color="gray">{p}</Badge>)}</div> return <div className="flex flex-wrap gap-1">{perms.map(p => <Badge key={p} color="gray">{p}</Badge>)}</div>
}}, }},
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> }, { key: 'is_active', title: '状态', render: (r) => <button onClick={() => toggleActive(r)}><Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge></button> },
{ key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' }, { key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' },
{ key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' }, { key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' },
{ key: 'created_at', title: '创建时间' }, { key: 'created_at', title: '创建时间' },
{ key: 'actions', title: '操作', render: (r) => ( { key: 'actions', title: '操作', render: (r) => (
<button onClick={() => setDeleteTarget(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Trash2 size={16} /></button> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
)}, )},
] ]
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key </h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key </h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"> API</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus size={16} /> Key</Button> <Button size="sm" onClick={() => setCreateOpen(true)}><Plus size={16} /> Key</Button>
</div> </div>
@ -101,15 +113,20 @@ export default function ApiKeysPage() {
<Button onClick={handleCreate} loading={saving}></Button> <Button onClick={handleCreate} loading={saving}></Button>
</> </>
}> }>
<div className="space-y-4">
<Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" /> <Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" />
<p className="text-xs text-slate-500 dark:text-slate-400"><Badge color="gray">assets:read</Badge></p>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</Modal> </Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除"> <Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<p className="text-sm text-slate-600 dark:text-slate-400"> API Key{deleteTarget?.name}使 Key 访</p> <>
<div className="flex justify-end gap-3 mt-4">
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button> <Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button> <Button variant="danger" onClick={handleDelete}></Button>
</div> </>
}>
<p className="text-sm text-slate-600 dark:text-slate-400"> API Key{deleteTarget?.name}使 Key 访</p>
</Modal> </Modal>
</div> </div>
) )

View File

@ -17,7 +17,9 @@ interface Role {
const allPermissions = [ const allPermissions = [
{ key: 'assets:read', label: '查看资产' }, { key: 'assets:read', label: '查看资产' },
{ key: 'assets:write', label: '编辑资产' }, { key: 'assets:create', label: '新增资产' },
{ key: 'assets:import', label: '导入资产' },
{ key: 'assets:update', label: '编辑资产' },
{ key: 'assets:delete', label: '删除资产' }, { key: 'assets:delete', label: '删除资产' },
{ key: 'assets:export:selected', label: '导出选中资产' }, { key: 'assets:export:selected', label: '导出选中资产' },
{ key: 'assets:export:all', label: '导出全部资产' }, { key: 'assets:export:all', label: '导出全部资产' },
@ -54,6 +56,7 @@ export default function RolesPage() {
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] }) const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
const [error, setError] = useState('') const [error, setError] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null)
const fetchRoles = async () => { const fetchRoles = async () => {
try { try {
@ -117,11 +120,10 @@ export default function RolesPage() {
finally { setSaving(false) } finally { setSaving(false) }
} }
const handleDelete = async (id: number) => { const handleDelete = async () => {
if (!confirm('确定删除此角色?')) return if (!deleteTarget) return
const res = await fetch(`/api/roles/${id}`, { method: 'DELETE' }) const res = await fetch(`/api/roles/${deleteTarget.id}`, { method: 'DELETE' })
if (res.ok) fetchRoles() if (res.ok) { setDeleteTarget(null); fetchRoles() }
else { const d = await res.json(); alert(d.error || '删除失败') }
} }
const columns: Column<Role>[] = [ const columns: Column<Role>[] = [
@ -143,7 +145,7 @@ export default function RolesPage() {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button> <Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
{!BUILTIN_ROLES.includes(r.name) && ( {!BUILTIN_ROLES.includes(r.name) && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
)} )}
</div> </div>
)}, )},
@ -204,6 +206,15 @@ export default function RolesPage() {
</div> </div>
</div> </div>
</Modal> </Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button>
</>
}>
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p>
</Modal>
</div> </div>
) )
} }

View File

@ -0,0 +1,126 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Badge from '@/components/ui/Badge'
import { ArrowLeft, Edit } from 'lucide-react'
interface UserDetail {
id: number; username: string; display_name: string; email: string | null
role: string; is_active: number; created_at: string; updated_at: string
last_login_at: string | null; is_online: number
}
export default function UserDetailPage() {
const { id } = useParams()
const router = useRouter()
const [user, setUser] = useState<UserDetail | null>(null)
const [loading, setLoading] = useState(true)
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
const [editOpen, setEditOpen] = useState(false)
const [form, setForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '', is_active: 1 })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function fetchUser() {
const res = await fetch(`/api/users/${id}`)
if (res.ok) {
const d = await res.json()
setUser(d.user)
setForm({ display_name: d.user.display_name, email: d.user.email || '', role: d.user.role, password: '', password_confirm: '', is_active: d.user.is_active })
}
setLoading(false)
}
async function fetchRoles() {
try {
const res = await fetch('/api/roles')
const d = await res.json()
if (d.roles) setRoleOptions(d.roles.map((r: { name: string; display_name: string }) => ({ value: r.name, label: r.display_name })))
} catch { /* ignore */ }
}
useEffect(() => { Promise.all([fetchUser(), fetchRoles()]) }, [id])
function openEdit() { setError(''); setEditOpen(true) }
async function handleSave() {
setSaving(true); setError('')
try {
if (form.password && form.password !== form.password_confirm) { setError('两次输入的密码不一致'); return }
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, is_active: form.is_active }
if (form.password) body.password = form.password
if (user!.username !== 'admin' && user!.username !== 'localadmin') body.role = form.role
const res = await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
setEditOpen(false)
fetchUser()
} finally { setSaving(false) }
}
if (loading) return <div className="py-20 text-center text-slate-500">...</div>
if (!user) return <div className="py-20 text-center text-slate-500"></div>
const roleLabel = roleOptions.find(r => r.value === user.role)?.label || user.role
return (
<div className="space-y-6">
<button onClick={() => router.back()} className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
<ArrowLeft size={16} />
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"> {user.display_name}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={openEdit}><Edit size={16} /></Button>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
{[
{ label: '用户名', value: user.username },
{ label: '显示名称', value: user.display_name },
{ label: '邮箱', value: user.email || '-' },
{ label: '角色', value: <Badge color={user.role === 'admin' ? 'blue' : user.role === 'editor' ? 'green' : 'gray'}>{roleLabel}</Badge> },
{ label: '状态', value: <Badge color={user.is_active ? 'green' : 'red'}>{user.is_active ? '启用' : '禁用'}</Badge> },
{ label: '在线', value: <span className="inline-flex items-center gap-1.5"><span className={`inline-block w-2 h-2 rounded-full ${user.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} /><span className="text-xs text-slate-500">{user.is_online ? '在线' : '离线'}</span></span> },
{ label: '最后登录', value: user.last_login_at ? <span className="text-sm text-slate-500">{user.last_login_at}</span> : <span className="text-sm text-slate-400"></span> },
{ label: '创建时间', value: <span className="text-sm text-slate-500">{user.created_at}</span> },
].map(row => (
<div key={row.label} className="flex items-center px-6 py-4">
<span className="w-24 text-sm font-medium text-slate-600 dark:text-slate-400 shrink-0">{row.label}</span>
<span className="text-sm text-slate-900 dark:text-slate-100">{row.value}</span>
</div>
))}
</div>
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="编辑用户" footer={
<>
<Button variant="ghost" onClick={() => setEditOpen(false)}></Button>
<Button onClick={handleSave} loading={saving}></Button>
</>
}>
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
<div className="space-y-4">
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
<Select label="状态" value={String(form.is_active)} onChange={e => setForm(p => ({ ...p, is_active: parseInt(e.target.value) }))} options={[{ value: '1', label: '启用' }, { value: '0', label: '禁用' }]} />
<Input label="新密码(留空不修改)" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
{form.password && <Input label="确认新密码" type="password" value={form.password_confirm} onChange={e => setForm(p => ({ ...p, password_confirm: e.target.value }))} />}
{user.username === 'admin' || user.username === 'localadmin' ? (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<p className="text-sm text-slate-500 dark:text-slate-400 py-2"></p>
</div>
) : (
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
)}
</div>
</Modal>
</div>
)
}

View File

@ -6,22 +6,23 @@ import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select' import Select from '@/components/ui/Select'
import Modal from '@/components/ui/Modal' import Modal from '@/components/ui/Modal'
import Badge from '@/components/ui/Badge' import Badge from '@/components/ui/Badge'
import { Plus, Edit, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import Link from 'next/link'
interface UserItem { interface UserItem {
id: number; username: string; display_name: string; email: string | null; id: number; username: string; display_name: string; email: string | null;
role: string; is_active: number; created_at: string role: string; is_active: number; created_at: string; last_login_at: string | null;
is_online: number;
} }
export default function UsersPage() { export default function UsersPage() {
const [users, setUsers] = useState<UserItem[]>([]) const [users, setUsers] = useState<UserItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([]) const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
const [modalOpen, setModalOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<UserItem | null>(null) const [createForm, setCreateForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' }) const [createError, setCreateError] = useState('')
const [error, setError] = useState('') const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null) const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null)
async function fetchUsers() { async function fetchUsers() {
@ -43,36 +44,25 @@ export default function UsersPage() {
Promise.all([fetchUsers(), fetchRoles()]).finally(() => setLoading(false)) Promise.all([fetchUsers(), fetchRoles()]).finally(() => setLoading(false))
}, []) }, [])
function openCreate() { async function handleCreate() {
setEditing(null) setCreating(true); setCreateError('')
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
setError('')
setModalOpen(true)
}
function openEdit(user: UserItem) {
setEditing(user)
setForm({ username: user.username, password: '', display_name: user.display_name, email: user.email || '', role: user.role })
setError('')
setModalOpen(true)
}
async function handleSave() {
setSaving(true); setError('')
try { try {
if (editing) { if (!createForm.username || !createForm.password) { setCreateError('用户名和密码不能为空'); return }
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role } const res = await fetch('/api/users', {
if (form.password) body.password = form.password method: 'POST',
const res = await fetch(`/api/users/${editing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) headers: { 'Content-Type': 'application/json' },
if (!res.ok) { const d = await res.json(); setError(d.error); return } body: JSON.stringify(createForm)
} else { })
if (!form.username || !form.password) { setError('用户名和密码不能为空'); return } if (!res.ok) { const d = await res.json(); setCreateError(d.error); return }
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }) setCreateOpen(false)
if (!res.ok) { const d = await res.json(); setError(d.error); return } setCreateForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
} fetchUsers()
setModalOpen(false) } finally { setCreating(false) }
}
async function toggleActive(user: UserItem) {
await fetch(`/api/users/${user.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: user.is_active ? 0 : 1 }) })
fetchUsers() fetchUsers()
} finally { setSaving(false) }
} }
async function handleDelete() { async function handleDelete() {
@ -82,19 +72,28 @@ export default function UsersPage() {
} }
const columns: Column<UserItem>[] = [ const columns: Column<UserItem>[] = [
{ key: 'username', title: '用户名' }, { key: 'username', title: '用户名', render: (r) => (
<Link href={`/settings/users/${r.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{r.username}</Link>
)},
{ key: 'display_name', title: '显示名称', render: (r) => <span className="text-slate-900 dark:text-white font-medium">{r.display_name}</span> }, { key: 'display_name', title: '显示名称', render: (r) => <span className="text-slate-900 dark:text-white font-medium">{r.display_name}</span> },
{ key: 'email', title: '邮箱', render: (r) => r.email || '-' }, { key: 'email', title: '邮箱', render: (r) => <span className="text-slate-500 dark:text-slate-400">{r.email || '-'}</span> },
{ key: 'role', title: '角色', render: (r) => { { key: 'role', title: '角色', render: (r) => {
const option = roleOptions.find(ro => ro.value === r.role) const option = roleOptions.find(ro => ro.value === r.role)
return <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{option?.label || r.role}</Badge> return <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{option?.label || r.role}</Badge>
}}, }},
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> }, { key: 'is_active', title: '状态', render: (r) => <button onClick={() => toggleActive(r)}><Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge></button> },
{ key: 'created_at', title: '创建时间' }, { key: 'is_online', title: '在线', render: (r) => (
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${r.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
<span className="text-slate-500 dark:text-slate-400">{r.is_online ? '在线' : '离线'}</span>
</span>
)},
{ key: 'last_login_at', title: '最后登录', render: (r) => r.last_login_at ? <span className="text-slate-500 dark:text-slate-400">{r.last_login_at}</span> : <span className="text-slate-400 dark:text-slate-500"></span> },
{ key: 'actions', title: '操作', render: (r) => ( { key: 'actions', title: '操作', render: (r) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => openEdit(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Edit size={16} /></button> {r.username !== 'admin' && r.username !== 'localadmin' && (
<button onClick={() => setDeleteTarget(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Trash2 size={16} /></button> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
)}
</div> </div>
)}, )},
] ]
@ -102,34 +101,38 @@ export default function UsersPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<Button size="sm" onClick={openCreate}><Plus size={16} /></Button> <p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} /></Button>
</div> </div>
{loading ? <div className="py-20 text-center text-slate-500">...</div> : <Table columns={columns} data={users} rowKey={r => r.id} />} {loading ? <div className="py-20 text-center text-slate-500">...</div> : <Table columns={columns} data={users} rowKey={r => r.id} />}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editing ? '编辑用户' : '新建用户'} footer={ <Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
<> <>
<Button variant="ghost" onClick={() => setModalOpen(false)}></Button> <Button variant="ghost" onClick={() => setCreateOpen(false)}></Button>
<Button onClick={handleSave} loading={saving}></Button> <Button onClick={handleCreate} loading={creating}></Button>
</> </>
}> }>
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>} {createError && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{createError}</div>}
<div className="space-y-4"> <div className="space-y-4">
{!editing && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} />} <Input label="用户名" value={createForm.username} onChange={e => setCreateForm(p => ({ ...p, username: e.target.value }))} />
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} /> <Input label="显示名称" value={createForm.display_name} onChange={e => setCreateForm(p => ({ ...p, display_name: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} /> <Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
<Input label={editing ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} /> <Input label="密码" type="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} /> <Select label="角色" value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
</div> </div>
</Modal> </Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除"> <Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p> <>
<div className="flex justify-end gap-3 mt-4">
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button> <Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button> <Button variant="danger" onClick={handleDelete}></Button>
</div> </>
}>
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p>
</Modal> </Modal>
</div> </div>
) )

View File

@ -1,15 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, generateApiKey, hashApiKey } from '@/lib/auth' import { getSession, generateApiKey, hashApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, verifyApiKey } from '@/lib/auth' import { getSession, verifyApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
function getApiKeyAuth(request: Request) { function getApiKeyAuth(request: Request) {
const auth = request.headers.get('Authorization') || '' const auth = request.headers.get('Authorization') || ''
@ -31,7 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession() const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'assets:write')) { if (!checkPermission(session.role, 'assets:update')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 }) return NextResponse.json({ error: '权限不足' }, { status: 403 })
} }
@ -76,7 +70,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 }) return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
} }
updates.push("updated_at = datetime('now')") updates.push("updated_at = datetime('now', '+8 hours')")
values.push(id) values.push(id)
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values) db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)

View File

@ -1,15 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
const UPDATABLE_FIELDS = [ const UPDATABLE_FIELDS = [
'device_type', 'device_purpose', 'room', 'rack_position', 'status', 'device_type', 'device_purpose', 'room', 'rack_position', 'status',
@ -19,7 +14,7 @@ const UPDATABLE_FIELDS = [
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getSession() const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'assets:write')) { if (!checkPermission(session.role, 'assets:update')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 }) return NextResponse.json({ error: '权限不足' }, { status: 403 })
} }
@ -46,7 +41,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 }) return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 })
} }
updates.push("updated_at = datetime('now')") updates.push("updated_at = datetime('now', '+8 hours')")
const placeholders = ids.map(() => '?').join(', ') const placeholders = ids.map(() => '?').join(', ')
const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`) const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`)

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { exportAssetsToBuffer } from '@/lib/excel' import { exportAssetsToBuffer } from '@/lib/excel'
@ -30,7 +30,7 @@ export async function GET(request: Request) {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const payload = await getSession()
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 }) if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 })

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
const ALLOWED_FIELDS = new Set([ const ALLOWED_FIELDS = new Set([
'device_type', 'device_purpose', 'room', 'rack_position', 'node_name', 'device_type', 'device_purpose', 'room', 'rack_position', 'node_name',
@ -24,7 +24,7 @@ export async function GET(request: Request) {
const cookieStore = await cookies() const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const payload = await getSession()
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)

View File

@ -1,21 +1,16 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { parseImportBuffer } from '@/lib/excel' import { parseImportBuffer } from '@/lib/excel'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getSession() const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'assets:write')) { if (!checkPermission(session.role, 'assets:import')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 }) return NextResponse.json({ error: '权限不足' }, { status: 403 })
} }
@ -70,7 +65,7 @@ export async function POST(request: Request) {
} }
} }
if (updates.length > 0) { if (updates.length > 0) {
updates.push("updated_at = datetime('now')") updates.push("updated_at = datetime('now', '+8 hours')")
values.push(existing.id) values.push(existing.id)
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values) db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
updated++ updated++

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, verifyApiKey } from '@/lib/auth' import { getSession, verifyApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
function getApiKeyAuth(request: Request) { function getApiKeyAuth(request: Request) {
const auth = request.headers.get('Authorization') || '' const auth = request.headers.get('Authorization') || ''
@ -134,7 +128,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getSession() const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'assets:write')) { if (!checkPermission(session.role, 'assets:create')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 }) return NextResponse.json({ error: '权限不足' }, { status: 403 })
} }

View File

@ -1,14 +1,14 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { getSession } from '@/lib/auth'
import { verifyJwt } from '@/lib/auth' import { checkPermission } from '@/lib/permissions'
import { generateTemplateBuffer } from '@/lib/excel' import { generateTemplateBuffer } from '@/lib/excel'
export async function GET() { export async function GET() {
const cookieStore = await cookies() const payload = await getSession()
const token = cookieStore.get('session_assets')?.value
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token)
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(payload.role, 'assets:import')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const buffer = generateTemplateBuffer() const buffer = generateTemplateBuffer()
return new NextResponse(buffer, { return new NextResponse(buffer, {

View File

@ -1,18 +1,97 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyPassword, signJwt } from '@/lib/auth' import { verifyPassword, signJwt, hashPassword } from '@/lib/auth'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
import { ldapAuth } from '@/lib/ldap'
import type { User } from '@/types' import type { User } from '@/types'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { username, password } = await request.json() const { username, password } = await request.json()
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as User | undefined
if (!user || !verifyPassword(password, user.password_hash)) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) let userId: number
const token = signJwt({ userId: user.id, username: user.username, role: user.role }) let role: string
const cookieStore = await cookies() let displayName: string
cookieStore.set('session_assets', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 86400, path: '/' })
return NextResponse.json({ user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role } }) // 1. localadmin纯本地 BCrypt不依赖 LLDAP
} catch { return NextResponse.json({ error: '登录失败' }, { status: 500 }) } if (username === 'localadmin') {
const localUser = db.prepare(
'SELECT * FROM users WHERE username = ? AND is_active = 1'
).get(username) as User | undefined
if (!localUser || !verifyPassword(password, localUser.password_hash)) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
}
userId = localUser.id
role = localUser.role
displayName = localUser.display_name || username
} else {
// 2. 其他用户LLDAP 优先
const ldapResult = await ldapAuth(username, password)
if (ldapResult.success) {
// LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
displayName = ldapResult.displayName || username
const pwHash = hashPassword(password)
const existing = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string } | undefined
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, display_name = ?, email = ? WHERE id = ?')
.run(pwHash, displayName, ldapResult.email || null, existing.id)
userId = existing.id
role = existing.role
} else {
db.prepare(
"INSERT INTO users (username, password_hash, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, pwHash, displayName, ldapResult.email || null)
const created = db.prepare(
'SELECT id, role FROM users WHERE username = ?'
).get(username) as { id: number; role: string } | undefined
if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
userId = created.id
role = created.role
}
} else if (ldapResult.unreachable) {
// LLDAP 不可达 → 回退本地密码缓存
const localUser = db.prepare(
'SELECT * FROM users WHERE username = ? AND is_active = 1'
).get(username) as User | undefined
if (!localUser || !verifyPassword(password, localUser.password_hash)) {
return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
}
userId = localUser.id
role = localUser.role
displayName = localUser.display_name || username
} else {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
}
}
// 3. 更新最后登录时间和活跃时间
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
// 4. 签发两个 cookie
const localToken = signJwt({ userId, username, role })
const sharedToken = signSharedJwt({ username, displayName })
const sharedCfg = sharedCookieConfig()
const cookieStore = await cookies()
cookieStore.set('session_assets', localToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 86400,
path: '/',
})
cookieStore.set(sharedCfg.name, sharedToken, sharedCfg)
return NextResponse.json({
user: { id: userId, username, display_name: displayName, role },
})
} catch {
return NextResponse.json({ error: '登录失败' }, { status: 500 })
}
} }

View File

@ -1,7 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
export async function POST() { export async function POST() {
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' }) cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }

View File

@ -1,19 +1,24 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
export async function GET() { export async function GET() {
try { try {
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const payload = verifyJwt(token) const user = db.prepare(
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) 'SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1'
const user = db.prepare('SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as Record<string, unknown> | undefined ).get(session.userId) as Record<string, unknown> | undefined
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
const roleRow = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined
const roleRow = db.prepare(
'SELECT permissions FROM roles WHERE name = ?'
).get(user.role) as { permissions: string } | undefined
const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : [] const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : []
return NextResponse.json({ user: { ...user, permissions } }) return NextResponse.json({ user: { ...user, permissions } })
} catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) } } catch {
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
}
} }

View File

@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import db from '@/lib/db'
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
export async function GET(request: Request) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const roles = db.prepare('SELECT name, display_name FROM roles ORDER BY name').all() as { name: string; display_name: string }[]
return NextResponse.json({ roles })
}

View File

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
@ -12,11 +11,8 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params const { id } = await params
@ -45,11 +41,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params const { id } = await params

View File

@ -1,17 +1,13 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
export async function GET() { export async function GET() {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const roles = db.prepare('SELECT * FROM roles ORDER BY id').all() const roles = db.prepare('SELECT * FROM roles ORDER BY id').all()
return NextResponse.json({ roles }) return NextResponse.json({ roles })
@ -19,11 +15,8 @@ export async function GET() {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
initDatabase() initDatabase()
const cookieStore = await cookies() const session = await getSession()
const token = cookieStore.get('session_assets')?.value if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
const session = verifyJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json() const body = await request.json()

View File

@ -1,14 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt } from '@/lib/auth' import { getSession } from '@/lib/auth'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()

View File

@ -1,14 +1,23 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, hashPassword } from '@/lib/auth' import { getSession, hashPassword } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!token) return null const session = await getSession()
return verifyJwt(token) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
const { id } = await params
const user = db.prepare(`
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?
`).get(id) as Record<string, unknown> | undefined
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
return NextResponse.json({ user })
} }
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
@ -19,11 +28,17 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
} }
const { id } = await params const { id } = await params
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id) const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
try { try {
const body = await request.json() const body = await request.json()
// 禁止修改系统保留用户的角色
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
}
const updates: string[] = [] const updates: string[] = []
const values: unknown[] = [] const values: unknown[] = []
@ -37,11 +52,14 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 }) return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
} }
updates.push("updated_at = datetime('now')") updates.push("updated_at = datetime('now', '+8 hours')")
values.push(id) values.push(id)
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values) db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values)
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id) const user = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?`).get(id)
return NextResponse.json({ user }) return NextResponse.json({ user })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '更新用户失败' const msg = e instanceof Error ? e.message : '更新用户失败'
@ -61,8 +79,11 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 }) return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 })
} }
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id) const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
if (existing.username === 'admin' || existing.username === 'localadmin') {
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
}
db.prepare('DELETE FROM users WHERE id = ?').run(id) db.prepare('DELETE FROM users WHERE id = ?').run(id)
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })

View File

@ -1,15 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { verifyJwt, hashPassword } from '@/lib/auth' import { getSession, hashPassword } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_assets')?.value
if (!token) return null
return verifyJwt(token)
}
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()
@ -18,7 +12,10 @@ export async function GET() {
return NextResponse.json({ error: '权限不足' }, { status: 403 }) return NextResponse.json({ error: '权限不足' }, { status: 403 })
} }
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all() const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users ORDER BY id`).all()
return NextResponse.json({ users }) return NextResponse.json({ users })
} }
@ -42,7 +39,7 @@ export async function POST(request: Request) {
} }
const passwordHash = hashPassword(password) const passwordHash = hashPassword(password)
const result = db.prepare('INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)') const result = db.prepare("INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
.run(username, passwordHash, display_name, email || null, role || 'viewer') .run(username, passwordHash, display_name, email || null, role || 'viewer')
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid) const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions'
import { ldapGetUserInfo } from '@/lib/ldap'
export async function POST() {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'users:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const users = db.prepare(
'SELECT id, username FROM users WHERE email IS NULL OR email = \'\''
).all() as { id: number; username: string }[]
let synced = 0
let failed = 0
for (const u of users) {
const info = await ldapGetUserInfo(u.username)
if (info?.email) {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, u.id)
synced++
} else {
failed++
}
}
return NextResponse.json({ synced, failed, total: users.length })
}

View File

@ -9,7 +9,7 @@ interface AppShellProps { children: ReactNode; user?: { display_name: string; ro
export default function AppShell({ children, user }: AppShellProps) { export default function AppShell({ children, user }: AppShellProps) {
return ( return (
<ThemeProvider> <ThemeProvider>
<Sidebar /> <Sidebar role={user?.role} />
<TopBar user={user} /> <TopBar user={user} />
<main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950"> <main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950">
<div className="p-6">{children}</div> <div className="p-6">{children}</div>

View File

@ -15,8 +15,9 @@ const settingsItems = [
{ href: '/settings/api-keys', label: 'API Key', icon: Key }, { href: '/settings/api-keys', label: 'API Key', icon: Key },
] ]
export default function Sidebar() { export default function Sidebar({ role }: { role?: string }) {
const pathname = usePathname() const pathname = usePathname()
const isAdmin = role === 'admin'
return ( return (
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40"> <aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800"> <div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
@ -28,6 +29,7 @@ export default function Sidebar() {
const Icon = item.icon const Icon = item.icon
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>) return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})} })}
{isAdmin && (
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3"> <div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider"> <div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
<Settings size={14} /> <Settings size={14} />
@ -38,6 +40,7 @@ export default function Sidebar() {
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>) return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})} })}
</div> </div>
)}
</nav> </nav>
</aside> </aside>
) )

View File

@ -10,22 +10,23 @@ export default function TopBar({ user }: TopBarProps) {
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
async function handleLogout() { async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' }) await fetch('/api/auth/logout', { method: 'POST' })
// 清除所有 cookies 后跳转登录页,下次请求将触发 SSO 重新认证
router.push('/login'); router.refresh() router.push('/login'); router.refresh()
} }
return ( return (
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30"> <header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
<div /> <div />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={toggleTheme} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}> <button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />} {theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
{user && ( {user && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<User size={16} /> <User size={16} />
<span>{user.display_name}</span> <span>{user.display_name}</span>
</div> </div>
<button onClick={handleLogout} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title="退出登录"> <button onClick={handleLogout} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="退出登录">
<LogOut size={18} /> <LogOut size={18} />
</button> </button>
</div> </div>

View File

@ -1,8 +1,41 @@
'use client' 'use client'
import { ReactNode, useEffect } from 'react' import { ReactNode, useEffect, useRef } from 'react'
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode } interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
export default function Modal({ open, onClose, title, children, footer }: ModalProps) { export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = '' } }, [open]) const footerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [open])
useEffect(() => {
if (!open) return
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') { onClose(); return }
if (!footerRef.current) return
const buttons = footerRef.current.querySelectorAll('button:not([disabled])')
if (buttons.length === 0) return
const currentIdx = Array.from(buttons).indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const next = e.key === 'ArrowRight'
? (currentIdx + 1) % buttons.length
: (currentIdx - 1 + buttons.length) % buttons.length
;(buttons[next] as HTMLButtonElement).focus()
} else if (e.key === 'Enter' && currentIdx >= 0) {
e.preventDefault()
;(document.activeElement as HTMLButtonElement).click()
}
}
document.addEventListener('keydown', handleKeyDown)
const t = setTimeout(() => {
const btn = footerRef.current?.querySelector('button:not([disabled])')
if (btn) (btn as HTMLButtonElement).focus()
}, 100)
return () => { document.removeEventListener('keydown', handleKeyDown); clearTimeout(t) }
}, [open, onClose])
if (!open) return null if (!open) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
@ -15,7 +48,9 @@ export default function Modal({ open, onClose, title, children, footer }: ModalP
</div> </div>
)} )}
<div className="p-6">{children}</div> <div className="p-6">{children}</div>
{footer && <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>} {footer && (
<div ref={footerRef} className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>
)}
</div> </div>
</div> </div>
) )

View File

@ -37,6 +37,64 @@ export function hashApiKey(key: string): string { return crypto.createHash('sha2
export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` } export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` }
export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) } export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) }
// 统一获取当前会话:优先 tlyq_session共享 JWT回退 session_assets本地 JWT
import { cookies } from 'next/headers'
import { verifySharedJwt } from '@/lib/jwt'
import { ldapUserExists, ldapGetUserInfo } from '@/lib/ldap'
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
// 1. tlyq_session共享 JWTLDAP 用户)
const sharedToken = cookieStore.get('tlyq_session')?.value
if (sharedToken) {
const sharedPayload = verifySharedJwt(sharedToken)
if (sharedPayload) {
// Q1: 检查 LLDAP 中用户是否仍存在(已删除则强制退出)
if (!(await ldapUserExists(sharedPayload.username))) {
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
return null
}
const row = db.prepare(
'SELECT id, username, role, email FROM users WHERE username = ? AND is_active = 1'
).get(sharedPayload.username) as { id: number; username: string; role: string; email: string | null } | undefined
if (row) {
if (!row.email) {
const info = await ldapGetUserInfo(sharedPayload.username)
if (info?.email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, row.id)
}
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
return { userId: row.id, username: row.username, role: row.role }
}
// SSO 免登录LLDAP 验证通过但本地无记录 → 自动创建viewer 角色)
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
const displayName = ldapInfo?.displayName || sharedPayload.displayName
const email = ldapInfo?.email ?? null
db.prepare(
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(sharedPayload.username, displayName, email)
const newRow = db.prepare(
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
).get(sharedPayload.username) as { id: number; username: string; role: string } | undefined
if (newRow) {
return { userId: newRow.id, username: newRow.username, role: newRow.role }
}
}
}
// 2. session_assets本地 JWTadmin 账号或紧急绕过)
const token = cookieStore.get('session_assets')?.value
if (token) {
const payload = verifyJwt(token)
if (payload) {
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(payload.userId)
return payload
}
}
return null
}
export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null { export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null {
if (!key.startsWith('ak_')) return null if (!key.startsWith('ak_')) return null
const keyHash = hashApiKey(key) const keyHash = hashApiKey(key)
@ -44,6 +102,6 @@ export function verifyApiKey(key: string): { id: number; name: string; permissio
.get(keyHash) as { id: number; name: string; permissions: string } | undefined .get(keyHash) as { id: number; name: string; permissions: string } | undefined
if (!row) return null if (!row) return null
if (row.expires_at && new Date(row.expires_at) < new Date()) return null if (row.expires_at && new Date(row.expires_at) < new Date()) return null
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id) db.prepare("UPDATE api_keys SET last_used_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) } return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
} }

View File

@ -11,21 +11,21 @@ export function initDatabase() {
email TEXT, email TEXT,
role TEXT NOT NULL DEFAULT 'viewer', role TEXT NOT NULL DEFAULT 'viewer',
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
); );
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
permissions TEXT NOT NULL DEFAULT '[]', permissions TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
); );
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -36,7 +36,7 @@ export function initDatabase() {
expires_at TEXT, expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id), created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
); );
CREATE TABLE IF NOT EXISTS assets ( CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -73,8 +73,8 @@ export function initDatabase() {
psu_total_power TEXT, psu_total_power TEXT,
board_model TEXT, board_count INTEGER, board_model TEXT, board_count INTEGER,
raw_data TEXT, raw_data TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
); );
CREATE TABLE IF NOT EXISTS audit_logs ( CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -85,7 +85,7 @@ export function initDatabase() {
entity_id INTEGER, entity_id INTEGER,
details TEXT, details TEXT,
ip_address TEXT, ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
); );
CREATE INDEX IF NOT EXISTS idx_assets_node_name ON assets(node_name); CREATE INDEX IF NOT EXISTS idx_assets_node_name ON assets(node_name);
CREATE INDEX IF NOT EXISTS idx_assets_business_ip ON assets(business_ip); CREATE INDEX IF NOT EXISTS idx_assets_business_ip ON assets(business_ip);
@ -93,15 +93,25 @@ export function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status); CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
`) `)
// 迁移:添加 last_login_at 和 last_active_at 列
try { db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT') } catch { /* 列已存在 */ }
try { db.exec('ALTER TABLE users ADD COLUMN last_active_at TEXT') } catch { /* 列已存在 */ }
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin') const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
if (!existingAdmin) { if (!existingAdmin) {
const passwordHash = bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin123', 10) const passwordHash = bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin123', 10)
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)') db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
.run('admin', passwordHash, '管理员', 'admin') .run('admin', passwordHash, '管理员', 'admin')
} }
const existingLocalAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('localadmin')
if (!existingLocalAdmin) {
const localPasswordHash = bcrypt.hashSync(process.env.LOCALADMIN_PASSWORD || 'admin123', 10)
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
.run('localadmin', localPasswordHash, '本地管理员', 'admin')
}
const defaultRoles = [ const defaultRoles = [
{ name: 'admin', display_name: '管理员', permissions: '["*"]' }, { name: 'admin', display_name: '管理员', permissions: '["*"]' },
{ name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete","assets:export:selected"]' }, { name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:create","assets:import","assets:update","assets:delete","assets:export:selected"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' }, { name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' },
] ]
const builtinNames = new Set(defaultRoles.map(r => r.name)) const builtinNames = new Set(defaultRoles.map(r => r.name))
@ -110,16 +120,15 @@ export function initDatabase() {
if (!existing) { if (!existing) {
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)') db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
.run(role.name, role.display_name, role.permissions) .run(role.name, role.display_name, role.permissions)
} else { } else if (role.name === 'admin') {
// 内置角色始终同步到最新默认权限 // 仅 admin 角色始终同步到最新默认权限editor/viewer 允许用户自定义
db.prepare('UPDATE roles SET permissions = ? WHERE name = ?').run(role.permissions, role.name) db.prepare('UPDATE roles SET permissions = ? WHERE name = ?').run(role.permissions, role.name)
} }
} }
// 迁移自定义角色中遗留的旧 assets:export 权限(拆分为 selected/all // 迁移旧权限(所有角色,包括内置角色
const allRoles = db.prepare('SELECT id, name, permissions FROM roles').all() as { id: number; name: string; permissions: string }[] const allRoles = db.prepare('SELECT id, name, permissions FROM roles').all() as { id: number; name: string; permissions: string }[]
for (const r of allRoles) { for (const r of allRoles) {
if (builtinNames.has(r.name)) continue
const perms: string[] = JSON.parse(r.permissions) const perms: string[] = JSON.parse(r.permissions)
if (perms.includes('assets:export')) { if (perms.includes('assets:export')) {
const upgraded = perms.filter(p => p !== 'assets:export') const upgraded = perms.filter(p => p !== 'assets:export')
@ -127,5 +136,10 @@ export function initDatabase() {
if (perms.includes('*')) upgraded.push('assets:export:all') if (perms.includes('*')) upgraded.push('assets:export:all')
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id) db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
} }
if (perms.includes('assets:write')) {
const upgraded = perms.filter(p => p !== 'assets:write')
upgraded.push('assets:create', 'assets:import', 'assets:update')
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
}
} }
} }

View File

@ -3,11 +3,11 @@
* issue-ai API * issue-ai API
* *
* *
* ISSUE_API_URL issue-ai API http://localhost:5176/api云上必须设 http://issue-ai:3000/api * ISSUE_API_URL issue-ai API http://localhost:6176/api云上必须设 http://issue-ai:3000/api
* ISSUE_API_KEY API Cookie * ISSUE_API_KEY API Cookie
*/ */
const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:5176/api' const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:6176/api'
const API_KEY = process.env.ISSUE_API_KEY || '' const API_KEY = process.env.ISSUE_API_KEY || ''
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

61
src/lib/jwt.ts Normal file
View File

@ -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,
}
}

90
src/lib/ldap.ts Normal file
View File

@ -0,0 +1,90 @@
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 密码,避免明文存于多个 .env
// 需要容器挂载 /var/run/docker.sock
function getLdapAdminPassword(): string {
try {
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
{ timeout: 3000 }).toString().trim()
} catch { return 'admin123' }
}
export interface LdapResult {
success: boolean
unreachable: boolean
username?: string
displayName?: string
email?: 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', 'mail'],
timeLimit: 3,
})
const entry = searchEntries[0] as any
const displayName = entry?.displayName || username
const email = entry?.mail || null
return { success: true, unreachable: false, username, displayName, email }
} 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()
}
}
// 从 LLDAP 获取用户信息displayName + email不可达返回 null
export async function ldapGetUserInfo(username: string): Promise<{ displayName: string; email: string | null } | null> {
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
const adminPass = getLdapAdminPassword()
const client = new Client({ url: LDAP_URL, timeout: 5000 })
try {
await client.bind(adminDn, adminPass)
const { searchEntries } = await client.search(LDAP_BASE_DN, {
scope: 'sub', filter: `(uid=${username})`, attributes: ['displayName', 'mail'], timeLimit: 3,
})
const entry = searchEntries[0] as any
return entry ? { displayName: entry.displayName || username, email: entry.mail || null } : null
} catch { return null }
finally { await client.unbind() }
}
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
export async function ldapUserExists(username: string): Promise<boolean> {
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
const adminPass = getLdapAdminPassword()
const client = new Client({ url: LDAP_URL, timeout: 5000 })
try {
await client.bind(adminDn, adminPass)
const { searchEntries } = await client.search(LDAP_BASE_DN, {
scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
})
return searchEntries.length > 0
} catch {
return true // LLDAP 不可达 → 不阻断,容错放行
} finally {
await client.unbind()
}
}

View File

@ -1,6 +1,16 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key
// 注意middleware 运行在 Edge Runtime不能使用 better-sqlite3 等 Node.js 原生模块
// DB 级别的 key 验证在 route handler 中进行auth.ts verifyApiKey
function verifyApiKey(key: string): boolean {
if (!key.startsWith('ak_')) return false
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
if (!allowedKeys) return false
return allowedKeys.split(',').map(k => k.trim()).includes(key)
}
function decodeJwtPayload(token: string): Record<string, unknown> | null { function decodeJwtPayload(token: string): Record<string, unknown> | null {
try { try {
const parts = token.split('.') const parts = token.split('.')
@ -11,36 +21,72 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
} catch { return null } } 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))
}
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
if (pathname === '/login' || pathname.startsWith('/api/auth/login')) return NextResponse.next()
if (pathname.startsWith('/api/')) { // 登录/退出路径 + 内部 API 放行(自有 key 认证)
const authHeader = request.headers.get('authorization') if (pathname === '/login' || pathname.startsWith('/api/auth/login') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) {
if (authHeader?.startsWith('Bearer ak_')) return NextResponse.next()
const token = request.cookies.get('session_assets')?.value
const payload = token ? decodeJwtPayload(token) : null
if (!payload?.userId) {
return NextResponse.json({ error: '未授权' }, { status: 401 })
}
return NextResponse.next() return NextResponse.next()
} }
const token = request.cookies.get('session_assets')?.value // API 路由:检查 Bearer API Key 或 session cookie
const payload = token ? decodeJwtPayload(token) : null if (pathname.startsWith('/api/')) {
const isValidToken = payload?.userId != null const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ak_')) {
if (verifyApiKey(authHeader.slice(7))) return NextResponse.next()
return NextResponse.json({ error: '未授权' }, { status: 401 })
}
if (!isValidToken) { const sharedToken = request.cookies.get('tlyq_session')?.value
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
if (isValidPayload(sharedPayload)) return NextResponse.next()
const localToken = request.cookies.get('session_assets')?.value
const localPayload = localToken ? decodeJwtPayload(localToken) : null
if (isValidPayload(localPayload)) return NextResponse.next()
return NextResponse.json({ error: '未授权' }, { status: 401 })
}
// 页面路由:优先检查 tlyq_session共享 JWT回退 session_assets本地 JWT
const sharedToken = request.cookies.get('tlyq_session')?.value
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
if (isValidPayload(sharedPayload)) {
const response = NextResponse.next()
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
httpOnly: true,
sameSite: 'lax',
path: '/',
})
return response
}
const localToken = request.cookies.get('session_assets')?.value
const localPayload = localToken ? decodeJwtPayload(localToken) : null
if (isValidPayload(localPayload)) {
const response = NextResponse.next()
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
httpOnly: true,
sameSite: 'lax',
path: '/',
})
return response
}
// 未认证 → 重定向登录页
const loginUrl = new URL('/login', request.url) const loginUrl = new URL('/login', request.url)
const dest = pathname + (request.nextUrl.search || '') const dest = pathname + (request.nextUrl.search || '')
loginUrl.searchParams.set('redirect', dest) loginUrl.searchParams.set('redirect', dest)
const response = NextResponse.redirect(loginUrl) const response = NextResponse.redirect(loginUrl)
if (token) response.cookies.delete('session_assets') if (sharedToken) response.cookies.delete('tlyq_session')
return response if (localToken) response.cookies.delete('session_assets')
}
const response = NextResponse.next()
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
return response return response
} }