23b04bd343
- 新增 IMAP/POP3 事件表与清理任务,并接入 Dovecot auth policy。 - 暴露默认权限限额接口,前端权限组页面改为动态读取。 - 为 Dovecot 配置用户配额、连接数限制和 auth policy。
1577 lines
100 KiB
TypeScript
1577 lines
100 KiB
TypeScript
import * as React from "react"
|
||
import DOMPurify from "dompurify"
|
||
import { useSearchParams } from "react-router-dom"
|
||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Checkbox } from "@/components/ui/checkbox"
|
||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { Switch } from "@/components/ui/switch"
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||
import { useMe } from "@/hooks/use-me"
|
||
import { useToast } from "@/hooks/use-toast"
|
||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||
import type { PermissionKey } from "@/lib/api-types"
|
||
|
||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||
|
||
const sectionLabels: Record<Section, string> = {
|
||
overview: "概览",
|
||
users: "用户",
|
||
permissionGroups: "权限组",
|
||
domains: "域名",
|
||
mailboxes: "邮箱账号",
|
||
aliases: "别名转发",
|
||
messages: "全部邮件",
|
||
settings: "系统设置",
|
||
}
|
||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||
const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||
overview: ["admin.overview.view"],
|
||
users: ["admin.users.view"],
|
||
permissionGroups: ["admin.permission_groups.view"],
|
||
domains: ["admin.domains.view", "admin.dns.view"],
|
||
mailboxes: ["admin.mailboxes.view"],
|
||
aliases: ["admin.aliases.view"],
|
||
messages: ["admin.messages.view"],
|
||
settings: ["admin.settings.view", "admin.templates.view"],
|
||
}
|
||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||
|
||
export function AdminPage() {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const canOverview = hasPermission(user, "admin.overview.view")
|
||
const canUsersView = hasPermission(user, "admin.users.view")
|
||
const canPermissionGroupsView = hasPermission(user, "admin.permission_groups.view")
|
||
const canDomainsView = hasPermission(user, "admin.domains.view")
|
||
const canDNSView = hasPermission(user, "admin.dns.view")
|
||
const canMailboxesView = hasPermission(user, "admin.mailboxes.view")
|
||
const canAliasesView = hasPermission(user, "admin.aliases.view")
|
||
const canMessagesView = hasPermission(user, "admin.messages.view")
|
||
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||
const canTemplatesView = hasPermission(user, "admin.templates.view")
|
||
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview })
|
||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
|
||
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
|
||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
|
||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView) })
|
||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
||
const [params, setParams] = useSearchParams()
|
||
|
||
const domainItems = domains.data?.items || []
|
||
const mailboxItems = mailboxes.data?.items || []
|
||
const aliasItems = aliases.data?.items || []
|
||
const userItems = users.data?.items || []
|
||
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== "pg_super_admin" && group.id !== "pg_regular_user")
|
||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||
const rawSection = params.get("section") as Section | null
|
||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||
|
||
return (
|
||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||
<main className="p-4 sm:p-6">
|
||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
|
||
</div>
|
||
|
||
{section === "overview" && canOverview && (
|
||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} />
|
||
<Stat icon={<Globe2 />} label="域名" value={overview.data?.domains || 0} />
|
||
<Stat icon={<Mailbox />} label="邮箱账号" value={overview.data?.mailboxes || 0} />
|
||
<Stat icon={<ShieldCheck />} label="存储" value={formatBytes(overview.data?.storageBytes || 0)} />
|
||
</div>
|
||
)}
|
||
|
||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} />}
|
||
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||
</main>
|
||
</ScrollArea>
|
||
)
|
||
}
|
||
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||
<Card>
|
||
<CardHeader><CardTitle>系统状态</CardTitle></CardHeader>
|
||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
<InfoBox label="活跃用户" value={overview?.activeUsers || 0} />
|
||
<InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} />
|
||
<InfoBox label="别名转发" value={overview?.aliases || 0} />
|
||
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader><CardTitle>首次配置</CardTitle></CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{checklist.map((item) => (
|
||
<Button key={item.key} type="button" variant="outline" className="h-auto w-full justify-start gap-3 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
|
||
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block font-medium">{item.title}</span>
|
||
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span>
|
||
</span>
|
||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
</Button>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||
<Card>
|
||
<CardHeader><CardTitle>DNS 状态</CardTitle></CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{domains.map((domain) => <DomainBadgeRow key={domain.id} domain={domain} />)}
|
||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader><CardTitle>运行提示</CardTitle></CardHeader>
|
||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
|
||
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
|
||
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
|
||
<InfoLine label="用户自助申请" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function setupChecklist(overview: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number } | undefined, domains: Domain[], settings?: SystemSettings) {
|
||
const hasDomain = domains.length > 0
|
||
const dnsReady = domains.some((domain) => domain.dnsStatus === "ok")
|
||
const hasMailbox = (overview?.activeMailboxes || 0) > 0
|
||
const hasMail = (overview?.messages || 0) > 0
|
||
return [
|
||
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
|
||
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
|
||
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给超级管理员或普通用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
||
{ key: "smtp", title: "确认发信链路", detail: settings?.smtpHost ? `内置 Postfix:${settings.smtpHost}:${settings.smtpPort}` : "默认使用内置 Postfix", done: true, section: "settings" as Section },
|
||
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
|
||
]
|
||
}
|
||
|
||
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
||
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
||
}
|
||
|
||
function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permissionGroups: PermissionGroup[] }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [query, setQuery] = React.useState("")
|
||
const [roleFilter, setRoleFilter] = React.useState("all")
|
||
const [statusFilter, setStatusFilter] = React.useState("all")
|
||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||
const canCreate = hasPermission(user, "admin.users.create")
|
||
const canDelete = hasPermission(user, "admin.users.delete")
|
||
const filteredUsers = users.filter((user) => {
|
||
const keyword = query.trim().toLowerCase()
|
||
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||
const matchesRole = roleFilter === "all" || user.role === roleFilter
|
||
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
|
||
return matchesKeyword && matchesRole && matchesStatus
|
||
})
|
||
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "用户已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<CardTitle>用户管理</CardTitle>
|
||
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex flex-col gap-3 lg:flex-row">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索用户、邮箱、显示名称" className="pl-9" />
|
||
</div>
|
||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部角色</SelectItem>
|
||
<SelectItem value="admin">超级管理员</SelectItem>
|
||
<SelectItem value="user">普通用户</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部状态</SelectItem>
|
||
<SelectItem value="active">正常</SelectItem>
|
||
<SelectItem value="disabled">停用</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-3 md:hidden">
|
||
{filteredUsers.map((user) => (
|
||
<div key={user.id} className="rounded-lg border p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium">{user.displayName}</div>
|
||
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
||
</div>
|
||
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<RoleBadge user={user} />
|
||
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
|
||
<Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
|
||
</div>
|
||
<div className="mt-3"><UserPermissionGroupsCell user={user} /></div>
|
||
<div className="mt-3"><UserMailboxCell user={user} /></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="hidden md:block">
|
||
<Table>
|
||
<TableHeader><TableRow><TableHead>用户</TableHead><TableHead>身份</TableHead><TableHead>权限组</TableHead><TableHead>邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建时间</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||
<TableBody>
|
||
{filteredUsers.map((user) => (
|
||
<TableRow key={user.id}>
|
||
<TableCell>
|
||
<div className="font-medium">{user.displayName}</div>
|
||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||
</TableCell>
|
||
<TableCell><RoleBadge user={user} /></TableCell>
|
||
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
||
<TableCell><UserMailboxCell user={user} /></TableCell>
|
||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
|
||
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{filteredUsers.length === 0 && <Empty text="没有匹配的用户" />}
|
||
</CardContent>
|
||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[]; catalog: PermissionInfo[] }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [query, setQuery] = React.useState("")
|
||
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
|
||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||
const canCreate = hasPermission(user, "admin.permission_groups.create")
|
||
const canUpdate = hasPermission(user, "admin.permission_groups.update")
|
||
const canDelete = hasPermission(user, "admin.permission_groups.delete")
|
||
const remove = useMutation({
|
||
mutationFn: api.deletePermissionGroup,
|
||
onSuccess: () => {
|
||
setPendingConfirm(null)
|
||
invalidateAdmin(qc)
|
||
toast({ title: "权限组已删除" })
|
||
},
|
||
onError: (e) => toast({ title: "删除失败", description: e.message }),
|
||
})
|
||
const filtered = groups.filter((group) => {
|
||
const keyword = query.trim().toLowerCase()
|
||
if (!keyword) return true
|
||
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
||
})
|
||
const isEditable = (group: PermissionGroup) => group.id !== "pg_super_admin"
|
||
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<CardTitle>权限组管理</CardTitle>
|
||
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限组、说明或权限键" className="pl-9" />
|
||
</div>
|
||
<div className="grid gap-3 lg:grid-cols-2">
|
||
{filtered.map((group) => (
|
||
<div key={group.id} className="rounded-lg border p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="font-medium">{group.name}</div>
|
||
{group.system && <Badge variant="outline">系统组</Badge>}
|
||
{!group.system && <Badge variant="secondary">自定义</Badge>}
|
||
<Badge variant="outline">{group.userCount} 人</Badge>
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||
</div>
|
||
{(canUpdate || canDelete) && <DropdownMenu>
|
||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限组</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="text-destructive"
|
||
disabled={!isDeletable(group) || !canDelete}
|
||
onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
|
||
>
|
||
删除权限组
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>}
|
||
</div>
|
||
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
||
<PermissionLimitBadges limits={group.limits} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
{filtered.length === 0 && <Empty text="暂无匹配的权限组" />}
|
||
</CardContent>
|
||
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
|
||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: PermissionGroup; catalog: PermissionInfo[]; open?: boolean; onOpenChange?: (open: boolean) => void }) {
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||
const dialogOpen = open ?? internalOpen
|
||
const setDialogOpen = onOpenChange ?? setInternalOpen
|
||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
|
||
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
|
||
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
|
||
React.useEffect(() => {
|
||
if (dialogOpen) {
|
||
setPermissions(group?.permissions || [])
|
||
setLimits(group?.limits || defaultPermissionLimits)
|
||
}
|
||
}, [dialogOpen, group])
|
||
const mutation = useMutation({
|
||
mutationFn: (form: FormData) => {
|
||
const payload = {
|
||
name: String(form.get("name") || ""),
|
||
description: String(form.get("description") || ""),
|
||
permissions,
|
||
limits,
|
||
}
|
||
return group ? api.updatePermissionGroup(group.id, payload) : api.createPermissionGroup(payload)
|
||
},
|
||
onSuccess: () => {
|
||
invalidateAdmin(qc)
|
||
setDialogOpen(false)
|
||
toast({ title: group ? "权限组已更新" : "权限组已创建" })
|
||
},
|
||
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
|
||
})
|
||
const trigger = group ? null : (
|
||
<DialogTrigger asChild>
|
||
<Button size="sm"><Plus className="h-4 w-4" />权限组</Button>
|
||
</DialogTrigger>
|
||
)
|
||
return (
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
{trigger}
|
||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||
<DialogHeader><DialogTitle>{group ? "编辑权限组" : "创建权限组"}</DialogTitle></DialogHeader>
|
||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
|
||
</div>
|
||
<PermissionLimitEditor value={limits} onChange={setLimits} />
|
||
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
||
<DialogFooter><Button disabled={mutation.isPending}>{mutation.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void }) {
|
||
const grouped = groupPermissionCatalog(catalog)
|
||
function toggle(permission: PermissionKey, checked: boolean) {
|
||
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
|
||
}
|
||
function toggleCategory(items: PermissionInfo[], checked: boolean) {
|
||
const keys = items.map((item) => item.key)
|
||
onChange(checked ? Array.from(new Set([...value, ...keys])) : value.filter((item) => !keys.includes(item)))
|
||
}
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Label>菜单与操作权限</Label>
|
||
<Badge variant="outline">{value.length} 项</Badge>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{grouped.map(({ category, items }) => {
|
||
const allChecked = items.every((item) => value.includes(item.key))
|
||
return (
|
||
<div key={category} className="rounded-lg border">
|
||
<div className="flex items-center justify-between gap-3 border-b px-3 py-2">
|
||
<label className="flex items-center gap-2 font-medium">
|
||
<Checkbox checked={allChecked} onCheckedChange={(next) => toggleCategory(items, next === true)} />
|
||
{category}
|
||
</label>
|
||
<span className="text-xs text-muted-foreground">{items.filter((item) => value.includes(item.key)).length}/{items.length}</span>
|
||
</div>
|
||
<div className="grid gap-2 p-3 md:grid-cols-2">
|
||
{items.map((item) => (
|
||
<label key={item.key} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
||
<Checkbox checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
|
||
<span className="min-w-0">
|
||
<span className="block text-sm font-medium">{item.label}</span>
|
||
<span className="line-clamp-2 text-xs text-muted-foreground">{item.description}</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; onChange: (value: PermissionLimits) => void }) {
|
||
function update(key: keyof PermissionLimits, raw: string) {
|
||
const next = Number(raw)
|
||
onChange({ ...value, [key]: Number.isFinite(next) && next > 0 ? Math.floor(next) : 0 })
|
||
}
|
||
return (
|
||
<div className="space-y-3 rounded-lg border p-3">
|
||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||
<Label>账号配额</Label>
|
||
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label>附件上限 MB</Label>
|
||
<Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>SMTP 每日封数</Label>
|
||
<Input type="number" min={0} value={value.smtpDailyLimit} onChange={(event) => update("smtpDailyLimit", event.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>SMTP 每分钟封数</Label>
|
||
<Input type="number" min={0} value={value.smtpMinuteLimit} onChange={(event) => update("smtpMinuteLimit", event.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>IMAP 每分钟命令数</Label>
|
||
<Input type="number" min={0} value={value.imapMinuteLimit} onChange={(event) => update("imapMinuteLimit", event.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>POP3 每分钟命令数</Label>
|
||
<Input type="number" min={0} value={value.pop3MinuteLimit} onChange={(event) => update("pop3MinuteLimit", event.target.value)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
||
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
|
||
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground">无后台权限</div>
|
||
return (
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{permissions.slice(0, 10).map((permission) => (
|
||
<Badge key={permission} variant="outline" className="font-normal">{labelByKey.get(permission) || permission}</Badge>
|
||
))}
|
||
{permissions.length > 10 && <Badge variant="secondary">+{permissions.length - 10}</Badge>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits })
|
||
const value = limits || defaultLimitsQuery.data || defaultPermissionLimits
|
||
return (
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
<Badge variant="secondary" className="font-normal">附件 {limitText(value.maxAttachmentMb, "MB")}</Badge>
|
||
<Badge variant="secondary" className="font-normal">SMTP 每日 {limitText(value.smtpDailyLimit, "封")}</Badge>
|
||
<Badge variant="secondary" className="font-normal">SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")}</Badge>
|
||
<Badge variant="secondary" className="font-normal">IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")}</Badge>
|
||
<Badge variant="secondary" className="font-normal">POP3 每分钟 {limitText(value.pop3MinuteLimit, "次")}</Badge>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function limitText(value: number, unit: string) {
|
||
return value > 0 ? `${value} ${unit}` : "不限"
|
||
}
|
||
|
||
function groupPermissionCatalog(catalog: PermissionInfo[]) {
|
||
const order: string[] = []
|
||
const grouped = new Map<string, PermissionInfo[]>()
|
||
for (const item of catalog) {
|
||
if (!grouped.has(item.category)) {
|
||
grouped.set(item.category, [])
|
||
order.push(item.category)
|
||
}
|
||
grouped.get(item.category)!.push(item)
|
||
}
|
||
return order.map((category) => ({ category, items: grouped.get(category)! }))
|
||
}
|
||
|
||
function DomainsSection({ domains }: { domains: Domain[] }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||
const canCreate = hasPermission(user, "admin.domains.create")
|
||
const canUpdate = hasPermission(user, "admin.domains.update")
|
||
const canDelete = hasPermission(user, "admin.domains.delete")
|
||
const canViewDNS = hasPermission(user, "admin.dns.view")
|
||
const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||
const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<CardTitle>域名管理</CardTitle>
|
||
{canCreate && <CreateDomainDialog />}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{domains.map((domain) => (
|
||
<div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<div className="font-medium">{domain.name}</div>
|
||
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant={domain.status === "active" ? "default" : "secondary"}>{domain.status === "active" ? "启用" : "停用"}</Badge>
|
||
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
||
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
||
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
|
||
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、别名和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||
</CardContent>
|
||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function DomainDNSDialog({ domain }: { domain: Domain }) {
|
||
return (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline" size="sm">DNS</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||
<DialogHeader><DialogTitle>{domain.name} DNS</DialogTitle></DialogHeader>
|
||
<DNSPanel domain={domain} embedded />
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxType[]; users: AdminUser[]; domains: Domain[] }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||
const canCreate = hasPermission(user, "admin.mailboxes.create")
|
||
const canUpdate = hasPermission(user, "admin.mailboxes.update")
|
||
const canDelete = hasPermission(user, "admin.mailboxes.delete")
|
||
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<CardTitle>邮箱账号管理</CardTitle>
|
||
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3 md:hidden">
|
||
{mailboxes.map((mailbox) => (
|
||
<div key={mailbox.id} className="rounded-lg border p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium">{mailbox.address}</div>
|
||
<div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
|
||
</div>
|
||
<MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} />
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
|
||
<Badge variant="outline">{mailbox.quotaMb} MB</Badge>
|
||
<Badge variant="outline">{mailbox.displayName || "未命名"}</Badge>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="hidden md:block">
|
||
<Table>
|
||
<TableHeader><TableRow><TableHead>地址</TableHead><TableHead>归属用户</TableHead><TableHead>名称</TableHead><TableHead>配额</TableHead><TableHead>状态</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||
<TableBody>
|
||
{mailboxes.map((mailbox) => (
|
||
<TableRow key={mailbox.id}>
|
||
<TableCell className="font-medium">{mailbox.address}</TableCell>
|
||
<TableCell className="text-muted-foreground">{mailbox.userEmail || mailbox.userId}</TableCell>
|
||
<TableCell>{mailbox.displayName}</TableCell>
|
||
<TableCell>{mailbox.quotaMb} MB</TableCell>
|
||
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
|
||
<TableCell><MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} /></TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{mailboxes.length === 0 && <Empty text="暂无邮箱账号" />}
|
||
</CardContent>
|
||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||
const canCreate = hasPermission(user, "admin.aliases.create")
|
||
const canUpdate = hasPermission(user, "admin.aliases.update")
|
||
const canDelete = hasPermission(user, "admin.aliases.delete")
|
||
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<CardTitle>别名/转发管理</CardTitle>
|
||
{canCreate && <CreateAliasDialog domains={domains} />}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3 md:hidden">
|
||
{aliases.map((alias) => (
|
||
<div key={alias.id} className="rounded-lg border p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium">{alias.source}</div>
|
||
<div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
|
||
</div>
|
||
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
|
||
<Badge variant="outline">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</Badge>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="hidden md:block">
|
||
<Table>
|
||
<TableHeader><TableRow><TableHead>来源</TableHead><TableHead>目标</TableHead><TableHead>域名</TableHead><TableHead>状态</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||
<TableBody>
|
||
{aliases.map((alias) => (
|
||
<TableRow key={alias.id}>
|
||
<TableCell className="font-medium">{alias.source}</TableCell>
|
||
<TableCell>{alias.destination}</TableCell>
|
||
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
|
||
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
|
||
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{aliases.length === 0 && <Empty text="暂无别名转发" />}
|
||
</CardContent>
|
||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||
const qc = useQueryClient()
|
||
const [query, setQuery] = React.useState("")
|
||
const [mailboxId, setMailboxId] = React.useState("all")
|
||
const [folder, setFolder] = React.useState("all")
|
||
const [selectedId, setSelectedId] = React.useState<string | null>(null)
|
||
const messages = useInfiniteQuery({
|
||
queryKey: ["admin", "messages", mailboxId, folder, query],
|
||
queryFn: ({ pageParam }) => api.adminMessages({
|
||
mailboxId: mailboxId === "all" ? "" : mailboxId,
|
||
folder: folder === "all" ? "" : folder,
|
||
q: query,
|
||
cursor: typeof pageParam === "string" ? pageParam : "",
|
||
}),
|
||
initialPageParam: "",
|
||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||
})
|
||
const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId })
|
||
const items = messages.data?.pages.flatMap((page) => page.items || []) || []
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<CardTitle>全部邮件</CardTitle>
|
||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "messages"] })}>
|
||
<RefreshCcw className="h-4 w-4" />刷新
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex flex-col gap-3 xl:flex-row">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索主题、发件人、收件人、邮箱" className="pl-9" />
|
||
</div>
|
||
<Select value={mailboxId} onValueChange={setMailboxId}>
|
||
<SelectTrigger className="xl:w-72"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部邮箱</SelectItem>
|
||
<SelectItem value="unregistered">未注册收件</SelectItem>
|
||
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={folder} onValueChange={setFolder}>
|
||
<SelectTrigger className="xl:w-40"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部文件夹</SelectItem>
|
||
<SelectItem value="Inbox">收件箱</SelectItem>
|
||
<SelectItem value="Sent">已发送</SelectItem>
|
||
<SelectItem value="Archive">归档</SelectItem>
|
||
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
||
<SelectItem value="Trash">回收站</SelectItem>
|
||
<SelectItem value="Unregistered">未注册收件</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-3 md:hidden">
|
||
{items.map((message) => (
|
||
<div key={message.id} className="rounded-lg border p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium">{message.subject}</div>
|
||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={() => setSelectedId(message.id)}>查看</Button>
|
||
</div>
|
||
<div className="mt-3 space-y-2 text-sm">
|
||
<div className="truncate text-muted-foreground">邮箱:{message.mailboxAddress || message.recipientAddress || "-"}</div>
|
||
<div className="truncate text-muted-foreground">发件人:{adminSenderDisplayName(message)}</div>
|
||
<div className="truncate text-muted-foreground">收件人:{message.recipientAddress || message.to?.join(", ") || ""}</div>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge variant="secondary">{folderName(message.folder)}</Badge>
|
||
<Badge variant="outline">{formatDate(message.receivedAt)}</Badge>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="hidden md:block">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>邮件</TableHead>
|
||
<TableHead>邮箱</TableHead>
|
||
<TableHead>发件人</TableHead>
|
||
<TableHead>收件人</TableHead>
|
||
<TableHead>文件夹</TableHead>
|
||
<TableHead>时间</TableHead>
|
||
<TableHead className="w-20"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((message) => (
|
||
<TableRow key={message.id}>
|
||
<TableCell className="max-w-[360px]">
|
||
<div className="truncate font-medium">{message.subject}</div>
|
||
<div className="truncate text-xs text-muted-foreground">{message.snippet}</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div>
|
||
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>}
|
||
</TableCell>
|
||
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell>
|
||
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to?.join(", ") || ""}</TableCell>
|
||
<TableCell><Badge variant="secondary">{folderName(message.folder)}</Badge></TableCell>
|
||
<TableCell className="text-muted-foreground">{formatDate(message.receivedAt)}</TableCell>
|
||
<TableCell><Button variant="ghost" size="sm" onClick={() => setSelectedId(message.id)}>查看</Button></TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{messages.isLoading && <Empty text="加载中..." />}
|
||
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
|
||
{!messages.isLoading && messages.hasNextPage && (
|
||
<div className="flex justify-center">
|
||
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
||
{messages.isFetchingNextPage ? "加载中..." : "加载更多"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
<AdminMessageDialog message={detail.data} loading={detail.isLoading} open={!!selectedId} onOpenChange={(open) => { if (!open) setSelectedId(null) }} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||
const canUpdateSettings = hasPermission(user, "admin.settings.update")
|
||
const canTestSMTP = hasPermission(user, "admin.settings.test_smtp")
|
||
const canViewTemplates = hasPermission(user, "admin.templates.view")
|
||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||
const [openRegistration, setOpenRegistration] = React.useState(false)
|
||
const [twoFactorEnabled, setTwoFactorEnabled] = React.useState(false)
|
||
const [turnstileEnabled, setTurnstileEnabled] = React.useState(false)
|
||
const [catchAllEnabled, setCatchAllEnabled] = React.useState(false)
|
||
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
|
||
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
|
||
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
|
||
React.useEffect(() => {
|
||
if (!settings) return
|
||
setSmtpRequireTls(settings.smtpRequireTls)
|
||
setAllowInsecureHttp(settings.allowInsecureHttp)
|
||
setOpenRegistration(settings.openRegistration)
|
||
setTwoFactorEnabled(settings.twoFactorEnabled)
|
||
setTurnstileEnabled(settings.turnstileEnabled)
|
||
setCatchAllEnabled(settings.catchAllEnabled)
|
||
setMailAutoRefresh(settings.mailAutoRefresh)
|
||
setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled)
|
||
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
|
||
}, [settings])
|
||
const save = useMutation({
|
||
mutationFn: (form: FormData) => api.updateSystemSettings({
|
||
publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""),
|
||
publicBaseUrl: fieldValue(form, "publicBaseUrl", settings?.publicBaseUrl || ""),
|
||
smtpHost: fieldValue(form, "smtpHost", settings?.smtpHost || ""),
|
||
smtpPort: fieldValue(form, "smtpPort", settings?.smtpPort || "25"),
|
||
smtpUsername: fieldValue(form, "smtpUsername", settings?.smtpUsername || ""),
|
||
smtpPassword: fieldValue(form, "smtpPassword", ""),
|
||
smtpRequireTls,
|
||
maildirRoot: fieldValue(form, "maildirRoot", settings?.maildirRoot || ""),
|
||
maildirScanSeconds: fieldNumber(form, "maildirScanSeconds", settings?.maildirScanSeconds || 30),
|
||
sessionTtlHours: fieldNumber(form, "sessionTtlHours", settings?.sessionTtlHours || 168),
|
||
allowInsecureHttp,
|
||
openRegistration,
|
||
twoFactorEnabled,
|
||
turnstileEnabled,
|
||
turnstileSiteKey: fieldValue(form, "turnstileSiteKey", settings?.turnstileSiteKey || ""),
|
||
turnstileSecretKey: fieldValue(form, "turnstileSecretKey", ""),
|
||
catchAllEnabled,
|
||
mailAutoRefresh,
|
||
mailRefreshSeconds: fieldNumber(form, "mailRefreshSeconds", settings?.mailRefreshSeconds || 30),
|
||
userMailboxApplyEnabled,
|
||
userMailboxDomainIds,
|
||
reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""),
|
||
}),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||
qc.invalidateQueries({ queryKey: ["dns-records"] })
|
||
qc.invalidateQueries({ queryKey: ["public-settings"] })
|
||
toast({ title: "系统设置已保存" })
|
||
},
|
||
onError: (e) => toast({ title: "保存失败", description: e.message }),
|
||
})
|
||
const formKey = settings ? [
|
||
settings.publicHostname,
|
||
settings.publicBaseUrl,
|
||
settings.smtpHost,
|
||
settings.smtpPort,
|
||
settings.smtpUsername,
|
||
settings.smtpPasswordSet,
|
||
settings.smtpRequireTls,
|
||
settings.maildirRoot,
|
||
settings.maildirScanSeconds,
|
||
settings.sessionTtlHours,
|
||
settings.allowInsecureHttp,
|
||
settings.openRegistration,
|
||
settings.twoFactorEnabled,
|
||
settings.turnstileEnabled,
|
||
settings.turnstileSiteKey,
|
||
settings.turnstileSecretSet,
|
||
settings.catchAllEnabled,
|
||
settings.mailAutoRefresh,
|
||
settings.mailRefreshSeconds,
|
||
settings.userMailboxApplyEnabled,
|
||
(settings.userMailboxDomainIds || []).join(","),
|
||
settings.reservedMailboxPrefixes,
|
||
].join("|") : "loading"
|
||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||
...(canSettingsView ? [
|
||
{ key: "base" as const, label: "基础" },
|
||
{ key: "smtp" as const, label: "SMTP" },
|
||
{ key: "storage" as const, label: "存储" },
|
||
{ key: "mail" as const, label: "邮件" },
|
||
] : []),
|
||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
|
||
{ key: "about", label: "关于" },
|
||
]
|
||
React.useEffect(() => {
|
||
if (tabs.some((tab) => tab.key === settingsTab)) return
|
||
setSettingsTab(tabs[0]?.key || "about")
|
||
}, [settingsTab, tabs])
|
||
return (
|
||
<form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
||
<div className="flex flex-wrap gap-2 rounded-lg border bg-card p-2">
|
||
{tabs.map((tab) => (
|
||
<Button key={tab.key} type="button" variant={settingsTab === tab.key ? "default" : "ghost"} size="sm" onClick={() => setSettingsTab(tab.key)}>
|
||
{tab.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
{settingsTab === "base" && <Card>
|
||
<CardHeader><CardTitle>基础设置</CardTitle></CardHeader>
|
||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||
<Field name="publicHostname" label="公网主机名" defaultValue={settings?.publicHostname || ""} placeholder="mail.example.com" />
|
||
<Field name="publicBaseUrl" label="访问地址" defaultValue={settings?.publicBaseUrl || ""} placeholder="https://mail.example.com" required={false} />
|
||
<Field name="sessionTtlHours" label="登录有效期小时" type="number" defaultValue={String(settings?.sessionTtlHours || 168)} />
|
||
<Field name="maildirScanSeconds" label="Maildir 扫描秒数" type="number" defaultValue={String(settings?.maildirScanSeconds || 30)} />
|
||
<SwitchRow label="允许 HTTP 调试" checked={allowInsecureHttp} onCheckedChange={setAllowInsecureHttp} className="md:col-span-2" />
|
||
</CardContent>
|
||
</Card>}
|
||
|
||
{settingsTab === "smtp" && <Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<CardTitle>发信通道</CardTitle>
|
||
</div>
|
||
{canTestSMTP && <TestSMTPDialog disabled={!settings} />}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||
<div className="font-medium text-foreground">当前默认:内置 Postfix</div>
|
||
<div>Host 填 127.0.0.1、端口 25、用户名/密码留空、强制 TLS 关闭。这里的“强制 TLS”仅用于外部 SMTP 中继(587 STARTTLS 或 465 TLS)。</div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field name="smtpHost" label="发信主机" defaultValue={settings?.smtpHost || ""} placeholder="127.0.0.1" required={false} />
|
||
<Field name="smtpPort" label="发信端口" defaultValue={settings?.smtpPort || "25"} />
|
||
<Field name="smtpUsername" label="中继用户名(内置 Postfix 留空)" defaultValue={settings?.smtpUsername || ""} required={false} />
|
||
<Field name="smtpPassword" label={settings?.smtpPasswordSet ? "中继密码(留空不变)" : "中继密码(内置 Postfix 留空)"} type="password" required={false} />
|
||
<SwitchRow label="外部中继强制 TLS" checked={smtpRequireTls} onCheckedChange={setSmtpRequireTls} className="md:col-span-2" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>}
|
||
|
||
{settingsTab === "storage" && <Card>
|
||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||
</CardContent>
|
||
</Card>}
|
||
|
||
{settingsTab === "mail" && <Card>
|
||
<CardHeader><CardTitle>邮件设置</CardTitle></CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} />
|
||
<Separator />
|
||
<SwitchRow label="用户自助申请邮箱" checked={userMailboxApplyEnabled} onCheckedChange={setUserMailboxApplyEnabled} />
|
||
{userMailboxApplyEnabled && (
|
||
<div className="space-y-5 border-t pt-5">
|
||
<div className="space-y-3">
|
||
<Label>开放域名</Label>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
{domains.map((domain) => {
|
||
const checked = userMailboxDomainIds.includes(domain.id)
|
||
const disabled = domain.status !== "active"
|
||
return (
|
||
<label key={domain.id} className={cn("flex min-h-11 items-center gap-3 rounded-md border px-3 py-2", disabled && "cursor-not-allowed opacity-50")}>
|
||
<Checkbox
|
||
checked={checked}
|
||
disabled={disabled}
|
||
onCheckedChange={(value) => setUserMailboxDomainIds((items) => value === true ? Array.from(new Set([...items, domain.id])) : items.filter((id) => id !== domain.id))}
|
||
/>
|
||
<span className="text-sm font-medium">{domain.name}</span>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>禁止前缀</Label>
|
||
<Textarea name="reservedMailboxPrefixes" defaultValue={settings?.reservedMailboxPrefixes || ""} className="min-h-28 font-mono text-sm" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Separator />
|
||
<SwitchRow label="自动刷新" checked={mailAutoRefresh} onCheckedChange={setMailAutoRefresh} />
|
||
{mailAutoRefresh && (
|
||
<div className="border-t pt-5">
|
||
<Field name="mailRefreshSeconds" label="刷新间隔秒数" type="number" min={5} defaultValue={String(settings?.mailRefreshSeconds || 30)} />
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>}
|
||
|
||
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||
|
||
{settingsTab === "security" && <Card>
|
||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<SwitchRow label="开放注册" checked={openRegistration} onCheckedChange={setOpenRegistration} />
|
||
<Separator />
|
||
<SwitchRow label="双因素认证 (2FA)" checked={twoFactorEnabled} onCheckedChange={setTwoFactorEnabled} />
|
||
<Separator />
|
||
<SwitchRow label="Turnstile" checked={turnstileEnabled} onCheckedChange={setTurnstileEnabled} />
|
||
{turnstileEnabled && (
|
||
<div className="grid gap-4 border-t pt-5 md:grid-cols-2">
|
||
<Field name="turnstileSiteKey" label="Site Key" defaultValue={settings?.turnstileSiteKey || ""} required />
|
||
<Field name="turnstileSecretKey" label={settings?.turnstileSecretSet ? "Secret Key(留空不变)" : "Secret Key"} type="password" required={!settings?.turnstileSecretSet} />
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>}
|
||
|
||
{settingsTab === "about" && <AboutProjectCard />}
|
||
|
||
{settingsTab !== "about" && canUpdateSettings && <div className="flex justify-end">
|
||
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
|
||
</div>}
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function AboutProjectCard() {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>关于</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4 text-sm">
|
||
<AboutRow label="版本">
|
||
{projectTag ? (
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||
<GitBranch className="h-5 w-5 text-primary" />
|
||
{projectTag}
|
||
</a>
|
||
</Button>
|
||
) : (
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" disabled>
|
||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||
未发布版本
|
||
</Button>
|
||
)}
|
||
</AboutRow>
|
||
<AboutRow label="交流">
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
|
||
<Github className="h-5 w-5" />
|
||
GitHub
|
||
</a>
|
||
</Button>
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||
<a href={`${projectRepositoryUrl}/issues`} target="_blank" rel="noreferrer">
|
||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||
Issues
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</AboutRow>
|
||
<AboutRow label="支持">
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
|
||
<Star className="h-5 w-5 text-yellow-500" />
|
||
给项目点 Star
|
||
</a>
|
||
</Button>
|
||
</AboutRow>
|
||
<AboutRow label="帮助">
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||
<a href={`${projectRepositoryUrl}#readme`} target="_blank" rel="noreferrer">
|
||
<BookOpen className="h-5 w-5 text-sky-500" />
|
||
项目文档
|
||
</a>
|
||
</Button>
|
||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||
<a href={`${projectRepositoryUrl}/blob/main/LICENSE`} target="_blank" rel="noreferrer">
|
||
<Scale className="h-5 w-5 text-emerald-500" />
|
||
开源协议
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</AboutRow>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function AboutRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="grid gap-2 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center">
|
||
<div className="font-medium text-muted-foreground">{label}:</div>
|
||
<div className="min-w-0">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TestSMTPDialog({ disabled }: { disabled?: boolean }) {
|
||
const { toast } = useToast()
|
||
const [open, setOpen] = React.useState(false)
|
||
const test = useMutation({
|
||
mutationFn: (form: FormData) => api.testSmtp(String(form.get("to") || "")),
|
||
onSuccess: () => {
|
||
setOpen(false)
|
||
toast({ title: "测试邮件已发送" })
|
||
},
|
||
onError: (e) => toast({ title: "发送失败", description: e.message }),
|
||
})
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button type="button" variant="outline" size="sm" disabled={disabled}>测试发送</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>SMTP 测试发送</DialogTitle></DialogHeader>
|
||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); test.mutate(new FormData(event.currentTarget)) }}>
|
||
<Field name="to" label="收件邮箱" type="email" placeholder="test@example.com" />
|
||
<DialogFooter><Button disabled={test.isPending}>{test.isPending ? "发送中..." : "发送"}</Button></DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function MailTemplatesPanel({ templates, loading, canUpdate, canReset }: { templates: MailTemplate[]; loading: boolean; canUpdate: boolean; canReset: boolean }) {
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [selectedKey, setSelectedKey] = React.useState("")
|
||
const selected = templates.find((template) => template.key === selectedKey) || templates[0]
|
||
const [subject, setSubject] = React.useState("")
|
||
const [bodyText, setBodyText] = React.useState("")
|
||
const [bodyHtml, setBodyHtml] = React.useState("")
|
||
React.useEffect(() => {
|
||
if (!selectedKey && templates[0]) setSelectedKey(templates[0].key)
|
||
}, [selectedKey, templates])
|
||
React.useEffect(() => {
|
||
if (!selected) return
|
||
setSubject(selected.subject)
|
||
setBodyText(selected.bodyText)
|
||
setBodyHtml(selected.bodyHtml)
|
||
}, [selected])
|
||
const save = useMutation({
|
||
mutationFn: () => api.updateMailTemplate(selected!.key, { subject, bodyText, bodyHtml }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["admin", "mail-templates"] })
|
||
toast({ title: "模板已保存" })
|
||
},
|
||
onError: (e) => toast({ title: "保存失败", description: e.message }),
|
||
})
|
||
const reset = useMutation({
|
||
mutationFn: () => api.resetMailTemplate(selected!.key),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["admin", "mail-templates"] })
|
||
toast({ title: "模板已恢复" })
|
||
},
|
||
onError: (e) => toast({ title: "恢复失败", description: e.message }),
|
||
})
|
||
if (loading) return <Card><CardContent className="p-6"><Empty text="加载中..." /></CardContent></Card>
|
||
if (!selected) return <Card><CardContent className="p-6"><Empty text="暂无模板" /></CardContent></Card>
|
||
return (
|
||
<Card>
|
||
<CardHeader><CardTitle>邮件模板</CardTitle></CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<SelectField label="模板" value={selected.key} onValueChange={setSelectedKey} items={templates.map((template) => [template.key, template.name])} />
|
||
<div className="space-y-2">
|
||
<Label>主题</Label>
|
||
<Input value={subject} onChange={(event) => setSubject(event.target.value)} />
|
||
</div>
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>纯文本</Label>
|
||
<Textarea value={bodyText} onChange={(event) => setBodyText(event.target.value)} className="min-h-64 font-mono text-sm" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>HTML</Label>
|
||
<Textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} className="min-h-64 font-mono text-sm" />
|
||
</div>
|
||
</div>
|
||
{(canUpdate || canReset) && <div className="flex justify-end gap-2">
|
||
{canReset && <Button type="button" variant="outline" disabled={reset.isPending || save.isPending} onClick={() => reset.mutate()}>
|
||
{reset.isPending ? "恢复中..." : "恢复默认"}
|
||
</Button>}
|
||
{canUpdate && <Button type="button" disabled={save.isPending || reset.isPending} onClick={() => save.mutate()}>
|
||
{save.isPending ? "保存中..." : "保存模板"}
|
||
</Button>}
|
||
</div>}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?: MailMessage; loading: boolean; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-4xl">
|
||
<DialogHeader><DialogTitle>{loading ? "加载中..." : message?.subject || "邮件详情"}</DialogTitle></DialogHeader>
|
||
{message && (
|
||
<div className="space-y-5">
|
||
<div className="grid gap-3 rounded-lg border p-4 text-sm md:grid-cols-2">
|
||
<MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} />
|
||
<MessageMeta label="所属用户" value={message.ownerEmail || ""} />
|
||
<MessageMeta label="发件人" value={adminSenderTitle(message)} />
|
||
<MessageMeta label="收件人" value={message.recipientAddress || message.to?.join(", ") || ""} />
|
||
<MessageMeta label="文件夹" value={folderName(message.folder)} />
|
||
<MessageMeta label="时间" value={formatDate(message.receivedAt)} />
|
||
</div>
|
||
<div className="mail-html prose max-w-none rounded-lg border p-5 text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(message.bodyHtml || `<pre>${escapeHtml(message.bodyText || message.snippet || "")}</pre>`) }} />
|
||
{message.attachments && message.attachments.length > 0 && (
|
||
<div className="rounded-lg border p-4">
|
||
<div className="mb-3 font-medium">附件</div>
|
||
<div className="space-y-2">
|
||
{message.attachments.map((attachment) => (
|
||
<a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/admin/attachments/${attachment.id}`} key={attachment.id}>
|
||
<span className="truncate">{attachment.filename}</span>
|
||
<span className="text-muted-foreground">{formatBytes(attachment.sizeBytes)}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function MessageMeta({ label, value }: { label: string; value: string }) {
|
||
return <div className="min-w-0"><div className="text-xs text-muted-foreground">{label}</div><div className="truncate font-medium">{value || "-"}</div></div>
|
||
}
|
||
|
||
function folderName(folder: string) {
|
||
const labels: Record<string, string> = { Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站", Unregistered: "未注册收件" }
|
||
return labels[folder] || folder
|
||
}
|
||
|
||
function escapeHtml(value: string) {
|
||
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] || char)
|
||
}
|
||
|
||
function adminSenderDisplayName(message: MailMessage) {
|
||
const fromName = decodeMimeHeader(message.fromName?.trim() || "")
|
||
if (fromName) return fromName
|
||
const text = decodeMimeHeader(message.from.trim())
|
||
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
|
||
const name = namedAddress?.[1]?.trim()
|
||
if (name) return name
|
||
const address = text.match(/<([^>]+)>/)?.[1]?.trim() || text
|
||
return address.split("@")[0]?.trim() || text || "未知发件人"
|
||
}
|
||
|
||
function adminSenderTitle(message: MailMessage) {
|
||
const name = decodeMimeHeader(message.fromName?.trim() || "")
|
||
const from = decodeMimeHeader(message.from)
|
||
return name ? `${name} <${from}>` : from
|
||
}
|
||
|
||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
||
}
|
||
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
|
||
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||
function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className="flex items-center justify-between rounded-lg border p-3"><span className="font-medium">{domain.name}</span><Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}</Badge></div> }
|
||
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||
|
||
function UserMailboxCell({ user }: { user: AdminUser }) {
|
||
const mailboxes = user.mailboxes || []
|
||
if (mailboxes.length === 0) return <span className="text-muted-foreground">未绑定</span>
|
||
return (
|
||
<div className="flex max-w-md flex-wrap gap-1">
|
||
{mailboxes.slice(0, 2).map((mailbox) => <Badge key={mailbox} variant="outline" className="font-normal">{mailbox}</Badge>)}
|
||
{mailboxes.length > 2 && <Badge variant="secondary">+{mailboxes.length - 2}</Badge>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function UserPermissionGroupsCell({ user }: { user: AdminUser }) {
|
||
const groups = user.permissionGroups || []
|
||
if (groups.length === 0) return <span className="text-muted-foreground">普通用户</span>
|
||
return (
|
||
<div className="flex max-w-md flex-wrap gap-1">
|
||
{groups.map((group) => (
|
||
<Badge key={group.id} variant={group.id === "pg_super_admin" ? "default" : "secondary"} className="font-normal">
|
||
{group.name}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function assignableUserGroupIDs(user: AdminUser) {
|
||
return (user.permissionGroupIds || []).filter((id) => id !== "pg_super_admin" && id !== "pg_regular_user")
|
||
}
|
||
|
||
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
|
||
function toggle(groupID: string, checked: boolean) {
|
||
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
|
||
}
|
||
return (
|
||
<div className="space-y-2">
|
||
<Label>权限组</Label>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
{groups.map((group) => {
|
||
const checked = value.includes(group.id)
|
||
return (
|
||
<label key={group.id} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
||
<Checkbox checked={checked} onCheckedChange={(next) => toggle(group.id, next === true)} />
|
||
<span className="min-w-0">
|
||
<span className="block text-sm font-medium">{group.name}</span>
|
||
<span className="line-clamp-2 text-xs text-muted-foreground">{group.description}</span>
|
||
</span>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
{groups.length === 0 && <Empty text="暂无可分配权限组" />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RoleBadge({ user }: { user: AdminUser }) {
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||
{user.protected && <Badge variant="outline">默认账号</Badge>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
|
||
const me = useMe()
|
||
const currentUser = me.data?.user
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [editOpen, setEditOpen] = React.useState(false)
|
||
const [passwordOpen, setPasswordOpen] = React.useState(false)
|
||
const canUpdate = hasPermission(currentUser, "admin.users.update")
|
||
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
|
||
const update = useMutation({
|
||
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
|
||
onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已更新" }) },
|
||
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
||
})
|
||
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
|
||
const role = patch.role || user.role
|
||
update.mutate({
|
||
displayName: user.displayName,
|
||
role,
|
||
disabled: patch.disabled ?? user.disabled,
|
||
permissionGroupIds: role === "user" ? assignableUserGroupIDs(user) : [],
|
||
})
|
||
}
|
||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑用户</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为超级管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除用户</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||
}
|
||
|
||
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
|
||
const qc = useQueryClient()
|
||
const { toast } = useToast()
|
||
const [open, setOpen] = React.useState(false)
|
||
const [role, setRole] = React.useState<"admin" | "user">("user")
|
||
const [status, setStatus] = React.useState("active")
|
||
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
|
||
const create = useMutation({
|
||
mutationFn: (form: FormData) => api.createUser({ email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), role, disabled: status === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }),
|
||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "用户已创建" }) },
|
||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||
})
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4" />用户</Button></DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>创建用户</DialogTitle></DialogHeader>
|
||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
||
<Field name="email" label="登录邮箱" type="email" placeholder="user@example.com" />
|
||
<Field name="displayName" label="显示名称" placeholder="用户名称" />
|
||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "超级管理员"]]} />
|
||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||
</div>
|
||
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||
const [open, setOpen] = React.useState(false)
|
||
if (!canUpdate && !onDelete) return null
|
||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||
}
|
||
|
||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||
if (!onToggle && !onDelete) return null
|
||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除别名</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||
}
|
||
|
||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||
const qc = useQueryClient(); const { toast } = useToast(); const [role, setRole] = React.useState(user.role); const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active"); const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>(assignableUserGroupIDs(user))
|
||
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active"); setPermissionGroupIds(assignableUserGroupIDs(user)) }, [user, open])
|
||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "用户已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑用户</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="email" label="登录邮箱" value={user.email} readOnly /><Field name="displayName" label="显示名称" defaultValue={user.displayName} /><div className="grid grid-cols-2 gap-3"><SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','超级管理员']]} disabled={user.protected} /><SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[['active','正常'],['disabled','停用']]} disabled={user.protected} /></div>{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||
}
|
||
|
||
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
|
||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="用户" value={user.email} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||
}
|
||
|
||
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||
const qc = useQueryClient(); const { toast } = useToast(); const [userId, setUserId] = React.useState(mailbox.userId); const [status, setStatus] = React.useState(mailbox.status)
|
||
React.useEffect(() => { setUserId(mailbox.userId); setStatus(mailbox.status) }, [mailbox, open])
|
||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateMailbox(mailbox.id, { userId, displayName: String(form.get("displayName") || ""), quotaMb: Number(form.get("quotaMb") || 1024), status }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "邮箱已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||
}
|
||
|
||
function CreateDomainDialog() {
|
||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false)
|
||
const mut = useMutation({ mutationFn: (form: FormData) => api.createDomain(String(form.get("name"))), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "域名已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><Plus className="h-4 w-4" />域名</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>添加域名</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="name" label="域名" placeholder="example.com" /><DialogFooter><Button disabled={mut.isPending}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||
}
|
||
|
||
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
|
||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱账号</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按邮箱匹配用户'],['existing','追加到已有用户']]} />{ownerMode === "existing" ? <SelectField label="已有用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /> : <Field name="ownerEmail" label="归属用户邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','超级管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||
}
|
||
|
||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState("")
|
||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
|
||
const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "别名已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><Plus className="h-4 w-4" />别名</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建别名/转发</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||
}
|
||
|
||
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
||
const me = useMe()
|
||
const user = me.data?.user
|
||
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
||
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) } })
|
||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||
const content = <>
|
||
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
||
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
|
||
{check.data && <>
|
||
<Separator className="my-4" />
|
||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
|
||
</>}</>
|
||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||
if (embedded) return <div className="space-y-4"><div className="flex items-center justify-between"><div className="font-medium">DNS 记录</div>{checkButton}</div>{content}</div>
|
||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||
}
|
||
|
||
const dnsDescriptions: Record<string, string> = {
|
||
MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
|
||
TXT: "", // 具体含义根据内容区分
|
||
}
|
||
|
||
function dnsDescription(record: DNSRecord): string {
|
||
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
||
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
||
if (record.type === "TXT" && record.value.includes("spf1")) return "声明哪些服务器有权使用你的域名发件,防止伪造。"
|
||
if (record.type === "MX") return `确保 ${record.name} 的 A 记录已指向你的服务器 IP,邮件才能到达。`
|
||
return ""
|
||
}
|
||
|
||
function DNSRecordRow({ record }: { record: DNSRecord }) {
|
||
const { toast } = useToast(); const text = `${record.type} ${record.name} ${record.value}`
|
||
const desc = dnsDescription(record)
|
||
return <div className="rounded-lg border bg-card p-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<Badge variant="outline" className="font-mono">{record.type}</Badge>
|
||
<Button size="sm" variant="ghost" className="h-7 gap-1 text-xs" onClick={() => { navigator.clipboard.writeText(text); toast({ title: "已复制" }) }}><Copy className="h-3.5 w-3.5" />复制</Button>
|
||
</div>
|
||
{desc && <p className="mb-2 text-xs text-muted-foreground">{desc}</p>}
|
||
<div className="break-all font-mono text-xs text-muted-foreground">
|
||
<div><span className="text-foreground">Name:</span> {record.name}</div>
|
||
<div><span className="text-foreground">Value:</span> {record.value}</div>
|
||
<div><span className="text-foreground">TTL:</span> {record.ttl}s</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
function fieldValue(form: FormData, name: string, fallback: string) {
|
||
const value = form.get(name)
|
||
return value === null ? fallback : String(value)
|
||
}
|
||
function fieldNumber(form: FormData, name: string, fallback: number) {
|
||
const value = form.get(name)
|
||
if (value === null) return fallback
|
||
const n = Number(value)
|
||
return Number.isFinite(n) && n > 0 ? n : fallback
|
||
}
|
||
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
||
return (
|
||
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
||
<Label className="text-base font-medium">{label}</Label>
|
||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||
</div>
|
||
)
|
||
}
|
||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||
|
||
|
||
|