feat(auth): 增强注册流程并完善默认邮箱初始化
- 注册页支持选择可用域名并自动创建邮箱,密码输入改为可见性切换组件。 - 后端默认管理员不再依赖固定密码,未配置时自动生成随机密码并创建对应域名、邮箱和欢迎消息。 - 公共配置接口返回可注册域名,补充 404 页面与相关路由。 - 更新测试以适配新的默认种子数据和邮箱创建逻辑。
This commit is contained in:
@@ -300,6 +300,10 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||||
@@ -610,7 +614,16 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -620,12 +633,38 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||||
return errors.New("invalid admin email")
|
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)
|
if _, 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)
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,13 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
|
|||||||
t.Fatalf("login code=%d body=%v", code, login)
|
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)
|
mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil)
|
||||||
mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil)
|
mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil)
|
||||||
@@ -322,8 +328,8 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
|||||||
var mine struct {
|
var mine struct {
|
||||||
Items []Mailbox `json:"items"`
|
Items []Mailbox `json:"items"`
|
||||||
}
|
}
|
||||||
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 0 {
|
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 {
|
||||||
t.Fatalf("registered user should not get implicit mailbox: code=%d items=%+v", code, mine.Items)
|
t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
another := &testClient{t: t, server: ts}
|
another := &testClient{t: t, server: ts}
|
||||||
@@ -353,17 +359,20 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
|
|||||||
t.Cleanup(func() { _ = a.Close() })
|
t.Cleanup(func() { _ = a.Close() })
|
||||||
|
|
||||||
ctx := context.Background()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
domainID, err := a.createDomainTx(ctx, nil, "gmail.com")
|
|
||||||
if err != nil {
|
// Get the domain ID for the verification step
|
||||||
t.Fatal(err)
|
var domainID string
|
||||||
}
|
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "gmail.com").Scan(&domainID); err != nil {
|
||||||
if _, err := a.createMailbox(ctx, adminID, domainID, "lanqinnet", "LanQin Admin", "Password123!", 1024, "active"); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -455,8 +464,14 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
|||||||
t.Fatalf("login code=%d body=%v", code, login)
|
t.Fatalf("login code=%d body=%v", code, login)
|
||||||
}
|
}
|
||||||
|
|
||||||
domainID := createTestDomain(t, admin, "lanqin.local").ID
|
// seed() already created domain lanqin.local and mailbox admin@lanqin.local
|
||||||
createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
|
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)
|
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})
|
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 {
|
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)
|
t.Fatalf("login code=%d body=%v", code, login)
|
||||||
}
|
}
|
||||||
domainID := createTestDomain(t, admin, "lanqin.local").ID
|
// seed() already created domain lanqin.local and mailbox admin@lanqin.local
|
||||||
createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
|
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{
|
payload := map[string]any{
|
||||||
"to": []string{"ghost@lanqin.local"},
|
"to": []string{"ghost@lanqin.local"},
|
||||||
"subject": "should be rejected by default",
|
"subject": "should be rejected by default",
|
||||||
@@ -707,8 +726,8 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
|||||||
|
|
||||||
func TestDNSRecords(t *testing.T) {
|
func TestDNSRecords(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
domainID, err := a.createDomainTx(context.Background(), nil, "lanqin.local")
|
var domainID string
|
||||||
if err != nil {
|
if err := a.db.QueryRowContext(context.Background(), `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
d, err := a.domainByID(context.Background(), domainID)
|
d, err := a.domainByID(context.Background(), domainID)
|
||||||
@@ -729,15 +748,20 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
a.cfg.MaildirRoot = root
|
a.cfg.MaildirRoot = root
|
||||||
domainID, err := a.createDomainTx(ctx, nil, "lanqin.local")
|
var domainID string
|
||||||
if err != nil {
|
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
|
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
TurnstileToken string `json:"turnstileToken"`
|
TurnstileToken string `json:"turnstileToken"`
|
||||||
|
DomainID string `json:"domainId"`
|
||||||
|
LocalPart string `json:"localPart"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to create session")
|
||||||
return
|
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})
|
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func LoadConfig() Config {
|
|||||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
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"),
|
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||||
|
|||||||
@@ -60,11 +60,17 @@ type systemSettingsUpdate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicSettings struct {
|
type PublicSettings struct {
|
||||||
OpenRegistration bool `json:"openRegistration"`
|
OpenRegistration bool `json:"openRegistration"`
|
||||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||||
MailRefreshMs int `json:"mailRefreshMs"`
|
MailRefreshMs int `json:"mailRefreshMs"`
|
||||||
|
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicDomain struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type smtpTestRequest struct {
|
type smtpTestRequest struct {
|
||||||
@@ -81,7 +87,24 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
if refreshSeconds <= 0 {
|
if refreshSeconds <= 0 {
|
||||||
refreshSeconds = 30
|
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) {
|
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const [show, setShow] = React.useState(false)
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input ref={ref} type={show ? "text" : "password"} className="pr-10" {...props} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShow(!show)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
<span className="sr-only">{show ? "隐藏密码" : "显示密码"}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
PasswordInput.displayName = "PasswordInput"
|
||||||
@@ -48,7 +48,8 @@ export type SystemSettings = {
|
|||||||
reservedMailboxPrefixes: string
|
reservedMailboxPrefixes: string
|
||||||
}
|
}
|
||||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
|
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { 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 LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: 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 }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { RegisterPage } from "@/pages/register"
|
|||||||
import { MailPage } from "@/pages/mail"
|
import { MailPage } from "@/pages/mail"
|
||||||
import { AdminPage } from "@/pages/admin"
|
import { AdminPage } from "@/pages/admin"
|
||||||
import { ProfilePage } from "@/pages/profile"
|
import { ProfilePage } from "@/pages/profile"
|
||||||
|
import { NotFoundPage } from "@/pages/not-found"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||||
@@ -23,6 +24,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: "profile", element: <ProfilePage /> },
|
{ path: "profile", element: <ProfilePage /> },
|
||||||
{ path: "admin", element: <AdminOnly><AdminPage /></AdminOnly> },
|
{ path: "admin", element: <AdminOnly><AdminPage /></AdminOnly> },
|
||||||
] },
|
] },
|
||||||
|
{ path: "*", element: <NotFoundPage /> },
|
||||||
])
|
])
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { TurnstileBox } from "@/components/turnstile-box"
|
import { TurnstileBox } from "@/components/turnstile-box"
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -47,7 +48,7 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">密码</Label>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
|
|||||||
import { useLogout } from "@/hooks/use-logout"
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
import { validatePasswordConfirm } from "@/lib/validation"
|
import { validatePasswordConfirm } from "@/lib/validation"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -351,14 +352,14 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
||||||
<Field label="当前密码">
|
<Field label="当前密码">
|
||||||
<Input name="currentPassword" type="password" required />
|
<PasswordInput name="currentPassword" required />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="新密码">
|
<Field label="新密码">
|
||||||
<Input name="newPassword" type="password" minLength={8} required />
|
<PasswordInput name="newPassword" minLength={8} required />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="确认新密码">
|
<Field label="确认新密码">
|
||||||
<Input name="confirmPassword" type="password" minLength={8} required />
|
<PasswordInput name="confirmPassword" minLength={8} required />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import * as React from "react"
|
|||||||
import { Link, Navigate, useNavigate } from "react-router-dom"
|
import { Link, Navigate, useNavigate } from "react-router-dom"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
|
import type { PublicDomain } from "@/lib/api"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { TurnstileBox } from "@/components/turnstile-box"
|
import { TurnstileBox } from "@/components/turnstile-box"
|
||||||
import { validatePasswordConfirm } from "@/lib/validation"
|
import { validatePasswordConfirm } from "@/lib/validation"
|
||||||
|
|
||||||
@@ -17,11 +20,30 @@ export function RegisterPage() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||||
const [turnstileToken, setTurnstileToken] = React.useState("")
|
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({
|
const register = useMutation({
|
||||||
mutationFn: (form: FormData) => {
|
mutationFn: (form: FormData) => {
|
||||||
const password = String(form.get("password") || "")
|
const password = String(form.get("password") || "")
|
||||||
const confirmPassword = String(form.get("confirmPassword") || "")
|
const confirmPassword = String(form.get("confirmPassword") || "")
|
||||||
validatePasswordConfirm(password, 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({
|
return api.register({
|
||||||
email: String(form.get("email") || ""),
|
email: String(form.get("email") || ""),
|
||||||
displayName: String(form.get("displayName") || ""),
|
displayName: String(form.get("displayName") || ""),
|
||||||
@@ -53,21 +75,41 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}>
|
<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">
|
{domains.length > 0 ? (
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<div className="space-y-2">
|
||||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
<Label htmlFor="localPart">邮箱地址</Label>
|
||||||
</div>
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="displayName">显示名称</Label>
|
<Label htmlFor="displayName">显示名称</Label>
|
||||||
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
|
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">密码</Label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
<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>
|
</div>
|
||||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||||
|
|||||||
Reference in New Issue
Block a user