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
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:
@@ -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 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user