feat(admin): support user profile editing, password resetting, and email column with search

- Add UpdateUser API and logics supporting nickname, email, admin flag modification, and password reset.
- Relocate user delete button and confirmation Alert into the EditUserModal.
- Optimize admin Switch change to trigger instant API request with rollback support.
- Fix missing email field in edit form initialization by fetching full profile metadata.
- Render email column in users list and support email-based filtering in UserFilterBar.
- Remove hardcoded styles and sizes from Switch components to follow global theme.
This commit is contained in:
ryan
2026-06-28 11:22:11 +08:00
parent 01ebec6dfb
commit 9c7896df50
18 changed files with 564 additions and 118 deletions
@@ -327,7 +327,7 @@ export function EventsTab() {
<Switch
checked={event.enabled}
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
className="scale-75"
/>
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
@@ -324,7 +324,7 @@ export function SettingsTab() {
}
})
}}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
className="scale-75"
/>
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
@@ -0,0 +1,280 @@
"use client"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {useAuth} from "@/components/providers/auth-provider"
import type {AdminUser} from "@/lib/services/admin"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import {Loader2} from "lucide-react"
interface EditUserForm {
nickname: string
email: string
is_admin: boolean
password?: string
}
export function EditUserModal({
isOpen,
onClose,
user,
}: {
isOpen: boolean
onClose: () => void
user: AdminUser | null
}) {
const { updateUser, deleteUser, getUserDetail } = useAdminUsers()
const { user: currentUser } = useAuth()
const isSelf = currentUser && user && currentUser.id.toString() === user.id.toString()
const [form, setForm] = useState<EditUserForm>({
nickname: "",
email: "",
is_admin: false,
password: "",
})
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => {
let active = true
if (isOpen && user) {
setForm({
nickname: user.nickname || "",
email: user.email || "",
is_admin: user.is_admin || false,
password: "",
})
setErrors({})
getUserDetail(user.id)
.then((detail) => {
if (active && detail) {
setForm({
nickname: detail.nickname || "",
email: detail.email || "",
is_admin: detail.is_admin || false,
password: "",
})
}
})
.catch(() => {
// ignore fetching error
})
} else {
setSaving(false)
}
return () => {
active = false
}
}, [isOpen, user, getUserDetail])
const validate = () => {
const newErrors: Record<string, string> = {}
if (!form.email.trim()) {
newErrors.email = "邮箱不能为空"
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = "邮箱格式不正确"
}
if (form.password && form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!user || !validate()) return
setSaving(true)
try {
await updateUser(user.id, {
nickname: form.nickname.trim() || undefined,
email: form.email.trim(),
is_admin: form.is_admin,
password: form.password?.trim() || undefined,
})
onClose()
} catch {
// Errors are handled by context toast notifications
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!user) return
setSaving(true)
try {
await deleteUser(user)
onClose()
} catch {
// Errors are handled by context toast notifications
} finally {
setSaving(false)
setShowDeleteConfirm(false)
}
}
if (!user) return null
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>编辑用户</DialogTitle>
<DialogDescription>
修改用户档案、授予/撤销管理权限或重置其密码。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">用户名 (不可修改)</Label>
<Input
id="username"
value={user.username}
disabled
className="bg-muted/50 cursor-not-allowed select-none font-mono"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<Input
id="nickname"
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="请输入邮箱地址"
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">重置密码 (选填)</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="留空表示保持当前密码,输入则重置 (至少 8 位)"
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">管理员权限</div>
<div className="text-xs text-muted-foreground">
{isSelf ? "不能撤销当前登录用户的管理员权限。" : "开启后此账号将拥有后台管理权限。"}
</div>
</div>
<Switch
checked={form.is_admin}
disabled={!!isSelf || saving}
onCheckedChange={async (checked) => {
setForm((prev) => ({ ...prev, is_admin: checked }))
setSaving(true)
try {
await updateUser(user.id, {
nickname: form.nickname.trim() || undefined,
email: form.email.trim(),
is_admin: checked,
})
} catch {
setForm((prev) => ({ ...prev, is_admin: !checked }))
} finally {
setSaving(false)
}
}}
/>
</div>
<div className="flex justify-between items-center pt-2 border-t mt-2">
<div>
{!user.is_admin && (
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={saving}
>
删除用户
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" type="button" onClick={onClose} disabled={saving}>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {user.nickname || user.username} 吗?该操作会移除用户账号,删除后无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={saving}
>
{saving && <Loader2 className="size-3 animate-spin mr-1" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -1,14 +1,14 @@
"use client"
import * as React from "react"
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, UserCheck,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import type {AdminUser} from "@/lib/services/admin"
import {cn, formatDateTime} from "@/lib/utils"
import {formatDateTime} from "@/lib/utils"
interface UserDetailSheetProps {
selectedUser: AdminUser | null
@@ -16,7 +16,6 @@ interface UserDetailSheetProps {
onOpenChange: (open: boolean) => void
detailLoading: boolean
onStatusToggle: (user: AdminUser) => Promise<void>
onDeleteTarget: (user: AdminUser) => void
}
export function UserDetailSheet({
@@ -25,7 +24,6 @@ export function UserDetailSheet({
onOpenChange,
detailLoading,
onStatusToggle,
onDeleteTarget,
}: UserDetailSheetProps) {
const displayValue = (value?: string) => value && value.trim() ? value : "-"
@@ -56,7 +54,7 @@ export function UserDetailSheet({
UID: {selectedUser.id}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Admin
</Badge>
)}
@@ -156,15 +154,10 @@ export function UserDetailSheet({
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
className="w-full h-9 text-xs font-medium transition-all active:scale-[0.98]"
onClick={() => onStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
@@ -179,14 +172,6 @@ export function UserDetailSheet({
</>
)}
</Button>
<Button
variant="outline"
className="w-full h-9 text-xs font-medium"
onClick={() => onDeleteTarget(selectedUser)}
>
<Trash2 className="size-3 mr-1" />
删除用户
</Button>
</div>
)}
</div>
@@ -18,17 +18,19 @@ export function UserFilterBar() {
pageSize,
searchUserId,
searchUsername,
searchEmail,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setSearchEmail,
setStatusFilter,
fetchUsers
} = useAdminUsers()
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
const hasSearchFilter = Boolean(searchUserId || searchUsername || searchEmail)
return (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
@@ -49,8 +51,8 @@ export function UserFilterBar() {
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
variant="default"
className="text-[10px] h-3 px-1 rounded-full"
>
!
</Badge>
@@ -72,6 +74,12 @@ export function UserFilterBar() {
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入邮箱..."
value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
@@ -80,6 +88,7 @@ export function UserFilterBar() {
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setSearchEmail("")
}}
>
清除
@@ -105,8 +114,8 @@ export function UserFilterBar() {
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
variant="default"
className="text-[10px] h-3 px-1 rounded-full"
>
1
</Badge>
@@ -160,6 +169,7 @@ export function UserFilterBar() {
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setSearchEmail("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
+31 -67
View File
@@ -6,18 +6,8 @@ import {Switch} from "@/components/ui/switch"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Eye, Layers, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
import {Eye, Layers, Pencil, Plus, UserRound, UserX,} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import type {AdminUser} from "@/lib/services/admin"
import {formatDateTime} from "@/lib/utils"
@@ -28,6 +18,7 @@ import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "./components/create-user-modal"
import {UserFilterBar} from "./components/user-filter-bar"
import {UserDetailSheet} from "./components/user-detail-sheet"
import {EditUserModal} from "./components/edit-user-modal"
export default function UsersPage() {
const {
@@ -36,16 +27,14 @@ export default function UsersPage() {
error,
fetchUsers,
getUserDetail,
updateUserStatus,
deleteUser
updateUserStatus
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editTarget, setEditTarget] = useState<AdminUser | null>(null)
useEffect(() => {
fetchUsers()
@@ -74,21 +63,7 @@ export default function UsersPage() {
}
}
const handleDeleteUser = async () => {
if (!deleteTarget) return
setDeleteLoading(true)
try {
await deleteUser(deleteTarget)
if (selectedUser?.id === deleteTarget.id) {
setDetailOpen(false)
setSelectedUser(null)
}
setDeleteTarget(null)
} finally {
setDeleteLoading(false)
}
}
return (
<div className="py-6 space-y-4">
@@ -125,7 +100,8 @@ export default function UsersPage() {
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[160px] py-2 h-8 pl-4">邮箱</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
@@ -163,6 +139,10 @@ export default function UsersPage() {
</div>
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{user.email || "-"}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
@@ -181,7 +161,7 @@ export default function UsersPage() {
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
className="scale-75"
/>
</div>
</TooltipTrigger>
@@ -201,23 +181,18 @@ export default function UsersPage() {
</TooltipContent>
</Tooltip>
{!user.is_admin && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(user)}
>
<Trash2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
删除用户
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => setEditTarget(user)}>
<Pencil className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
编辑用户
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
@@ -235,27 +210,16 @@ export default function UsersPage() {
onOpenChange={setDetailOpen}
detailLoading={detailLoading}
onStatusToggle={handleStatusToggle}
onDeleteTarget={setDeleteTarget}
/>
{/* 删除确认警告弹窗 */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 编辑用户弹窗 */}
<EditUserModal
user={editTarget}
isOpen={!!editTarget}
onClose={() => setEditTarget(null)}
/>
{/* 新建用户模态弹窗 */}
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
+3 -3
View File
@@ -7,8 +7,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
--font-sans: var(--font-inter), 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'PingFang SC', 'Microsoft YaHei', monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -196,4 +196,4 @@
80% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
}
}
+8 -1
View File
@@ -1,4 +1,5 @@
import type {Metadata} from "next";
import {Inter} from "next/font/google";
import {Toaster} from "@/components/ui/sonner";
import {ThemeProvider} from "@/components/layout/theme-provider";
import {CustomThemeProvider} from "@/lib/theme";
@@ -10,6 +11,12 @@ import {SiteTitleUpdater} from "@/components/providers/title-updater";
import {RobotsMeta} from "@/components/layout/robots-meta";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: "OpenFlare",
description: "OpenFlare 边缘节点与反向代理管理平台",
@@ -23,7 +30,7 @@ export default function RootLayout({
return (
<html
lang="zh-CN"
className="hide-scrollbar font-sans"
className={`hide-scrollbar font-sans ${inter.variable}`}
suppressHydrationWarning
>
<body