feat: Implement user authentication for the admin web UI, including login, session management, and securing API calls.

This commit is contained in:
CJACK
2026-02-01 03:40:25 +08:00
parent 23bd4970d9
commit 4193336dd8
9 changed files with 437 additions and 34 deletions

View File

@@ -3,6 +3,7 @@ import AccountManager from './components/AccountManager'
import ApiTester from './components/ApiTester'
import BatchImport from './components/BatchImport'
import VercelSync from './components/VercelSync'
import Login from './components/Login'
const TABS = [
{ id: 'accounts', label: '🔑 账号管理' },
@@ -16,51 +17,154 @@ export default function App() {
const [config, setConfig] = useState({ keys: [], accounts: [] })
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState(null)
const [token, setToken] = useState(null)
const [authChecking, setAuthChecking] = useState(true)
// 检查已存储的 Token
useEffect(() => {
const checkAuth = async () => {
// 检查 localStorage 或 sessionStorage
const storedToken = localStorage.getItem('ds2api_token') || sessionStorage.getItem('ds2api_token')
const expiresAt = parseInt(localStorage.getItem('ds2api_token_expires') || sessionStorage.getItem('ds2api_token_expires') || '0')
if (storedToken && expiresAt > Date.now()) {
// 验证 token 是否有效
try {
const res = await fetch('/admin/verify', {
headers: { 'Authorization': `Bearer ${storedToken}` }
})
if (res.ok) {
setToken(storedToken)
} else {
// Token 无效,清除
localStorage.removeItem('ds2api_token')
localStorage.removeItem('ds2api_token_expires')
sessionStorage.removeItem('ds2api_token')
sessionStorage.removeItem('ds2api_token_expires')
}
} catch {
// 网络错误,保留 token 重试
setToken(storedToken)
}
}
setAuthChecking(false)
}
checkAuth()
}, [])
// 带认证的 fetch
const authFetch = async (url, options = {}) => {
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`
}
const res = await fetch(url, { ...options, headers })
// 401 时自动登出
if (res.status === 401) {
handleLogout()
throw new Error('认证已过期,请重新登录')
}
return res
}
const fetchConfig = async () => {
if (!token) return
try {
setLoading(true)
const res = await fetch('/admin/config')
const res = await authFetch('/admin/config')
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error('获取配置失败:', e)
showMessage('error', e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchConfig()
}, [])
if (token) {
fetchConfig()
}
}, [token])
const showMessage = (type, text) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}
const handleLogin = (newToken) => {
setToken(newToken)
}
const handleLogout = () => {
setToken(null)
localStorage.removeItem('ds2api_token')
localStorage.removeItem('ds2api_token_expires')
sessionStorage.removeItem('ds2api_token')
sessionStorage.removeItem('ds2api_token_expires')
}
const renderTab = () => {
switch (activeTab) {
case 'accounts':
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} />
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'test':
return <ApiTester config={config} onMessage={showMessage} />
return <ApiTester config={config} onMessage={showMessage} authFetch={authFetch} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} />
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} />
return <VercelSync onMessage={showMessage} authFetch={authFetch} />
default:
return null
}
}
// 认证检查中
if (authChecking) {
return (
<div className="app">
<div className="login-container">
<div className="login-card">
<div className="empty-state">
<span className="loading"></span> 检查登录状态...
</div>
</div>
</div>
</div>
)
}
// 未登录
if (!token) {
return (
<div className="app">
{message && (
<div className={`alert alert-${message.type}`}>
{message.text}
</div>
)}
<Login onLogin={handleLogin} onMessage={showMessage} />
</div>
)
}
// 已登录
return (
<div className="app">
<header className="header">
<h1>DS2API Admin</h1>
<p>账号管理 · API 测试 · Vercel 部署</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1>DS2API Admin</h1>
<p>账号管理 · API 测试 · Vercel 部署</p>
</div>
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>
🚪 登出
</button>
</div>
</header>
{message && (
@@ -104,3 +208,4 @@ export default function App() {
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
export default function AccountManager({ config, onRefresh, onMessage }) {
export default function AccountManager({ config, onRefresh, onMessage, authFetch }) {
const [showAddKey, setShowAddKey] = useState(false)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState('')
@@ -13,10 +13,13 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
const [queueStatus, setQueueStatus] = useState(null)
// 使用 authFetch 或回退到普通 fetch
const apiFetch = authFetch || fetch
// 获取队列状态
const fetchQueueStatus = async () => {
try {
const res = await fetch('/admin/queue/status')
const res = await apiFetch('/admin/queue/status')
if (res.ok) {
const data = await res.json()
setQueueStatus(data)
@@ -36,7 +39,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
if (!newKey.trim()) return
setLoading(true)
try {
const res = await fetch('/admin/keys', {
const res = await apiFetch('/admin/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: newKey.trim() }),
@@ -60,7 +63,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const deleteKey = async (key) => {
if (!confirm('确定删除此 API Key')) return
try {
const res = await fetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', '删除成功')
onRefresh()
@@ -79,7 +82,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
}
setLoading(true)
try {
const res = await fetch('/admin/accounts', {
const res = await apiFetch('/admin/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAccount),
@@ -103,7 +106,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const deleteAccount = async (id) => {
if (!confirm('确定删除此账号?')) return
try {
const res = await fetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', '删除成功')
onRefresh()
@@ -119,7 +122,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const validateAccount = async (identifier) => {
setValidating(prev => ({ ...prev, [identifier]: true }))
try {
const res = await fetch('/admin/accounts/validate', {
const res = await apiFetch('/admin/accounts/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
@@ -155,7 +158,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const id = acc.email || acc.mobile
try {
const res = await fetch('/admin/accounts/validate', {
const res = await apiFetch('/admin/accounts/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),
@@ -179,7 +182,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const testAccount = async (identifier) => {
setTesting(prev => ({ ...prev, [identifier]: true }))
try {
const res = await fetch('/admin/accounts/test', {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
@@ -215,7 +218,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
const id = acc.email || acc.mobile
try {
const res = await fetch('/admin/accounts/test', {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),

View File

@@ -7,7 +7,7 @@ const MODELS = [
{ id: 'deepseek-reasoner-search', name: 'deepseek-reasoner-search' },
]
export default function ApiTester({ config, onMessage }) {
export default function ApiTester({ config, onMessage, authFetch }) {
const [model, setModel] = useState('deepseek-chat')
const [message, setMessage] = useState('你好,请用一句话介绍你自己。')
const [apiKey, setApiKey] = useState('')
@@ -15,6 +15,9 @@ export default function ApiTester({ config, onMessage }) {
const [response, setResponse] = useState(null)
const [loading, setLoading] = useState(false)
// 使用 authFetch 或回退到普通 fetchadmin API 用 authFetchOpenAI 兼容 API 用普通 fetch
const apiFetch = authFetch || fetch
// 获取账号列表
const accounts = config.accounts || []
@@ -22,7 +25,7 @@ export default function ApiTester({ config, onMessage }) {
setLoading(true)
setResponse(null)
try {
const res = await fetch('/admin/test', {
const res = await apiFetch('/admin/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -96,7 +99,7 @@ export default function ApiTester({ config, onMessage }) {
// 如果选择了指定账号,使用账号测试接口
if (selectedAccount) {
try {
const res = await fetch('/admin/accounts/test', {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -51,11 +51,14 @@ const TEMPLATES = {
}
}
export default function BatchImport({ onRefresh, onMessage }) {
export default function BatchImport({ onRefresh, onMessage, authFetch }) {
const [jsonInput, setJsonInput] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
// 使用 authFetch 或回退到普通 fetch
const apiFetch = authFetch || fetch
const handleImport = async () => {
if (!jsonInput.trim()) {
onMessage('error', '请输入 JSON 配置')
@@ -73,7 +76,7 @@ export default function BatchImport({ onRefresh, onMessage }) {
setLoading(true)
setResult(null)
try {
const res = await fetch('/admin/import', {
const res = await apiFetch('/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@@ -103,7 +106,7 @@ export default function BatchImport({ onRefresh, onMessage }) {
const handleExport = async () => {
try {
const res = await fetch('/admin/export')
const res = await apiFetch('/admin/export')
if (res.ok) {
const data = await res.json()
setJsonInput(JSON.stringify(JSON.parse(data.json), null, 2))
@@ -116,7 +119,7 @@ export default function BatchImport({ onRefresh, onMessage }) {
const copyBase64 = async () => {
try {
const res = await fetch('/admin/export')
const res = await apiFetch('/admin/export')
if (res.ok) {
const data = await res.json()
await navigator.clipboard.writeText(data.base64)

View File

@@ -0,0 +1,90 @@
import { useState } from 'react'
export default function Login({ onLogin, onMessage }) {
const [adminKey, setAdminKey] = useState('')
const [loading, setLoading] = useState(false)
const [remember, setRemember] = useState(true)
const handleLogin = async (e) => {
e.preventDefault()
setLoading(true)
try {
const res = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ admin_key: adminKey }),
})
const data = await res.json()
if (res.ok && data.success) {
// 存储 token
const storage = remember ? localStorage : sessionStorage
storage.setItem('ds2api_token', data.token)
storage.setItem('ds2api_token_expires', Date.now() + data.expires_in * 1000)
onLogin(data.token)
if (data.message) {
onMessage('warning', data.message)
}
} else {
onMessage('error', data.detail || '登录失败')
}
} catch (e) {
onMessage('error', '网络错误: ' + e.message)
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🔐 DS2API Admin</h1>
<p>请输入管理密钥登录</p>
</div>
<form onSubmit={handleLogin}>
<div className="form-group">
<label className="form-label">管理密钥</label>
<input
type="password"
className="form-input"
placeholder="输入 DS2API_ADMIN_KEY..."
value={adminKey}
onChange={e => setAdminKey(e.target.value)}
autoFocus
/>
</div>
<div className="form-group" style={{ flexDirection: 'row', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="remember"
checked={remember}
onChange={e => setRemember(e.target.checked)}
/>
<label htmlFor="remember" style={{ cursor: 'pointer' }}>
记住登录状态
</label>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', justifyContent: 'center' }}
>
{loading ? <span className="loading"></span> : '🚀 登录'}
</button>
</form>
<div className="login-footer">
<p>Session 有效期 24 小时</p>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
export default function VercelSync({ onMessage }) {
export default function VercelSync({ onMessage, authFetch }) {
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
@@ -8,11 +8,14 @@ export default function VercelSync({ onMessage }) {
const [result, setResult] = useState(null)
const [preconfig, setPreconfig] = useState(null)
// 使用 authFetch 或回退到普通 fetch
const apiFetch = authFetch || fetch
// 自动加载预配置的 Vercel 信息
useEffect(() => {
const loadPreconfig = async () => {
try {
const res = await fetch('/admin/vercel/config')
const res = await apiFetch('/admin/vercel/config')
if (res.ok) {
const data = await res.json()
setPreconfig(data)
@@ -42,7 +45,7 @@ export default function VercelSync({ onMessage }) {
setLoading(true)
setResult(null)
try {
const res = await fetch('/admin/vercel/sync', {
const res = await apiFetch('/admin/vercel/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -505,6 +505,46 @@ textarea.form-input {
color: var(--error);
}
/* Login Page */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
}
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.login-header p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
}
@media (max-width: 640px) {
.app {
padding: 1rem;