feat: 共享标准库(auth/audit/wechat/alert/ui)

- shared/lib/auth/: JWT、OIDC、LDAP、config 工厂、middleware 工厂
- shared/lib/audit/: 审计日志标准库
- shared/lib/wechat/: WeChatPusher + 消息格式化
- shared/lib/alert/: AlertManager + HealthChecker + 类型定义
- shared/lib/db/: 数据库 schema 常量
- shared/ui/: 统一登录页组件

P1 共享库迁移完成,独立审查通过
This commit is contained in:
aiyimickey 2026-07-01 18:42:16 +08:00
parent 83b26021b2
commit 447c0fce90
20 changed files with 1631 additions and 0 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules/ node_modules/
.DS_Store

419
CLAUDE.md Normal file
View File

@ -0,0 +1,419 @@
# CLAUDE.md — shared 共享库
## 概述
shared 是 tlyq.ai 项目群的共享库,包含后端认证、告警、审计、企业微信推送等标准模块,以及前端 React UI 组件库。所有模块遵循零业务依赖、配置驱动、TypeScript 优先的设计原则。
---
## 目录结构
```
shared/
├── lib/ # 后端服务端库(无 barrel按路径直接导入
│ ├── auth/ # 认证标准库
│ │ ├── types.ts # 类型定义AuthConfig, SessionPayload, OidcUserinfo 等)
│ │ ├── jwt.ts # JWT 签名/验证零依赖Node crypto
│ │ ├── ldap.ts # LDAP 认证ldapts 客户端)
│ │ ├── oidc.ts # OIDC PKCE 流程openid-client
│ │ ├── middleware.ts # Next.js 路由守卫工厂
│ │ └── user-sync.ts # OIDC 用户同步到本地 SQLite
│ ├── alert/ # 告警引擎
│ │ ├── types.ts # 类型定义AlertLevel, ServiceStatus, CheckResult 等)
│ │ ├── alert-manager.ts # AlertManager 告警决策引擎
│ │ └── health-checker.ts # HealthChecker 健康检查引擎
│ ├── audit/ # 审计日志
│ │ ├── audit-schema.ts # audit_logs 表 DDL
│ │ └── write-audit-log.ts # writeAuditLog 函数
│ ├── db/ # 数据库 Schema 定义
│ │ └── alert-schema.ts # 5 张表 DDLservices, alert_channels 等)
│ └── wechat/ # 企业微信 Webhook
│ ├── message-formatter.ts # 消息格式化
│ └── wechat-pusher.ts # WeChatPusher 推送客户端
└── ui/ # 前端 React 组件库(@tlyq/shared-ui
├── package.json # 发布为 @tlyq/shared-ui v1.0.0
├── index.tsx # Barrel 导出9 个组件)
├── Button.tsx # 按钮4 种 variant3 种 sizeloading 状态)
├── Badge.tsx # 标签5 种 variant
├── Card.tsx # 卡片容器
├── Input.tsx # 输入框(含 label 和 error
├── Select.tsx # 下拉选择
├── Modal.tsx # 模态框backdrop 点击关闭body scroll 锁定)
├── Table.tsx # 表格styled theadtbody 由调用方提供)
├── Toast.tsx # 通知条(右下角固定定位)
├── Pagination.tsx # 分页(中文标签:"上一页"/"下一页"
└── login-page.tsx # 统一登录页SSO + LDAP 切换,不在 barrel 中导出)
```
---
## 设计原则
### 零业务依赖
共享库不包含任何站点特定的业务逻辑。所有需要外部状态(数据库、配置)的模块通过 TypeScript 接口注入依赖,不耦合具体实现。
### 配置驱动
模块行为通过配置对象控制,而非硬编码。例如:
- `createMiddleware(config)` 通过 `AuthConfig` 配置公开路径、管理路径、cookie 名称
- `AlertManager` 通过 `CooldownProvider` 接口获取冷却状态
- `HealthChecker` 通过 `registerChecker()` 注册检查器
### TypeScript 优先
所有模块使用 TypeScript 编写,导出完整类型定义。消费方通过 `tsconfig.json``paths` 映射导入。
---
## 各模块详解
### lib/auth/ — 认证标准库
统一认证栈OIDC SSO + LDAP 本地登录 + localadmin 应急用户 + 共享 JWT session。
#### types.ts
```typescript
interface AuthConfig {
jwtSecret: string
cookieDomain: string
cookieName?: string // 默认 'tlyq_session'
cookieMaxAge?: number // 默认 7 天(秒)
publicPaths?: string[] // 公开路径(不需要认证)
adminPaths?: string[] // 管理路径(需要 admin 角色)
ldapUrl?: string
ldapBaseDn?: string
autheliaUrl?: string
oidcClientId?: string
oidcClientSecret?: string
oidcRedirectUri?: string
}
interface SessionPayload {
sub: string // 用户 ID 或 username
username: string
role: string
exp: number
iat: number
}
interface OidcUserinfo {
sub: string
preferred_username: string
email?: string
name?: string
groups?: string[]
}
interface LdapAuthResult {
success: boolean
username?: string
displayName?: string
email?: string
isAdmin?: boolean
error?: string
}
```
#### jwt.ts
```typescript
signJwt(options: { payload: SessionPayload, secret: string, expiresIn?: number }): string
verifyJwt(token: string, secret: string): SessionPayload | null
sharedCookieConfig(domain: string, maxAge?: number): CookieConfig
```
零外部依赖,使用 Node.js `crypto` 模块实现 HS256 JWT 签名和验证。
#### ldap.ts
```typescript
ldapAuth(config: { url: string, baseDn: string }, username: string, password: string): Promise<LdapAuthResult>
checkAdminGroup(config: { url: string, baseDn: string }, username: string): Promise<boolean>
```
使用 `ldapts` 客户端进行 LDAP bind 认证和管理员组成员检查。
#### oidc.ts
```typescript
discoverOidcConfig(url: string): Promise<OidcConfig>
generatePkce(): { codeVerifier: string, codeChallenge: string }
generateState(): string
buildAuthorizeUrl(config: OidcConfig, params: { clientId: string, redirectUri: string, state: string, codeChallenge: string }): string
exchangeCodeForToken(config: OidcConfig, code: string, verifier: string): Promise<TokenSet>
getUserinfo(url: string, accessToken: string): Promise<OidcUserinfo>
```
完整的 OIDC 授权码 + PKCE 流程,基于 Authelia 实现。
#### middleware.ts
```typescript
createMiddleware(config: AuthConfig): NextMiddleware
```
Next.js 路由守卫工厂。验证 `tlyq_session` cookieJWT payload 解码,检查过期时间)。公开路径放行,管理路径检查 admin 角色,未认证请求重定向到 `/login`
#### user-sync.ts
```typescript
syncOidcUser(store: UserSyncStore, userinfo: OidcUserinfo): Promise<{ id: number, role: string, isNew: boolean }>
```
将 OIDC userinfo 同步到本地 SQLite users 表。`UserSyncStore` 是依赖注入接口,不耦合具体数据库驱动。
---
### lib/alert/ — 告警引擎
告警决策和健康检查的核心引擎。
#### types.ts
```typescript
type AlertLevel = 'critical' | 'warning' | 'info'
type ServiceStatus = 'normal' | 'abnormal' | 'unknown'
type CheckType = 'http' | 'docker' | 'ssh'
type AlertType = 'alert' | 'recovery' | 'flapping'
interface HealthCheckConfig {
type: CheckType
url?: string // HTTP 检查
expectedStatus?: number
expectedBody?: string
containerName?: string // Docker 检查
command?: string // SSH 检查
}
interface CheckResult {
success: boolean
responseTime?: number
errorMessage?: string
}
interface AlertDecision {
shouldAlert: boolean
reason?: string
suppressedBy?: 'quiet_period' | 'cooldown' | 'flapping'
}
interface AlertChannelConfig {
id: number
levelCritical: boolean
levelWarning: boolean
levelInfo: boolean
quietEnabled: boolean
quietStart?: string
quietEnd?: string
quietBypassCritical: boolean
cooldownMinutes: number
}
```
#### alert-manager.ts
```typescript
class AlertManager {
constructor(cooldownProvider: CooldownProvider)
evaluate(channel: AlertChannelConfig, serviceId: number, level: AlertLevel, now?: Date): AlertDecision
isQuietPeriod(channel: AlertChannelConfig, now?: Date): boolean
isCoolingDown(channelId: number, serviceId: number, level: AlertLevel, cooldownMinutes: number): boolean
recordAlert(channelId: number, serviceId: number, level: AlertLevel): void
}
```
三阶段告警决策:级别匹配 → 免打扰检查(支持跨午夜) → 冷却窗口检查。
`CooldownProvider` 是依赖注入接口:
```typescript
interface CooldownProvider {
getCooldown(key: string): number | null // 返回 Unix 时间戳
setCooldown(key: string, timestamp: number): void
}
```
#### health-checker.ts
```typescript
class HealthChecker {
registerChecker(type: CheckType, handler: Checker): void
check(checks: HealthCheckConfig[], timeoutMs: number): Promise<CheckResult[]>
}
class HttpChecker implements Checker { ... }
class DockerChecker implements Checker { ... }
```
策略模式:通过 `registerChecker()` 注册检查器,新检查类型(如 SSH可无缝扩展。内部使用 `Promise.allSettled` 并发执行。
---
### lib/audit/ — 审计日志
#### audit-schema.ts
```typescript
const AUDIT_LOGS_TABLE_SQL: string // audit_logs 表 DDL
```
#### write-audit-log.ts
```typescript
interface AuditLogEntry {
userId?: number
username?: string
action: string
entityType: string
entityId?: number
details?: Record<string, unknown>
ipAddress?: string
}
interface AuditStore {
exec(sql: string): void
}
function writeAuditLog(store: AuditStore, entry: AuditLogEntry): void
```
使用 SQL 字符串拼接 + 单引号转义(非参数化查询),这是已知的设计选择。
---
### lib/db/ — 数据库 Schema
#### alert-schema.ts
```typescript
const SERVICES_TABLE_SQL: string
const ALERT_CHANNELS_TABLE_SQL: string
const ALERT_SETTINGS_TABLE_SQL: string
const STATUS_HISTORY_TABLE_SQL: string
const ALERT_HISTORY_TABLE_SQL: string
const ALL_TABLES_SQL: string // 以上 5 个的拼接
```
定义监控相关的 5 张核心表,供各站点初始化数据库使用。
---
### lib/wechat/ — 企业微信 Webhook
#### message-formatter.ts
```typescript
formatAvailabilityMessage(params: { serviceName: string, status: string, ... }): string
formatAlertLevel(level: AlertLevel): string // 返回带 emoji 的级别标签
```
#### wechat-pusher.ts
```typescript
class WeChatPusher {
constructor(webhookUrl: string)
pushMarkdown(title: string, content: string): Promise<boolean>
pushText(content: string): Promise<boolean>
}
class WeChatPusherCompat {
// 兼容 issue-ai 的旧接口(返回 boolean
pushMarkdown(title: string, content: string): Promise<boolean>
}
```
5 秒超时markdown 格式推送。`WeChatPusherCompat` 为迁移提供向后兼容。
---
### ui/ — React 组件库
所有组件均为 `'use client'`Next.js App Router 兼容),使用 Tailwind CSS 工具类,支持 `dark:` 暗色模式。
**导出方式**
```typescript
import { Button, Card, Badge, Table, Modal, Input, Select, Toast, Pagination } from '@shared/ui'
import LoginPage from '@shared/ui/login-page' // 单独导入,不在 barrel 中
```
| 组件 | 核心 Props | 说明 |
|------|-----------|------|
| Button | `variant: 'primary'|'secondary'|'danger'|'ghost'`, `size: 'sm'|'md'|'lg'`, `loading: boolean` | forwardRefloading 时显示 SVG spinner |
| Badge | `variant: 'default'|'success'|'warning'|'danger'|'info'` | 药丸形状标签 |
| Card | `children`, `className?` | 白色/暗色圆角容器 |
| Input | `label?`, `error?` | 带标签和错误提示的输入框 |
| Select | `label?`, `options: {value, label}[]` | 下拉选择器 |
| Modal | `open: boolean`, `onClose`, `title?`, `maxWidth?` | 无 portal 模态框backdrop 点击关闭,锁定 body scroll |
| Table | `headers: string[]`, `children: ReactNode` | 样式化 theadtbody 由调用方提供 |
| Toast | `message`, `type: 'success'|'error'|'info'`, `onClose` | 右下角固定通知条 |
| Pagination | `page`, `totalPages`, `onPageChange` | 中文标签totalPages <= 1 时隐藏 |
| LoginPage | `siteName: string`, `title?: string` | 完整登录页,支持 SSO 和 LDAP 切换 |
---
## 如何导入
### tsconfig.json 配置
在消费方项目的 `tsconfig.json` 中添加路径映射:
```json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["./shared/*"]
}
}
}
```
### 符号链接
在消费方项目根目录创建符号链接:
```bash
cd /Users/niuniu/programs/docker/monitor-ai
ln -s ../shared shared
```
### 导入示例
```typescript
// 后端认证
import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'
import { ldapAuth } from '@shared/lib/auth/ldap'
import { discoverOidcConfig, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
// 告警引擎
import { AlertManager } from '@shared/lib/alert/alert-manager'
import { HealthChecker, HttpChecker, DockerChecker } from '@shared/lib/alert/health-checker'
// 审计日志
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
// 数据库 Schema
import { ALL_TABLES_SQL } from '@shared/lib/db/alert-schema'
// 企业微信
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
// UI 组件
import { Button, Card, Badge, Table, Modal, Input, Select, Toast, Pagination } from '@shared/ui'
import LoginPage from '@shared/ui/login-page'
```
---
## 设计模式
| 模式 | 应用位置 | 说明 |
|------|---------|------|
| 依赖注入 | `CooldownProvider`, `UserSyncStore`, `AuditStore`, `Checker` 接口 | 通过接口注入外部状态,不耦合具体数据库驱动 |
| 策略模式 | `HealthChecker.registerChecker()` | 可插拔检查器,新检查类型无需修改引擎 |
| 工厂模式 | `createMiddleware(config)` | 返回配置化的 Next.js middleware |
| 适配器模式 | `WeChatPusherCompat` | 包装 `WeChatPusher` 提供旧接口兼容 |
| Barrel 导出 | `ui/index.tsx` | 统一导出入口,消费方一行导入 |

View File

@ -0,0 +1,97 @@
// shared/lib/alert/alert-manager.ts — 告警管理引擎
import type { AlertLevel, AlertChannelConfig, AlertDecision } from './types'
export interface CooldownProvider {
getLastAlertTime(channelId: number, serviceId: number, level: string): Promise<number | null>
setLastAlertTime(channelId: number, serviceId: number, level: string, timestamp: number): Promise<void>
}
export interface AlertPushChannel {
push(title: string, content: string, level: string): Promise<{ success: boolean }>
}
export class AlertManager {
private cooldownProvider: CooldownProvider
constructor(cooldownProvider: CooldownProvider) {
this.cooldownProvider = cooldownProvider
}
// 评估是否向某 channel 推送告警
async evaluate(
channel: AlertChannelConfig,
serviceId: number,
level: AlertLevel,
now: Date = new Date()
): Promise<AlertDecision> {
// 1. 级别匹配
if (!this.levelMatches(channel, level)) {
return { shouldPush: false, reason: `level ${level} not enabled for channel ${channel.name}` }
}
// 2. 免打扰时段
if (this.isQuietPeriod(channel, now) && !(level === 'critical' && channel.quietBypassCritical)) {
return { shouldPush: false, reason: `quiet period for channel ${channel.name}` }
}
// 3. 冷却检查
const cooling = await this.isCoolingDown(channel.id, serviceId, level, channel.cooldownMinutes, now)
if (cooling) {
return { shouldPush: false, reason: `cooldown for channel ${channel.name}` }
}
return { shouldPush: true }
}
// 级别匹配检查
private levelMatches(channel: AlertChannelConfig, level: AlertLevel): boolean {
switch (level) {
case 'critical': return channel.levelCritical
case 'warning': return channel.levelWarning
case 'info': return channel.levelInfo
}
}
// 免打扰时段判定(支持跨日时段)
isQuietPeriod(channel: AlertChannelConfig, now: Date): boolean {
if (!channel.quietEnabled) return false
const [sh, sm] = channel.quietStart.split(':').map(Number)
const [eh, em] = channel.quietEnd.split(':').map(Number)
const current = now.getHours() * 60 + now.getMinutes()
const start = sh * 60 + sm
const end = eh * 60 + em
if (start <= end) {
// 同日时段,如 02:00 - 06:00
return current >= start && current < end
} else {
// 跨日时段,如 23:00 - 07:00
return current >= start || current < end
}
}
// 冷却检查
async isCoolingDown(
channelId: number,
serviceId: number,
level: AlertLevel,
cooldownMinutes: number,
now: Date = new Date()
): Promise<boolean> {
const lastTime = await this.cooldownProvider.getLastAlertTime(channelId, serviceId, level)
if (!lastTime) return false
const elapsed = now.getTime() - lastTime
return elapsed < cooldownMinutes * 60 * 1000
}
// 记录告警时间
async recordAlert(
channelId: number,
serviceId: number,
level: AlertLevel,
timestamp: number = Date.now()
): Promise<void> {
await this.cooldownProvider.setLastAlertTime(channelId, serviceId, level, timestamp)
}
}

View File

@ -0,0 +1,97 @@
// shared/lib/alert/health-checker.ts — 健康检查引擎
import type { CheckType, HealthCheckConfig, CheckResult } from './types'
export interface Checker {
check(config: HealthCheckConfig): Promise<CheckResult>
}
export class HealthChecker {
private checkers = new Map<CheckType, Checker>()
registerChecker(type: CheckType, handler: Checker): void {
this.checkers.set(type, handler)
}
// 并发检查所有维度
async check(checks: HealthCheckConfig[], timeoutMs: number): Promise<CheckResult[]> {
const results = await Promise.allSettled(
checks.map(config => this.checkOne(config, timeoutMs))
)
return results.map(r => (r.status === 'fulfilled' ? r.value : {
success: false,
status: 'unknown' as const,
error: 'Checker execution failed',
latency: timeoutMs,
checkType: 'http' as const,
}))
}
private async checkOne(config: HealthCheckConfig, timeoutMs: number): Promise<CheckResult> {
const checker = this.checkers.get(config.type)
if (!checker) {
return { success: false, status: 'unknown', error: `Unknown check type: ${config.type}`, latency: 0, checkType: config.type }
}
return checker.check(config)
}
}
// 内置 HTTP 检查器
export class HttpChecker implements Checker {
async check(config: HealthCheckConfig): Promise<CheckResult> {
const start = Date.now()
try {
const res = await fetch(config.url!, {
signal: AbortSignal.timeout(5000),
headers: { Accept: 'text/html,application/json' },
})
const latency = Date.now() - start
if (config.expectedStatus && res.status !== config.expectedStatus) {
return { success: false, status: 'abnormal', error: `Expected ${config.expectedStatus}, got ${res.status}`, latency, checkType: 'http' }
}
if (config.expectedBody) {
const body = await res.text()
if (!body.includes(config.expectedBody)) {
return { success: false, status: 'abnormal', error: `Body does not contain "${config.expectedBody}"`, latency, checkType: 'http' }
}
}
return { success: true, status: 'normal', latency, checkType: 'http' }
} catch (err) {
return { success: false, status: 'abnormal', error: String(err), latency: Date.now() - start, checkType: 'http' }
}
}
}
// 内置 Docker 检查器(同机部署,需要 docker.sock 只读挂载)
export class DockerChecker implements Checker {
async check(config: HealthCheckConfig): Promise<CheckResult> {
const start = Date.now()
try {
const { execFileSync } = await import('child_process')
const containerName = config.containerName!
const stdout = execFileSync('docker', ['ps', '--filter', `name=${containerName}`, '--format', '{{.Status}}'], {
encoding: 'utf-8',
timeout: 5000,
}).trim()
const latency = Date.now() - start
if (!stdout) {
return { success: false, status: 'abnormal', error: `Container ${containerName} not found or stopped`, latency, checkType: 'docker' }
}
if (stdout.startsWith('Up')) {
return { success: true, status: 'normal', latency, checkType: 'docker' }
}
return { success: false, status: 'abnormal', error: `Container ${containerName} status: ${stdout}`, latency, checkType: 'docker' }
} catch (err) {
const latency = Date.now() - start
// 区分 daemon 不可达 vs 其他错误
const errStr = String(err)
if (errStr.includes('Cannot connect') || errStr.includes('Is the docker daemon')) {
return { success: false, status: 'unknown', error: 'Docker daemon unreachable', latency, checkType: 'docker' }
}
return { success: false, status: 'abnormal', error: errStr, latency, checkType: 'docker' }
}
}
}

57
lib/alert/types.ts Normal file
View File

@ -0,0 +1,57 @@
// shared/lib/alert/types.ts — 告警类型定义
export type AlertLevel = 'critical' | 'warning' | 'info'
export type ServiceStatus = 'normal' | 'abnormal' | 'unknown'
export type CheckType = 'http' | 'docker' | 'ssh'
export type AlertType = 'alert' | 'recovery' | 'flapping'
export interface HealthCheckConfig {
type: CheckType
url?: string
expectedStatus?: number
expectedBody?: string
containerName?: string
sshHost?: string
sshCommand?: string
}
export interface MonitoredService {
id: number
name: string
category: 'container' | 'endpoint' | 'custom'
alertLevel: AlertLevel
checks: HealthCheckConfig[]
checkInterval: number
checkTimeout: number
enabled: boolean
currentStatus: ServiceStatus
statusSince: string
}
export interface CheckResult {
success: boolean
status: ServiceStatus
error?: string
latency: number
checkType: CheckType
}
export interface AlertDecision {
shouldPush: boolean
reason?: string
}
export interface AlertChannelConfig {
id: number
name: string
channelType: 'wechat' | 'email'
webhookUrl: string
enabled: boolean
levelCritical: boolean
levelWarning: boolean
levelInfo: boolean
quietEnabled: boolean
quietStart: string
quietEnd: string
quietBypassCritical: boolean
cooldownMinutes: number
}

17
lib/audit/audit-schema.ts Normal file
View File

@ -0,0 +1,17 @@
// shared/lib/audit/audit-schema.ts — audit_logs 表结构常量
export const AUDIT_LOGS_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
username TEXT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
details TEXT,
ip_address TEXT,
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_time ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(username, created_at);
`

View File

@ -0,0 +1,66 @@
// shared/lib/audit/write-audit-log.ts — 通用审计日志写入函数
export interface AuditLogEntry {
userId?: number | null
username?: string | null
apiKeyId?: number | null
action: string
entityType: string
entityId?: number | null
details?: Record<string, unknown> | null
ipAddress?: string | null
}
export interface AuditStore {
exec(sql: string): void
}
// 写入审计日志(通用版本,调用方传入 store 执行 SQL
export function writeAuditLog(store: AuditStore, entry: AuditLogEntry): void {
const userId = entry.userId ?? 'NULL'
const username = entry.username ? `'${entry.username.replace(/'/g, "''")}'` : 'NULL'
const apiKeyId = entry.apiKeyId ?? 'NULL'
const action = `'${entry.action.replace(/'/g, "''")}'`
const entityType = `'${entry.entityType.replace(/'/g, "''")}'`
const entityId = entry.entityId ?? 'NULL'
const details = entry.details ? `'${JSON.stringify(entry.details).replace(/'/g, "''")}'` : 'NULL'
const ipAddress = entry.ipAddress ? `'${entry.ipAddress.replace(/'/g, "''")}'` : 'NULL'
const sql = `INSERT INTO audit_logs (user_id, username, api_key_id, action, entity_type, entity_id, details, ip_address, created_at)
VALUES (${userId}, ${username}, ${apiKeyId}, ${action}, ${entityType}, ${entityId}, ${details}, ${ipAddress}, datetime('now', '+8 hours'))`
try {
store.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
}
// 获取客户端 IP
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
}

55
lib/auth/config.ts Normal file
View File

@ -0,0 +1,55 @@
// shared/lib/auth/config.ts — 认证配置工厂(各站点统一使用)
import type { AuthConfig } from './types'
export interface SiteAuthConfig {
/** 站点名称,用于日志和调试 */
siteName: string
/** 本地 cookie 名(如 'session_issue'),不设则不使用本地 cookie 回退 */
localCookieName?: string
/** 需要 admin 权限的路径前缀,默认 ['/admin'] */
adminPaths?: string[]
/** 公开路径(不需要认证),默认 ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'] */
publicPaths?: string[]
/** OIDC client ID默认 `${siteName}-oidc`,可手动覆盖 */
oidcClientId?: string
/** OIDC client secret默认从 OIDC_CLIENT_SECRET 环境变量读取 */
oidcClientSecret?: string
/** OIDC redirect URI默认从 OIDC_REDIRECT_URI 环境变量读取 */
oidcRedirectUri?: string
/** 启用 API Key 认证(读取 ALLOWED_API_KEYS 环境变量),默认 false */
enableApiKey?: boolean
}
/**
*
*
* @example
* ```ts
* // src/lib/auth-config.ts
* import { createAuthConfig } from '@shared/lib/auth/config'
* export const authConfig = createAuthConfig({
* siteName: 'issue-ai',
* localCookieName: 'session_issue',
* adminPaths: ['/settings'],
* enableApiKey: true,
* })
* ```
*/
export function createAuthConfig(site: SiteAuthConfig): AuthConfig & {
localCookieName?: string
enableApiKey?: boolean
} {
return {
jwtSecret: process.env.JWT_SECRET || 'default-secret-change-me',
cookieDomain: process.env.COOKIE_DOMAIN || '',
autheliaUrl: process.env.AUTHELIA_URL || 'https://127.0.0.1:6180',
oidcClientId: site.oidcClientId || process.env.OIDC_CLIENT_ID || `${site.siteName}-oidc`,
oidcClientSecret: site.oidcClientSecret || process.env.OIDC_CLIENT_SECRET || '',
oidcRedirectUri: site.oidcRedirectUri || process.env.OIDC_REDIRECT_URI || '',
ldapUrl: process.env.LDAP_URL || 'ldap://localhost:3890',
publicPaths: site.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'],
adminPaths: site.adminPaths || ['/admin'],
localCookieName: site.localCookieName,
enableApiKey: site.enableApiKey || false,
}
}

57
lib/auth/jwt.ts Normal file
View File

@ -0,0 +1,57 @@
// shared/lib/auth/jwt.ts — 共享 JWT 签发/验证HS256跨站点 cookie
import crypto from 'crypto'
export interface SignJwtOptions {
secret: string
payload: Record<string, unknown>
expiresInSeconds?: number
}
export function signJwt({ secret, payload, expiresInSeconds = 604800 }: SignJwtOptions): string {
const header = { alg: 'HS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const body = { ...payload, iat: now, exp: now + expiresInSeconds }
const encoded = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signature = crypto
.createHmac('sha256', secret)
.update(`${encoded(header)}.${encoded(body)}`)
.digest('base64url')
return `${encoded(header)}.${encoded(body)}.${signature}`
}
export function verifyJwt(token: string, secret: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
if (payload.exp && payload.exp * 1000 < Date.now()) return null
const signature = crypto
.createHmac('sha256', secret)
.update(`${parts[0]}.${parts[1]}`)
.digest('base64url')
return signature === parts[2] ? payload : null
} catch {
return null
}
}
// 签发共享 tlyq_session cookie 配置
export function sharedCookieConfig(domain: string, maxAge = 604800): Record<string, unknown> {
return {
name: 'tlyq_session',
value: '',
options: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
domain,
path: '/',
maxAge,
},
}
}

46
lib/auth/ldap.ts Normal file
View File

@ -0,0 +1,46 @@
// shared/lib/auth/ldap.ts — LLDAP 认证
import { Client } from 'ldapts'
import type { LdapAuthResult } from './types'
export interface LdapConfig {
url: string
baseDn: string
adminDn?: string
adminPassword?: string
adminGroup?: string
}
// LDAP bind 认证用户
export async function ldapAuth(config: LdapConfig, username: string, password: string): Promise<LdapAuthResult> {
const client = new Client({ url: config.url, timeout: 5000 })
try {
const userDn = `uid=${username},ou=people,${config.baseDn}`
await client.bind(userDn, password)
return { success: true, username }
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'LDAP auth failed'
return { success: false, error: message }
} finally {
await client.unbind()
}
}
// 检查用户是否在 admin 组中
export async function checkAdminGroup(config: LdapConfig, username: string): Promise<boolean> {
if (!config.adminDn || !config.adminPassword || !config.adminGroup) return false
const client = new Client({ url: config.url, timeout: 5000 })
try {
await client.bind(config.adminDn, config.adminPassword)
const { searchEntries } = await client.search(`cn=${config.adminGroup},ou=groups,${config.baseDn}`, {
scope: 'sub',
filter: `(member=uid=${username},ou=people,${config.baseDn})`,
attributes: ['cn'],
sizeLimit: 1,
})
return searchEntries.length > 0
} catch {
return false
} finally {
await client.unbind()
}
}

169
lib/auth/middleware.ts Normal file
View File

@ -0,0 +1,169 @@
// shared/lib/auth/middleware.ts — 通用 middleware 工厂Edge Runtime 兼容)
// 不使用 Node.js crypto仅用 atob 解码 JWT payload
import { NextResponse, type NextRequest } from 'next/server'
export interface MiddlewareConfig {
/** 公开路径(不需要认证),默认 ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'] */
publicPaths?: string[]
/** 需要 admin 权限的路径前缀,默认 ['/admin'] */
adminPaths?: string[]
/** 本地 cookie 名(如 'session_issue'),用于回退验证 */
localCookieName?: string
/** 启用 API Key 认证(读取 ALLOWED_API_KEYS 环境变量),默认 false */
enableApiKey?: boolean
/** 管理员角色名,默认 'admin' */
adminRole?: string
}
// Edge Runtime 兼容:用 atob 解码 JWT payload不验证签名
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
while (payload.length % 4) payload += '='
return JSON.parse(atob(payload))
} catch { return null }
}
function isValidPayload(payload: Record<string, unknown> | null): boolean {
if (!payload) return false
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
}
function noCache(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
return response
}
/**
* middleware
*
* @example
* ```ts
* // src/middleware.ts — 使用默认配置
* export { middleware, config } from '@shared/lib/auth/middleware'
* ```
*
* @example
* ```ts
* // src/middleware.ts — 自定义配置
* import { createMiddleware } from '@shared/lib/auth/middleware'
* export const middleware = createMiddleware({
* localCookieName: 'session_issue',
* adminPaths: ['/settings'],
* enableApiKey: true,
* })
* export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
* ```
*/
export function createMiddleware(cfg: MiddlewareConfig = {}) {
const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico']
const adminPaths = cfg.adminPaths || ['/admin']
const localCookieName = cfg.localCookieName
const enableApiKey = cfg.enableApiKey || false
const adminRole = cfg.adminRole || 'admin'
return function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 公开路径放行
if (publicPaths.some(p => pathname.startsWith(p))) {
// 登录页:已登录用户自动跳转首页
if (pathname === '/login') {
const token = request.cookies.get('tlyq_session')?.value
|| (localCookieName ? request.cookies.get(localCookieName)?.value : undefined)
const payload = token ? decodeJwtPayload(token) : null
if (isValidPayload(payload)) {
return NextResponse.redirect(new URL('/', request.url))
}
}
return NextResponse.next()
}
// API Key 认证(可选)
if (enableApiKey) {
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7)
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
if (allowedKeys && allowedKeys.split(',').map(k => k.trim()).includes(key)) {
return NextResponse.next()
}
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '无效的 API Key' }, { status: 401 })
}
}
}
// 验证 tlyq_session共享 JWT
const sharedToken = request.cookies.get('tlyq_session')?.value
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
if (isValidPayload(sharedPayload)) {
// 管理路径检查 admin 权限
if (adminPaths.some(p => pathname.startsWith(p))) {
const role = sharedPayload?.role as string
if (role !== adminRole && role !== 'admin') {
return new NextResponse('Forbidden', { status: 403 })
}
}
// 设置 session cookie兼容旧代码
if (sharedPayload) {
const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next())
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
httpOnly: true, sameSite: 'lax', path: '/',
})
return response
}
}
// 回退本地 cookie可选
if (localCookieName) {
const localToken = request.cookies.get(localCookieName)?.value
const localPayload = localToken ? decodeJwtPayload(localToken) : null
if (isValidPayload(localPayload)) {
// 管理路径检查 admin 权限
if (adminPaths.some(p => pathname.startsWith(p))) {
const role = localPayload?.role as string
if (role !== adminRole && role !== 'admin') {
return new NextResponse('Forbidden', { status: 403 })
}
}
if (pathname.startsWith('/api/')) {
return NextResponse.next()
}
if (localPayload) {
const response = noCache(NextResponse.next())
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
httpOnly: true, sameSite: 'lax', path: '/',
})
return response
}
}
// 清除无效的本地 cookie
if (localToken) {
const loginUrl = new URL('/login', request.url)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete(localCookieName)
if (sharedToken) response.cookies.delete('tlyq_session')
return response
}
}
// 未认证API 返回 401页面重定向登录
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
return noCache(NextResponse.redirect(new URL('/login', request.url)))
}
}
// 默认导出(无配置,适用于简单站点)
export const middleware = createMiddleware()
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }

95
lib/auth/oidc.ts Normal file
View File

@ -0,0 +1,95 @@
// shared/lib/auth/oidc.ts — OIDC 客户端Authelia discovery + PKCE flow
import crypto from 'crypto'
import type { OidcUserinfo } from './types'
export interface OidcClientConfig {
autheliaUrl: string
clientId: string
clientSecret: string
redirectUri: string
}
// OpenID Connect Discovery
export async function discoverOidcConfig(autheliaUrl: string) {
const res = await fetch(`${autheliaUrl}/.well-known/openid-configuration`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`)
return res.json() as Promise<{ authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string; end_session_endpoint?: string }>
}
// 生成 PKCE 参数
export function generatePkce(): { codeVerifier: string; codeChallenge: string } {
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
return { codeVerifier, codeChallenge }
}
// 生成 state 和 nonce
export function generateState(): string {
return crypto.randomBytes(32).toString('base64url')
}
// 构建 OIDC authorize URL
export function buildAuthorizeUrl(config: OidcClientConfig, params: {
codeChallenge: string
state: string
nonce: string
prompt?: string
}): string {
const { autheliaUrl, clientId, redirectUri } = config
const url = new URL(`${autheliaUrl}/api/oidc/authorization`)
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', params.state)
url.searchParams.set('nonce', params.nonce)
url.searchParams.set('code_challenge', params.codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
if (params.prompt) url.searchParams.set('prompt', params.prompt)
return url.toString()
}
// code 换取 token
export async function exchangeCodeForToken(config: OidcClientConfig, code: string, codeVerifier: string) {
const { autheliaUrl, clientId, clientSecret, redirectUri } = config
const oidcConfig = await discoverOidcConfig(autheliaUrl)
const res = await fetch(oidcConfig.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
code_verifier: codeVerifier,
}),
})
if (!res.ok) return { success: false as const, error: `Token exchange failed: ${res.status}` }
const data = await res.json()
// nonce 在 ID token 的 claims 中,不在 token endpoint 响应中
let nonce: string | undefined
if (data.id_token) {
try {
const payload = JSON.parse(Buffer.from(data.id_token.split('.')[1], 'base64url').toString())
nonce = payload.nonce
} catch { /* 解析失败则 nonce 为 undefined */ }
}
return { success: true as const, accessToken: data.access_token, idToken: data.id_token, nonce }
}
// 获取 userinfo
export async function getUserinfo(autheliaUrl: string, accessToken: string): Promise<OidcUserinfo> {
const oidcConfig = await discoverOidcConfig(autheliaUrl)
const res = await fetch(oidcConfig.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error(`Userinfo fetch failed: ${res.status}`)
return res.json()
}

35
lib/auth/types.ts Normal file
View File

@ -0,0 +1,35 @@
// shared/lib/auth/types.ts — 认证标准库类型定义
export interface AuthConfig {
jwtSecret: string
cookieDomain: string
autheliaUrl: string
oidcClientId: string
oidcClientSecret: string
oidcRedirectUri: string
ldapUrl?: string
publicPaths?: string[]
adminPaths?: string[]
}
export interface SessionPayload {
username: string
displayName?: string
role?: string
iat?: number
exp?: number
}
export interface OidcUserinfo {
sub: string
preferred_username: string
name?: string
email?: string
email_verified?: boolean
}
export interface LdapAuthResult {
success: boolean
username?: string
displayName?: string
error?: string
}

24
lib/auth/user-sync.ts Normal file
View File

@ -0,0 +1,24 @@
// shared/lib/auth/user-sync.ts — 用户同步工具OIDC userinfo → 本地 SQLite
import type { OidcUserinfo } from './types'
export interface UserSyncStore {
getUser(username: string): { id: number; role: string } | null
createUser(username: string, displayName: string, email: string): { id: number; role: string }
updateUser(username: string, displayName: string, email: string): void
}
// 首次 OIDC 登录时自动创建本地用户记录,已存在则更新信息
export function syncOidcUser(store: UserSyncStore, userinfo: OidcUserinfo): { id: number; role: string; isNew: boolean } {
const username = userinfo.preferred_username
const displayName = userinfo.name || username
const email = userinfo.email || ''
const existing = store.getUser(username)
if (existing) {
store.updateUser(username, displayName, email)
return { id: existing.id, role: existing.role, isNew: false }
}
const created = store.createUser(username, displayName, email)
return { id: created.id, role: created.role || 'viewer', isNew: true }
}

99
lib/db/alert-schema.ts Normal file
View File

@ -0,0 +1,99 @@
// shared/lib/db/alert-schema.ts — 数据库表结构常量
// 供 monitor-ai 及未来其他项目引用
export const SERVICES_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'endpoint',
alert_level TEXT NOT NULL DEFAULT 'warning',
checks TEXT NOT NULL DEFAULT '[]',
check_interval INTEGER DEFAULT 30,
check_timeout INTEGER DEFAULT 10,
enabled INTEGER DEFAULT 1,
current_status TEXT DEFAULT 'unknown',
status_since TEXT,
display_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', '+8 hours')),
updated_at TEXT DEFAULT (datetime('now', '+8 hours'))
);
`
export const ALERT_CHANNELS_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS alert_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
channel_type TEXT DEFAULT 'wechat',
webhook_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
level_critical INTEGER DEFAULT 1,
level_warning INTEGER DEFAULT 1,
level_info INTEGER DEFAULT 0,
quiet_enabled INTEGER DEFAULT 0,
quiet_start TEXT DEFAULT '23:00',
quiet_end TEXT DEFAULT '07:00',
quiet_bypass_critical INTEGER DEFAULT 1,
cooldown_minutes INTEGER DEFAULT 5,
created_at TEXT DEFAULT (datetime('now', '+8 hours')),
updated_at TEXT DEFAULT (datetime('now', '+8 hours'))
);
`
export const ALERT_SETTINGS_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS alert_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TEXT DEFAULT (datetime('now', '+8 hours'))
);
`
export const STATUS_HISTORY_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS status_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id),
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
duration_seconds INTEGER,
error_message TEXT,
failed_checks TEXT,
truncated_by_restart INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
);
CREATE INDEX IF NOT EXISTS idx_status_history_service ON status_history(service_id, started_at);
CREATE INDEX IF NOT EXISTS idx_status_history_open ON status_history(ended_at) WHERE ended_at IS NULL;
`
export const ALERT_HISTORY_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER REFERENCES alert_channels(id),
service_id INTEGER REFERENCES services(id),
status_event_id INTEGER REFERENCES status_history(id),
alert_type TEXT NOT NULL DEFAULT 'alert',
level TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
sent_at TEXT NOT NULL,
success INTEGER DEFAULT 0,
response_code INTEGER,
response_body TEXT,
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
);
CREATE INDEX IF NOT EXISTS idx_alert_history_time ON alert_history(sent_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_channel ON alert_history(channel_id, sent_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_service ON alert_history(service_id, sent_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_event ON alert_history(status_event_id);
`
export const ALL_TABLES_SQL = [
SERVICES_TABLE_SQL,
ALERT_CHANNELS_TABLE_SQL,
ALERT_SETTINGS_TABLE_SQL,
STATUS_HISTORY_TABLE_SQL,
ALERT_HISTORY_TABLE_SQL,
].join('\n')

View File

@ -0,0 +1,27 @@
// shared/lib/wechat/message-formatter.ts — 消息格式化工具
// 从 issue-ai 提取的纯函数,不依赖任何外部状态
// 格式化可用性消息issue-ai 邮件监控使用)
export function formatAvailabilityMessage(params: {
ticketNo: string
ip: string
sn: string
rackPosition: string
faultTime: string
deadline: string
}): string {
return [
`工单号: ${params.ticketNo}`,
`IP: ${params.ip}`,
`SN: ${params.sn}`,
`机架位置: ${params.rackPosition}`,
`故障时间: ${params.faultTime}`,
`可用性截止: ${params.deadline}`,
].join('\n')
}
// 格式化告警级别标签
export function formatAlertLevel(level: 'critical' | 'warning' | 'info'): string {
const emoji = { critical: '🔴', warning: '🟡', info: '🔵' }
return `${emoji[level]} ${level}`
}

View File

@ -0,0 +1,59 @@
// shared/lib/wechat/wechat-pusher.ts — 企业微信 Webhook 通用客户端
export interface PushResult {
success: boolean
responseCode: number | null
responseBody: string | null
error?: string
}
export class WeChatPusher {
private webhookUrl: string
constructor(webhookUrl: string) {
this.webhookUrl = webhookUrl
}
// 推送 markdown 消息
async pushMarkdown(title: string, content: string): Promise<PushResult> {
return this.push({
msgtype: 'markdown',
markdown: { content: `## ${title}\n${content}` },
})
}
// 推送纯文本消息
async pushText(content: string): Promise<PushResult> {
return this.push({ msgtype: 'text', text: { content } })
}
// 通用推送
private async push(message: Record<string, unknown>): Promise<PushResult> {
try {
const res = await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
signal: AbortSignal.timeout(5000),
})
const body = await res.text().catch(() => null)
return { success: res.ok, responseCode: res.status, responseBody: body }
} catch (err) {
return { success: false, responseCode: null, responseBody: null, error: String(err) }
}
}
}
// 兼容适配器(降低 issue-ai 迁移成本)
export class WeChatPusherCompat {
private pusher: WeChatPusher
constructor(webhookUrl: string) {
this.pusher = new WeChatPusher(webhookUrl)
}
// 兼容旧接口:返回 Promise<boolean>
async pushText(text: string, _webhookUrl?: string): Promise<boolean> {
const result = await this.pusher.pushText(text)
return result.success
}
}

102
package-lock.json generated Normal file
View File

@ -0,0 +1,102 @@
{
"name": "shared",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shared",
"dependencies": {
"ldapts": "^8.1.8",
"lucide-react": "^1.8.0",
"openid-client": "^6.8.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/ldapts": {
"version": "8.1.8",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
"integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
"license": "MIT",
"dependencies": {
"strict-event-emitter-types": "2.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/lucide-react": {
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz",
"integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.2",
"oauth4webapi": "^3.8.5"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"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"
}
}
}

1
package.json Normal file
View File

@ -0,0 +1 @@
{"name":"shared","private":true,"dependencies":{"ldapts":"^8.1.8","lucide-react":"^1.8.0","openid-client":"^6.8.4","react":"^19.0.0","react-dom":"^19.0.0"}}

108
ui/login-page.tsx Normal file
View File

@ -0,0 +1,108 @@
'use client'
// shared/ui/login-page.tsx — 统一登录页组件(与 issue-ai 登录页风格一致)
import { useState } from 'react'
interface LoginPageProps {
siteName: string // 站点名称,如 "IT 工单跟踪系统"、"告警监控中心"
title?: string // 副标题(可选),如 "monitor-ai"
}
export default function LoginPage({ siteName, title }: LoginPageProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showLdapForm, setShowLdapForm] = useState(false)
const handleSubmit = async (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 }
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/'
window.location.href = dest
} catch { setError('网络错误') } finally { setLoading(false) }
}
function handleSsoLogin() {
window.location.href = '/api/auth/login/oidc'
}
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-1 text-slate-900 dark:text-white">{siteName}</h1>
{title && <p className="text-center text-xs text-slate-400 mb-5">{title}</p>}
{!title && <div className="mb-5" />}
{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>
)}
{!showLdapForm ? (
<>
<button
onClick={handleSsoLogin}
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors duration-200 mb-3"
>
</button>
<p className="text-center text-xs text-slate-400 mb-3"> SSO </p>
<p className="text-center">
<button
onClick={() => setShowLdapForm(true)}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
>
使 LDAP
</button>
</p>
</>
) : (
<>
<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>
<p className="text-center mt-3">
<button
onClick={() => { setShowLdapForm(false); setError('') }}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
>
</button>
</p>
</>
)}
</div>
</div>
)
}