feat: 多用户管理与公开注册功能
- 后端:User 实体新增 role/enabled 字段、UserController CRUD、UserService - 安全:SecurityConfig /api/users/** 要求 ROLE_ADMIN、JWT 过滤器检查账号状态 - 注册:POST /api/auth/register 公开注册,固定 ROLE_USER - 保护:删除/禁用/降级最后 admin 均拒绝,DataInitializer 含 backfill - 前端:用户管理页面、登录/注册切换、admin 专属导航入口 - 测试:UserServiceTest 19 个 + UserControllerTest 6 个 + AuthControllerTest 适配
This commit is contained in:
+15
-1
@@ -2,11 +2,13 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import WorkspacePage from './pages/WorkspacePage'
|
||||
import UserManagementPage from './pages/UserManagementPage'
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
const { isAuthenticated, user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = user?.role === 'ROLE_ADMIN'
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -37,6 +39,18 @@ function AppRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
isAuthenticated && isAdmin ? (
|
||||
<UserManagementPage onBack={() => navigate('/workspace')} />
|
||||
) : isAuthenticated ? (
|
||||
<Navigate to="/workspace" replace />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={isAuthenticated ? '/workspace' : '/login'} replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { changePassword as changePasswordApi, getMe, login as loginApi } from '../services/auth'
|
||||
import type { CurrentUser, LoginResponse } from '../types'
|
||||
import { changePassword as changePasswordApi, getMe, login as loginApi, register as registerApi } from '../services/auth'
|
||||
import type { CurrentUser, LoginResponse, RegisterRequest } from '../types'
|
||||
|
||||
interface AuthContextValue {
|
||||
token: string | null
|
||||
@@ -8,6 +8,7 @@ interface AuthContextValue {
|
||||
loading: boolean
|
||||
isAuthenticated: boolean
|
||||
login: (username: string, password: string) => Promise<LoginResponse>
|
||||
register: (payload: RegisterRequest) => Promise<LoginResponse>
|
||||
logout: () => void
|
||||
refreshMe: () => Promise<void>
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>
|
||||
@@ -52,6 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser({
|
||||
username: response.data.username,
|
||||
displayName: response.data.displayName,
|
||||
role: response.data.role,
|
||||
passwordChangeRequired: response.data.passwordChangeRequired,
|
||||
})
|
||||
return response.data
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (payload: RegisterRequest) => {
|
||||
const response = await registerApi(payload)
|
||||
localStorage.setItem('token', response.data.token)
|
||||
setToken(response.data.token)
|
||||
setUser({
|
||||
username: response.data.username,
|
||||
displayName: response.data.displayName,
|
||||
role: response.data.role,
|
||||
passwordChangeRequired: response.data.passwordChangeRequired,
|
||||
})
|
||||
return response.data
|
||||
@@ -69,11 +84,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
loading,
|
||||
isAuthenticated: !!token,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshMe,
|
||||
changePassword,
|
||||
}),
|
||||
[token, user, loading, login, logout, refreshMe, changePassword],
|
||||
[token, user, loading, login, register, logout, refreshMe, changePassword],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { Lock, Terminal, User } from 'lucide-react'
|
||||
import { Lock, Terminal, User, UserPlus } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
||||
const { login } = useAuth()
|
||||
const { login, register } = useAuth()
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
|
||||
// Login fields
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('admin123')
|
||||
|
||||
// Register fields
|
||||
const [regUsername, setRegUsername] = useState('')
|
||||
const [regDisplayName, setRegDisplayName] = useState('')
|
||||
const [regPassword, setRegPassword] = useState('')
|
||||
const [regConfirmPassword, setRegConfirmPassword] = useState('')
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
@@ -26,6 +36,42 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (regPassword.length < 8) {
|
||||
setError('密码至少需要 8 个字符')
|
||||
return
|
||||
}
|
||||
if (regPassword !== regConfirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await register({
|
||||
username: regUsername.trim(),
|
||||
password: regPassword,
|
||||
displayName: regDisplayName.trim() || undefined,
|
||||
})
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message ||
|
||||
'注册失败'
|
||||
setError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
setMode(mode === 'login' ? 'register' : 'login')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-950 p-4">
|
||||
<div className="absolute left-[12%] top-[18%] h-80 w-80 rounded-full bg-cyan-500/10 blur-3xl" />
|
||||
@@ -43,46 +89,136 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">账号</span>
|
||||
<div className="relative">
|
||||
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="请输入管理员账号"
|
||||
/>
|
||||
{mode === 'login' ? (
|
||||
<form className="space-y-5" onSubmit={handleLogin}>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">账号</span>
|
||||
<div className="relative">
|
||||
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="请输入账号"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">密码</span>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-3 font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-slate-500 transition hover:text-cyan-400"
|
||||
onClick={switchMode}
|
||||
>
|
||||
没有账号?<span className="font-medium">注册账号</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-5" onSubmit={handleRegister}>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">用户名 *</span>
|
||||
<div className="relative">
|
||||
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={regUsername}
|
||||
onChange={(event) => setRegUsername(event.target.value)}
|
||||
placeholder="登录使用的用户名"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">密码</span>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">显示名(选填)</span>
|
||||
<div className="relative">
|
||||
<UserPlus className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={regDisplayName}
|
||||
onChange={(event) => setRegDisplayName(event.target.value)}
|
||||
placeholder="你希望别人看到的名称"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">密码 *</span>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={regPassword}
|
||||
onChange={(event) => setRegPassword(event.target.value)}
|
||||
placeholder="至少 8 个字符"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">确认密码 *</span>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={regConfirmPassword}
|
||||
onChange={(event) => setRegConfirmPassword(event.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !regUsername.trim()}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 to-teal-600 px-4 py-3 font-medium text-white shadow-lg shadow-emerald-900/30 transition hover:from-emerald-400 hover:to-teal-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? '注册中...' : '注 册'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-slate-500 transition hover:text-cyan-400"
|
||||
onClick={switchMode}
|
||||
>
|
||||
已有账号?<span className="font-medium">去登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-3 font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">默认测试账号: admin / admin123</div>
|
||||
{mode === 'login' && (
|
||||
<div className="mt-6 text-center text-sm text-slate-500">默认测试账号: admin / admin123</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Shield, ShieldOff, Trash2, UserPlus, KeyRound, Pencil, ChevronLeft, AlertCircle } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { createUser, deleteUser, listUsers, resetPassword, updateUser } from '../services/users'
|
||||
import type { CreateUserRequest, UpdateUserRequest, UserDto } from '../types'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function UserManagementPage({ onBack }: { onBack: () => void }) {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [users, setUsers] = useState<UserDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Modal states
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<UserDto | null>(null)
|
||||
const [resettingUser, setResettingUser] = useState<UserDto | null>(null)
|
||||
const [deletingUser, setDeletingUser] = useState<UserDto | null>(null)
|
||||
|
||||
// Form states
|
||||
const [formUsername, setFormUsername] = useState('')
|
||||
const [formPassword, setFormPassword] = useState('')
|
||||
const [formDisplayName, setFormDisplayName] = useState('')
|
||||
const [formRole, setFormRole] = useState('ROLE_USER')
|
||||
const [formEnabled, setFormEnabled] = useState(true)
|
||||
const [formNewPassword, setFormNewPassword] = useState('')
|
||||
const [formSubmitting, setFormSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await listUsers()
|
||||
setUsers(response.data)
|
||||
} catch (err) {
|
||||
setError(
|
||||
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '加载用户列表失败',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
function openCreateModal() {
|
||||
setFormUsername('')
|
||||
setFormPassword('')
|
||||
setFormDisplayName('')
|
||||
setFormRole('ROLE_USER')
|
||||
setFormError(null)
|
||||
setShowCreate(true)
|
||||
}
|
||||
|
||||
function openEditModal(user: UserDto) {
|
||||
setEditingUser(user)
|
||||
setFormDisplayName(user.displayName || '')
|
||||
setFormRole(user.role)
|
||||
setFormEnabled(user.enabled)
|
||||
setFormError(null)
|
||||
}
|
||||
|
||||
function openResetModal(user: UserDto) {
|
||||
setResettingUser(user)
|
||||
setFormNewPassword('')
|
||||
setFormError(null)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
setFormSubmitting(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
const payload: CreateUserRequest = {
|
||||
username: formUsername.trim(),
|
||||
password: formPassword,
|
||||
displayName: formDisplayName.trim() || undefined,
|
||||
role: formRole,
|
||||
}
|
||||
await createUser(payload)
|
||||
setShowCreate(false)
|
||||
await fetchUsers()
|
||||
} catch (err) {
|
||||
setFormError(
|
||||
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '创建用户失败',
|
||||
)
|
||||
} finally {
|
||||
setFormSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!editingUser) return
|
||||
setFormSubmitting(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
const payload: UpdateUserRequest = {
|
||||
displayName: formDisplayName.trim() || undefined,
|
||||
role: formRole,
|
||||
enabled: formEnabled,
|
||||
}
|
||||
await updateUser(editingUser.id, payload)
|
||||
setEditingUser(null)
|
||||
await fetchUsers()
|
||||
} catch (err) {
|
||||
setFormError(
|
||||
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '更新用户失败',
|
||||
)
|
||||
} finally {
|
||||
setFormSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deletingUser) return
|
||||
setFormSubmitting(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
await deleteUser(deletingUser.id)
|
||||
setDeletingUser(null)
|
||||
await fetchUsers()
|
||||
} catch (err) {
|
||||
setFormError(
|
||||
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '删除用户失败',
|
||||
)
|
||||
} finally {
|
||||
setFormSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!resettingUser) return
|
||||
if (formNewPassword.length < 8) {
|
||||
setFormError('密码至少需要 8 个字符')
|
||||
return
|
||||
}
|
||||
setFormSubmitting(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
await resetPassword(resettingUser.id, formNewPassword)
|
||||
setResettingUser(null)
|
||||
} catch (err) {
|
||||
setFormError(
|
||||
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '重置密码失败',
|
||||
)
|
||||
} finally {
|
||||
setFormSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(isoString: string) {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-slate-950 text-slate-100">
|
||||
{/* Header */}
|
||||
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-white"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
返回
|
||||
</button>
|
||||
<div className="h-6 w-px bg-slate-800" />
|
||||
<h1 className="text-lg font-semibold tracking-wide text-blue-400">
|
||||
用户管理
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500"
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
新建用户
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-red-400">
|
||||
<AlertCircle size={18} />
|
||||
{error}
|
||||
<button className="ml-2 underline hover:text-red-300" onClick={() => void fetchUsers()}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/80">
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">ID</th>
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">用户名</th>
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">显示名</th>
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">角色</th>
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">状态</th>
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">创建时间</th>
|
||||
<th className="px-5 py-3.5 font-medium text-slate-400">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-16 text-center text-slate-500">
|
||||
暂无用户
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => {
|
||||
const isSelf = currentUser?.username === u.username
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-slate-800/50 transition hover:bg-slate-800/40">
|
||||
<td className="px-5 py-3.5 text-slate-500">{u.id}</td>
|
||||
<td className="px-5 py-3.5 font-medium text-slate-200">
|
||||
{u.username}
|
||||
{isSelf && <span className="ml-2 rounded bg-blue-500/15 px-1.5 py-0.5 text-xs text-blue-400">当前</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-slate-300">{u.displayName || '-'}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
u.role === 'ROLE_ADMIN'
|
||||
? 'bg-amber-500/15 text-amber-400'
|
||||
: 'bg-slate-700/50 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{u.role === 'ROLE_ADMIN' ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{u.enabled ? (
|
||||
<span className="flex items-center gap-1.5 text-emerald-400">
|
||||
<Shield size={14} />
|
||||
启用
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-red-400">
|
||||
<ShieldOff size={14} />
|
||||
禁用
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-slate-500">{formatDateTime(u.createdAt)}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-white"
|
||||
title="编辑"
|
||||
onClick={() => openEditModal(u)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-amber-400"
|
||||
title="重置密码"
|
||||
onClick={() => openResetModal(u)}
|
||||
>
|
||||
<KeyRound size={15} />
|
||||
</button>
|
||||
{!isSelf && (
|
||||
<button
|
||||
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-red-400"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
setFormError(null)
|
||||
setDeletingUser(u)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal title="新建用户" open={showCreate} onClose={() => setShowCreate(false)} maxWidth="max-w-md">
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">用户名 *</span>
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formUsername}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">密码 *</span>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
placeholder="至少 8 个字符"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">显示名</span>
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formDisplayName}
|
||||
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||
placeholder="用户显示名称(选填)"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">角色</span>
|
||||
<select
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value)}
|
||||
>
|
||||
<option value="ROLE_USER">普通用户</option>
|
||||
<option value="ROLE_ADMIN">管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||
)}
|
||||
<button
|
||||
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={formSubmitting || !formUsername.trim() || formPassword.length < 8}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{formSubmitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal title="编辑用户" open={!!editingUser} onClose={() => setEditingUser(null)} maxWidth="max-w-md">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-2.5 text-sm text-slate-400">
|
||||
用户名: <span className="text-slate-200">{editingUser?.username}</span>
|
||||
</div>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">显示名</span>
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formDisplayName}
|
||||
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">角色</span>
|
||||
<select
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value)}
|
||||
>
|
||||
<option value="ROLE_USER">普通用户</option>
|
||||
<option value="ROLE_ADMIN">管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-800 text-cyan-500 focus:ring-cyan-500/30"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-slate-300">账号已启用</span>
|
||||
</label>
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||
)}
|
||||
<button
|
||||
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={formSubmitting}
|
||||
onClick={() => void handleUpdate()}
|
||||
>
|
||||
{formSubmitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<Modal title="重置密码" open={!!resettingUser} onClose={() => setResettingUser(null)} maxWidth="max-w-md">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-2.5 text-sm text-slate-400">
|
||||
用户: <span className="text-slate-200">{resettingUser?.username}</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400">重置后该用户下次登录将被要求修改密码。</p>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm text-slate-300">新密码 *</span>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||
value={formNewPassword}
|
||||
onChange={(e) => setFormNewPassword(e.target.value)}
|
||||
placeholder="至少 8 个字符"
|
||||
/>
|
||||
</label>
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||
)}
|
||||
<button
|
||||
className="w-full rounded-xl bg-gradient-to-r from-amber-500 to-orange-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-amber-900/30 transition hover:from-amber-400 hover:to-orange-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={formSubmitting || formNewPassword.length < 8}
|
||||
onClick={() => void handleResetPassword()}
|
||||
>
|
||||
{formSubmitting ? '重置中...' : '确认重置'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirm */}
|
||||
<Modal title="确认删除" open={!!deletingUser} onClose={() => setDeletingUser(null)} maxWidth="max-w-sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
确定要删除用户 <span className="font-medium text-white">{deletingUser?.username}</span> 吗?此操作不可撤销。
|
||||
</p>
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="rounded-xl border border-slate-700 px-4 py-2 text-sm text-slate-300 transition hover:bg-slate-800"
|
||||
onClick={() => setDeletingUser(null)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={formSubmitting}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
{formSubmitting ? '删除中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
Settings,
|
||||
SplitSquareHorizontal,
|
||||
Terminal,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||
import {
|
||||
buildSessionTree,
|
||||
@@ -110,6 +112,7 @@ export default function WorkspacePage({
|
||||
onLogout: () => void
|
||||
}) {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [connections, setConnections] = useState<Connection[]>([])
|
||||
const [layout, setLayout] = useState<WorkspaceLayout>('split')
|
||||
const [treeLayout, setTreeLayout] = useState<SessionTreeLayoutPayload | null>(null)
|
||||
@@ -582,6 +585,15 @@ export default function WorkspacePage({
|
||||
<FileUp size={16} className="text-blue-400" />
|
||||
传输中心
|
||||
</button>
|
||||
{user?.role === 'ROLE_ADMIN' && (
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => navigate('/users')}
|
||||
>
|
||||
<Users size={16} className="text-emerald-400" />
|
||||
用户管理
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import http from './http'
|
||||
import type { CurrentUser, LoginResponse } from '../types'
|
||||
import type { CurrentUser, LoginResponse, RegisterRequest } from '../types'
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
return http.post<LoginResponse>('/auth/login', { username, password })
|
||||
}
|
||||
|
||||
export function register(payload: RegisterRequest) {
|
||||
return http.post<LoginResponse>('/auth/register', payload)
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return http.get<CurrentUser>('/auth/me')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import http from './http'
|
||||
import type { CreateUserRequest, UpdateUserRequest, UserDto } from '../types'
|
||||
|
||||
export function listUsers() {
|
||||
return http.get<UserDto[]>('/users')
|
||||
}
|
||||
|
||||
export function getUser(id: number) {
|
||||
return http.get<UserDto>(`/users/${id}`)
|
||||
}
|
||||
|
||||
export function createUser(payload: CreateUserRequest) {
|
||||
return http.post<UserDto>('/users', payload)
|
||||
}
|
||||
|
||||
export function updateUser(id: number, payload: UpdateUserRequest) {
|
||||
return http.put<UserDto>(`/users/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteUser(id: number) {
|
||||
return http.delete<{ message: string }>(`/users/${id}`)
|
||||
}
|
||||
|
||||
export function resetPassword(id: number, newPassword: string) {
|
||||
return http.post<{ message: string; passwordChangeRequired: boolean }>(`/users/${id}/reset-password`, { newPassword })
|
||||
}
|
||||
@@ -10,15 +10,47 @@ export interface LoginResponse {
|
||||
token: string
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
passwordChangeRequired: boolean
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
passwordChangeRequired?: boolean
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
passwordChangedAt: string
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
password: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
displayName?: string
|
||||
role?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user