feat(auth): 增强注册流程并完善默认邮箱初始化

- 注册页支持选择可用域名并自动创建邮箱,密码输入改为可见性切换组件。
- 后端默认管理员不再依赖固定密码,未配置时自动生成随机密码并创建对应域名、邮箱和欢迎消息。
- 公共配置接口返回可注册域名,补充 404 页面与相关路由。
- 更新测试以适配新的默认种子数据和邮箱创建逻辑。
This commit is contained in:
LanQin_
2026-06-16 10:39:49 +08:00
parent 45122162d0
commit 556389b734
12 changed files with 266 additions and 43 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { TurnstileBox } from "@/components/turnstile-box"
import { PasswordInput } from "@/components/ui/password-input"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -47,7 +48,7 @@ export function LoginPage() {
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" autoComplete="current-password" required className="h-11 text-base" />
<PasswordInput id="password" name="password" autoComplete="current-password" required className="h-11 text-base" />
</div>
</>
) : (
+29
View File
@@ -0,0 +1,29 @@
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Home, MailQuestion } from "lucide-react"
export function NotFoundPage() {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
<MailQuestion className="h-10 w-10 text-muted-foreground" />
</div>
</div>
<h1 className="mb-2 text-6xl font-bold tracking-tight">404</h1>
<p className="mb-8 text-lg text-muted-foreground"></p>
<div className="flex justify-center gap-3">
<Button asChild>
<Link to="/">
<Home className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/login"></Link>
</Button>
</div>
</div>
</div>
)
}
+4 -3
View File
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { validatePasswordConfirm } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { PasswordInput } from "@/components/ui/password-input"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
@@ -351,14 +352,14 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
<CardContent>
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
<Field label="当前密码">
<Input name="currentPassword" type="password" required />
<PasswordInput name="currentPassword" required />
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field label="新密码">
<Input name="newPassword" type="password" minLength={8} required />
<PasswordInput name="newPassword" minLength={8} required />
</Field>
<Field label="确认新密码">
<Input name="confirmPassword" type="password" minLength={8} required />
<PasswordInput name="confirmPassword" minLength={8} required />
</Field>
</div>
<div className="flex justify-end">
+48 -6
View File
@@ -2,11 +2,14 @@ import * as React from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import type { PublicDomain } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useToast } from "@/hooks/use-toast"
import { PasswordInput } from "@/components/ui/password-input"
import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation"
@@ -17,11 +20,30 @@ export function RegisterPage() {
const { toast } = useToast()
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const [turnstileToken, setTurnstileToken] = React.useState("")
const [domainId, setDomainId] = React.useState("")
const domains: PublicDomain[] = publicSettings.data?.mailboxDomains || []
const selectedDomain = domains.find((d) => d.id === domainId)
const register = useMutation({
mutationFn: (form: FormData) => {
const password = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
validatePasswordConfirm(password, confirmPassword)
if (domainId && selectedDomain) {
const localPart = String(form.get("localPart") || "").trim()
if (!localPart) throw new Error("请输入邮箱前缀")
return api.register({
email: `${localPart}@${selectedDomain.name}`,
displayName: String(form.get("displayName") || ""),
password,
turnstileToken,
domainId,
localPart,
})
}
// Fallback: no domains available, use email directly
return api.register({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
@@ -53,21 +75,41 @@ export function RegisterPage() {
</div>
) : (
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
{domains.length > 0 ? (
<div className="space-y-2">
<Label htmlFor="localPart"></Label>
<div className="flex items-center gap-2">
<Input id="localPart" name="localPart" className="h-11 flex-1 text-base" placeholder="your-name" required />
<span className="text-sm text-muted-foreground">@</span>
<Select value={domainId} onValueChange={setDomainId} required>
<SelectTrigger className="h-11 w-[140px]">
<SelectValue placeholder="选择域名" />
</SelectTrigger>
<SelectContent>
{domains.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
)}
<div className="space-y-2">
<Label htmlFor="displayName"></Label>
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
</div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>