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/
|
.next/
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.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) — 页面设计、配色方案
|
- [OA UI 设计](../docs/OA-UI-DESIGN.md) — 页面设计、配色方案
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,13 +10,18 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ldapts": "^6.0.0",
|
"ldapts": "^6.0.0",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
"resend": "^6.0.3",
|
"openid-client": "^5.7.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"resend": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
"autoprefixer": "^10.5.2",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "^5.0.0"
|
"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() {
|
export async function POST() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
// 清除所有相关 cookie
|
||||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
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 const metadata = { title: 'OA 统一门户' }
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -13,24 +15,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||||
} catch(e) {}
|
} 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>
|
</head>
|
||||||
<body style={{
|
<body>
|
||||||
margin: 0,
|
|
||||||
background: 'var(--bg)',
|
|
||||||
color: 'var(--text)',
|
|
||||||
fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
transition: 'background 0.3s, color 0.3s',
|
|
||||||
}}>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,87 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault(); setError(''); setLoading(true)
|
||||||
setError('')
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
const data = await res.json()
|
||||||
const d = await res.json()
|
if (!res.ok) { setError(data.error || '登录失败'); return }
|
||||||
setError(d.error || '登录失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
} catch {
|
} catch { setError('网络错误') } finally { setLoading(false) }
|
||||||
setError('网络错误')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
function handleSsoLogin() {
|
||||||
width: '100%', height: 42, padding: '0 12px',
|
fetch('/api/auth/login/oidc').then(res => {
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
if (res.status === 200) {
|
||||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
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 (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
||||||
<div style={{
|
<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">
|
||||||
width: 400, background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12,
|
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">统一门户</h1>
|
||||||
padding: '40px 36px', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
||||||
}}>
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
{!showLocalForm ? (
|
||||||
<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>
|
<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">
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>TLYQ.IDENTITY</p>
|
统一认证登录
|
||||||
</div>
|
</button>
|
||||||
<form onSubmit={handleSubmit}>
|
<p className="text-center text-xs text-slate-400 mb-3">通过 SSO 统一身份认证</p>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<p className="text-center">
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>账号</div>
|
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
||||||
<input type="text" placeholder="LDAP 用户名" value={username}
|
使用 LDAP 直接登录
|
||||||
onChange={e => setUsername(e.target.value)}
|
</button>
|
||||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
</p>
|
||||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
</>
|
||||||
style={inputStyle} />
|
) : (
|
||||||
</div>
|
<>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>密码</div>
|
<div>
|
||||||
<input type="password" placeholder="输入密码" value={password}
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||||
onChange={e => setPassword(e.target.value)}
|
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名"
|
||||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
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 />
|
||||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
</div>
|
||||||
style={inputStyle} />
|
<div>
|
||||||
</div>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||||
{error && <p style={{ color: '#dc2626', fontSize: 13, marginBottom: 12, padding: '8px 12px', background: '#fef2f2', borderRadius: 8 }}>{error}</p>}
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码"
|
||||||
<button type="submit" disabled={loading} style={{
|
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 />
|
||||||
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb',
|
</div>
|
||||||
color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
|
<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>
|
||||||
{loading ? '验证中...' : '登 录'}
|
{!isLocalMode && (
|
||||||
</button>
|
<p className="text-center mt-3">
|
||||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 12, color: 'var(--text-muted)' }}>
|
<button onClick={() => setShowLdapForm(false)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
||||||
通过 <span style={{ color: '#2563eb', fontWeight: 500 }}>LLDAP</span> 统一身份认证
|
返回统一认证登录
|
||||||
</p>
|
</button>
|
||||||
</form>
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,14 @@ function siteUrl(url: string, domain: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CORE_SITES = [
|
const CORE_SITES = [
|
||||||
{ name: '资产管理', url: 'http://localhost:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
|
{ name: '资产管理', url: 'http://127.0.0.1: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:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const OTHER_SITES = [
|
const OTHER_SITES = [
|
||||||
{ name: '官网', url: 'http://localhost:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
{ name: '官网', url: 'http://127.0.0.1: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: '云平台', url: 'http://127.0.0.1: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: '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' },
|
{ name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,77 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'auto'
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
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(() => {
|
useEffect(() => {
|
||||||
setDark(document.documentElement.classList.contains('dark'))
|
const stored = localStorage.getItem('theme') as Theme | null
|
||||||
|
setTheme(stored || 'auto')
|
||||||
|
applyTheme(stored || 'auto')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function toggle() {
|
useEffect(() => {
|
||||||
const next = !dark
|
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||||
setDark(next)
|
document.addEventListener('mousedown', handler)
|
||||||
document.documentElement.classList.toggle('dark', next)
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
localStorage.setItem('theme', next ? 'dark' : 'light')
|
}, [])
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
<button
|
<button onClick={() => setOpen(!open)} title="切换主题" style={{
|
||||||
onClick={toggle}
|
width: 36, height: 36, borderRadius: 8, border: 'none',
|
||||||
className="theme-toggle-btn"
|
background: open ? 'var(--bg-hover)' : 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
||||||
title={dark ? '切换到亮色模式' : '切换到暗色模式'}
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
style={{
|
fontSize: 18, transition: 'background 0.15s',
|
||||||
width: 36, height: 36, borderRadius: 8, border: 'none',
|
}}>
|
||||||
background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
{icons[theme]}
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 18, transition: 'background 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dark ? '☀' : '☾'}
|
|
||||||
</button>
|
</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) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
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()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
// /admin 管理页面需要认证
|
// /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