diff --git a/.github/release-notes/v1.2.29.md b/.github/release-notes/v1.2.29.md new file mode 100644 index 0000000..e7867aa --- /dev/null +++ b/.github/release-notes/v1.2.29.md @@ -0,0 +1,13 @@ +- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。 +- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。 +- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。 +- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。 +- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。 +- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。 +- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。 +- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。 +- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。 +- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。 +- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。 +- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。 +- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。 diff --git a/VERSION b/VERSION index f6b839d..550c9e9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.28 +1.2.29 diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 8753a20..7be8332 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) { } func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { - rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') + rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id - GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at + GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`) if err != nil { respondError(w, http.StatusInternalServerError, "failed to list users") @@ -64,7 +64,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created, mailboxCSV string - if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { + if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan users") return } @@ -101,6 +101,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` Disabled bool `json:"disabled"` MailboxLimitOverride *int `json:"mailboxLimitOverride"` + StorageQuotaMB int `json:"storageQuotaMb"` PermissionGroupIDs []string `json:"permissionGroupIds"` } if err := decodeJSON(r, &req); err != nil { @@ -142,6 +143,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if role == "admin" { mailboxLimitOverride = nil } + storageQuotaMB := req.StorageQuotaMB + if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB { + badRequest(w, errors.New("共享存储容量不能小于 100 MB")) + return + } + if storageQuotaMB == 0 { + storageQuotaMB = defaultUserStorageQuotaMB + } if !hasMinimumPasswordLength(req.Password) { badRequest(w, errors.New("password must be at least 6 characters")) return @@ -159,11 +168,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil { badRequest(w, err) return } + localPart, domainName, _ := strings.Cut(primaryEmail, "@") + var primaryDomainID string + if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil { + if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil { + badRequest(w, err) + return + } + } else if !errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusInternalServerError, "failed to load account domain") + return + } permissionGroupIDs := req.PermissionGroupIDs if role == "admin" { permissionGroupIDs = nil @@ -194,6 +214,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { Role string `json:"role"` Disabled *bool `json:"disabled"` MailboxLimitOverride *int `json:"mailboxLimitOverride"` + StorageQuotaMB *int `json:"storageQuotaMb"` PermissionGroupIDs *[]string `json:"permissionGroupIds"` } if err := decodeJSON(r, &req); err != nil { @@ -267,6 +288,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if role == "admin" { mailboxLimitOverride = nil } + var storageQuotaMB int + if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load storage quota") + return + } + if req.StorageQuotaMB != nil { + storageQuotaMB = *req.StorageQuotaMB + } + if storageQuotaMB < 100 { + badRequest(w, errors.New("共享存储容量不能小于 100 MB")) + return + } if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil { badRequest(w, err) return @@ -311,8 +344,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`, - loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { + if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`, + loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { badRequest(w, errors.New("主登录邮箱已被使用")) return @@ -409,11 +442,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { if target, err := a.userByID(r.Context(), id); err != nil { respondError(w, http.StatusNotFound, "user not found") return - } else if a.isDefaultAdminUser(target) { - badRequest(w, errors.New("default administrator cannot be deleted")) - return - } else if target.Role == "admin" && (current == nil || current.Role != "admin") { - respondError(w, http.StatusForbidden, "only administrators can delete administrator users") + } else if target.Role == "admin" { + badRequest(w, errors.New("administrator accounts cannot be deleted")) return } if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil { @@ -553,6 +583,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) { m.CreatedAt = parseTime(created) items = append(items, m) } + markPrimaryMailboxes(items) respondJSON(w, http.StatusOK, map[string]any{"items": items}) } @@ -563,7 +594,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { DisplayName string `json:"displayName"` Password string `json:"password"` QuotaMB int `json:"quotaMb"` - Role string `json:"role"` OwnerLoginName string `json:"ownerLoginName"` OwnerEmail string `json:"ownerEmail"` UserID string `json:"userId"` @@ -580,20 +610,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - if !hasMinimumPasswordLength(req.Password) { - badRequest(w, errors.New("password must be at least 6 characters")) - return - } - role := req.Role - if role == "" { - role = "user" - } - if role != "user" && role != "admin" { - badRequest(w, errors.New("invalid role")) - return - } - if role == "admin" { - respondError(w, http.StatusForbidden, "管理员只能由安装流程创建") + userID := strings.TrimSpace(req.UserID) + if req.QuotaMB < 0 { + badRequest(w, errors.New("quotaMb must be zero or greater")) return } @@ -611,15 +630,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - now := a.now().UTC().Format(time.RFC3339Nano) - userID := strings.TrimSpace(req.UserID) displayName := req.DisplayName if displayName == "" { displayName = address } + var disabled, ownerStorageQuotaMB int + var passwordHash, ownerRole string if userID != "" { - var disabled int - if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil { + if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil { if errors.Is(err, sql.ErrNoRows) { respondError(w, http.StatusNotFound, "owner user not found") } else { @@ -627,11 +645,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { } return } - if intBool(disabled) { - badRequest(w, errors.New("owner user is disabled")) + } else { + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("password must be at least 6 characters")) return } - } else { ownerEmailInput := req.OwnerEmail if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") { ownerEmailInput = req.OwnerLoginName @@ -641,21 +659,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID) + err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB) if errors.Is(err, sql.ErrNoRows) { - passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { + hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if hashErr != nil { respondError(w, http.StatusInternalServerError, "failed to hash password") return } userID = newID("usr") - ownerDisplayName := displayName - if !strings.EqualFold(ownerEmail, address) { - ownerDisplayName = ownerEmail - } - _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now) - if err != nil { + passwordHash = string(hash) + ownerRole = "user" + ownerStorageQuotaMB = defaultUserStorageQuotaMB + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil { badRequest(w, err) return } @@ -664,16 +681,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { return } } - if err := tx.Commit(); err != nil { - respondError(w, http.StatusInternalServerError, "failed to prepare owner user") + if intBool(disabled) { + badRequest(w, errors.New("owner user is disabled")) return } - - mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active") + quotaMB := req.QuotaMB + if quotaMB == 0 { + quotaMB = ownerStorageQuotaMB + } + if ownerRole == "admin" { + quotaMB = 0 + } + mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active") if err != nil { badRequest(w, err) return } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to create mailbox") + return + } m, err := a.mailboxByID(r.Context(), mailboxID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load mailbox") @@ -699,8 +726,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("displayName is required")) return } - if req.QuotaMB <= 0 { - req.QuotaMB = 1024 + if req.QuotaMB < 0 { + badRequest(w, errors.New("quotaMb must be zero or greater")) + return } status := strings.TrimSpace(req.Status) if status == "" { @@ -710,13 +738,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("invalid status")) return } + existingMailbox, err := a.mailboxByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + if existingMailbox.Primary && status != existingMailbox.Status { + badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改")) + return + } userID := strings.TrimSpace(req.UserID) if userID == "" { badRequest(w, errors.New("userId is required")) return } + if existingMailbox.Primary && userID != existingMailbox.UserID { + badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改")) + return + } var disabled int - if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil { + var ownerRole, ownerPasswordHash string + if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil { if errors.Is(err, sql.ErrNoRows) { respondError(w, http.StatusNotFound, "owner user not found") } else { @@ -728,8 +770,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("owner user is disabled")) return } - res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, - userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) + if ownerRole == "admin" { + req.QuotaMB = 0 + } + res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, + userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update mailbox") return @@ -749,6 +794,14 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + if err := a.ensureMailboxDeletable(r.Context(), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "邮箱不存在或已被删除") + } else { + badRequest(w, err) + } + return + } rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) if err != nil { respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败") @@ -1118,15 +1171,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) { } func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) { - row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') + row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id WHERE u.id=? - GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id) + GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id) var item AdminUser var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created, mailboxCSV string - if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { + if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil { return nil, err } item.Disabled = intBool(disabled) @@ -1191,9 +1244,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) { return nil, err } m.CreatedAt = parseTime(created) + if err := a.markMailboxPrimary(ctx, &m); err != nil { + return nil, err + } return &m, nil } +func markPrimaryMailboxes(items []Mailbox) { + primaryByUser := make(map[string]int) + for i := range items { + candidate, ok := primaryByUser[items[i].UserID] + if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) { + primaryByUser[items[i].UserID] = i + } + } + for _, index := range primaryByUser { + items[index].Primary = true + } +} + +func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error { + var primaryID string + err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id + WHERE mb.user_id=? + ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID) + if err != nil { + return err + } + mailbox.Primary = mailbox.ID == primaryID + return nil +} + +func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error { + mailbox, err := a.mailboxByID(ctx, id) + if err != nil { + return err + } + if mailbox.Primary { + return errors.New("用户默认邮箱不能删除") + } + return nil +} + func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) { row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID) var m Mailbox diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 0bb680d..a24a200 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -40,6 +40,12 @@ type App struct { telegramDeliveryMu sync.Mutex } +const ( + defaultUserStorageQuotaMB = 100 + defaultAdminStorageQuotaMB = 1024 + minimumStorageQuotaMB = 100 +) + func (a *App) config() Config { a.cfgMu.RLock() defer a.cfgMu.RUnlock() @@ -97,6 +103,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { db.Close() return nil, err } + if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil { + db.Close() + return nil, err + } if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil { db.Close() return nil, err @@ -168,6 +178,7 @@ func (a *App) migrate(ctx context.Context) error { two_factor_secret TEXT NOT NULL DEFAULT '', two_factor_enabled INTEGER NOT NULL DEFAULT 0, mailbox_limit_override INTEGER, + storage_quota_mb INTEGER NOT NULL DEFAULT 100, disabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -673,6 +684,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateUserMailboxLimitOverride(ctx); err != nil { return err } + if err := a.migrateUserStorageQuota(ctx); err != nil { + return err + } if err := a.migrateMailRulesBuilder(ctx); err != nil { return err } @@ -1335,6 +1349,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error { return err } +func (a *App) migrateUserStorageQuota(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`) + if err != nil { + return err + } + hasColumn := false + for rows.Next() { + var cid int + var name, typ string + var notnull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + rows.Close() + return err + } + if name == "storage_quota_mb" { + hasColumn = true + } + } + if err := rows.Close(); err != nil { + return err + } + if hasColumn { + return nil + } + if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB) + return err +} + func (a *App) migrateMessagesForUnregistered(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`) if err != nil { @@ -1513,8 +1560,8 @@ func (a *App) seed(ctx context.Context) error { } now := a.now().UTC().Format(time.RFC3339Nano) userID := newID("usr") - 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, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { + if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil { return err } a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) @@ -1535,7 +1582,7 @@ func (a *App) seed(ctx context.Context) error { } // Create mailbox for admin - mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active") + mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active") if err != nil { return err } @@ -1656,7 +1703,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error if !errors.Is(err, sql.ErrNoRows) { return err } - mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active") + mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active") if err != nil { return err } @@ -1871,8 +1918,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u if localPart == "" { return "", errors.New("invalid local part") } - if quotaMB <= 0 { - quotaMB = 1024 + if quotaMB < 0 { + return "", errors.New("quotaMb must be zero or greater") + } + var ownerRole string + if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil { + return "", err + } + if ownerRole == "admin" { + quotaMB = 0 } if status == "" { status = "active" @@ -1905,6 +1959,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u return id, nil } +func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error { + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now) + return err +} + func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error { cfg := a.config() folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox") diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 82abaa2..2579664 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis return mailbox } +func TestAdminMailboxCreationUsesOwnerPasswordAndQuota(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + var login map[string]any + 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) + } + adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a) + adminDetails, err := a.adminUserByID(context.Background(), adminUser.ID) + if err != nil { + t.Fatal(err) + } + if adminDetails.StorageQuotaMB != defaultAdminStorageQuotaMB { + t.Fatalf("administrator storage quota=%d, want %d", adminDetails.StorageQuotaMB, defaultAdminStorageQuotaMB) + } + domainID := mustDefaultDomainID(t, a) + + var secondary Mailbox + if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{ + "domainId": domainID, + "localPart": "admin-secondary", + "userId": adminUser.ID, + }, &secondary); code != http.StatusCreated { + t.Fatalf("create admin secondary mailbox code=%d", code) + } + if secondary.QuotaMB != 0 { + t.Fatalf("admin secondary quota=%d, want unlimited", secondary.QuotaMB) + } + + var primaryHash, secondaryHash string + if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, adminMailbox.ID).Scan(&primaryHash); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, secondary.ID).Scan(&secondaryHash); err != nil { + t.Fatal(err) + } + if primaryHash != secondaryHash { + t.Fatal("admin secondary mailbox did not inherit the owner password") + } + + var regular AdminUser + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": "owner@lanqin.local", + "displayName": "Owner", + "password": "OwnerPassword123!", + "role": "user", + }, ®ular); code != http.StatusCreated { + t.Fatalf("create regular owner code=%d", code) + } + if regular.MailboxCount != 1 { + t.Fatalf("new account mailbox count=%d, want one protected primary mailbox", regular.MailboxCount) + } + if regular.StorageQuotaMB != defaultUserStorageQuotaMB { + t.Fatalf("regular account storage quota=%d, want %d", regular.StorageQuotaMB, defaultUserStorageQuotaMB) + } + if _, err := a.mailboxByAddress(context.Background(), regular.Email); err != nil { + t.Fatalf("new account primary mailbox missing: %v", err) + } + var primaryStatusErr map[string]any + primaryMailbox, err := a.mailboxByAddress(context.Background(), regular.Email) + if err != nil { + t.Fatal(err) + } + if code := admin.do("POST", "/api/admin/mailboxes/"+primaryMailbox.ID, map[string]any{ + "userId": regular.ID, "displayName": primaryMailbox.DisplayName, "quotaMb": primaryMailbox.QuotaMB, "status": "disabled", + }, &primaryStatusErr); code != http.StatusBadRequest { + t.Fatalf("primary mailbox status update code=%d body=%v", code, primaryStatusErr) + } + var regularMailbox Mailbox + if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{ + "domainId": domainID, + "localPart": "owner-secondary", + "userId": regular.ID, + }, ®ularMailbox); code != http.StatusCreated { + t.Fatalf("create regular secondary mailbox code=%d", code) + } + if regularMailbox.QuotaMB != defaultUserStorageQuotaMB { + t.Fatalf("regular secondary quota=%d, want %d", regularMailbox.QuotaMB, defaultUserStorageQuotaMB) + } + var userHash, mailboxHash string + if err := a.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, regular.ID).Scan(&userHash); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, regularMailbox.ID).Scan(&mailboxHash); err != nil { + t.Fatal(err) + } + if userHash != mailboxHash { + t.Fatal("regular secondary mailbox did not inherit the owner password") + } +} + +func TestAdministratorAccountAndPrimaryMailboxesCannotBeDeleted(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + var login map[string]any + 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) + } + adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a) + var errBody map[string]any + if code := admin.do("DELETE", "/api/admin/users/"+adminUser.ID, nil, &errBody); code != http.StatusBadRequest { + t.Fatalf("administrator account delete code=%d body=%v", code, errBody) + } + if code := admin.do("DELETE", "/api/admin/mailboxes/"+adminMailbox.ID, nil, &errBody); code != http.StatusBadRequest { + t.Fatalf("administrator primary mailbox delete code=%d body=%v", code, errBody) + } + if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{ + "email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 99, + }, &errBody); code != http.StatusBadRequest { + t.Fatalf("storage quota below 100 MB code=%d body=%v", code, errBody) + } + var updatedAdmin AdminUser + if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{ + "email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 100, + }, &updatedAdmin); code != http.StatusOK || updatedAdmin.StorageQuotaMB != 100 { + t.Fatalf("administrator storage quota code=%d user=%+v", code, updatedAdmin) + } + + var regular AdminUser + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": "protected-primary@lanqin.local", "displayName": "Protected Primary", "role": "user", "password": "Password123!", + }, ®ular); code != http.StatusCreated { + t.Fatalf("create regular user code=%d user=%+v", code, regular) + } + primary, err := a.mailboxByAddress(context.Background(), regular.Email) + if err != nil { + t.Fatal(err) + } + if code := admin.do("DELETE", "/api/admin/mailboxes/"+primary.ID, nil, &errBody); code != http.StatusBadRequest { + t.Fatalf("regular primary mailbox delete code=%d body=%v", code, errBody) + } + secondary := createTestMailbox(t, admin, primary.DomainID, "deletable-secondary", "Secondary", "", map[string]any{"userId": regular.ID}) + if code := admin.do("DELETE", "/api/admin/mailboxes/"+secondary.ID, nil, &errBody); code != http.StatusOK { + t.Fatalf("secondary mailbox delete code=%d body=%v", code, errBody) + } +} + func createTestAPIToken(t *testing.T, client *testClient, name string) string { return createTestAPITokenWithScopes(t, client, name, nil) } @@ -1574,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) { if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" { t.Fatalf("register code=%d user=%+v", code, registered.User) } + var storageQuotaMB int + if err := a.db.QueryRow(`SELECT storage_quota_mb FROM users WHERE id=?`, registered.User.ID).Scan(&storageQuotaMB); err != nil { + t.Fatal(err) + } + if storageQuotaMB != defaultUserStorageQuotaMB { + t.Fatalf("registered account storage quota=%d, want %d", storageQuotaMB, defaultUserStorageQuotaMB) + } var me struct { User User `json:"user"` } @@ -3177,20 +3325,45 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) { if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK { t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain) } + var owner AdminUser + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": "open-api-owner@lanqin.local", "displayName": "Open API Owner", "role": "user", "password": "Password123!", + }, &owner); code != http.StatusCreated { + t.Fatalf("create open api mailbox owner code=%d owner=%+v", code, owner) + } var mailbox Mailbox if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{ "domainId": domain.ID, "localPart": "api-user", "displayName": "API User", - "password": "Password123!", + "password": "DifferentPassword123!", "quotaMb": 256, + "userId": owner.ID, }, &mailbox); code != http.StatusCreated { t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox) } + ownerPrimary, err := a.mailboxByAddress(context.Background(), owner.Email) + if err != nil { + t.Fatal(err) + } + var protectedMailboxErr map[string]any + if code := openAdmin.do("POST", "/api/open/mailboxes/"+ownerPrimary.ID, map[string]any{"status": "disabled"}, &protectedMailboxErr); code != http.StatusBadRequest { + t.Fatalf("open api primary mailbox status update code=%d body=%v", code, protectedMailboxErr) + } if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 { t.Fatalf("mailbox=%+v", mailbox) } + var ownerPasswordHash, mailboxPasswordHash string + if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM users WHERE id=?`, owner.ID).Scan(&ownerPasswordHash); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM mailboxes WHERE id=?`, mailbox.ID).Scan(&mailboxPasswordHash); err != nil { + t.Fatal(err) + } + if mailboxPasswordHash != ownerPasswordHash { + t.Fatal("open api mailbox did not inherit the owner password") + } var mailboxes struct { Items []Mailbox `json:"items"` } @@ -3207,6 +3380,9 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) { if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" { t.Fatalf("updated mailbox=%+v", updated) } + if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"status": "active"}, &updated); code != http.StatusOK || updated.QuotaMB != 512 { + t.Fatalf("open api mailbox omitted quota should preserve 512 MB: code=%d mailbox=%+v", code, updated) + } var ok map[string]any if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK { t.Fatalf("delete open api mailbox code=%d body=%v", code, ok) @@ -5195,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) { }, &errBody); code != http.StatusForbidden { t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody) } - var regularUpdateErr map[string]any + var updatedRegular PermissionGroup if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{ "name": "Changed Regular", "description": "Should not change", - "permissions": []string{PermissionAdminOverview}, - }, ®ularUpdateErr); code != http.StatusForbidden { - t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr) + "permissions": regularUserDefaultPermissions(), + "limits": defaultPermissionLimits(), + }, &updatedRegular); code != http.StatusOK { + t.Fatalf("regular system permission group update code=%d group=%+v", code, updatedRegular) + } + if updatedRegular.Name != "普通用户" || updatedRegular.Description != "仅可使用自己的邮箱功能,不包含后台权限。" { + t.Fatalf("regular system permission group identity changed: %+v", updatedRegular) } regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular) if err != nil { t.Fatal(err) } if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) { - t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup) + t.Fatalf("regular group should retain the saved default permissions=%+v", regularGroup) } if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden { t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody) @@ -6357,7 +6537,7 @@ func TestMailboxQuotaRejectsNewMessage(t *testing.T) { ctx := context.Background() user, mb := defaultAdminUserAndMailbox(t, a) clearMailboxMessagesForTest(t, a, mb.ID) - if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil { + if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=1 WHERE id=?`, user.ID); err != nil { t.Fatal(err) } _, err := a.sendMailNow(ctx, user, mb, mailComposeInput{ @@ -6433,7 +6613,11 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) { if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK { t.Fatalf("stats code=%d stats=%+v", code, stats) } - if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 { + var aliceStorageQuotaMB int64 + if err := a.db.QueryRowContext(ctx, `SELECT storage_quota_mb FROM users WHERE id=?`, aliceUser.ID).Scan(&aliceStorageQuotaMB); err != nil { + t.Fatal(err) + } + if stats.QuotaBytes != aliceStorageQuotaMB*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 { t.Fatalf("stats quota/attachment not populated: %+v", stats) } if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 { diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go index 7dbf06e..d60fc97 100644 --- a/apps/api/internal/app/auth_handlers.go +++ b/apps/api/internal/app/auth_handlers.go @@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil { + if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { respondError(w, http.StatusConflict, "该邮箱已被注册") return @@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试") return } - if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil { + if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { respondError(w, http.StatusConflict, "该邮箱已被注册") } else { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 5df1d6a..5c29ac4 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -87,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { m.CreatedAt = parseTime(created) items = append(items, m) } + markPrimaryMailboxes(items) respondJSON(w, http.StatusOK, map[string]any{"items": items}) } @@ -2619,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma return nil } var quotaMB int64 - if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil { + var userID string + if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan("aMB, &userID); err != nil { return err } if quotaMB <= 0 { return nil } var used int64 - if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil { + if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil { return err } quotaBytes := quotaMB * 1024 * 1024 diff --git a/apps/api/internal/app/mailbox_delete_test.go b/apps/api/internal/app/mailbox_delete_test.go index ee8d40e..ee2ec41 100644 --- a/apps/api/internal/app/mailbox_delete_test.go +++ b/apps/api/internal/app/mailbox_delete_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) { +func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) defer ts.Close() @@ -21,10 +21,13 @@ func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) { if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 { t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items)) } - if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK { - t.Fatalf("delete final mailbox code=%d", code) + if !mailboxes.Items[0].Primary { + t.Fatal("administrator mailbox should be marked as primary") } - if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 { + if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest { + t.Fatalf("delete primary mailbox code=%d", code) + } + if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 { t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items)) } var me map[string]any diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go index 5756ead..a160513 100644 --- a/apps/api/internal/app/open_api_handlers.go +++ b/apps/api/internal/app/open_api_handlers.go @@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) badRequest(w, errors.New("password must be at least 6 characters")) return } + if req.QuotaMB < 0 { + badRequest(w, errors.New("quotaMb must be zero or greater")) + return + } domain, err := a.domainByID(r.Context(), req.DomainID) if err != nil { respondError(w, http.StatusNotFound, "domain not found") @@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) respondMailboxOwnerError(w, err) return } - mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active") + var ownerPasswordHash, ownerRole string + var ownerStorageQuotaMB int + if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load owner user") + return + } + quotaMB := req.QuotaMB + if quotaMB == 0 { + quotaMB = ownerStorageQuotaMB + } + if ownerRole == "admin" { + quotaMB = 0 + } + mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active") if err != nil { badRequest(w, err) return @@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) displayName = current.DisplayName } quotaMB := req.QuotaMB - if quotaMB <= 0 { + if quotaMB < 0 { + badRequest(w, errors.New("quotaMb must be zero or greater")) + return + } + if quotaMB == 0 { quotaMB = current.QuotaMB } status := strings.TrimSpace(req.Status) @@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) if userID == "" { userID = current.UserID } + if current.Primary && userID != current.UserID { + badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改")) + return + } + if current.Primary && status != current.Status { + badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改")) + return + } if err := a.ensureActiveUserExists(r.Context(), userID); err != nil { respondMailboxOwnerError(w, err) return } - res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, - userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) + var ownerRole, ownerPasswordHash string + if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load owner user") + return + } + if ownerRole == "admin" { + quotaMB = 0 + } + res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, + userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update mailbox") return @@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + if err := a.ensureMailboxDeletable(r.Context(), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "邮箱不存在或已被删除") + } else { + badRequest(w, err) + } + return + } rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) if err != nil { respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败") @@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own if displayName == "" { displayName = email } - _, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now) + _, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now) return userID, err } diff --git a/apps/api/internal/app/permission_group_handlers.go b/apps/api/internal/app/permission_group_handlers.go index 89aae09..9cb5861 100644 --- a/apps/api/internal/app/permission_group_handlers.go +++ b/apps/api/internal/app/permission_group_handlers.go @@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var existingSystem int - if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil { + var existingName, existingDescription string + if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil { respondError(w, http.StatusNotFound, "permission group not found") return } - if intBool(existingSystem) { + if intBool(existingSystem) && id != PermissionGroupRegular { respondError(w, http.StatusForbidden, "system permission groups cannot be edited") return } @@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request return } name := strings.TrimSpace(req.Name) + if id == PermissionGroupRegular { + name = existingName + req.Description = existingDescription + } if name == "" { badRequest(w, errors.New("name is required")) return diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index 2735fb1..e769238 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("displayName must be at most 80 characters")) return } - mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active") + quotaMB := defaultUserStorageQuotaMB + if user.Role == "admin" { + quotaMB = 0 + } + mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active") if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { respondError(w, http.StatusConflict, "该邮箱地址已被占用") @@ -900,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to load send queue stats") return } - if mailboxID != "" && !isAllMailboxID(mailboxID) { - var quotaMB int64 - if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil { - respondError(w, http.StatusInternalServerError, "failed to load quota") - return - } - stats.QuotaBytes = quotaMB * 1024 * 1024 - if stats.QuotaBytes > 0 { - stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100 - } - } else { - var quotaMB int64 - if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil { - respondError(w, http.StatusInternalServerError, "failed to load quota") - return - } - stats.QuotaBytes = quotaMB * 1024 * 1024 - if stats.QuotaBytes > 0 { - stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100 - } + var quotaMB int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan("aMB); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load quota") + return + } + stats.QuotaBytes = quotaMB * 1024 * 1024 + if stats.QuotaBytes > 0 { + stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100 } rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index de31c5c..4f4f025 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -21,8 +21,9 @@ type User struct { type AdminUser struct { User - MailboxCount int `json:"mailboxCount"` - Mailboxes []string `json:"mailboxes"` + MailboxCount int `json:"mailboxCount"` + Mailboxes []string `json:"mailboxes"` + StorageQuotaMB int `json:"storageQuotaMb"` } type APIToken struct { @@ -71,6 +72,7 @@ type Mailbox struct { DisplayName string `json:"displayName"` QuotaMB int `json:"quotaMb"` Status string `json:"status"` + Primary bool `json:"primary"` UnreadCount int `json:"unreadCount"` CreatedAt time.Time `json:"createdAt"` } diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index 70db88f..d0d3b90 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -5,7 +5,6 @@ import { useMe } from "@/hooks/use-me" import { useLogout } from "@/hooks/use-logout" import { AuthGuard } from "@/components/auth-guard" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { SystemVersionDialog } from "@/components/system-version-dialog" import { hasAnyPermission } from "@/lib/permissions" @@ -110,9 +109,6 @@ function ProtectedContent() { {user.displayName} {user.email} - - {user.role === "admin" ? "管理员" : "普通用户"} - diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index 378b5d1..41f6cd1 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -8,6 +8,7 @@ const Card = React.forwardRef< >(({ className, ...props }, ref) => (
(({ className, ...props }, ref) => (
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef< >(({ className, ...props }, ref) => (
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)) CardContent.displayName = "CardContent" diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx index c0df655..bc64568 100644 --- a/apps/web/src/components/ui/table.tsx +++ b/apps/web/src/components/ui/table.tsx @@ -20,7 +20,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )) TableHeader.displayName = "TableHeader" @@ -57,6 +57,7 @@ const TableRow = React.forwardRef< >(({ className, ...props }, ref) => ( (({ className, ...props }, ref) => ( [role=checkbox]]:translate-y-[2px]", className @@ -87,6 +89,7 @@ const TableCell = React.forwardRef< >(({ className, ...props }, ref) => ( [role=checkbox]]:translate-y-[2px]", className diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d35c692..3adcaa8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -140,12 +140,50 @@ } [data-sidebar="menu-button"][data-active="true"] { - background: hsl(var(--sidebar-accent)); + background: hsl(var(--sidebar-accent) / 0.82); color: hsl(var(--sidebar-accent-foreground)); + box-shadow: inset 2px 0 0 hsl(var(--foreground)); } [data-sidebar="menu-button"][data-active="true"] svg { - color: hsl(var(--muted-foreground)); + color: hsl(var(--foreground)); + } + + .admin-page [data-slot="card"] { + border-radius: 0.5rem; + box-shadow: none; + } + + .admin-page [data-slot="card-header"] { + padding: 1.25rem; + } + + .admin-page [data-slot="card-content"] { + padding: 0 1.25rem 1.25rem; + } + + .admin-page [data-slot="card-title"] { + font-size: 0.9375rem; + line-height: 1.4; + } + + .admin-page [data-slot="table-header"] { + background: hsl(var(--muted) / 0.48); + } + + .admin-page [data-slot="table-head"] { + height: 2.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + font-size: 0.75rem; + } + + .admin-page [data-slot="table-cell"] { + padding: 0.75rem; + } + + .admin-page [data-slot="table-row"]:hover { + background: hsl(var(--muted) / 0.32); } } diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 3a1c932..eb27d14 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -52,10 +52,10 @@ export type PermissionGroupSummary = { id: string; name: string } export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string } export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string } -export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] } +export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number } export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } -export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string } +export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 21ac4d3..ff59da6 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -172,8 +172,8 @@ export const api = { updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), defaultPermissionLimits: () => request("/api/admin/permission-limits/defaults"), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), - createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), - updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), + createUser: (payload: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), + updateUser: (id: string, payload: { email?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; storageQuotaMb?: number; permissionGroupIds?: string[] }) => request(`/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 }) }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), domains: () => request>("/api/admin/domains"), @@ -181,7 +181,7 @@ export const api = { updateDomain: (id: string, payload: { status: string }) => request(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }), deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }), mailboxes: () => request>("/api/admin/mailboxes"), - createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), + createMailbox: (payload: { domainId: string; localPart: string; displayName?: string; userId: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }), deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }), aliases: () => request>("/api/admin/aliases"), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 7e29d8e..003a7c4 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -32,10 +32,10 @@ type PendingConfirm = { title: string; description?: string; confirmText: string const sectionMeta: Record = { overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" }, - users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" }, + users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" }, permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" }, domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" }, - mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" }, + mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" }, aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" }, messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" }, sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" }, @@ -58,8 +58,12 @@ const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email" const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5" const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } const defaultMailboxLimitOverride = 9 +const defaultUserStorageQuotaMb = 100 +const defaultAdminStorageQuotaMb = 1024 +const superAdminPermissionGroupId = "pg_super_admin" +const regularUserPermissionGroupId = "pg_regular_user" +const defaultStorageQuotaMb = (role: AdminUser["role"]) => role === "admin" ? defaultAdminStorageQuotaMb : defaultUserStorageQuotaMb const accountPrimaryEmail = (user: Pick) => user.email || user.loginName || "" - function compareAdminUsers(left: AdminUser, right: AdminUser) { if (left.role === "admin" && right.role !== "admin") return -1 if (left.role !== "admin" && right.role === "admin") return 1 @@ -86,7 +90,7 @@ export function AdminPage() { const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview }) const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) }) const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) }) - const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) }) + const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) }) const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) }) const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView }) const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView }) @@ -97,7 +101,7 @@ export function AdminPage() { const mailboxItems = mailboxes.data?.items || [] const aliasItems = aliases.data?.items || [] const userItems = users.data?.items || [] - const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== "pg_super_admin" && group.id !== "pg_regular_user") + const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId) const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key])) const rawSection = params.get("section") as Section | null const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview" @@ -129,7 +133,7 @@ export function AdminPage() { return ( -
+
{sectionQuery?.isError && { void sectionQuery.refetch() }} />} @@ -144,7 +148,7 @@ export function AdminPage() { )} {section === "overview" && setParams(next === "overview" ? {} : { section: next })} />} - {section === "users" && } + {section === "users" && } {section === "permissionGroups" && } {section === "domains" && } {section === "mailboxes" && } @@ -175,7 +179,6 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; - NewSzxcn
@@ -252,7 +255,7 @@ function InfoLine({ label, value }: { label: string; value: React.ReactNode }) { return
{label}{value}
} -function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permissionGroups: PermissionGroup[] }) { +function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) { const me = useMe() const user = me.data?.user const qc = useQueryClient() @@ -279,7 +282,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
账号管理 - {canCreate && } +
+ {canCreate && } +
@@ -317,8 +322,8 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
- {user.disabled ? "停用" : "正常"} - {new Date(user.createdAt).toLocaleDateString()} + + {new Date(user.createdAt).toLocaleDateString()}
@@ -338,7 +343,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss - {user.disabled ? "停用" : "正常"} + {new Date(user.createdAt).toLocaleDateString()} setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /> @@ -360,6 +365,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ const { toast } = useToast() const [query, setQuery] = React.useState("") const [editing, setEditing] = React.useState(null) + const [viewing, setViewing] = React.useState(null) const [pendingConfirm, setPendingConfirm] = React.useState(null) const canCreate = hasPermission(user, "admin.permission_groups.create") const canUpdate = hasPermission(user, "admin.permission_groups.update") @@ -378,14 +384,16 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ if (!keyword) return true return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword)) }) - const isEditable = (group: PermissionGroup) => !group.system + const isEditable = (group: PermissionGroup) => !group.system || group.id === regularUserPermissionGroupId const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0 return (
权限配置 - {canCreate && } +
+ {canCreate && } +
@@ -400,26 +408,21 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{group.name}
- {group.system && 系统组} - {!group.system && 自定义} - {group.userCount} 人 + {group.system ? "系统配置" : "自定义配置"} · {group.userCount} 个账号
{group.description || "未填写说明"}
- {(canUpdate || canDelete) && - - - setEditing(group)}>编辑权限配置 - - setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })} - > - 删除权限配置 - - - } +
+ + {((isEditable(group) && canUpdate) || (isDeletable(group) && canDelete)) && + + + {isEditable(group) && canUpdate && setEditing(group)}>{group.id === regularUserPermissionGroupId ? "编辑普通用户权限" : "编辑权限配置"}} + {isEditable(group) && canUpdate && isDeletable(group) && canDelete && } + {isDeletable(group) && canDelete && setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}>删除权限配置} + + } +
@@ -429,6 +432,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ {filtered.length === 0 && } {editing && { if (!open) setEditing(null) }} />} + {viewing && { if (!open) setViewing(null) }} />} { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> ) @@ -440,6 +444,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: const [internalOpen, setInternalOpen] = React.useState(false) const dialogOpen = open ?? internalOpen const setDialogOpen = onOpenChange ?? setInternalOpen + const fixedIdentity = group?.id === regularUserPermissionGroupId const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen }) const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits const [permissions, setPermissions] = React.useState(group?.permissions || []) @@ -469,18 +474,18 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: }) const trigger = group ? null : ( - + ) return ( {trigger} - {group ? "编辑权限配置" : "创建权限配置"} + {fixedIdentity ? "编辑普通用户权限" : group ? "编辑权限配置" : "创建权限配置"}
{ event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
- - + +
@@ -491,7 +496,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: ) } -function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void }) { +function PermissionGroupDetailsDialog({ group, catalog, open, onOpenChange }: { group: PermissionGroup; catalog: PermissionInfo[]; open: boolean; onOpenChange: (open: boolean) => void }) { + return ( + + + {group.name}的全部权限 +

共 {group.permissions.length} 项权限。{group.id === superAdminPermissionGroupId ? "管理员权限由账号身份决定,不能修改。" : ""}

+ + undefined} readOnly /> +
+
+ ) +} + +function PermissionPicker({ catalog, value, onChange, readOnly = false }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void; readOnly?: boolean }) { const grouped = groupPermissionCatalog(catalog) function toggle(permission: PermissionKey, checked: boolean) { onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission)) @@ -504,24 +522,25 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf
- {value.length} 项 + 已选 {value.length} 项
{grouped.map(({ category, items }) => { const allChecked = items.every((item) => value.includes(item.key)) + const selectedCount = items.filter((item) => value.includes(item.key)).length return (
-
{items.map((item) => ( -
- {role === "user" && !user.protected && } +
+ {role === "user" && !user.protected && } + +
{role === "user" && !user.protected && } @@ -2093,17 +2144,10 @@ function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; op return 重置密码
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
} -function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) { - const qc = useQueryClient(); const { toast } = useToast(); const [userId, setUserId] = React.useState(mailbox.userId); const [status, setStatus] = React.useState(mailbox.status) - React.useEffect(() => { setUserId(mailbox.userId); setStatus(mailbox.status) }, [mailbox, open]) - const mut = useMutation({ mutationFn: (form: FormData) => api.updateMailbox(mailbox.id, { userId, displayName: String(form.get("displayName") || ""), quotaMb: Number(form.get("quotaMb") || 1024), status }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "邮箱已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) - return 编辑邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> !u.disabled).map((u) => [u.id, u.email])} />
-} - function CreateDomainDialog() { const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false) const mut = useMutation({ mutationFn: (form: FormData) => api.createDomain(String(form.get("name"))), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "域名已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) - return 添加域名
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
+ return 添加域名
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
} function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) { @@ -2111,7 +2155,6 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm const { toast } = useToast() const [open, setOpen] = React.useState(false) const [domainId, setDomainId] = React.useState("") - const [ownerMode, setOwnerMode] = React.useState("new") const [userId, setUserId] = React.useState("") React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) @@ -2121,38 +2164,22 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), - displayName: String(form.get("displayName")), - password: String(form.get("password")), - quotaMb: Number(form.get("quotaMb") || 1024), - role: "user", - ownerEmail: String(form.get("ownerEmail") || ""), - userId: ownerMode === "existing" ? userId : "", + userId, }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }), }) return ( - + 创建邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> -
- - -
- - {ownerMode === "existing" ? ( - !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} /> - ) : ( - - )} -
- - -
- + + !item.disabled).sort(compareAdminUsers).map((item) => [item.id, accountPrimaryEmail(item)])} /> +

该邮箱使用绑定账号的登录密码;账号修改密码后会自动同步。

+
@@ -2163,7 +2190,7 @@ function CreateAliasDialog({ domains }: { domains: Domain[] }) { const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState("") React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId]) const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "转发已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) - return 创建邮件转发
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
+ return 创建邮件转发
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
} function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {