feat: Implement OpenAPI with API Key authentication and role-based access control

This commit is contained in:
beilunyang
2025-02-10 11:25:25 +08:00
parent 1bc0369b83
commit 9ad3115833
28 changed files with 2339 additions and 144 deletions
+8 -2
View File
@@ -2,9 +2,10 @@
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useAdminContact } from "@/hooks/use-admin-contact"
export function NoPermissionDialog() {
const router = useRouter()
const { adminContact } = useAdminContact()
return (
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
@@ -12,7 +13,12 @@ export function NoPermissionDialog() {
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
<div className="text-center space-y-4">
<h1 className="text-xl md:text-2xl font-bold"></h1>
<p className="text-sm md:text-base text-muted-foreground">访</p>
<p className="text-sm md:text-base text-muted-foreground">访</p>
{
adminContact && (
<p className="text-sm md:text-base text-muted-foreground">{adminContact}</p>
)
}
<Button
onClick={() => router.push("/")}
className="mt-4 w-full md:w-auto"
+432
View File
@@ -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 3600000186400000160480000070</li>
<li>domain /api/emails/domains </li>
<li>cursor nextCursor</li>
<li> X-API-Key </li>
</ul>
</div>
</div>
)}
</div>
</>
)}
</div>
)
}
</div>
)
}
+19 -1
View File
@@ -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 -1
View File
@@ -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
+92 -71
View File
@@ -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>
)
+11 -6
View File
@@ -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",
+33
View File
@@ -2,6 +2,7 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
@@ -39,6 +40,10 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
@@ -58,6 +63,20 @@ const DialogHeader = ({
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -73,11 +92,25 @@ const DialogTitle = React.forwardRef<
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogClose,
DialogDescription,
}