serve 多用户认证体系

单密码→多用户:auth.json 格式迁移,login 支持用户名密码,
新增用户管理 API 和前端页面,会话关联 username,
auth middleware 将当前用户注入 request.state。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 12:49:20 +08:00
parent 22049582df
commit fc69f51e23
7 changed files with 649 additions and 96 deletions
+20 -4
View File
@@ -589,16 +589,16 @@ export const api = {
// ===== Auth (访问认证) =====
authStatus: () =>
request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'),
request<{ configured: boolean; authenticated: boolean; username?: string | null; role?: string | null }>('/api/auth/status'),
authSetup: (password: string) =>
request<{ ok: boolean }>('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ password }),
}),
authLogin: (password: string) =>
request<{ ok: boolean }>('/api/auth/login', {
authLogin: (username: string, password: string) =>
request<{ ok: boolean; username?: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
body: JSON.stringify({ username, password }),
}),
authLogout: () =>
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
@@ -607,6 +607,22 @@ export const api = {
method: 'POST',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
}),
authUsers: () =>
request<{ users: { username: string; role: string; created_at?: number }[] }>('/api/auth/users'),
authCreateUser: (username: string, password: string, role: string) =>
request<{ ok: boolean }>('/api/auth/users', {
method: 'POST',
body: JSON.stringify({ username, password, role }),
}),
authDeleteUser: (username: string) =>
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}`, {
method: 'DELETE',
}),
authResetPassword: (username: string, newPassword: string) =>
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
}),
settings: () => request<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
+14 -2
View File
@@ -20,6 +20,7 @@ import { cn } from '@/lib/cn'
export function Auth() {
const navigate = useNavigate()
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
const [showPwd, setShowPwd] = useState(false)
@@ -43,7 +44,7 @@ export function Auth() {
if (isSetup) {
return api.authSetup(password)
}
return api.authLogin(password)
return api.authLogin(username, password)
},
onSuccess: () => {
// 成功: 跳回原页面(或首页)
@@ -112,6 +113,17 @@ export function Auth() {
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{/* 用户名(仅登录模式) */}
{!isSetup && (
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="用户名"
autoFocus
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
/>
)}
{/* 密码输入 */}
<div className="relative">
<input
@@ -119,7 +131,7 @@ export function Auth() {
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="访问密码"
autoFocus
autoFocus={isSetup}
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
/>
<button
+228
View File
@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Trash2, Key, Loader2, Shield, User, Check } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api'
import { toast } from '@/components/Toast'
import { cn } from '@/lib/cn'
export function UserManage() {
const qc = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newRole, setNewRole] = useState('viewer')
const [resetTarget, setResetTarget] = useState<string | null>(null)
const [resetPwd, setResetPwd] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['auth-users'],
queryFn: () => api.authUsers(),
})
const users = data?.users ?? []
const createMut = useMutation({
mutationFn: () => api.authCreateUser(newUsername, newPassword, newRole),
onSuccess: () => {
toast('用户已创建', 'success')
setShowCreate(false)
setNewUsername('')
setNewPassword('')
setNewRole('viewer')
qc.invalidateQueries({ queryKey: ['auth-users'] })
},
onError: (err: any) => toast(err?.message || '创建失败', 'error'),
})
const deleteMut = useMutation({
mutationFn: (username: string) => api.authDeleteUser(username),
onSuccess: () => {
toast('用户已删除', 'success')
qc.invalidateQueries({ queryKey: ['auth-users'] })
},
onError: (err: any) => toast(err?.message || '删除失败', 'error'),
})
const resetMut = useMutation({
mutationFn: () => api.authResetPassword(resetTarget!, resetPwd),
onSuccess: () => {
toast('密码已重置', 'success')
setResetTarget(null)
setResetPwd('')
qc.invalidateQueries({ queryKey: ['auth-users'] })
},
onError: (err: any) => toast(err?.message || '重置失败', 'error'),
})
return (
<>
<PageHeader
title="用户管理"
subtitle="管理面板访问账号"
/>
<div className="px-8 py-6 max-w-2xl space-y-4">
{/* 操作栏 */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted">{users.length} </span>
<button
onClick={() => setShowCreate(v => !v)}
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{/* 创建用户表单 */}
<AnimatePresence>
{showCreate && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="rounded-card border border-border bg-surface p-4 space-y-3">
<input
type="text"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
placeholder="用户名"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="密码 (至少 6 位)"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
/>
<select
value={newRole}
onChange={e => setNewRole(e.target.value)}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
>
<option value="viewer"> (viewer)</option>
<option value="admin"> (admin)</option>
</select>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowCreate(false)}
className="rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground transition-colors"
>
</button>
<button
onClick={() => createMut.mutate()}
disabled={createMut.isPending || !newUsername || newPassword.length < 6}
className="inline-flex items-center gap-1 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{createMut.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 用户列表 */}
{isLoading ? (
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
) : users.length === 0 ? (
<EmptyState icon={User} title="暂无用户" hint="添加第一个用户以允许访问" />
) : (
<div className="space-y-2">
{users.map(u => (
<div key={u.username} className="flex items-center gap-3 rounded-card border border-border bg-surface p-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent/10 text-accent">
<User className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{u.username}</span>
<span className={cn(
'inline-flex items-center rounded-full px-1.5 py-px text-[9px] font-medium',
u.role === 'admin' ? 'bg-purple-400/10 text-purple-400' : 'bg-accent/10 text-accent',
)}>
{u.role === 'admin' ? <><Shield className="h-2.5 w-2.5 mr-0.5" />admin</> : 'viewer'}
</span>
</div>
</div>
<button
onClick={() => { setResetTarget(u.username); setResetPwd('') }}
className="rounded p-1.5 text-muted hover:text-accent hover:bg-elevated transition-colors"
title="重置密码"
>
<Key className="h-3.5 w-3.5" />
</button>
{u.role !== 'admin' && (
<button
onClick={() => {
if (confirm(`确定删除用户「${u.username}」?`)) deleteMut.mutate(u.username)
}}
disabled={deleteMut.isPending}
className="rounded p-1.5 text-muted hover:text-danger hover:bg-danger/10 transition-colors disabled:opacity-50"
title="删除用户"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{/* 重置密码弹窗 */}
<AnimatePresence>
{resetTarget && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setResetTarget(null)}
>
<motion.div
initial={{ scale: 0.96, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
className="w-full max-w-sm rounded-card border border-border bg-surface p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<h3 className="text-sm font-medium text-foreground mb-3"> {resetTarget} </h3>
<input
type="password"
value={resetPwd}
onChange={e => setResetPwd(e.target.value)}
placeholder="新密码 (至少 6 位)"
autoFocus
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
/>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setResetTarget(null)}
className="rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground transition-colors"
>
</button>
<button
onClick={() => resetMut.mutate()}
disabled={resetMut.isPending || resetPwd.length < 6}
className="inline-flex items-center gap-1 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{resetMut.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</>
)
}
+2
View File
@@ -15,6 +15,7 @@ import { Review } from './pages/Review'
import { LimitUpLadder } from './pages/LimitUpLadder'
import { Branding } from './pages/Branding'
import { AiSettings } from './pages/AiSettings'
import { UserManage } from './pages/UserManage'
import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices'
import { Dev } from './pages/Dev'
@@ -81,6 +82,7 @@ export const router = createBrowserRouter([
// 旧路由兼容重定向
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> },
{ path: 'settings/ai', element: <AiSettings /> },
{ path: 'settings/users', element: <UserManage /> },
{ path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
],
},