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:
liumangmang
2026-05-28 09:13:27 +08:00
parent d038dabc44
commit 1f1d1db65a
25 changed files with 1800 additions and 80 deletions
+15 -1
View File
@@ -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>
+19 -3
View File
@@ -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>
+175 -39
View File
@@ -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>
)
+462
View File
@@ -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>
)
}
+12
View File
@@ -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">
+5 -1
View File
@@ -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')
}
+26
View File
@@ -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 })
}
+32
View File
@@ -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