feat: SSO 统一认证 + Tailwind CSS + 主题切换下拉菜单 + 登录页统一
- 安装 openid-client v5,新增 OIDC 登录/回调端点 - 安装 Tailwind CSS v4,统一登录页样式 - ThemeToggle 支持浅色/深色/自动三种模式 - middleware 支持已登录用户访问登录页自动跳转首页 - 退出登录重定向到 Authelia end_session - login/oidc 端点处理会话冲突响应
This commit is contained in:
parent
ae1e58a595
commit
ec4bbcb71b
|
|
@ -2,3 +2,4 @@ node_modules/
|
|||
.next/
|
||||
.env
|
||||
.DS_Store
|
||||
.env.local
|
||||
|
|
|
|||
|
|
@ -46,5 +46,5 @@ npm run dev # http://localhost:6179
|
|||
|
||||
## 相关文档
|
||||
|
||||
- [OA 设计文档](../docs/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤
|
||||
- [OA 设计文档](../docs/design/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤
|
||||
- [OA UI 设计](../docs/OA-UI-DESIGN.md) — 页面设计、配色方案
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,13 +10,18 @@
|
|||
"dependencies": {
|
||||
"ldapts": "^6.0.0",
|
||||
"next": "^15.0.0",
|
||||
"resend": "^6.0.3",
|
||||
"openid-client": "^5.7.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"resend": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
const config = { plugins: { '@tailwindcss/postcss': {} } }
|
||||
export default config
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getOidcClient } from '@/lib/oidc'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
||||
|
||||
// 从 OIDC_REDIRECT_URI 提取 base URL(避免 request.url 使用 localhost)
|
||||
function getBaseUrl(): string {
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback'
|
||||
const url = new URL(redirectUri)
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const cookieStore = await cookies()
|
||||
|
||||
// 1. 错误处理
|
||||
if (error) {
|
||||
return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl))
|
||||
}
|
||||
|
||||
// 2. 验证 state
|
||||
const savedState = cookieStore.get('oidc_state')?.value
|
||||
if (!savedState || savedState !== state) {
|
||||
return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl))
|
||||
}
|
||||
|
||||
// 3. 取出 code_verifier
|
||||
const codeVerifier = cookieStore.get('oidc_code_verifier')?.value
|
||||
if (!codeVerifier) {
|
||||
return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl))
|
||||
}
|
||||
|
||||
// 4. 验证 nonce
|
||||
const savedNonce = cookieStore.get('oidc_nonce')?.value
|
||||
|
||||
try {
|
||||
// 5. 换取 token
|
||||
const client = await getOidcClient()
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback'
|
||||
const params = { code, state, iss: searchParams.get('iss') }
|
||||
const checks = {
|
||||
code_verifier: codeVerifier,
|
||||
nonce: savedNonce,
|
||||
state: savedState,
|
||||
}
|
||||
|
||||
const tokenSet = await client.callback(redirectUri, params, checks)
|
||||
|
||||
// 6. 验证 nonce
|
||||
if (savedNonce && tokenSet.claims) {
|
||||
const claims = tokenSet.claims()
|
||||
if (claims.nonce !== savedNonce) {
|
||||
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 获取 userinfo
|
||||
const userinfo = await client.userinfo(tokenSet.access_token!)
|
||||
|
||||
// 8. 使用 preferred_username 作为用户名(sub 可能是 UUID)
|
||||
const username = (userinfo as any).preferred_username || userinfo.sub!
|
||||
const displayName = userinfo.name || username
|
||||
|
||||
// 9. 签发 tlyq_session cookie
|
||||
const sharedToken = signSharedJwt({ username: username as string, displayName: displayName as string })
|
||||
const cfg = sharedCookieConfig()
|
||||
|
||||
const response = NextResponse.redirect(new URL('/', baseUrl))
|
||||
response.cookies.set(cfg.name, sharedToken, cfg)
|
||||
|
||||
// 10. 存储 id_token 用于登出
|
||||
if (tokenSet.id_token) {
|
||||
response.cookies.set('oidc_id_token', tokenSet.id_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 86400,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
// 11. 清理 OIDC 临时 cookie
|
||||
response.cookies.delete('oidc_state')
|
||||
response.cookies.delete('oidc_nonce')
|
||||
response.cookies.delete('oidc_code_verifier')
|
||||
|
||||
return response
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
console.error('OIDC callback error:', errorMsg)
|
||||
return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies()
|
||||
const existingSession = cookieStore.get('tlyq_session')?.value
|
||||
const url = new URL(request.url)
|
||||
const switchUser = url.searchParams.get('switch') === '1'
|
||||
|
||||
// 检查是否已有登录用户
|
||||
if (existingSession && !switchUser) {
|
||||
const existing = verifySharedJwt(existingSession)
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
conflict: true,
|
||||
currentUser: existing.username,
|
||||
displayName: existing.displayName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 预检 Authelia 健康状态
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
try {
|
||||
const healthRes = await fetch(`${autheliaUrl}/api/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!healthRes.ok) {
|
||||
return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 })
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 })
|
||||
}
|
||||
|
||||
// 生成 PKCE 参数
|
||||
const { codeVerifier, codeChallenge } = generatePKCE()
|
||||
const state = generateState()
|
||||
const nonce = generateNonce()
|
||||
|
||||
// 构建授权 URL
|
||||
const client = await getOidcClient()
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
...(switchUser && { prompt: 'login' }),
|
||||
})
|
||||
|
||||
// 存储到 httpOnly cookie(5 分钟过期)
|
||||
const response = NextResponse.redirect(authorizationUrl)
|
||||
|
||||
response.cookies.set('oidc_code_verifier', codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 300,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('oidc_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 300,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('oidc_nonce', nonce, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 300,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -3,6 +3,13 @@ import { cookies } from 'next/headers'
|
|||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
// 清除所有相关 cookie
|
||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://localhost:6179'))
|
||||
cookieStore.set('session', '', { maxAge: 0, path: '/' })
|
||||
cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/' })
|
||||
|
||||
// Authelia 4.38 不支持 end_session_endpoint,直接跳转登录页
|
||||
// Authelia session 会在 cookie 过期后自动清除
|
||||
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://127.0.0.1:6179'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
@import "tailwindcss";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
|
||||
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
|
||||
}
|
||||
.dark {
|
||||
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
|
||||
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import './globals.css'
|
||||
|
||||
export const metadata = { title: 'OA 统一门户' }
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -13,24 +15,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
} catch(e) {}
|
||||
})();
|
||||
`}} />
|
||||
<style>{`
|
||||
:root {
|
||||
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
|
||||
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
|
||||
}
|
||||
.dark {
|
||||
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
|
||||
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body style={{
|
||||
margin: 0,
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text)',
|
||||
fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
transition: 'background 0.3s, color 0.3s',
|
||||
}}>
|
||||
<body>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,81 +1,87 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showLdapForm, setShowLdapForm] = useState(false)
|
||||
const searchParams = useSearchParams()
|
||||
const isLocalMode = searchParams.get('method') === 'local'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
e.preventDefault(); setError(''); setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json()
|
||||
setError(d.error || '登录失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error || '登录失败'); return }
|
||||
window.location.href = '/'
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch { setError('网络错误') } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 42, padding: '0 12px',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
||||
function handleSsoLogin() {
|
||||
fetch('/api/auth/login/oidc').then(res => {
|
||||
if (res.status === 200) {
|
||||
return res.json().then(data => {
|
||||
if (data.conflict) { window.location.href = '/' }
|
||||
else { setError(data.error || '登录失败') }
|
||||
})
|
||||
}
|
||||
window.location.href = res.url || '/api/auth/login/oidc'
|
||||
}).catch(() => { window.location.href = '/api/auth/login/oidc' })
|
||||
}
|
||||
|
||||
const showLocalForm = isLocalMode || showLdapForm
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
||||
<div style={{
|
||||
width: 400, background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12,
|
||||
padding: '40px 36px', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{ width: 44, height: 44, margin: '0 auto 14px', background: '#2563eb', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#fff' }}>◎</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', margin: 0 }}>统一门户</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>TLYQ.IDENTITY</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>账号</div>
|
||||
<input type="text" placeholder="LDAP 用户名" value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>密码</div>
|
||||
<input type="password" placeholder="输入密码" value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
{error && <p style={{ color: '#dc2626', fontSize: 13, marginBottom: 12, padding: '8px 12px', background: '#fef2f2', borderRadius: 8 }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{
|
||||
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb',
|
||||
color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}>
|
||||
{loading ? '验证中...' : '登 录'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
通过 <span style={{ color: '#2563eb', fontWeight: 500 }}>LLDAP</span> 统一身份认证
|
||||
</p>
|
||||
</form>
|
||||
<div 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>}
|
||||
|
||||
{!showLocalForm ? (
|
||||
<>
|
||||
<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 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>
|
||||
{!isLocalMode && (
|
||||
<p className="text-center mt-3">
|
||||
<button onClick={() => setShowLdapForm(false)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
||||
返回统一认证登录
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ function siteUrl(url: string, domain: string): string {
|
|||
}
|
||||
|
||||
const CORE_SITES = [
|
||||
{ name: '资产管理', url: 'http://localhost:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
|
||||
{ name: '工单跟踪', url: 'http://localhost:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
|
||||
{ name: '资产管理', url: 'http://127.0.0.1:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
|
||||
{ name: '工单跟踪', url: 'http://127.0.0.1:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
|
||||
]
|
||||
|
||||
const OTHER_SITES = [
|
||||
{ name: '官网', url: 'http://localhost:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
||||
{ name: '云平台', url: 'http://localhost:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
|
||||
{ name: 'Token 工厂', url: 'http://localhost:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
|
||||
{ name: '官网', url: 'http://127.0.0.1:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
||||
{ name: '云平台', url: 'http://127.0.0.1:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
|
||||
{ name: 'Token 工厂', url: 'http://127.0.0.1:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
|
||||
{ name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [dark, setDark] = useState(true)
|
||||
const [theme, setTheme] = useState<Theme>('auto')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains('dark'))
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
setTheme(stored || 'auto')
|
||||
applyTheme(stored || 'auto')
|
||||
}, [])
|
||||
|
||||
function toggle() {
|
||||
const next = !dark
|
||||
setDark(next)
|
||||
document.documentElement.classList.toggle('dark', next)
|
||||
localStorage.setItem('theme', next ? 'dark' : 'light')
|
||||
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)
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function select(t: Theme) {
|
||||
setTheme(t)
|
||||
localStorage.setItem('theme', t)
|
||||
applyTheme(t)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const icons: Record<Theme, string> = { light: '☀', dark: '☾', auto: '◐' }
|
||||
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="theme-toggle-btn"
|
||||
title={dark ? '切换到亮色模式' : '切换到暗色模式'}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 8, border: 'none',
|
||||
background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{dark ? '☀' : '☾'}
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setOpen(!open)} title="切换主题" style={{
|
||||
width: 36, height: 36, borderRadius: 8, border: 'none',
|
||||
background: open ? 'var(--bg-hover)' : 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, transition: 'background 0.15s',
|
||||
}}>
|
||||
{icons[theme]}
|
||||
</button>
|
||||
<style>{`.theme-toggle-btn:hover { background: var(--bg-hover) !important; }`}</style>
|
||||
</>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: 4,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, padding: '4px 0', minWidth: 100, zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
|
||||
<button key={t} onClick={() => select(t)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '6px 12px', border: 'none', background: t === theme ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text)', cursor: 'pointer', fontSize: 13, textAlign: 'left',
|
||||
}}>
|
||||
<span>{icons[t]}</span>
|
||||
<span>{labels[t]}</span>
|
||||
{t === theme && <span style={{ marginLeft: 'auto', color: '#2563eb' }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
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 || 'oa-oidc'
|
||||
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://oa.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'],
|
||||
})
|
||||
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')
|
||||
}
|
||||
|
|
@ -24,8 +24,18 @@ function noCache(response: NextResponse) {
|
|||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// 登录/设置密码/API 路径放行(API 路由自行验证)
|
||||
if (pathname === '/login' || pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
|
||||
// 登录页:已登录用户自动跳转首页
|
||||
if (pathname === '/login') {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
const payload = token ? decodeJwtPayload(token) : null
|
||||
if (isValidPayload(payload)) {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// 设置密码/API 路径放行(API 路由自行验证)
|
||||
if (pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
// /admin 管理页面需要认证
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue