mirror of
https://github.com/beilunyang/moemail.git
synced 2026-09-07 00:06:44 +08:00
feat(roles): add delete user functionality to role management
Add ability for Emperor to delete users from the role management panel: - New POST /api/roles/delete endpoint with PROMOTE_USER permission guard, blocks deleting self or other emperors, cascades user data cleanup - Trash button on each non-emperor row with confirmation dialog - Auto-navigate to previous page when deleting the last user on a page - i18n strings added for all 5 locales
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
import { createDb } from "@/lib/db";
|
||||||
|
import { users, userRoles, apiKeys } from "@/lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ROLES, PERMISSIONS } from "@/lib/permissions";
|
||||||
|
import { checkPermission } from "@/lib/auth";
|
||||||
|
import { getUserId } from "@/lib/apiKey";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const canManage = await checkPermission(PERMISSIONS.PROMOTE_USER);
|
||||||
|
if (!canManage) {
|
||||||
|
return Response.json({ error: "权限不足" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId } = await request.json() as { userId: string };
|
||||||
|
if (!userId) {
|
||||||
|
return Response.json({ error: "缺少必要参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = await getUserId();
|
||||||
|
if (userId === currentUserId) {
|
||||||
|
return Response.json({ error: "不能删除自己" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
const currentUserRole = await db.query.userRoles.findFirst({
|
||||||
|
where: eq(userRoles.userId, userId),
|
||||||
|
with: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUserRole?.role.name === ROLES.EMPEROR) {
|
||||||
|
return Response.json({ error: "不能删除皇帝" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeys 未配置级联删除,需先手动删除;其余(accounts / emails→messages / webhooks / userRoles)由外键级联处理
|
||||||
|
await db.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
||||||
|
await db.delete(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete user:", error);
|
||||||
|
return Response.json({ error: "操作失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Crown, Gem, Sword, User2, Loader2, Search, ChevronLeft, ChevronRight, Users } from "lucide-react"
|
import { Crown, Gem, Sword, User2, Loader2, Search, ChevronLeft, ChevronRight, Users, Trash2 } from "lucide-react"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
@@ -14,6 +14,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
const roleIcons = {
|
const roleIcons = {
|
||||||
[ROLES.EMPEROR]: Crown,
|
[ROLES.EMPEROR]: Crown,
|
||||||
@@ -44,6 +54,8 @@ export function PromotePanel() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
|
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
|
||||||
|
const [deletingUserId, setDeletingUserId] = useState<string | null>(null)
|
||||||
|
const [userToDelete, setUserToDelete] = useState<UserItem | null>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const roleNames = {
|
const roleNames = {
|
||||||
@@ -120,6 +132,37 @@ export function PromotePanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (user: UserItem) => {
|
||||||
|
setDeletingUserId(user.id)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/roles/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId: user.id }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json() as { error: string }
|
||||||
|
throw new Error(error.error)
|
||||||
|
}
|
||||||
|
toast({ title: t("deleteSuccess") })
|
||||||
|
setUserToDelete(null)
|
||||||
|
// 若删除的是当前页最后一条,且不在首页,则退回上一页(useEffect 会重新拉取)
|
||||||
|
if (users.length === 1 && page > 1) {
|
||||||
|
setPage((p) => p - 1)
|
||||||
|
} else {
|
||||||
|
await fetchUsers()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("deleteFailed"),
|
||||||
|
description: error instanceof Error ? error.message : undefined,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeletingUserId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
@@ -189,44 +232,55 @@ export function PromotePanel() {
|
|||||||
{roleNames[ROLES.EMPEROR]}
|
{roleNames[ROLES.EMPEROR]}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="flex items-center gap-2">
|
||||||
{isUpdating && (
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 rounded z-10">
|
{isUpdating && (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 rounded z-10">
|
||||||
</div>
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
value={user.role || ROLES.CIVILIAN}
|
|
||||||
onValueChange={(v) => handleRoleChange(user.id, v as RoleWithoutEmperor)}
|
|
||||||
disabled={isUpdating}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-32 h-8 text-sm">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RoleIcon className="w-3.5 h-3.5" />
|
|
||||||
<SelectValue />
|
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
<Select
|
||||||
<SelectItem value={ROLES.DUKE}>
|
value={user.role || ROLES.CIVILIAN}
|
||||||
<div className="flex items-center gap-2">
|
onValueChange={(v) => handleRoleChange(user.id, v as RoleWithoutEmperor)}
|
||||||
<Gem className="w-4 h-4" />
|
disabled={isUpdating}
|
||||||
{roleNames[ROLES.DUKE]}
|
>
|
||||||
|
<SelectTrigger className="w-32 h-8 text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<RoleIcon className="w-3.5 h-3.5" />
|
||||||
|
<SelectValue />
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value={ROLES.KNIGHT}>
|
<SelectContent>
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem value={ROLES.DUKE}>
|
||||||
<Sword className="w-4 h-4" />
|
<div className="flex items-center gap-2">
|
||||||
{roleNames[ROLES.KNIGHT]}
|
<Gem className="w-4 h-4" />
|
||||||
</div>
|
{roleNames[ROLES.DUKE]}
|
||||||
</SelectItem>
|
</div>
|
||||||
<SelectItem value={ROLES.CIVILIAN}>
|
</SelectItem>
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem value={ROLES.KNIGHT}>
|
||||||
<User2 className="w-4 h-4" />
|
<div className="flex items-center gap-2">
|
||||||
{roleNames[ROLES.CIVILIAN]}
|
<Sword className="w-4 h-4" />
|
||||||
</div>
|
{roleNames[ROLES.KNIGHT]}
|
||||||
</SelectItem>
|
</div>
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
<SelectItem value={ROLES.CIVILIAN}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User2 className="w-4 h-4" />
|
||||||
|
{roleNames[ROLES.CIVILIAN]}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setUserToDelete(user)}
|
||||||
|
title={t("deleteUser")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -261,6 +315,47 @@ export function PromotePanel() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!userToDelete}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deletingUserId) setUserToDelete(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("deleteConfirm", {
|
||||||
|
name:
|
||||||
|
userToDelete?.name ||
|
||||||
|
userToDelete?.username ||
|
||||||
|
userToDelete?.email ||
|
||||||
|
"",
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={!!deletingUserId}>
|
||||||
|
{t("cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={!!deletingUserId}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (userToDelete) handleDelete(userToDelete)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deletingUserId ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("deleteConfirmButton")
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@
|
|||||||
"totalUsers": "{count} users total",
|
"totalUsers": "{count} users total",
|
||||||
"prevPage": "Previous",
|
"prevPage": "Previous",
|
||||||
"nextPage": "Next",
|
"nextPage": "Next",
|
||||||
"pageInfo": "Page {current} / {total}"
|
"pageInfo": "Page {current} / {total}",
|
||||||
|
"deleteUser": "Delete User",
|
||||||
|
"deleteTitle": "Delete User",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete user {name}? All of their emails, messages and other data will be permanently deleted and cannot be recovered.",
|
||||||
|
"deleteConfirmButton": "Delete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"deleteSuccess": "User deleted successfully",
|
||||||
|
"deleteFailed": "Failed to delete user"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@
|
|||||||
"totalUsers": "合計 {count} ユーザー",
|
"totalUsers": "合計 {count} ユーザー",
|
||||||
"prevPage": "前へ",
|
"prevPage": "前へ",
|
||||||
"nextPage": "次へ",
|
"nextPage": "次へ",
|
||||||
"pageInfo": "{current} / {total} ページ"
|
"pageInfo": "{current} / {total} ページ",
|
||||||
|
"deleteUser": "ユーザーを削除",
|
||||||
|
"deleteTitle": "ユーザーを削除",
|
||||||
|
"deleteConfirm": "ユーザー {name} を削除してもよろしいですか?このユーザーのメール・メッセージ等のデータはすべて完全に削除され、復元できません。",
|
||||||
|
"deleteConfirmButton": "削除",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"deleteSuccess": "ユーザーを削除しました",
|
||||||
|
"deleteFailed": "ユーザーの削除に失敗しました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@
|
|||||||
"totalUsers": "총 {count}명의 사용자",
|
"totalUsers": "총 {count}명의 사용자",
|
||||||
"prevPage": "이전",
|
"prevPage": "이전",
|
||||||
"nextPage": "다음",
|
"nextPage": "다음",
|
||||||
"pageInfo": "{current} / {total} 페이지"
|
"pageInfo": "{current} / {total} 페이지",
|
||||||
|
"deleteUser": "사용자 삭제",
|
||||||
|
"deleteTitle": "사용자 삭제",
|
||||||
|
"deleteConfirm": "사용자 {name}을(를) 삭제하시겠습니까? 이 사용자의 모든 이메일, 메시지 등의 데이터가 영구적으로 삭제되며 복구할 수 없습니다.",
|
||||||
|
"deleteConfirmButton": "삭제",
|
||||||
|
"cancel": "취소",
|
||||||
|
"deleteSuccess": "사용자가 삭제되었습니다",
|
||||||
|
"deleteFailed": "사용자 삭제에 실패했습니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@
|
|||||||
"totalUsers": "共 {count} 个用户",
|
"totalUsers": "共 {count} 个用户",
|
||||||
"prevPage": "上一页",
|
"prevPage": "上一页",
|
||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
"pageInfo": "第 {current} / {total} 页"
|
"pageInfo": "第 {current} / {total} 页",
|
||||||
|
"deleteUser": "删除用户",
|
||||||
|
"deleteTitle": "删除用户",
|
||||||
|
"deleteConfirm": "确定要删除用户 {name} 吗?该用户名下的所有邮箱、邮件等数据将被永久删除且无法恢复。",
|
||||||
|
"deleteConfirmButton": "删除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"deleteSuccess": "用户删除成功",
|
||||||
|
"deleteFailed": "删除用户失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@
|
|||||||
"totalUsers": "共 {count} 個使用者",
|
"totalUsers": "共 {count} 個使用者",
|
||||||
"prevPage": "上一頁",
|
"prevPage": "上一頁",
|
||||||
"nextPage": "下一頁",
|
"nextPage": "下一頁",
|
||||||
"pageInfo": "第 {current} / {total} 頁"
|
"pageInfo": "第 {current} / {total} 頁",
|
||||||
|
"deleteUser": "刪除使用者",
|
||||||
|
"deleteTitle": "刪除使用者",
|
||||||
|
"deleteConfirm": "確定要刪除使用者 {name} 嗎?該使用者名下的所有郵箱、郵件等資料將被永久刪除且無法復原。",
|
||||||
|
"deleteConfirmButton": "刪除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"deleteSuccess": "使用者刪除成功",
|
||||||
|
"deleteFailed": "刪除使用者失敗"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user