feat(admin): 扩展后台管理与登录安全能力。

- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。
- 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。
- 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。
- 更新前端导航、个人中心 2FA 配置以及相关部署示例。
This commit is contained in:
LanQin
2026-06-15 00:37:43 +08:00
parent 3ef8caa319
commit 8a042ae3dc
27 changed files with 3558 additions and 384 deletions
File diff suppressed because it is too large Load Diff
+89 -13
View File
@@ -1,5 +1,6 @@
import * as React from "react"
import { Navigate } from "react-router-dom"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button"
@@ -11,11 +12,24 @@ export function LoginPage() {
const me = useMe()
const qc = useQueryClient()
const { toast } = useToast()
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const [turnstileToken, setTurnstileToken] = React.useState("")
const [challengeToken, setChallengeToken] = React.useState("")
const login = useMutation({
mutationFn: (form: FormData) => api.login(String(form.get("email")), String(form.get("password"))),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["me"] }) },
mutationFn: (form: FormData) => challengeToken
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }),
onSuccess: async (data) => {
if (data.twoFactorRequired && data.challengeToken) {
setChallengeToken(data.challengeToken)
toast({ title: "请输入双因素验证码" })
return
}
await qc.invalidateQueries({ queryKey: ["me"] })
},
onError: (e) => toast({ title: "登录失败", description: e.message }),
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/mail" replace />
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
@@ -23,20 +37,82 @@ export function LoginPage() {
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight">LanQin Email</h1>
</div>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); login.mutate(new FormData(e.currentTarget)) }}>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
</div>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (!challengeToken && turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; login.mutate(new FormData(e.currentTarget)) }}>
{!challengeToken ? (
<>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
</div>
</>
) : (
<div className="space-y-2">
<Label htmlFor="twoFactorCode"></Label>
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-base" />
</div>
)}
{!challengeToken && turnstileRequired && (
<TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />
)}
<Button className="h-11 w-full text-base" disabled={login.isPending}>
{login.isPending ? "登录中..." : "登录"}
{login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"}
</Button>
{challengeToken && <Button type="button" variant="ghost" className="w-full" onClick={() => setChallengeToken("")}></Button>}
</form>
</div>
</div>
)
}
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
remove: (widgetId: string) => void
}
}
}
function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
const ref = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!siteKey || !ref.current) return
let cancelled = false
let widgetId = ""
function render() {
if (cancelled || !ref.current || !window.turnstile) return
ref.current.innerHTML = ""
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
"error-callback": () => onToken(""),
})
}
if (window.turnstile) {
render()
} else {
const existing = document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]')
if (existing) {
existing.addEventListener("load", render, { once: true })
} else {
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.addEventListener("load", render, { once: true })
document.head.appendChild(script)
}
}
return () => {
cancelled = true
onToken("")
if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
}
}, [siteKey, onToken])
return <div className="flex justify-center rounded-md border p-2"><div ref={ref} /></div>
}
+11
View File
@@ -73,6 +73,7 @@ export function MailPage() {
const themeMountedRef = React.useRef(false)
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId })
const messages = useQuery({ queryKey: ["messages", selectedMailboxId, folder, query], queryFn: () => api.messages(folder, query, "", selectedMailboxId), enabled: !!selectedMailboxId })
@@ -125,6 +126,16 @@ export function MailPage() {
return () => events.close()
}, [qc])
React.useEffect(() => {
if (!publicSettings.data?.mailAutoRefresh) return
const interval = Math.max(publicSettings.data.mailRefreshMs || 30000, 5000)
const timer = window.setInterval(() => {
qc.invalidateQueries({ queryKey: ["messages"] })
qc.invalidateQueries({ queryKey: ["folders"] })
}, interval)
return () => window.clearInterval(timer)
}, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc])
const selected = detail.data
const allMessages = messages.data?.items || []
const visibleMessages = allMessages.filter((message) => {
+81 -3
View File
@@ -2,7 +2,8 @@ import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react"
import { QRCodeSVG } from "qrcode.react"
import { api, Mailbox, MailStats } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
@@ -40,6 +41,7 @@ export function ProfilePage() {
const [params, setParams] = useSearchParams()
const { toast } = useToast()
const passwordFormRef = React.useRef<HTMLFormElement>(null)
const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
@@ -73,6 +75,21 @@ export function ProfilePage() {
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
onError: (error) => toast({ title: "修改失败", description: error.message }),
})
const setupTwoFactor = useMutation({
mutationFn: api.setupTwoFactor,
onSuccess: () => toast({ title: "双因素密钥已生成" }),
onError: (error) => toast({ title: "生成失败", description: error.message }),
})
const enableTwoFactor = useMutation({
mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")),
onSuccess: (data) => { qc.setQueryData(["me"], data); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) },
onError: (error) => toast({ title: "启用失败", description: error.message }),
})
const disableTwoFactor = useMutation({
mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")),
onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) },
onError: (error) => toast({ title: "关闭失败", description: error.message }),
})
const createContact = useMutation({
mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
@@ -158,11 +175,11 @@ export function ProfilePage() {
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
}
}
function ProfileOverview({ user, profile, password, passwordFormRef, stats }: { user: { email: string; displayName: string; role: string; disabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats }) {
function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
return (
<div className="space-y-6">
<Card>
@@ -206,6 +223,67 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats }: {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2 text-sm">
<KeyRound className="h-4 w-4" />
</div>
<Badge variant={user.twoFactorEnabled ? "default" : "secondary"}>{user.twoFactorEnabled ? "已启用" : "未启用"}</Badge>
</div>
{!user.twoFactorEnabled && !setupTwoFactor.data && (
<Button onClick={() => setupTwoFactor.mutate()} disabled={setupTwoFactor.isPending}>{setupTwoFactor.isPending ? "生成中..." : "启用双因素认证"}</Button>
)}
{!user.twoFactorEnabled && setupTwoFactor.data && (
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); enableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="flex justify-center rounded-lg border bg-white p-4">
<QRCodeSVG value={setupTwoFactor.data.otpauthUrl} size={184} level="M" />
</div>
<div className="space-y-4">
<Field label="密钥">
<div className="flex gap-2">
<Input value={setupTwoFactor.data.secret} readOnly />
<Button type="button" variant="outline" onClick={() => onCopy(setupTwoFactor.data!.secret)}><Copy className="h-4 w-4" /></Button>
</div>
</Field>
<Field label="绑定地址">
<div className="flex gap-2">
<Input value={setupTwoFactor.data.otpauthUrl} readOnly />
<Button type="button" variant="outline" onClick={() => onCopy(setupTwoFactor.data!.otpauthUrl)}><Copy className="h-4 w-4" /></Button>
</div>
</Field>
</div>
</div>
<Field label="验证码">
<Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required />
</Field>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setupTwoFactor.reset()}></Button>
<Button disabled={enableTwoFactor.isPending}>{enableTwoFactor.isPending ? "启用中..." : "确认启用"}</Button>
</div>
</form>
)}
{user.twoFactorEnabled && (
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
<Field label="当前验证码">
<Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required />
</Field>
<div className="flex justify-end">
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭双因素认证"}</Button>
</div>
</form>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>