release: prepare v1.2.29
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-12 04:25:17 +08:00
parent 3d3a251af2
commit 2fd37bf635
19 changed files with 775 additions and 305 deletions
@@ -5,7 +5,6 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { hasAnyPermission } from "@/lib/permissions"
@@ -110,9 +109,6 @@ function ProtectedContent() {
<span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "普通用户"}
</Badge>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
+4 -1
View File
@@ -8,6 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card"
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-title"
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
+4 -1
View File
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
>(({ className, ...props }, ref) => (
<tr
ref={ref}
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
>(({ className, ...props }, ref) => (
<th
ref={ref}
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
data-slot="table-cell"
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
+40 -2
View File
@@ -140,12 +140,50 @@
}
[data-sidebar="menu-button"][data-active="true"] {
background: hsl(var(--sidebar-accent));
background: hsl(var(--sidebar-accent) / 0.82);
color: hsl(var(--sidebar-accent-foreground));
box-shadow: inset 2px 0 0 hsl(var(--foreground));
}
[data-sidebar="menu-button"][data-active="true"] svg {
color: hsl(var(--muted-foreground));
color: hsl(var(--foreground));
}
.admin-page [data-slot="card"] {
border-radius: 0.5rem;
box-shadow: none;
}
.admin-page [data-slot="card-header"] {
padding: 1.25rem;
}
.admin-page [data-slot="card-content"] {
padding: 0 1.25rem 1.25rem;
}
.admin-page [data-slot="card-title"] {
font-size: 0.9375rem;
line-height: 1.4;
}
.admin-page [data-slot="table-header"] {
background: hsl(var(--muted) / 0.48);
}
.admin-page [data-slot="table-head"] {
height: 2.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
font-size: 0.75rem;
}
.admin-page [data-slot="table-cell"] {
padding: 0.75rem;
}
.admin-page [data-slot="table-row"]:hover {
background: hsl(var(--muted) / 0.32);
}
}
+2 -2
View File
@@ -52,10 +52,10 @@ export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
+3 -3
View File
@@ -172,8 +172,8 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -181,7 +181,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
createMailbox: (payload: { domainId: string; localPart: string; displayName?: string; userId: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
+208 -181
View File
@@ -32,10 +32,10 @@ type PendingConfirm = { title: string; description?: string; confirmText: string
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
@@ -58,8 +58,12 @@ const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9
const defaultUserStorageQuotaMb = 100
const defaultAdminStorageQuotaMb = 1024
const superAdminPermissionGroupId = "pg_super_admin"
const regularUserPermissionGroupId = "pg_regular_user"
const defaultStorageQuotaMb = (role: AdminUser["role"]) => role === "admin" ? defaultAdminStorageQuotaMb : defaultUserStorageQuotaMb
const accountPrimaryEmail = (user: Pick<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
function compareAdminUsers(left: AdminUser, right: AdminUser) {
if (left.role === "admin" && right.role !== "admin") return -1
if (left.role !== "admin" && right.role === "admin") return 1
@@ -86,7 +90,7 @@ export function AdminPage() {
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 domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) })
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
@@ -97,7 +101,7 @@ export function AdminPage() {
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 assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId)
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"
@@ -129,7 +133,7 @@ export function AdminPage() {
return (
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
<main className="admin-page mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
@@ -144,7 +148,7 @@ export function AdminPage() {
)}
{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 === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
{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} />}
@@ -175,7 +179,6 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
</Button>
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
</div>
</div>
</div>
@@ -252,7 +255,7 @@ 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[] }) {
function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) {
const me = useMe()
const user = me.data?.user
const qc = useQueryClient()
@@ -279,7 +282,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle>
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
<div className="flex flex-wrap gap-2">
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} domains={domains} />}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
@@ -317,8 +322,8 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
</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>
<AccountStatus user={user} />
<span className="text-xs text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div className="mt-3"><UserPermissionGroupsCell user={user} /></div>
<div className="mt-3"><UserMailboxCell user={user} /></div>
@@ -338,7 +343,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<TableCell><RoleBadge user={user} /></TableCell>
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell><AccountStatus user={user} /></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
</TableRow>
@@ -360,6 +365,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
const { toast } = useToast()
const [query, setQuery] = React.useState("")
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
const [viewing, setViewing] = 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")
@@ -378,14 +384,16 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
if (!keyword) return true
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
})
const isEditable = (group: PermissionGroup) => !group.system
const isEditable = (group: PermissionGroup) => !group.system || group.id === regularUserPermissionGroupId
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 className="flex flex-wrap gap-2">
{canCreate && <PermissionGroupDialog catalog={catalog} />}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
@@ -400,26 +408,21 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
<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>
<span className="text-xs text-muted-foreground">{group.system ? "系统配置" : "自定义配置"} · {group.userCount} </span>
</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" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><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 className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => setViewing(group)}></Button>
{((isEditable(group) && canUpdate) || (isDeletable(group) && canDelete)) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isEditable(group) && canUpdate && <DropdownMenuItem onSelect={() => setEditing(group)}>{group.id === regularUserPermissionGroupId ? "编辑普通用户权限" : "编辑权限配置"}</DropdownMenuItem>}
{isEditable(group) && canUpdate && isDeletable(group) && canDelete && <DropdownMenuSeparator />}
{isDeletable(group) && canDelete && <DropdownMenuItem className="text-destructive" onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>}
</div>
</div>
<PermissionBadges permissions={group.permissions} catalog={catalog} />
<PermissionLimitBadges limits={group.limits} />
@@ -429,6 +432,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{filtered.length === 0 && <Empty text="暂无匹配的权限配置" />}
</CardContent>
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
{viewing && <PermissionGroupDetailsDialog group={viewing} catalog={catalog} open={!!viewing} onOpenChange={(open) => { if (!open) setViewing(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>
)
@@ -440,6 +444,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
const [internalOpen, setInternalOpen] = React.useState(false)
const dialogOpen = open ?? internalOpen
const setDialogOpen = onOpenChange ?? setInternalOpen
const fixedIdentity = group?.id === regularUserPermissionGroupId
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 || [])
@@ -469,18 +474,18 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
})
const trigger = group ? null : (
<DialogTrigger asChild>
<Button size="sm"></Button>
<Button size="sm" className="self-start"></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>
<DialogHeader><DialogTitle>{fixedIdentity ? "编辑普通用户权限" : 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} />
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" readOnly={fixedIdentity} />
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} readOnly={fixedIdentity} />
</div>
<PermissionLimitEditor value={limits} onChange={setLimits} />
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
@@ -491,7 +496,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
)
}
function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void }) {
function PermissionGroupDetailsDialog({ group, catalog, open, onOpenChange }: { group: PermissionGroup; catalog: PermissionInfo[]; open: boolean; onOpenChange: (open: boolean) => void }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader><DialogTitle>{group.name}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground"> {group.permissions.length} {group.id === superAdminPermissionGroupId ? "管理员权限由账号身份决定,不能修改。" : ""}</p>
<PermissionLimitBadges limits={group.limits} />
<PermissionPicker catalog={catalog} value={group.permissions} onChange={() => undefined} readOnly />
</DialogContent>
</Dialog>
)
}
function PermissionPicker({ catalog, value, onChange, readOnly = false }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void; readOnly?: boolean }) {
const grouped = groupPermissionCatalog(catalog)
function toggle(permission: PermissionKey, checked: boolean) {
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
@@ -504,24 +522,25 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label></Label>
<Badge variant="outline">{value.length} </Badge>
<span className="text-xs tabular-nums text-muted-foreground"> {value.length} </span>
</div>
<div className="space-y-3">
{grouped.map(({ category, items }) => {
const allChecked = items.every((item) => value.includes(item.key))
const selectedCount = items.filter((item) => value.includes(item.key)).length
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)} />
<label className={cn("flex items-center gap-2 font-medium", readOnly && "cursor-default")}>
<Checkbox className={cn(readOnly && "disabled:opacity-100")} disabled={readOnly} checked={allChecked ? true : selectedCount > 0 ? "indeterminate" : false} 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>
<span className="text-xs text-muted-foreground">{selectedCount}/{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)} />
<label key={item.key} className={cn("flex min-h-16 items-start gap-3 rounded-md border px-3 py-2", readOnly && "cursor-default")}>
<Checkbox className={cn(readOnly && "disabled:opacity-100")} disabled={readOnly} 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>
@@ -545,7 +564,7 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
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>
<Label>使</Label>
<span className="text-xs text-muted-foreground"> 0 </span>
</div>
<div className="grid gap-3 md:grid-cols-3">
@@ -581,29 +600,14 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
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>
)
const visible = permissions.slice(0, 5).map((permission) => labelByKey.get(permission) || permission)
return <div className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">{visible.join("、")}{permissions.length > visible.length ? `${permissions.length}` : ""}</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"> {limitText(value.maxMailboxCount, "个")}</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>
)
return <div className="mt-2 text-xs leading-5 text-muted-foreground"> {limitText(value.maxMailboxCount, "个")} · {limitText(value.maxAttachmentMb, "MB")} · SMTP {limitText(value.smtpDailyLimit, "封/日")} · IMAP {limitText(value.imapMinuteLimit, "次/分钟")} · POP3 {limitText(value.pop3MinuteLimit, "次/分钟")}</div>
}
function limitText(value: number, unit: string) {
@@ -651,8 +655,8 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
<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>
<StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" />
<StatusText active={domain.dnsStatus === "ok"} activeLabel="DNS 正常" inactiveLabel={domain.dnsStatus || "未检测"} />
{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>}
@@ -685,56 +689,96 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
const user = me.data?.user
const qc = useQueryClient()
const { toast } = useToast()
const [query, setQuery] = React.useState("")
const [expandedOwners, setExpandedOwners] = React.useState<string[]>([])
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 update = useMutation({
mutationFn: ({ mailbox, status }: { mailbox: MailboxType; status: "active" | "disabled" }) => api.updateMailbox(mailbox.id, { userId: mailbox.userId, displayName: mailbox.displayName || mailbox.address, quotaMb: mailbox.quotaMb, status }),
onSuccess: (mailbox) => { invalidateAdmin(qc); toast({ title: mailbox.status === "active" ? "邮箱已启用" : "邮箱已停用" }) },
onError: (e) => toast({ title: "状态更新失败", description: e.message }),
})
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
const keyword = query.trim().toLowerCase()
const knownOwnerIDs = new Set(users.map((item) => item.id))
const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [
...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort((left, right) => left.address.localeCompare(right.address, "en")) })),
...mailboxes.filter((mailbox) => !knownOwnerIDs.has(mailbox.userId)).map((mailbox) => ({ owner: undefined, mailboxes: [mailbox] })),
]
.filter((group) => group.mailboxes.length > 0)
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID])
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 className="flex items-center gap-2">
<span className="text-sm tabular-nums text-muted-foreground">{mailboxes.length} </span>
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
</div>
</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>
))}
<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="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 className="divide-y overflow-hidden rounded-md border">
<div className="hidden grid-cols-[minmax(0,1fr)_9rem_8rem] gap-4 bg-muted/40 px-11 py-2 text-xs font-medium text-muted-foreground sm:grid">
<span></span><span></span><span></span>
</div>
{mailboxGroups.map((group) => {
const ownerID = group.owner?.id || group.mailboxes[0].userId
const expanded = expandedOwners.includes(ownerID) || !!keyword
return (
<div key={ownerID}>
<Button type="button" variant="ghost" className="grid h-auto min-h-16 w-full grid-cols-[auto_minmax(0,1fr)_auto] gap-3 rounded-none px-4 py-3 text-left font-normal sm:grid-cols-[auto_minmax(0,1fr)_9rem_8rem]" onClick={() => toggleOwner(ownerID)} aria-expanded={expanded}>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", !expanded && "-rotate-90")} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{group.owner ? accountPrimaryEmail(group.owner) : group.mailboxes[0].userEmail || "未知账号"}</div>
<div className="truncate text-xs text-muted-foreground">{group.owner?.displayName || "账号信息不可用"}</div>
</div>
<span className="hidden sm:block">{group.owner ? <RoleBadge user={group.owner} /> : "-"}</span>
<span className="flex items-center justify-end gap-2 text-sm tabular-nums text-muted-foreground"><span>{group.mailboxes.length} </span><span className="hidden text-xs lg:inline"></span></span>
</Button>
{expanded && (
<div className="border-t bg-muted/20 px-4 py-2 sm:pl-11">
{group.mailboxes.map((mailbox) => (
<div key={mailbox.id} className="flex min-h-14 items-center gap-3 border-b py-2 last:border-b-0">
<Mail className="hidden h-4 w-4 shrink-0 text-muted-foreground sm:block" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{mailbox.address}</div>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{mailbox.displayName || "未命名"}</span>
{mailbox.primary && <span className="shrink-0"></span>}
</div>
</div>
<span className={cn("w-10 shrink-0 text-right text-xs font-medium", mailbox.status === "active" ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>{mailbox.status === "active" ? "启用" : "停用"}</span>
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
{!mailbox.primary && (canUpdate || canDelete) && (
<DropdownMenu>
<DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon" className="h-8 w-8" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canUpdate && <DropdownMenuItem disabled={mailbox.status === "active" || update.isPending} onSelect={() => update.mutate({ mailbox, status: "active" })}></DropdownMenuItem>}
{canUpdate && <DropdownMenuItem disabled={mailbox.status === "disabled" || update.isPending} onSelect={() => update.mutate({ mailbox, status: "disabled" })}></DropdownMenuItem>}
{canUpdate && canDelete && !mailbox.primary && <DropdownMenuSeparator />}
{canDelete && !mailbox.primary && <DropdownMenuItem className="text-destructive" onSelect={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。此操作无法撤销。`, confirmText: "确认删除", onConfirm: () => remove.mutate(mailbox.id) })}></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
{mailboxes.length === 0 && <Empty text="暂无邮箱" />}
{mailboxGroups.length === 0 && <Empty text={query ? "没有匹配的账号或邮箱" : "暂无邮箱"} />}
</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>
@@ -772,7 +816,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<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>
<StatusText active={alias.enabled} activeLabel="启用" inactiveLabel="停用" />
<Badge variant="outline">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</Badge>
</div>
</div>
@@ -787,7 +831,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<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><StatusText active={alias.enabled} activeLabel="启用" inactiveLabel="停用" /></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>
))}
@@ -1855,7 +1899,8 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
return sortedMailboxes.filter((mailbox) => mailbox.toLowerCase().includes(normalizedQuery))
}, [normalizedQuery, sortedMailboxes])
const limit = user.role === "admin" ? "不限" : limitText(user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride, "个")
const quota = <div className="text-[11px] text-muted-foreground"> {user.mailboxCount}/{limit}</div>
const storage = formatBytes((user.storageQuotaMb || defaultStorageQuotaMb(user.role)) * 1024 * 1024)
const quota = <div className="text-[11px] text-muted-foreground"> {user.mailboxCount}/{limit} · {storage}</div>
async function copyMailbox(address: string) {
if (!address) return
await navigator.clipboard.writeText(address)
@@ -1872,7 +1917,6 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
className="h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
title={selectedAddress}
>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{selectedAddress}</span>
{sortedMailboxes.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{sortedMailboxes.length} </span>}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -1898,7 +1942,6 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
onSelect={() => setSelectedAddress(mailbox)}
className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", selectedAddress === mailbox && "bg-accent text-accent-foreground")}
>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={mailbox}>{mailbox}</span>
</DropdownMenuItem>
))}
@@ -1916,17 +1959,10 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
}
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>
)
const groups = (user.permissionGroups || []).filter((group) => group.id !== "pg_regular_user")
if (user.role === "admin") return <span className="text-sm text-muted-foreground"></span>
if (groups.length === 0) return <span className="text-muted-foreground"></span>
return <span className="line-clamp-2 text-sm text-muted-foreground">{groups.map((group) => group.name).join("、")}</span>
}
function assignableUserGroupIDs(user: AdminUser) {
@@ -1934,6 +1970,7 @@ function assignableUserGroupIDs(user: AdminUser) {
}
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
if (groups.length === 0) return null
function toggle(groupID: string, checked: boolean) {
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
}
@@ -1954,18 +1991,20 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
)
})}
</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>
)
return <span className="text-sm font-medium">{user.role === "admin" ? "管理员" : "普通用户"}</span>
}
function AccountStatus({ user }: { user: AdminUser }) {
return <StatusText active={!user.disabled} activeLabel="正常" inactiveLabel="停用" />
}
function StatusText({ active, activeLabel, inactiveLabel }: { active: boolean; activeLabel: string; inactiveLabel: string }) {
return <span className="inline-flex items-center gap-2 whitespace-nowrap text-sm"><span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-600" : "bg-muted-foreground")} />{active ? activeLabel : inactiveLabel}</span>
}
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
@@ -1996,50 +2035,58 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><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 && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && 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(_props: { permissionGroups: PermissionGroup[] }) {
function CreateUserDialog({ permissionGroups, domains }: { permissionGroups: PermissionGroup[]; domains: Domain[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [status, setStatus] = React.useState("active")
const [domainId, setDomainId] = React.useState("")
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domainId, domains])
const create = useMutation({
mutationFn: (form: FormData) => api.createUser({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""),
role: "user",
disabled: status === "disabled",
mailboxLimitOverride: mailboxLimitFromForm(form),
}),
mutationFn: (form: FormData) => {
const domain = domains.find((item) => item.id === domainId)
if (!domain) throw new Error("请选择邮箱域名")
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
const email = `${localPart}@${domain.name}`
const inputPassword = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
if (inputPassword !== confirmPassword) throw new Error("两次输入的密码不一致")
const password = inputPassword || email
if (password.length < 6) throw new Error("密码至少需要 6 位")
return api.createUser({ email, displayName: String(form.get("displayName") || ""), password, role: "user", disabled: false, mailboxLimitOverride: mailboxLimitFromForm(form), storageQuotaMb: Number(form.get("storageQuotaMb") || defaultUserStorageQuotaMb), permissionGroupIds })
},
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"></Button></DialogTrigger>
<DialogTrigger asChild><Button size="sm"></Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<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" autoComplete="off" placeholder="user@example.com" />
<Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={6} />
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
<Field name="localPart" label="邮箱前缀" placeholder="user" />
<Field name="displayName" label="显示名称" placeholder="例如:SZX" />
</div>
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field name="password" label="密码(留空则使用邮箱账号)" type="password" required={false} autoComplete="new-password" />
<Field name="confirmPassword" label="再次输入密码" type="password" required={false} autoComplete="new-password" />
</div>
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
<Field name="storageQuotaMb" label="共享存储容量(MB" type="number" min={100} defaultValue={String(defaultUserStorageQuotaMb)} />
</div>
<PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />
<DialogFooter><Button disabled={create.isPending || !domainId}>{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" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><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" aria-label={`管理转发 ${alias.source}`} title="更多操作"><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>
@@ -2063,6 +2110,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
role,
disabled: disabled === "disabled",
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form, effectiveMailboxLimit(user)) : undefined,
storageQuotaMb: Number(form.get("storageQuotaMb") || user.storageQuotaMb || defaultStorageQuotaMb(user.role)),
permissionGroupIds: role === "user" ? permissionGroupIds : [],
}),
onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "账号已更新" }) },
@@ -2079,7 +2127,10 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
</div>
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
<Field name="storageQuotaMb" label="共享存储容量(MB" type="number" min={100} defaultValue={String(user.storageQuotaMb || defaultStorageQuotaMb(user.role))} />
</div>
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
</form>
@@ -2093,17 +2144,10 @@ function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; op
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={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><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"></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>
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button className="self-start"></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[] }) {
@@ -2111,7 +2155,6 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
const [ownerMode, setOwnerMode] = React.useState("new")
const [userId, setUserId] = React.useState("")
React.useEffect(() => {
if (!domainId && domains[0]) setDomainId(domains[0].id)
@@ -2121,38 +2164,22 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
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: "user",
ownerEmail: String(form.get("ownerEmail") || ""),
userId: ownerMode === "existing" ? userId : "",
userId,
}),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button></Button></DialogTrigger>
<DialogTrigger asChild><Button></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, accountPrimaryEmail(u)])} />
) : (
<Field name="ownerEmail" label="主登录邮箱" placeholder="留空则使用新邮箱地址" required={false} />
)}
<div className="grid grid-cols-2 gap-3">
<Field name="password" label="密码" type="password" placeholder="至少 6 位" />
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
</div>
<DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter>
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
<SelectField label="绑定用户账号" value={userId} onValueChange={setUserId} items={users.filter((item) => !item.disabled).sort(compareAdminUsers).map((item) => [item.id, accountPrimaryEmail(item)])} />
<p className="text-xs text-muted-foreground">使</p>
<DialogFooter><Button disabled={mut.isPending || !domainId || !userId}>{mut.isPending ? "创建中..." : "创建邮箱"}</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
@@ -2163,7 +2190,7 @@ 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"></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>
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button className="self-start"></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 }) {