mirror of
https://github.com/beilunyang/moemail.git
synced 2026-09-05 23:36:37 +08:00
feat: Implement OpenAPI with API Key authentication and role-based access control
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Key, Plus, Loader2, Copy, Trash2, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||
|
||||
type ApiKey = {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function ApiKeyPanel() {
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState("")
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
const { copyToClipboard } = useCopy()
|
||||
const [showExamples, setShowExamples] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { checkPermission } = useRolePermission()
|
||||
const canManageApiKey = checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/api-keys")
|
||||
if (!res.ok) throw new Error("获取 API Keys 失败")
|
||||
const data = await res.json() as { apiKeys: ApiKey[] }
|
||||
setApiKeys(data.apiKeys)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "获取 API Keys 列表失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageApiKey) {
|
||||
fetchApiKeys()
|
||||
}
|
||||
}, [canManageApiKey])
|
||||
|
||||
const { adminContact } = useAdminContact()
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("创建 API Key 失败")
|
||||
|
||||
const data = await res.json() as { key: string }
|
||||
setNewKey(data.key)
|
||||
fetchApiKeys()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
variant: "destructive"
|
||||
})
|
||||
setCreateDialogOpen(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setCreateDialogOpen(false)
|
||||
setNewKeyName("")
|
||||
setNewKey(null)
|
||||
}
|
||||
|
||||
const toggleApiKey = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/api-keys/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("更新失败")
|
||||
|
||||
setApiKeys(keys =>
|
||||
keys.map(key =>
|
||||
key.id === id ? { ...key, enabled } : key
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "更新失败",
|
||||
description: "更新 API Key 状态失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApiKey = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/api-keys/${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("删除失败")
|
||||
|
||||
setApiKeys(keys => keys.filter(key => key.id !== id))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "API Key 已删除"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "删除 API Key 失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6 space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
</div>
|
||||
{
|
||||
canManageApiKey && (
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
创建 API Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{newKey ? "API Key 创建成功" : "创建新的 API Key"}
|
||||
</DialogTitle>
|
||||
{newKey && (
|
||||
<DialogDescription className="text-destructive">
|
||||
请立即保存此密钥,它只会显示一次且无法恢复
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{!newKey ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="为你的 API Key 起个名字"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(newKey)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDialogClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{newKey ? "完成" : "取消"}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{!newKey && (
|
||||
<Button
|
||||
onClick={createApiKey}
|
||||
disabled={loading || !newKeyName.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"创建"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
!canManageApiKey ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p>需要公爵或更高权限才能管理 API Key</p>
|
||||
<p className="mt-2">请联系网站管理员升级您的角色</p>
|
||||
{
|
||||
adminContact && (
|
||||
<p className="mt-2">管理员联系方式:{adminContact}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||
<Key className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">没有 API Keys</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
点击上方的"创建 API Key"按钮来创建你的第一个 API Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{key.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
创建于 {new Date(key.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={key.enabled}
|
||||
onCheckedChange={(checked) => toggleApiKey(key.id, checked)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowExamples(!showExamples)}
|
||||
>
|
||||
{showExamples ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
查看使用文档
|
||||
</button>
|
||||
|
||||
{showExamples && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">生成临时邮箱</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X POST https://${window.location.host}/api/emails/generate \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}'`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X POST https://${window.location.host}/api/emails/generate \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}'`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取邮箱列表</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl https://${window.location.host}/api/emails?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl https://${window.location.host}/api/emails?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取邮件列表</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl https://${window.location.host}/api/emails/{emailId}/messages?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl https://${window.location.host}/api/emails/{emailId}/messages?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取单封邮件</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl https://${window.location.host}/api/emails/{emailId}/{messageId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl https://${window.location.host}/api/emails/{emailId}/{messageId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-4">
|
||||
<p>注意:</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>请将 YOUR_API_KEY 替换为你的实际 API Key</li>
|
||||
<li>emailId 是邮箱的唯一标识符</li>
|
||||
<li>messageId 是邮件的唯一标识符</li>
|
||||
<li>expiryTime 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)</li>
|
||||
<li>domain 是邮箱域名,可通过 /api/emails/domains 获取可用域名列表</li>
|
||||
<li>cursor 用于分页,从上一次请求的响应中获取 nextCursor</li>
|
||||
<li>所有请求都需要包含 X-API-Key 请求头</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
export function ConfigPanel() {
|
||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [adminContact, setAdminContact] = useState<string>("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
@@ -42,7 +44,11 @@ export function ConfigPanel() {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultRole, emailDomains }),
|
||||
body: JSON.stringify({
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("保存失败")
|
||||
@@ -77,6 +83,7 @@ export function ConfigPanel() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.DUKE}>公爵</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>骑士</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>平民</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -94,6 +101,17 @@ export function ConfigPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">管理员联系方式:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={adminContact}
|
||||
onChange={(e) => setAdminContact(e.target.value)}
|
||||
placeholder="如: 微信号、邮箱等"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { User } from "next-auth"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signOut } from "next-auth/react"
|
||||
import { Github, Mail, Settings, Crown, Sword, User2 } from "lucide-react"
|
||||
import { Github, Mail, Settings, Crown, Sword, User2, Gem } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { WebhookConfig } from "./webhook-config"
|
||||
import { PromotePanel } from "./promote-panel"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { ConfigPanel } from "./config-panel"
|
||||
import { ApiKeyPanel } from "./api-key-panel"
|
||||
|
||||
interface ProfileCardProps {
|
||||
user: User
|
||||
@@ -18,6 +19,7 @@ interface ProfileCardProps {
|
||||
|
||||
const roleConfigs = {
|
||||
emperor: { name: '皇帝', icon: Crown },
|
||||
duke: { name: '公爵', icon: Gem },
|
||||
knight: { name: '骑士', icon: Sword },
|
||||
civilian: { name: '平民', icon: User2 },
|
||||
} as const
|
||||
@@ -96,6 +98,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
|
||||
{canManageConfig && <ConfigPanel />}
|
||||
{canPromote && <PromotePanel />}
|
||||
{canManageWebhook && <ApiKeyPanel />}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
||||
<Button
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sword, Loader2, UserMinus } from "lucide-react"
|
||||
import { Gem, Sword, User2, Loader2 } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useState } from "react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
import { ROLES, Role } from "@/lib/permissions"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const roleIcons = {
|
||||
[ROLES.DUKE]: Gem,
|
||||
[ROLES.KNIGHT]: Sword,
|
||||
[ROLES.CIVILIAN]: User2,
|
||||
} as const
|
||||
|
||||
const roleNames = {
|
||||
[ROLES.DUKE]: "公爵",
|
||||
[ROLES.KNIGHT]: "骑士",
|
||||
[ROLES.CIVILIAN]: "平民",
|
||||
} as const
|
||||
|
||||
type RoleWithoutEmperor = Exclude<Role, typeof ROLES.EMPEROR>
|
||||
|
||||
export function PromotePanel() {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [action, setAction] = useState<"promote" | "demote">("promote")
|
||||
const [targetRole, setTargetRole] = useState<RoleWithoutEmperor>(ROLES.KNIGHT)
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleAction = async () => {
|
||||
@@ -23,15 +44,15 @@ export function PromotePanel() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ searchText })
|
||||
})
|
||||
const data = await res.json() as {
|
||||
user?: {
|
||||
const data = await res.json() as {
|
||||
user?: {
|
||||
id: string
|
||||
name?: string
|
||||
username?: string
|
||||
email: string
|
||||
role?: string
|
||||
role?: string
|
||||
}
|
||||
error?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(data.error || "未知错误")
|
||||
@@ -39,49 +60,42 @@ export function PromotePanel() {
|
||||
if (!data.user) {
|
||||
toast({
|
||||
title: "未找到用户",
|
||||
description: "请确认邮箱地址是否正确",
|
||||
description: "请确认用户名或邮箱地址是否正确",
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "promote" && data.user.role === ROLES.KNIGHT) {
|
||||
if (data.user.role === targetRole) {
|
||||
toast({
|
||||
title: "用户已是骑士",
|
||||
description: "无需重复册封",
|
||||
title: `用户已是${roleNames[targetRole]}`,
|
||||
description: "无需重复设置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "demote" && data.user.role === ROLES.CIVILIAN) {
|
||||
toast({
|
||||
title: "用户已是平民",
|
||||
description: "无需重复贬为平民",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const promoteRes = await fetch('/api/roles/promote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const promoteRes = await fetch("/api/roles/promote", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: data.user.id,
|
||||
roleName: action === "promote" ? ROLES.KNIGHT : ROLES.CIVILIAN
|
||||
roleName: targetRole
|
||||
})
|
||||
})
|
||||
|
||||
const result = await promoteRes.json() as { error: string }
|
||||
if (!promoteRes.ok) throw new Error(result.error)
|
||||
if (!promoteRes.ok) {
|
||||
const error = await promoteRes.json() as { error: string }
|
||||
throw new Error(error.error || "设置失败")
|
||||
}
|
||||
|
||||
toast({
|
||||
title: action === "promote" ? "册封成功" : "贬为平民",
|
||||
description: `已将 ${data.user.email || data.user.username} ${action === "promote" ? "册封为骑士" : "贬为平民"}`
|
||||
title: "设置成功",
|
||||
description: `已将用户 ${data.user.username || data.user.email} 设为${roleNames[targetRole]}`,
|
||||
})
|
||||
setSearchText("")
|
||||
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "操作失败",
|
||||
title: "设置失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -90,55 +104,62 @@ export function PromotePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = roleIcons[targetRole]
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Sword className="w-5 h-5 text-primary" />
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">角色管理</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="输入用户名或邮箱"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAction("promote")
|
||||
handleAction()
|
||||
}}
|
||||
disabled={!searchText || loading}
|
||||
className="flex-1 sm:flex-initial gap-2"
|
||||
>
|
||||
{loading && action === "promote" ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sword className="w-4 h-4" />
|
||||
)}
|
||||
册封骑士
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAction("demote")
|
||||
handleAction()
|
||||
}}
|
||||
disabled={!searchText || loading}
|
||||
variant="destructive"
|
||||
className="flex-1 sm:flex-initial gap-2"
|
||||
>
|
||||
{loading && action === "demote" ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<UserMinus className="w-4 h-4" />
|
||||
)}
|
||||
贬为平民
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="输入用户名或邮箱"
|
||||
/>
|
||||
</div>
|
||||
<Select value={targetRole} onValueChange={(value) => setTargetRole(value as RoleWithoutEmperor)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.DUKE}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
公爵
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sword className="w-4 h-4" />
|
||||
骑士
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>
|
||||
<div className="flex items-center gap-2">
|
||||
<User2 className="w-4 h-4" />
|
||||
平民
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAction}
|
||||
disabled={loading || !searchText.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
`设为${roleNames[targetRole]}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,8 +37,13 @@ export function WebhookConfig() {
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -138,8 +143,8 @@ export function WebhookConfig() {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !url}
|
||||
@@ -179,10 +184,10 @@ export function WebhookConfig() {
|
||||
Content-Type: application/json{'\n'}
|
||||
X-Webhook-Event: new_message
|
||||
</pre>
|
||||
|
||||
|
||||
<p>请求体示例:</p>
|
||||
<pre className="bg-background p-2 rounded text-xs overflow-auto">
|
||||
{`{
|
||||
{`{
|
||||
"emailId": "email-uuid",
|
||||
"messageId": "message-uuid",
|
||||
"fromAddress": "sender@example.com",
|
||||
|
||||
Reference in New Issue
Block a user