Compare commits

...

12 Commits

Author SHA1 Message Date
gitadmin 43dedb2ffd feat: #21 Phase 5 — Tailwind v4 语法迁移 (globals.css + 删除 tailwind.config.js) 2026-07-15 16:05:51 +08:00
gitadmin 6da9b6e4c0 feat: #21 Phase 1-4 — CSS导入+OKLCh+登录页统一+Sidebar品牌区+docker安全 2026-07-15 16:03:34 +08:00
gitadmin 6c426757e0 fix: #26 auth/ldap 认证对齐 + CLAUDE.md 更新 2026-07-15 16:00:23 +08:00
gitadmin da542e95e7 fix: middleware publicPaths /api/internal + Dockerfile sqlite3 2026-07-08 10:35:59 +08:00
gitadmin b118a16afd fix: logout redirect uses OIDC_REDIRECT_URI instead of request headers (open redirect fix) 2026-07-07 17:24:49 +08:00
gitadmin 39a88a8220 fix: logout 302 redirect instead of JSON, use x-forwarded-host 2026-07-07 17:18:19 +08:00
gitadmin 5961810a43 chore: .env.example 统一为本地开发配置(JWT_SECRET/AUTHELIA_URL/COOKIE_DOMAIN) 2026-07-03 14:01:38 +08:00
gitadmin 62016cf105 feat: SSO切换账号按钮 + 生产配置模板 2026-07-03 12:12:11 +08:00
gitadmin 9986d99ab3 refactor: P1 共享库迁移
- audit.ts: 引用 shared/lib/audit(包装器保持原接口)
- jwt.ts: 引用 shared/lib/auth/jwt(包装器保持 signSharedJwt/verifySharedJwt)
- middleware.ts: 使用 shared/lib/auth/middleware 工厂
- tsconfig: 添加 @shared/* 路径
- 添加 shared symlink

独立审查通过
2026-07-01 18:42:35 +08:00
gitadmin fc7f3d7255 feat: SSO 统一认证登录
- 集成 Authelia OIDC,支持统一认证登录
- 添加 OIDC 登录页面和回调处理
- 更新 docker-compose.yml 统一环境变量管理
- 更新 CHANGELOG.md 和 CLAUDE.md
2026-06-30 17:09:23 +08:00
gitadmin 2554a61b44 fix: logout 端点添加 domain 参数清除跨域 cookie
- logout 端点清除 cookie 时添加 domain 参数
- 修复 .tlyq.ai 域的 tlyq_session cookie 未正确清除的问题
2026-06-29 20:05:19 +08:00
gitadmin 60e4694d76 feat: SSO 统一认证 + ThemeProvider 三模式 + 登录页统一
- 安装 openid-client v5,新增 OIDC 登录/callback 端点
- ThemeProvider 支持 light/dark/auto 三种模式
- TopBar 主题切换改为下拉菜单
- middleware 登录页已登录自动跳转
- 退出登录重定向 Authelia end_session
- 登录页添加 SSO 按钮 + LDAP 回退
2026-06-29 18:39:29 +08:00
30 changed files with 904 additions and 372 deletions

View File

@ -1,9 +1,21 @@
# assets-ai 环境变量(本地开发)
DATABASE_PATH=./data/assets.db
JWT_SECRET=your-secret-key-change-in-production
JWT_SECRET=dev-jwt-secret-local
COOKIE_DOMAIN=
NODE_ENV=development
NODE_TLS_REJECT_UNAUTHORIZED=0
# issue-ai API 配置(用于故障历史功能)
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置
# 本地开发http://localhost:6176/tickets
# 云上生产https://issue.tlyq.ai/tickets
# LDAP 配置
LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
# OIDC 配置
AUTHELIA_URL=http://127.0.0.1:6180
OIDC_CLIENT_ID=assets-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6177/api/auth/callback
# 跨服务调用
ISSUE_API_URL=http://localhost:6176/api
NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets

2
.gitignore vendored
View File

@ -13,3 +13,5 @@ data/
db-backups/
.playwright-mcp/
*.tsbuildinfo
.env.local
.DS_Store

View File

@ -1,5 +1,19 @@
# 变更日志
## 2026-06-30
- [新增] SSO 统一认证:集成 Authelia OIDC支持统一认证登录
- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退
- [新增] `src/lib/oidc.ts`OIDC 客户端配置PKCE + state + nonce
- [新增] `src/app/api/auth/login/oidc/route.ts`OIDC 登录端点
- [新增] `src/app/api/auth/callback/route.ts`OIDC 回调处理(含用户自动创建)
- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数)
- [修复] NODE_TLS_REJECT_UNAUTHORIZED=0Authelia 使用自签名证书
- [修复] OIDC redirect_uri 回调重定向到 localhost添加 getBaseUrl() 函数
- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic
- [优化] docker-compose.yml统一环境变量管理移除 env_file
- [优化] .env.example添加 OIDC 配置模板
## 2026-06-24
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 6 个表 8 个列

View File

@ -97,6 +97,8 @@ npm run import # 导入设备数据
| POST | `/api/auth/logout` | 登出(清除两个 cookie |
| GET | `/api/auth/me` | 当前用户信息 |
| GET | `/api/internal/roles` | 内部 API返回角色列表x-internal-key 鉴权) |
| GET | `/api/internal/users` | 内部 API返回用户列表x-internal-key 鉴权) |
| POST | `/api/internal/users` | 内部 APIOA 同步用户角色x-internal-key 鉴权) |
### 资产
@ -154,6 +156,11 @@ npm run import # 导入设备数据
| `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 |
| `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 |
| Cookie 名 | `session_assets` | `session_assets` | 本地两系统用不同名防 localhost 域冲突 |
| `AUTHELIA_URL` | `https://sso.tlyq.ai` | 同 | Authelia 地址 |
| `OIDC_CLIENT_ID` | `assets-oidc` | 同 | OIDC 客户端 ID |
| `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 | OIDC 客户端密钥 |
| `OIDC_REDIRECT_URI` | `http://localhost:6177/api/auth/callback` | `https://assets.tlyq.ai/api/auth/callback` | OIDC 回调地址 |
| `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`Authelia 使用自签名证书) | TLS 证书验证 |
### `.env.local` 示例
@ -164,6 +171,10 @@ NODE_ENV=development
ISSUE_API_URL=http://localhost:6176/api
ISSUE_API_KEY=ak_<32字节十六进制>
NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=assets-oidc
OIDC_CLIENT_SECRET=<本地生成的哈希值>
OIDC_REDIRECT_URI=http://localhost:6177/api/auth/callback
```
---

View File

@ -19,6 +19,6 @@ RUN npm install --omit=dev && \
node_modules/@next/swc-linux-x64-musl
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN mkdir -p /app/data /app/uploads
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/* && mkdir -p /app/data /app/uploads
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 node server.js"]

15
config/.env.prod Normal file
View File

@ -0,0 +1,15 @@
# assets-ai 生产环境配置(模板)
DATABASE_PATH=/app/data/assets.db
JWT_SECRET=__JWT_SECRET__
COOKIE_DOMAIN=.tlyq.ai
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
LDAP_URL=ldap://lldap:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=assets-oidc
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
OIDC_REDIRECT_URI=https://assets.tlyq.ai/api/auth/callback
ISSUE_API_URL=http://issue-ai:3000/api
NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets

View File

@ -9,15 +9,17 @@ services:
- assets-uploads:/app/uploads
# .next 目录从主机挂载,主机上 npm run build 后直接生效
- ./.next:/app/.next
# 运行时从 LLDAP 容器动态读取 admin 密码
- /var/run/docker.sock:/var/run/docker.sock
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DATABASE_PATH=/app/data/assets.db
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
- NODE_ENV=production
- COOKIE_DOMAIN=.tlyq.ai
- TZ=Asia/Shanghai
- NODE_TLS_REJECT_UNAUTHORIZED=0
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
- LDAP_URL=ldap://lldap:3890
- LDAP_BASE_DN=dc=tlyq,dc=ai
@ -30,6 +32,16 @@ services:
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
# 故障历史跳转的工单系统地址(客户端使用)
- NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
# OIDC 配置
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-assets-oidc}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://assets.tlyq.ai/api/auth/callback}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped
networks:
- webnet

61
package-lock.json generated
View File

@ -14,6 +14,7 @@
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"openid-client": "^5.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
@ -2831,6 +2832,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -3177,6 +3187,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz",
@ -3369,6 +3391,24 @@
"node": ">=10"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -3378,6 +3418,21 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -4145,6 +4200,12 @@
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -17,6 +17,7 @@
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"openid-client": "^5.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

@ -1,46 +1,7 @@
'use client'
import { useState } from 'react'
// 登录表单 — 引用共享组件
import LoginPage from '@shared/ui/login-page'
export function LoginForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setError(''); setLoading(true)
try {
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
const data = await res.json()
if (!res.ok) { setError(data.error || '登录失败'); return }
// 直接从 URL 读取 redirect 参数,避免 Suspense/闭包导致的值捕获问题
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
window.location.href = dest
} catch { setError('网络错误,请重试') }
finally { setLoading(false) }
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white"></h1>
{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>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
</div>
<button type="submit" disabled={loading} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">{loading ? '登录中...' : '登录'}</button>
</form>
</div>
</div>
)
return <LoginPage siteName="资产管理系统" redirectPath="/dashboard" />
}

View File

@ -0,0 +1,51 @@
// GET /api/auth/callback — OIDC callbackV2使用 shared handleOidcCallback 工厂)
import { NextRequest } from 'next/server'
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import db from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'assets-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback'
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export async function GET(request: NextRequest) {
return handleOidcCallback(request, {
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
jwtSecret,
cookieDomain,
getUser: (username) => {
const row = db.prepare(
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string } | undefined
return row ? { id: row.id, role: row.role } : null
},
createUser: (username, displayName, email) => {
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(username, displayName, email)
const row = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string }
return { id: row?.id ?? 0, role: row?.role ?? 'viewer' }
},
updateUser: (username, displayName, email) => {
db.prepare(
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?"
).run(displayName, email, username)
},
onAuditLog: (userId, username, req) => {
writeAuditLog({
userId, username, action: 'login', entityType: 'auth',
details: { method: 'oidc' },
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
})
},
})
}

View File

@ -0,0 +1,13 @@
// GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'assets-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback'
export async function GET(request: Request) {
const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1'
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
}

View File

@ -1,25 +1,25 @@
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getSession } from '@/lib/auth'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: Request) {
const session = await getSession()
const cookieStore = await cookies()
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
if (session) {
writeAuditLog({
userId: session.userId,
apiKeyId: null,
action: 'logout',
entityType: 'auth',
entityId: session.userId,
details: { username: session.username },
ipAddress: getClientIP(request)
})
}
return NextResponse.json({ success: true })
/** 从 OIDC_REDIRECT_URI 提取 site URL不可信请求头见 LESSONS-LEARNED #51 */
function getSiteUrl(): string {
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
return process.env.NEXT_PUBLIC_SITE_URL || 'https://assets.tlyq.ai'
}
/** 清除 tlyq_session + session cookie → 302 跳转 /login */
function logoutResponse(): NextResponse {
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
response.cookies.set('tlyq_session', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session', '', { path: '/', maxAge: 0 })
return response
}
export async function GET() { return logoutResponse() }
export async function POST() { return logoutResponse() }

View File

@ -0,0 +1,2 @@
import { NextResponse } from 'next/server'
export async function GET() { return NextResponse.json({ status: 'OK' }) }

View File

@ -0,0 +1,23 @@
// PUT /api/internal/users/role — 供 OA 修改用户角色
import { NextResponse } from 'next/server'
import { execFileSync } from 'child_process'
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
const DB_PATH = process.env.DATABASE_PATH || '/app/data/assets.db'
export async function PUT(request: Request) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const { username, role } = await request.json()
if (!username || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
execFileSync('sqlite3', [DB_PATH], {
input: `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`,
timeout: 3000,
})
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: '更新失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,41 @@
// GET /api/internal/users — 供 OA 查询 assets 用户列表
// POST /api/internal/users — 供 OA 同步用户角色
import { NextRequest, 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: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const users = db.prepare(
'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'
).all() as { username: string; display_name: string; role: string }[]
return NextResponse.json({ users })
}
export async function POST(request: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await request.json()
const { username, displayName, role } = body
if (!username) {
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
}
const VALID_ROLES = ['admin', 'editor', 'viewer']
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
try {
db.prepare(
`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at)
VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?,
updated_at = datetime('now', '+8 hours')`
).run(username, displayName || username, safeRole, safeRole, displayName || username)
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: '同步失败' }, { status: 500 })
}
}

View File

@ -1,12 +1,15 @@
@import "tailwindcss";
@config "../../tailwind.config.js";
@custom-variant dark (&:where(.dark, .dark *));
@source "../../shared";
@source "../../src";
@import './tlyq-design-system.css' layer(base);
@layer base {
body {
@apply bg-slate-50 text-slate-900;
}
html.dark body {
@apply bg-slate-950 text-white;
font-family: var(--font-body);
color: var(--fg);
background-color: var(--bg);
}
}

View File

@ -0,0 +1,362 @@
/*
* TLYQ 统一设计系统 OKLCh 色彩空间2026-07-15 升级
* Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
* 感知均匀色域更广保持无障碍对比度WCAG AA 4.5:1
*
* 使用方式
* - CSS 变量@import './tlyq-design-system.css'
* - Tailwind颜色类见下方映射说明
*/
/* ==================== 设计令牌CSS 变量) ==================== */
:root {
/* 背景色 */
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
--surface: oklch(1 0 0); /* #ffffff (white) */
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
--surface-overlay: rgba(255, 255, 255, 0.95);
/* 文本色 */
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
/* 边框 */
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
/* 主色调 — 蓝色系 */
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
--accent-text: oklch(1 0 0); /* #ffffff */
/* 状态色 */
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
/* 字体 */
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
--font-sans: var(--font-body);
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 字体大小 */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
/* 行高 */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
/* 字间距 */
--tracking-tighter: -0.05em;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* 间距 */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* 圆角 */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* 阴影 */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
/* 过渡 */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* 层级系统 */
--z-base: 0;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 700;
/* 布局 */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--header-height: 56px;
--content-max-width: 1440px;
--content-padding: var(--space-6);
}
/* ==================== 深色模式 ==================== */
:root.dark {
/* 背景色 */
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
--surface-overlay: rgba(15, 23, 42, 0.95);
/* 文本色 */
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
/* 边框 */
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
/* 主色调 */
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
--accent-subtle: rgba(59, 130, 246, 0.1);
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
/* 状态色 */
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
--success-subtle: rgba(52, 211, 153, 0.1);
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
--warning-subtle: rgba(251, 191, 36, 0.1);
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
--danger-subtle: rgba(248, 113, 113, 0.1);
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
--info-subtle: rgba(96, 165, 250, 0.1);
/* 阴影(深色下更突出) */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
}
/* ==================== Tailwind 暗色模式映射 ==================== */
/*
* 以下 Tailwind 类名可直接使用
* 页面背景: bg-slate-50 dark:bg-slate-950
* 卡片背景: bg-white dark:bg-slate-900
* 边框: border-slate-200 dark:border-slate-800
* 正文: text-slate-900 dark:text-slate-50
* 次要文本: text-slate-700 dark:text-slate-300
* 弱文本: text-slate-500 dark:text-slate-400
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
* 成功: text-emerald-600 dark:text-emerald-400
* 警告: text-amber-600 dark:text-amber-400
* 危险: text-red-600 dark:text-red-400
*/
/* ==================== 全局重置 ==================== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
color: var(--fg);
background-color: var(--bg);
}
/* ==================== 组件规范 ==================== */
/* 按钮规范
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
*/
/* 输入框规范
* 边框: border border-slate-300 dark:border-slate-600
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
* 圆角: rounded-lg
* 尺寸: h-10(px-3 text-sm)
*/
/* 卡片规范
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
*/
/* 表格规范
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
* : border-b border-slate-200 dark:border-slate-700
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
*/
/* 徽标规范
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
* 圆角: rounded-full
* 尺寸: px-2.5 py-0.5 text-xs font-medium
*/
/* 弹窗规范
* 背景: bg-white dark:bg-slate-900
* 边框: border border-slate-200 dark:border-slate-800
* 圆角: rounded-xl
* 阴影: shadow-xl
* 标题: text-lg font-semibold
*/
/* 侧边栏规范
* 宽度: w-60 (240px)
* 定位: fixed left-0 top-0 bottom-0
* 背景: bg-white dark:bg-slate-900
* 边框: border-r border-slate-200 dark:border-slate-800
* 品牌区: h-14 flex items-center
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
*/
/* 顶栏规范
* 高度: h-14 (56px)
* 定位: fixed top-0
* 背景: bg-white dark:bg-slate-900
* 边框: border-b border-slate-200 dark:border-slate-800
* z-index: z-30
*/
/* 主内容区规范
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
* 门户布局: max-w-5xl mx-auto px-6 py-8
*/
/* ==================== 统计卡片 ==================== */
/*
* 用于仪表盘/概览页的统计数据展示
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
* 标签: text-sm text-slate-500 dark:text-slate-400
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
*/
/* ==================== 骨架屏加载 ==================== */
/*
* 用于数据加载时的占位效果
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
* 文本行: h-4 w-full / w-3/4
* 标题: h-6 w-1/3
* 卡片: h-32 w-full rounded-xl
*/
/* 无缝统计条 ==================== */
/*
* 仪表盘顶部关键指标展示分段之间仅 1px 间隙
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
* : flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
*/
/* 服务状态卡片 ==================== */
/*
* 仪表盘中展示被监控服务实时状态
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
* 红底红框突出显示
* 状态圆点: w-2.5 h-2.5 rounded-full
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
* 展开区: border-t mt-3 pt-3
*/
/* ==================== 图表容器 ==================== */
/*
* 用于包裹图表组件
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
* <div className="flex items-center justify-between mb-6">
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
* </div>
* [图表内容]
* </div>
*/
/* ==================== 下拉菜单 ==================== */
/*
* 用于用户菜单操作菜单
* 容器: absolute right-0 top-full mt-1
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
* hover:bg-slate-50 dark:hover:bg-slate-700
* text-slate-700 dark:text-slate-300
* 危险项: text-red-600 dark:text-red-400
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
*/
/* ==================== 站点标识色 ==================== */
/*
* OA: blue-600
* assets: blue-600
* issue: blue-600
* monitor: indigo-600
*
* 站点卡片标识色门户首页
* 资产管理: blue-600 tag: CMDB
* 工单跟踪: violet-600 tag: ITS
* 监控中心: indigo-600 tag: MONITOR
* 官网: emerald-600 tag: WWW
* 云平台: amber-600 tag: CLOUD
* Token: rose-600 tag: TOKEN
* 代码仓库: pink-600 tag: GIT
*
* 2026-07-15 HEX 迁移至 OKLCh 色彩空间
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
* 对比度验证白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) 6.5:1 4.5:1 通过
*/

View File

@ -1,68 +1,77 @@
'use client'
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key, FileText } from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
{ href: '/assets', label: '设备管理', icon: Server, perm: 'assets:read' },
{ href: '/assets/advanced-search', label: '高级查询', icon: Search, perm: 'assets:read' },
]
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
interface NavSection { title?: string; items: NavItem[] }
const settingsItems = [
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
]
function hasAnyAdminPerm(permissions: string[]): boolean {
return permissions.includes('*') || permissions.some(p =>
['users:', 'roles:', 'api-keys:', 'audit-logs:'].some(prefix => p.startsWith(prefix))
)
}
export default function Sidebar() {
export default function Sidebar({ role }: { role?: string }) {
const pathname = usePathname()
const [permissions, setPermissions] = useState<string[]>([])
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
const isAdmin = role === 'admin' || role === 'localadmin'
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
const canSee = (perm: string | null) => {
if (perm === null) return true
if (permissions.includes('*')) return true
return permissions.includes(perm)
}
const sections: NavSection[] = [
{
items: [
{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null },
],
},
{
title: '资产管理',
items: [
{ label: '设备管理', href: '/assets', icon: Server, perm: null },
{ label: '高级查询', href: '/assets/advanced-search', icon: Search, perm: null },
],
},
{
title: '系统设置',
items: [
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
],
},
]
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">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400"></span>
</div>
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
{navItems.filter(item => canSee(item.perm)).map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
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>)
})}
{hasAnyAdminPerm(permissions) && (
<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">
<Settings size={14} />
</div>
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
const isActive = pathname === item.href
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>)
})}
<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 z-[200] flex flex-col">
{/* 品牌区 */}
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm"></div>
<span className="font-semibold text-slate-900 dark:text-white"></span>
</Link>
</div>
{/* 导航区 */}
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
{sections.map((section, i) => (
<div key={i}>
{section.title && (
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{section.title}
</p>
)}
{section.items.filter(item => item.perm !== 'hidden').map(item => {
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-colors mb-0.5 ${
isActive(item.href)
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}>
<Icon size={18} />
<span>{item.label}</span>
</Link>
)
})}
</div>
))}
</nav>
</aside>
)

View File

@ -2,24 +2,53 @@
import { useRouter } from 'next/navigation'
import { useTheme } from '@/components/providers/ThemeProvider'
import { Sun, Moon, LogOut, User } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
type Theme = 'light' | 'dark' | 'auto'
interface TopBarProps { user?: { display_name: string; role: string } }
export default function TopBar({ user }: TopBarProps) {
const router = useRouter()
const { theme, toggleTheme } = useTheme()
const { theme, setTheme } = useTheme()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
// 清除所有 cookies 后跳转登录页,下次请求将触发 SSO 重新认证
router.push('/login'); router.refresh()
}
const icons: Record<Theme, React.ReactNode> = { light: <Sun size={16} />, dark: <Moon size={16} />, auto: <span style={{fontSize:14}}></span> }
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
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">
<div />
<div className="flex items-center gap-4">
<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} />}
<div ref={ref} className="relative">
<button onClick={() => setOpen(!open)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="切换主题">
{icons[theme]}
</button>
{open && (
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<span>{icons[t]}</span>
<span>{labels[t]}</span>
{t === theme && <span className="ml-auto text-blue-600"></span>}
</button>
))}
</div>
)}
</div>
{user && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">

View File

@ -1,33 +1,38 @@
'use client'
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType { theme: Theme; toggleTheme: () => void }
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} })
type Theme = 'light' | 'dark' | 'auto'
interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
export function useTheme() { return useContext(ThemeContext) }
function applyTheme(t: Theme) {
const root = document.documentElement
root.classList.remove('light', 'dark')
if (t === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.add(prefersDark ? 'dark' : 'light')
} else {
root.classList.add(t)
}
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark')
const [theme, setThemeState] = useState<Theme>('auto')
useEffect(() => {
// Read initial theme from localStorage or system preference
const stored = localStorage.getItem('theme') as Theme | null
const initial = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
setTheme(initial)
document.documentElement.classList.toggle('dark', initial === 'dark')
document.documentElement.classList.toggle('light', initial === 'light')
const initial = stored || 'auto'
setThemeState(initial)
applyTheme(initial)
}, [])
const toggleTheme = () => {
setTheme(prev => {
const next = prev === 'dark' ? 'light' : 'dark'
localStorage.setItem('theme', next)
document.documentElement.classList.toggle('dark', next === 'dark')
document.documentElement.classList.toggle('light', next === 'light')
return next
})
const setTheme = (t: Theme) => {
setThemeState(t)
localStorage.setItem('theme', t)
applyTheme(t)
}
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
}

View File

@ -1,85 +1,23 @@
// assets-ai/src/lib/audit.ts — 站点审计包装器(引用共享库)
import db from './db'
import { writeAuditLog as sharedWriteAuditLog, diffObjects, getClientIP } from '@shared/lib/audit/write-audit-log'
import type { AuditLogEntry, AuditStore } from '@shared/lib/audit/write-audit-log'
interface AuditLogOptions {
userId?: number | null
apiKeyId?: number | null
action: string
entityType: string
entityId?: number | null
details?: Record<string, unknown> | null
ipAddress?: string | null
}
export function writeAuditLog(opts: AuditLogOptions): void {
const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts
// 创建 assets-ai 的 store 适配器
const store: AuditStore = {
exec: (sql: string) => {
try {
// 每日清理(每天首次写入触发)
// 注意:禁止使用 toISOString(),会返回 UTC 时间导致时区偏移
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const lastCleanup = db.prepare(
"SELECT value FROM settings WHERE key = 'audit_cleanup_date'"
).get() as { value: string } | undefined
if (!lastCleanup || lastCleanup.value !== today) {
// 从 settings 读取保留天数,默认 180 天
const retentionRow = db.prepare(
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
).get() as { value: string } | undefined
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
db.prepare(
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
).run()
db.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)"
).run(today)
}
// 写入审计日志(显式设置 created_at 为北京时间)
db.prepare(`
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours'))
`).run(
userId ?? null,
apiKeyId ?? null,
action,
entityType,
entityId ?? null,
details ? JSON.stringify(details) : null,
ipAddress ?? null
)
db.exec(sql)
} catch (e) {
// 审计写入失败不阻断主操作
console.error('审计日志写入失败:', e)
}
},
}
export function diffObjects(
before: Record<string, unknown>,
after: Record<string, unknown>
): Record<string, { from: unknown; to: unknown }> {
const changes: Record<string, { from: unknown; to: unknown }> = {}
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
for (const key of keys) {
// 跳过系统字段
if (['created_at', 'updated_at'].includes(key)) continue
const from = before[key]
const to = after[key]
if (JSON.stringify(from) !== JSON.stringify(to)) {
changes[key] = { from, to }
}
}
return changes
// 保持原有调用方式writeAuditLog(opts) —— 内部调用共享库
export function writeAuditLog(opts: AuditLogEntry): void {
sharedWriteAuditLog(store, opts)
}
export function getClientIP(request: Request): string | null {
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) return forwarded.split(',')[0].trim()
return request.headers.get('x-real-ip') ?? null
}
// re-export 共享工具函数
export { diffObjects, getClientIP }

View File

@ -40,7 +40,7 @@ export function verifySession(token: string): SessionPayload | null { return ver
// 统一获取当前会话:优先 tlyq_session共享 JWT回退 session_assets本地 JWT
import { cookies } from 'next/headers'
import { verifySharedJwt } from '@/lib/jwt'
import { ldapUserExists, ldapGetUserInfo } from '@/lib/ldap'
import { ldapUserExists, ldapGetUserInfo, ldapIsAdmin } from '@/lib/ldap'
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
@ -67,13 +67,14 @@ export async function getSession(): Promise<SessionPayload | null> {
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 角色
// SSO 免登录LLDAP 验证通过但本地无记录 → 自动创建(从 LLDAP 判断角色oa-ai 同步更新
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
const displayName = ldapInfo?.displayName || sharedPayload.displayName
const email = ldapInfo?.email ?? null
const role = (await ldapIsAdmin(sharedPayload.username)) ? 'admin' : 'viewer'
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)
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(sharedPayload.username, displayName, email, role)
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

View File

@ -1,7 +1,14 @@
import crypto from 'crypto'
// assets-ai/src/lib/jwt.ts — 引用共享 JWT保持原有接口
import { verifyJwt } from '@shared/lib/auth/jwt'
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
import type { AuthConfig } from '@shared/lib/auth/types'
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
// 从环境变量读取配置
const config: AuthConfig = {
jwtSecret: process.env.JWT_SECRET || 'default-secret-change-me',
cookieDomain: process.env.COOKIE_DOMAIN || '',
autheliaUrl: '', oidcClientId: '', oidcClientSecret: '', oidcRedirectUri: '',
}
export interface SharedSession {
username: string
@ -10,51 +17,34 @@ export interface SharedSession {
exp: number
}
function base64url(str: string): string {
return Buffer.from(str).toString('base64url')
}
// 保持原有签名signSharedJwt(payload, expiresIn)
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('.')
return signJwtV2({ secret: config.jwtSecret, payload, iss: 'assets.tlyq.ai', expiresInSeconds: expiresIn })
}
// 保持原有签名verifySharedJwt(token)
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
const payload = verifyJwt(token, config.jwtSecret)
if (!payload) return null
return {
username: payload.username,
displayName: payload.displayName,
iat: payload.iat,
exp: payload.exp,
username: payload.username as string,
displayName: (payload.displayName || payload.username) as string,
iat: payload.iat as number,
exp: payload.exp as number,
}
} catch { return null }
}
// 保持原有签名sharedCookieConfig(maxAge)
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,
domain: config.cookieDomain || undefined,
path: '/',
maxAge,
}

View File

@ -1,16 +1,11 @@
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
// 从环境变量获取 LLDAP admin 密码(容器内无法执行 docker exec见 LESSONS-LEARNED #41
function getLdapAdminPassword(): string {
try {
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
{ timeout: 3000 }).toString().trim()
} catch { return 'admin123' }
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
}
export interface LdapResult {
@ -70,6 +65,22 @@ export async function ldapGetUserInfo(username: string): Promise<{ displayName:
finally { await client.unbind() }
}
// 检查 LLDAP 用户是否为 lldap_admin 组成员
export async function ldapIsAdmin(username: string): Promise<boolean> {
if (username === 'admin') return true
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(`ou=groups,${LDAP_BASE_DN}`, {
scope: 'sub', filter: `(&(cn=lldap_admin)(member=uid=${username},ou=people,${LDAP_BASE_DN}))`, timeLimit: 3,
})
return searchEntries.length > 0
} catch { return false }
finally { try { await client.unbind() } catch { /* */ } }
}
// 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'

48
src/lib/oidc.ts Normal file
View File

@ -0,0 +1,48 @@
import { Issuer } from 'openid-client'
import crypto from 'crypto'
const AUTHELIA_URL = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || 'assets-oidc'
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback'
let oidcClient: any = null
let lastDiscovery = 0
const DISCOVERY_TTL = 3600000 // 1 小时
export async function getOidcClient() {
const now = Date.now()
if (oidcClient && (now - lastDiscovery) < DISCOVERY_TTL) {
return oidcClient
}
try {
const issuer = await Issuer.discover(AUTHELIA_URL)
oidcClient = new issuer.Client({
client_id: OIDC_CLIENT_ID,
client_secret: OIDC_CLIENT_SECRET,
redirect_uris: [OIDC_REDIRECT_URI],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
})
lastDiscovery = now
return oidcClient
} catch (error) {
console.error('OIDC discovery 失败:', error)
throw error
}
}
export function generatePKCE() {
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
return { codeVerifier, codeChallenge }
}
export function generateState() {
return crypto.randomBytes(32).toString('base64url')
}
export function generateNonce() {
return crypto.randomBytes(32).toString('base64url')
}

View File

@ -1,93 +1,15 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// src/middleware.ts — V2 单 cookie 模型Edge 验签 + iss 校验
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
// 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)
}
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
while (payload.length % 4) payload += '='
return JSON.parse(atob(payload))
} catch { return null }
}
function isValidPayload(payload: Record<string, unknown> | null): boolean {
if (!payload) return false
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
if (pathname === '/login' || pathname.startsWith('/api/auth/login') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) {
return NextResponse.next()
}
// API 路由:检查 Bearer API Key 或 session cookie
if (pathname.startsWith('/api/')) {
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 })
}
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 dest = pathname + (request.nextUrl.search || '')
loginUrl.searchParams.set('redirect', dest)
const response = NextResponse.redirect(loginUrl)
if (sharedToken) response.cookies.delete('tlyq_session')
if (localToken) response.cookies.delete('session_assets')
return response
}
export const middleware = createMiddlewareV2({
jwtSecret,
cookieDomain,
allowedIssuers: ['*'],
enableApiKey: true,
publicPaths: ['/login', '/api/auth', '/api/health', '/api/internal', '/_next', '/favicon.ico'],
})
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }

View File

@ -1,7 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: { extend: {} },
plugins: [],
}

View File

@ -7,7 +7,8 @@
"resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
"baseUrl": ".",
"paths": { "@/*": ["./src/*"], "@shared/*": ["./shared/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]