feat: use usernames for administrator accounts
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-03 17:27:29 +08:00
parent 6059954596
commit 18f8d870e8
14 changed files with 148 additions and 20 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
脚本会自动完成: 脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2 - 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员邮箱和密码 - 询问邮件域名、访问地址、管理员用户名和密码
- 创建 `/opt/newszxcn-email` 持久化目录 - 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务 - 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌 - 生成后台在线更新所需的内部鉴权令牌
+27 -3
View File
@@ -108,7 +108,13 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
actor := currentUser(r) actor := currentUser(r)
loginName, err := cleanLoginName(req.LoginName, req.Email) var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
}
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
@@ -183,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
current := currentUser(r) current := currentUser(r)
var req struct { var req struct {
LoginName string `json:"loginName"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
Disabled *bool `json:"disabled"` Disabled *bool `json:"disabled"`
@@ -211,6 +218,15 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "user not found") respondError(w, http.StatusNotFound, "user not found")
return return
} }
requestedLoginName := strings.TrimSpace(req.LoginName)
loginName := existing.LoginName
if requestedLoginName != "" {
loginName, err = cleanUsername(requestedLoginName)
if err != nil {
badRequest(w, err)
return
}
}
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) { if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
respondError(w, http.StatusForbidden, "only administrators can modify administrator users") respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return return
@@ -278,8 +294,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`, emailIdentity := existing.Email
displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
emailIdentity = loginName
}
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("登录名已被使用"))
return
}
respondError(w, http.StatusInternalServerError, "failed to update user") respondError(w, http.StatusInternalServerError, "failed to update user")
return return
} }
+17
View File
@@ -1410,6 +1410,18 @@ func (a *App) seed(ctx context.Context) error {
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr") userID := newID("usr")
if strings.TrimSpace(a.cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(a.cfg.AdminUsername)
if err != nil {
return fmt.Errorf("invalid admin username: %w", err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
return nil
}
adminEmail := normalizeEmail(a.cfg.AdminEmail) adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") { if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email") return errors.New("invalid admin email")
@@ -1451,6 +1463,11 @@ func (a *App) seed(ctx context.Context) error {
} }
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error { func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
return err
}
adminEmail := normalizeEmail(a.cfg.AdminEmail) adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") { if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil return nil
+63
View File
@@ -1421,6 +1421,69 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
} }
} }
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
dir := t.TempDir()
cfg := Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminUsername: "admin",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
a := newTestAppWithConfig(t, cfg)
var domains, mailboxes int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM domains`).Scan(&domains); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
t.Fatal(err)
}
if domains != 0 || mailboxes != 0 {
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes)
}
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login struct {
User User `json:"user"`
}
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("username login code=%d", code)
}
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"loginName": "rootadmin",
"displayName": "Administrator",
"role": "admin",
"disabled": false,
}, nil); code != http.StatusOK {
t.Fatalf("rename administrator code=%d", code)
}
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
"loginName": "root@example.test",
"displayName": "Administrator",
"role": "admin",
"disabled": false,
}, nil); code != http.StatusBadRequest {
t.Fatalf("email-shaped login name code=%d", code)
}
oldLogin := &testClient{t: t, server: ts}
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("old username login code=%d", code)
}
newLogin := &testClient{t: t, server: ts}
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("renamed username login code=%d", code)
}
}
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) { func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
+7 -1
View File
@@ -50,7 +50,13 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试") respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return return
} }
loginName, err := cleanLoginName(req.LoginName, req.Email) var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
}
if err != nil { if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误") respondError(w, http.StatusUnauthorized, "账号或密码错误")
return return
+2
View File
@@ -14,6 +14,7 @@ type Config struct {
DataDir string DataDir string
CookieName string CookieName string
SessionTTLHours int SessionTTLHours int
AdminUsername string
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
PublicHostname string PublicHostname string
@@ -70,6 +71,7 @@ func LoadConfig() Config {
DataDir: dataDir, DataDir: dataDir,
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),
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
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", ""), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
+3
View File
@@ -1057,6 +1057,9 @@ func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil { if u == nil {
return false return false
} }
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
}
adminEmail := normalizeEmail(a.cfg.AdminEmail) adminEmail := normalizeEmail(a.cfg.AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail) return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
} }
+11
View File
@@ -187,6 +187,17 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) {
return loginName, nil return loginName, nil
} }
func cleanUsername(value string) (string, error) {
username, err := cleanLoginName(value)
if err != nil {
return "", err
}
if strings.Contains(username, "@") {
return "", errors.New("登录名不能使用邮箱地址")
}
return username, nil
}
func dedupeEmails(items []string) []string { func dedupeEmails(items []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
out := make([]string, 0, len(items)) out := make([]string, 0, len(items))
+1 -1
View File
@@ -164,7 +164,7 @@ export const api = {
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"), defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: 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 }) }), 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" }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"), domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
+2 -1
View File
@@ -1896,6 +1896,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
}, [user, open]) }, [user, open])
const mut = useMutation({ const mut = useMutation({
mutationFn: (form: FormData) => api.updateUser(user.id, { mutationFn: (form: FormData) => api.updateUser(user.id, {
loginName: String(form.get("loginName") || ""),
displayName: String(form.get("displayName") || ""), displayName: String(form.get("displayName") || ""),
role, role,
disabled: disabled === "disabled", disabled: disabled === "disabled",
@@ -1910,7 +1911,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> <form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /> <Field name="loginName" label="登录名" defaultValue={accountLoginName(user)} type="text" autoComplete="off" />
<Field name="displayName" label="显示名称" defaultValue={user.displayName} /> <Field name="displayName" label="显示名称" defaultValue={user.displayName} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} /> <SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} />
+6 -6
View File
@@ -340,7 +340,7 @@ export function ProfilePage() {
const sidebarContent = ( const sidebarContent = (
<div className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card"> <div className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card">
<div className="h-[64px] border-b"> <div className="h-[64px] border-b">
<AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} /> <AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.loginName || user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
</div> </div>
<nav className="min-h-0 flex-1 overflow-y-auto p-2"> <nav className="min-h-0 flex-1 overflow-y-auto p-2">
<div className="px-2 pb-2 pt-2 text-xs font-semibold text-muted-foreground"></div> <div className="px-2 pb-2 pt-2 text-xs font-semibold text-muted-foreground"></div>
@@ -556,7 +556,7 @@ function StatsRangeTabs({ rangeDays, onRangeChange }: { rangeDays: number; onRan
type AccountSettingsSectionProps = { type AccountSettingsSectionProps = {
activeTab: AccountSettingsTab activeTab: AccountSettingsTab
user: { id: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits } user: { id: string; loginName?: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }
profile: { mutate: (form: FormData) => void; isPending: boolean } profile: { mutate: (form: FormData) => void; isPending: boolean }
password: { mutate: (form: FormData) => void; isPending: boolean } password: { mutate: (form: FormData) => void; isPending: boolean }
passwordFormRef: React.RefObject<HTMLFormElement> passwordFormRef: React.RefObject<HTMLFormElement>
@@ -658,7 +658,7 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
} }
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) { function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
const accountName = cleanAccountName(user.displayName || user.email, user.email) const accountName = user.loginName || user.email
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0) const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
const storageBytes = stats?.storageBytes || 0 const storageBytes = stats?.storageBytes || 0
const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0 const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0
@@ -973,7 +973,7 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div> <div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
<div> <div>
<div className="font-semibold">{user.email}</div> <div className="font-semibold">{user.loginName || user.email}</div>
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground"></div>
</div> </div>
</div> </div>
@@ -1258,7 +1258,7 @@ function writeLocalLogs(key: string, value: MailboxActionLog[]) {
try { window.localStorage.setItem(key, JSON.stringify(value.slice(0, 50))) } catch {} try { window.localStorage.setItem(key, JSON.stringify(value.slice(0, 50))) } catch {}
} }
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; 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 }) { function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { loginName?: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -1287,7 +1287,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, show
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); profile.mutate(new FormData(e.currentTarget)) }}> <form className="space-y-4" onSubmit={(e) => { e.preventDefault(); profile.mutate(new FormData(e.currentTarget)) }}>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="用户名"> <Field label="用户名">
<Input value={user.email} readOnly /> <Input value={user.loginName || user.email} readOnly />
</Field> </Field>
<Field label="显示名称"> <Field label="显示名称">
<Input name="displayName" defaultValue={user.displayName} required /> <Input name="displayName" defaultValue={user.displayName} required />
+3 -2
View File
@@ -48,8 +48,9 @@ LANQIN_TLS_KEY_FILE=
# ========================= # =========================
# 初始管理员 # 初始管理员
# ========================= # =========================
# 第一次启动时创建这个管理员账号。 # 第一次启动时创建管理员账号,不会自动创建同名邮箱或域名
LANQIN_ADMIN_EMAIL=admin@example.com # 登录名不能使用邮箱地址,之后可在后台“账号”中修改。
LANQIN_ADMIN_USERNAME=admin
# 生产环境必须改掉默认密码。 # 生产环境必须改掉默认密码。
LANQIN_ADMIN_PASSWORD=ChangeMe123! LANQIN_ADMIN_PASSWORD=ChangeMe123!
+1 -1
View File
@@ -26,7 +26,7 @@ sudo newszxcn-email rollback
```bash ```bash
cd deploy cd deploy
cp .env.example .env cp .env.example .env
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD # 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_USERNAME / LANQIN_ADMIN_PASSWORD
docker compose pull docker compose pull
docker compose up -d docker compose up -d
``` ```
+4 -4
View File
@@ -120,12 +120,12 @@ configure_first_install() {
fi fi
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env" install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
local hostname public_url admin_email admin_password update_token local hostname public_url admin_username admin_password update_token
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")" hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
[[ "${hostname}" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || fail "邮件服务器域名格式不正确。" [[ "${hostname}" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || fail "邮件服务器域名格式不正确。"
public_url="$(prompt_value LANQIN_PUBLIC_BASE_URL "Webmail 访问地址" "https://${hostname}")" public_url="$(prompt_value LANQIN_PUBLIC_BASE_URL "Webmail 访问地址" "https://${hostname}")"
admin_email="$(prompt_value LANQIN_ADMIN_EMAIL "初始管理员邮箱" "admin@${hostname#mail.}")" admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "初始管理员用户名" "admin")"
[[ "${admin_email}" == *@*.* ]] || fail "管理员邮箱格式不正确。" [[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名格式不正确,需为 2-80 位且不能包含 @。"
admin_password="$(prompt_value LANQIN_ADMIN_PASSWORD "初始管理员密码" "" true)" admin_password="$(prompt_value LANQIN_ADMIN_PASSWORD "初始管理员密码" "" true)"
if [[ -z "${admin_password}" ]]; then if [[ -z "${admin_password}" ]]; then
admin_password="$(random_secret)" admin_password="$(random_secret)"
@@ -136,7 +136,7 @@ configure_first_install() {
set_env LANQIN_PUBLIC_HOSTNAME "${hostname}" set_env LANQIN_PUBLIC_HOSTNAME "${hostname}"
set_env LANQIN_PUBLIC_BASE_URL "${public_url}" set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
set_env LANQIN_ADMIN_EMAIL "${admin_email}" set_env LANQIN_ADMIN_USERNAME "${admin_username}"
set_env LANQIN_ADMIN_PASSWORD "${admin_password}" set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
set_env LANQIN_UPDATE_TOKEN "${update_token}" set_env LANQIN_UPDATE_TOKEN "${update_token}"
chmod 0600 "${INSTALL_DIR}/.env" chmod 0600 "${INSTALL_DIR}/.env"