serve 多用户认证体系
单密码→多用户:auth.json 格式迁移,login 支持用户名密码, 新增用户管理 API 和前端页面,会话关联 username, auth middleware 将当前用户注入 request.state。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user