From 556389b73453a39ddc4b7cfd77e67b995e31982a Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Tue, 16 Jun 2026 10:39:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=A2=9E=E5=BC=BA=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E6=B5=81=E7=A8=8B=E5=B9=B6=E5=AE=8C=E5=96=84=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=82=AE=E7=AE=B1=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注册页支持选择可用域名并自动创建邮箱,密码输入改为可见性切换组件。 - 后端默认管理员不再依赖固定密码,未配置时自动生成随机密码并创建对应域名、邮箱和欢迎消息。 - 公共配置接口返回可注册域名,补充 404 页面与相关路由。 - 更新测试以适配新的默认种子数据和邮箱创建逻辑。 --- apps/api/internal/app/app.go | 47 ++++++++++++-- apps/api/internal/app/app_test.go | 64 +++++++++++++------ apps/api/internal/app/auth_handlers.go | 34 ++++++++++ apps/api/internal/app/config.go | 2 +- apps/api/internal/app/settings_handlers.go | 35 ++++++++-- apps/web/src/components/ui/password-input.tsx | 27 ++++++++ apps/web/src/lib/api-types.ts | 5 +- apps/web/src/main.tsx | 2 + apps/web/src/pages/login.tsx | 3 +- apps/web/src/pages/not-found.tsx | 29 +++++++++ apps/web/src/pages/profile.tsx | 7 +- apps/web/src/pages/register.tsx | 54 ++++++++++++++-- 12 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/ui/password-input.tsx create mode 100644 apps/web/src/pages/not-found.tsx diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index b2e2d29..2d83454 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -300,6 +300,10 @@ func (a *App) migrate(ctx context.Context) error { return nil } +// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed() +// that implicitly created an admin mailbox with display_name "LanQin Admin". +// Current seed() creates mailboxes with display_name = admin email, so this migration +// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema. func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error { adminEmail := normalizeEmail(a.cfg.AdminEmail) if adminEmail == "" || !strings.Contains(adminEmail, "@") { @@ -610,7 +614,16 @@ func (a *App) seed(ctx context.Context) error { return nil } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.AdminPassword), bcrypt.DefaultCost) + adminPassword := a.cfg.AdminPassword + if adminPassword == "" { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return err + } + adminPassword = base64.RawURLEncoding.EncodeToString(buf) + a.log.Warn("LANQIN_ADMIN_PASSWORD not set; generated random password", "password", adminPassword) + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) if err != nil { return err } @@ -620,12 +633,38 @@ func (a *App) seed(ctx context.Context) error { if adminEmail == "" || !strings.Contains(adminEmail, "@") { return errors.New("invalid admin email") } - _, err = a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now) + if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, userID, adminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now); err != nil { + return err + } + a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) + + // Create domain from admin email + parts := strings.SplitN(adminEmail, "@", 2) + localPart := parts[0] + domainName := normalizeDomain(parts[1]) + var domainID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil { + domainID, err = a.createDomainTx(ctx, nil, domainName) + if err != nil { + return err + } + a.log.Info("created domain for administrator", "domain", domainName) + } else { + a.log.Info("domain already exists for administrator", "domain", domainName) + } + + // Create mailbox for admin + mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active") if err != nil { return err } - a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", a.cfg.AdminEmail) + a.log.Info("created mailbox for administrator", "address", adminEmail) + + // Send welcome message + if err := a.seedWelcomeMessage(ctx, mailboxID); err != nil { + a.log.Warn("failed to create welcome message", "error", err) + } return nil } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 4fd631e..d8f5e56 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -208,7 +208,13 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { t.Fatalf("login code=%d body=%v", code, login) } - domainID := createTestDomain(t, admin, "lanqin.local").ID + var domainList = struct { + Items []Domain `json:"items"` + }{} + if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 { + t.Fatalf("list domains code=%d items=%+v", code, domainList.Items) + } + domainID := domainList.Items[0].ID mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil) mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil) @@ -322,8 +328,8 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) { var mine struct { Items []Mailbox `json:"items"` } - if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 0 { - t.Fatalf("registered user should not get implicit mailbox: code=%d items=%+v", code, mine.Items) + if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 { + t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items) } another := &testClient{t: t, server: ts} @@ -353,17 +359,20 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T t.Cleanup(func() { _ = a.Close() }) ctx := context.Background() - var adminID string - if err := a.db.QueryRowContext(ctx, `SELECT id FROM users WHERE email=?`, cfg.AdminEmail).Scan(&adminID); err != nil { + + // seed() now creates user + domain gmail.com + mailbox lanqinnet@gmail.com + // with display_name = admin email (not "LanQin Admin"). + // Modify the mailbox to look like the old legacy pattern so the migration can find it. + if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET display_name='LanQin Admin' WHERE address=?`, cfg.AdminEmail); err != nil { t.Fatal(err) } - domainID, err := a.createDomainTx(ctx, nil, "gmail.com") - if err != nil { - t.Fatal(err) - } - if _, err := a.createMailbox(ctx, adminID, domainID, "lanqinnet", "LanQin Admin", "Password123!", 1024, "active"); err != nil { + + // Get the domain ID for the verification step + var domainID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "gmail.com").Scan(&domainID); err != nil { t.Fatal(err) } + if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil { t.Fatal(err) } @@ -455,8 +464,14 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { t.Fatalf("login code=%d body=%v", code, login) } - domainID := createTestDomain(t, admin, "lanqin.local").ID - createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"}) + // seed() already created domain lanqin.local and mailbox admin@lanqin.local + var domainList = struct { + Items []Domain `json:"items"` + }{} + if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 { + t.Fatalf("list domains code=%d items=%+v", code, domainList.Items) + } + domainID := domainList.Items[0].ID primary := createTestMailbox(t, admin, domainID, "multi", "Multi", "Password123!", nil) secondary := createTestMailbox(t, admin, domainID, "multi-work", "Multi Work", "Password456!", map[string]any{"ownerEmail": primary.Address}) @@ -506,9 +521,13 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) { if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { t.Fatalf("login code=%d body=%v", code, login) } - domainID := createTestDomain(t, admin, "lanqin.local").ID - createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"}) - + // seed() already created domain lanqin.local and mailbox admin@lanqin.local + var domainList = struct { + Items []Domain `json:"items"` + }{} + if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 { + t.Fatalf("list domains code=%d items=%+v", code, domainList.Items) + } payload := map[string]any{ "to": []string{"ghost@lanqin.local"}, "subject": "should be rejected by default", @@ -707,8 +726,8 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) { func TestDNSRecords(t *testing.T) { a := newTestApp(t) - domainID, err := a.createDomainTx(context.Background(), nil, "lanqin.local") - if err != nil { + var domainID string + if err := a.db.QueryRowContext(context.Background(), `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil { t.Fatal(err) } d, err := a.domainByID(context.Background(), domainID) @@ -729,15 +748,20 @@ func TestMaildirSyncImportsRFC822(t *testing.T) { ctx := context.Background() root := t.TempDir() a.cfg.MaildirRoot = root - domainID, err := a.createDomainTx(ctx, nil, "lanqin.local") - if err != nil { + var domainID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil { t.Fatal(err) } adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local") if err != nil { t.Fatal(err) } - if _, err := a.createMailbox(ctx, adminUser.ID, domainID, "admin", "Admin", "ChangeMe123!", 1024, "active"); err != nil { + // seed() already created mailbox admin@lanqin.local + var mailboxID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil { t.Fatal(err) } diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go index 570d566..9e097b1 100644 --- a/apps/api/internal/app/auth_handlers.go +++ b/apps/api/internal/app/auth_handlers.go @@ -85,6 +85,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { DisplayName string `json:"displayName"` Password string `json:"password"` TurnstileToken string `json:"turnstileToken"` + DomainID string `json:"domainId"` + LocalPart string `json:"localPart"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -143,6 +145,38 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to create session") return } + + // Create a mailbox for the registered user + var mailboxDomainID string + var mailboxLocalPart string + if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" { + // User selected a specific domain and local part + mailboxDomainID = strings.TrimSpace(req.DomainID) + mailboxLocalPart = normalizeLocalPart(req.LocalPart) + } else { + // Auto-detect: use the first active domain and email local part + if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil { + mailboxDomainID = "" + } + if mailboxDomainID != "" { + mailboxLocalPart = strings.SplitN(email, "@", 2)[0] + } + } + if mailboxDomainID != "" && mailboxLocalPart != "" { + // Check reserved prefixes + reserved := map[string]bool{} + for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) { + reserved[item] = true + } + if reserved[mailboxLocalPart] { + respondError(w, http.StatusForbidden, "localPart is reserved") + return + } + if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil { + a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email) + } + } + respondJSON(w, http.StatusCreated, map[string]any{"user": user}) } diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index d2cd325..df2b6de 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -47,7 +47,7 @@ func LoadConfig() Config { CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), - AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"), + AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), SMTPHost: getenv("LANQIN_SMTP_HOST", ""), diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go index d346e6e..db7dca8 100644 --- a/apps/api/internal/app/settings_handlers.go +++ b/apps/api/internal/app/settings_handlers.go @@ -60,11 +60,17 @@ type systemSettingsUpdate struct { } type PublicSettings struct { - OpenRegistration bool `json:"openRegistration"` - TurnstileEnabled bool `json:"turnstileEnabled"` - TurnstileSiteKey string `json:"turnstileSiteKey"` - MailAutoRefresh bool `json:"mailAutoRefresh"` - MailRefreshMs int `json:"mailRefreshMs"` + OpenRegistration bool `json:"openRegistration"` + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshMs int `json:"mailRefreshMs"` + MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"` +} + +type PublicDomain struct { + ID string `json:"id"` + Name string `json:"name"` } type smtpTestRequest struct { @@ -81,7 +87,24 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) { if refreshSeconds <= 0 { refreshSeconds = 30 } - respondJSON(w, http.StatusOK, PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}) + settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000} + + // Include available domains for mailbox creation during registration + if a.cfg.OpenRegistration { + rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`) + if err == nil { + defer rows.Close() + for rows.Next() { + var d PublicDomain + if err := rows.Scan(&d.ID, &d.Name); err != nil { + continue + } + settings.MailboxDomains = append(settings.MailboxDomains, d) + } + } + } + + respondJSON(w, http.StatusOK, settings) } func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) { diff --git a/apps/web/src/components/ui/password-input.tsx b/apps/web/src/components/ui/password-input.tsx new file mode 100644 index 0000000..40230e8 --- /dev/null +++ b/apps/web/src/components/ui/password-input.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import { Eye, EyeOff } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" + +export const PasswordInput = React.forwardRef>( + ({ className, ...props }, ref) => { + const [show, setShow] = React.useState(false) + return ( +
+ + +
+ ) + }, +) +PasswordInput.displayName = "PasswordInput" diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 600c1fb..b61eaf2 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -48,7 +48,8 @@ export type SystemSettings = { reservedMailboxPrefixes: string } export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string } -export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number } +export type PublicDomain = { id: string; name: string } +export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] } export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } -export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string } +export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 0ae4e86..985e4e6 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,6 +10,7 @@ import { RegisterPage } from "@/pages/register" import { MailPage } from "@/pages/mail" import { AdminPage } from "@/pages/admin" import { ProfilePage } from "@/pages/profile" +import { NotFoundPage } from "@/pages/not-found" import "./index.css" const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } }) @@ -23,6 +24,7 @@ const router = createBrowserRouter([ { path: "profile", element: }, { path: "admin", element: }, ] }, + { path: "*", element: }, ]) ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx index 2dcb723..95c047a 100644 --- a/apps/web/src/pages/login.tsx +++ b/apps/web/src/pages/login.tsx @@ -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() {
- +
) : ( diff --git a/apps/web/src/pages/not-found.tsx b/apps/web/src/pages/not-found.tsx new file mode 100644 index 0000000..3a6e8cb --- /dev/null +++ b/apps/web/src/pages/not-found.tsx @@ -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 ( +
+
+
+
+ +
+
+

404

+

页面不存在

+
+ + +
+
+
+ ) +} diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index a2ca0bb..161d268 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -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
{ e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}> - +
- + - +
diff --git a/apps/web/src/pages/register.tsx b/apps/web/src/pages/register.tsx index d848330..9384e77 100644 --- a/apps/web/src/pages/register.tsx +++ b/apps/web/src/pages/register.tsx @@ -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() {
) : ( { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}> -
- - -
+ {domains.length > 0 ? ( +
+ +
+ + @ + +
+
+ ) : ( +
+ + +
+ )}
- +
- +
{turnstileRequired && }