release: prepare v1.2.29
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:
@@ -0,0 +1,13 @@
|
|||||||
|
- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
|
||||||
|
- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
|
||||||
|
- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
|
||||||
|
- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
|
||||||
|
- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
|
||||||
|
- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
|
||||||
|
- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
|
||||||
|
- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
|
||||||
|
- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
|
||||||
|
- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
|
||||||
|
- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
|
||||||
|
- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
|
||||||
|
- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
|
||||||
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleListUsers(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
|
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`)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
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 disabled, twoFactorEnabled int
|
||||||
var mailboxLimitOverride sql.NullInt64
|
var mailboxLimitOverride sql.NullInt64
|
||||||
var created, mailboxCSV string
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,6 +101,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||||
|
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -142,6 +143,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
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) {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
@@ -159,11 +168,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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)
|
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), now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
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
|
permissionGroupIDs := req.PermissionGroupIDs
|
||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
permissionGroupIDs = nil
|
permissionGroupIDs = nil
|
||||||
@@ -194,6 +214,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Disabled *bool `json:"disabled"`
|
Disabled *bool `json:"disabled"`
|
||||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||||
|
StorageQuotaMB *int `json:"storageQuotaMb"`
|
||||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -267,6 +288,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
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 {
|
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -311,8 +344,8 @@ 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 login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
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), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
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") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
badRequest(w, errors.New("主登录邮箱已被使用"))
|
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||||
return
|
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 {
|
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||||
respondError(w, http.StatusNotFound, "user not found")
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
return
|
return
|
||||||
} else if a.isDefaultAdminUser(target) {
|
} else if target.Role == "admin" {
|
||||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
badRequest(w, errors.New("administrator accounts cannot be deleted"))
|
||||||
return
|
|
||||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
|
||||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
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)
|
m.CreatedAt = parseTime(created)
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
|
markPrimaryMailboxes(items)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": 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"`
|
DisplayName string `json:"displayName"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
QuotaMB int `json:"quotaMb"`
|
QuotaMB int `json:"quotaMb"`
|
||||||
Role string `json:"role"`
|
|
||||||
OwnerLoginName string `json:"ownerLoginName"`
|
OwnerLoginName string `json:"ownerLoginName"`
|
||||||
OwnerEmail string `json:"ownerEmail"`
|
OwnerEmail string `json:"ownerEmail"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@@ -580,20 +610,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !hasMinimumPasswordLength(req.Password) {
|
userID := strings.TrimSpace(req.UserID)
|
||||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
if req.QuotaMB < 0 {
|
||||||
return
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
}
|
|
||||||
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, "管理员只能由安装流程创建")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,15 +630,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
|
||||||
userID := strings.TrimSpace(req.UserID)
|
|
||||||
displayName := req.DisplayName
|
displayName := req.DisplayName
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = address
|
displayName = address
|
||||||
}
|
}
|
||||||
|
var disabled, ownerStorageQuotaMB int
|
||||||
|
var passwordHash, ownerRole string
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
var disabled int
|
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 err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "owner user not found")
|
respondError(w, http.StatusNotFound, "owner user not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -627,11 +645,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if intBool(disabled) {
|
} else {
|
||||||
badRequest(w, errors.New("owner user is disabled"))
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ownerEmailInput := req.OwnerEmail
|
ownerEmailInput := req.OwnerEmail
|
||||||
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
||||||
ownerEmailInput = req.OwnerLoginName
|
ownerEmailInput = req.OwnerLoginName
|
||||||
@@ -641,21 +659,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if hashErr != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID = newID("usr")
|
userID = newID("usr")
|
||||||
ownerDisplayName := displayName
|
passwordHash = string(hash)
|
||||||
if !strings.EqualFold(ownerEmail, address) {
|
ownerRole = "user"
|
||||||
ownerDisplayName = ownerEmail
|
ownerStorageQuotaMB = defaultUserStorageQuotaMB
|
||||||
}
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
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, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
|
||||||
if err != nil {
|
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -664,16 +681,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if intBool(disabled) {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
|
badRequest(w, errors.New("owner user is disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
quotaMB := req.QuotaMB
|
||||||
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
|
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 {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||||
|
return
|
||||||
|
}
|
||||||
m, err := a.mailboxByID(r.Context(), mailboxID)
|
m, err := a.mailboxByID(r.Context(), mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
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"))
|
badRequest(w, errors.New("displayName is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.QuotaMB <= 0 {
|
if req.QuotaMB < 0 {
|
||||||
req.QuotaMB = 1024
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -710,13 +738,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid status"))
|
badRequest(w, errors.New("invalid status"))
|
||||||
return
|
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)
|
userID := strings.TrimSpace(req.UserID)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
badRequest(w, errors.New("userId is required"))
|
badRequest(w, errors.New("userId is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existingMailbox.Primary && userID != existingMailbox.UserID {
|
||||||
|
badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
|
||||||
|
return
|
||||||
|
}
|
||||||
var disabled int
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respondError(w, http.StatusNotFound, "owner user not found")
|
respondError(w, http.StatusNotFound, "owner user not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -728,8 +770,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("owner user is disabled"))
|
badRequest(w, errors.New("owner user is disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
if ownerRole == "admin" {
|
||||||
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||||
return
|
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) {
|
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
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) {
|
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
|
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||||
WHERE 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 item AdminUser
|
||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
var mailboxLimitOverride sql.NullInt64
|
var mailboxLimitOverride sql.NullInt64
|
||||||
var created, mailboxCSV string
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
item.Disabled = intBool(disabled)
|
item.Disabled = intBool(disabled)
|
||||||
@@ -1191,9 +1244,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
|
if err := a.markMailboxPrimary(ctx, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &m, nil
|
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) {
|
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)
|
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
|
var m Mailbox
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ type App struct {
|
|||||||
telegramDeliveryMu sync.Mutex
|
telegramDeliveryMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultUserStorageQuotaMB = 100
|
||||||
|
defaultAdminStorageQuotaMB = 1024
|
||||||
|
minimumStorageQuotaMB = 100
|
||||||
|
)
|
||||||
|
|
||||||
func (a *App) config() Config {
|
func (a *App) config() Config {
|
||||||
a.cfgMu.RLock()
|
a.cfgMu.RLock()
|
||||||
defer a.cfgMu.RUnlock()
|
defer a.cfgMu.RUnlock()
|
||||||
@@ -97,6 +103,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -168,6 +178,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
two_factor_secret TEXT NOT NULL DEFAULT '',
|
two_factor_secret TEXT NOT NULL DEFAULT '',
|
||||||
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
mailbox_limit_override INTEGER,
|
mailbox_limit_override INTEGER,
|
||||||
|
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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 {
|
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateUserStorageQuota(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1349,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
|
|||||||
return err
|
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 {
|
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1513,8 +1560,8 @@ 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 _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
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, now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
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
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1656,7 +1703,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
|
|||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1871,8 +1918,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
|||||||
if localPart == "" {
|
if localPart == "" {
|
||||||
return "", errors.New("invalid local part")
|
return "", errors.New("invalid local part")
|
||||||
}
|
}
|
||||||
if quotaMB <= 0 {
|
if quotaMB < 0 {
|
||||||
quotaMB = 1024
|
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 == "" {
|
if status == "" {
|
||||||
status = "active"
|
status = "active"
|
||||||
@@ -1905,6 +1959,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
|||||||
return id, nil
|
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_mb<?`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.db.ExecContext(ctx, `UPDATE mailboxes
|
||||||
|
SET quota_mb=0, updated_at=?
|
||||||
|
WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||||
cfg := a.config()
|
cfg := a.config()
|
||||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
||||||
|
|||||||
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
|
|||||||
return mailbox
|
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 {
|
func createTestAPIToken(t *testing.T, client *testClient, name string) string {
|
||||||
return createTestAPITokenWithScopes(t, client, name, nil)
|
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" {
|
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)
|
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 {
|
var me struct {
|
||||||
User User `json:"user"`
|
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 {
|
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)
|
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
|
var mailbox Mailbox
|
||||||
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
|
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
|
||||||
"domainId": domain.ID,
|
"domainId": domain.ID,
|
||||||
"localPart": "api-user",
|
"localPart": "api-user",
|
||||||
"displayName": "API User",
|
"displayName": "API User",
|
||||||
"password": "Password123!",
|
"password": "DifferentPassword123!",
|
||||||
"quotaMb": 256,
|
"quotaMb": 256,
|
||||||
|
"userId": owner.ID,
|
||||||
}, &mailbox); code != http.StatusCreated {
|
}, &mailbox); code != http.StatusCreated {
|
||||||
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
|
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 {
|
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
|
||||||
t.Fatalf("mailbox=%+v", mailbox)
|
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 {
|
var mailboxes struct {
|
||||||
Items []Mailbox `json:"items"`
|
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" {
|
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
|
||||||
t.Fatalf("updated mailbox=%+v", updated)
|
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
|
var ok map[string]any
|
||||||
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
|
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)
|
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 {
|
}, &errBody); code != http.StatusForbidden {
|
||||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
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{
|
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||||
"name": "Changed Regular",
|
"name": "Changed Regular",
|
||||||
"description": "Should not change",
|
"description": "Should not change",
|
||||||
"permissions": []string{PermissionAdminOverview},
|
"permissions": regularUserDefaultPermissions(),
|
||||||
}, ®ularUpdateErr); code != http.StatusForbidden {
|
"limits": defaultPermissionLimits(),
|
||||||
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
|
}, &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)
|
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
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 {
|
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)
|
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()
|
ctx := context.Background()
|
||||||
user, mb := defaultAdminUserAndMailbox(t, a)
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
clearMailboxMessagesForTest(t, a, mb.ID)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
_, 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 {
|
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)
|
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)
|
t.Fatalf("stats quota/attachment not populated: %+v", stats)
|
||||||
}
|
}
|
||||||
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
|
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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)
|
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, now, now); err != nil {
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||||
return
|
return
|
||||||
@@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||||
return
|
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") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
|||||||
m.CreatedAt = parseTime(created)
|
m.CreatedAt = parseTime(created)
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
|
markPrimaryMailboxes(items)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": 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
|
return nil
|
||||||
}
|
}
|
||||||
var quotaMB int64
|
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
|
return err
|
||||||
}
|
}
|
||||||
if quotaMB <= 0 {
|
if quotaMB <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var used int64
|
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
|
return err
|
||||||
}
|
}
|
||||||
quotaBytes := quotaMB * 1024 * 1024
|
quotaBytes := quotaMB * 1024 * 1024
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
|
func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
defer ts.Close()
|
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 {
|
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))
|
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 {
|
if !mailboxes.Items[0].Primary {
|
||||||
t.Fatalf("delete final mailbox code=%d", code)
|
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))
|
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
|
||||||
}
|
}
|
||||||
var me map[string]any
|
var me map[string]any
|
||||||
|
|||||||
@@ -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"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.QuotaMB < 0 {
|
||||||
|
badRequest(w, errors.New("quotaMb must be zero or greater"))
|
||||||
|
return
|
||||||
|
}
|
||||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "domain not found")
|
respondError(w, http.StatusNotFound, "domain not found")
|
||||||
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
respondMailboxOwnerError(w, err)
|
respondMailboxOwnerError(w, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
displayName = current.DisplayName
|
displayName = current.DisplayName
|
||||||
}
|
}
|
||||||
quotaMB := req.QuotaMB
|
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
|
quotaMB = current.QuotaMB
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = current.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 {
|
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
||||||
respondMailboxOwnerError(w, err)
|
respondMailboxOwnerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
var ownerRole, ownerPasswordHash string
|
||||||
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||||
return
|
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) {
|
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||||
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
|||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = email
|
displayName = email
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
_, 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, now, now)
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
|
|||||||
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
var existingSystem int
|
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")
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if intBool(existingSystem) {
|
if intBool(existingSystem) && id != PermissionGroupRegular {
|
||||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if id == PermissionGroupRegular {
|
||||||
|
name = existingName
|
||||||
|
req.Description = existingDescription
|
||||||
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
badRequest(w, errors.New("name is required"))
|
badRequest(w, errors.New("name is required"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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"))
|
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
||||||
return
|
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 err != nil {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||||
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
var quotaMB int64
|
||||||
var quotaMB int64
|
if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan("aMB); err != nil {
|
||||||
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")
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
return
|
||||||
return
|
}
|
||||||
}
|
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
if stats.QuotaBytes > 0 {
|
||||||
if stats.QuotaBytes > 0 {
|
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
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
|
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ type User struct {
|
|||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
User
|
User
|
||||||
MailboxCount int `json:"mailboxCount"`
|
MailboxCount int `json:"mailboxCount"`
|
||||||
Mailboxes []string `json:"mailboxes"`
|
Mailboxes []string `json:"mailboxes"`
|
||||||
|
StorageQuotaMB int `json:"storageQuotaMb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToken struct {
|
type APIToken struct {
|
||||||
@@ -71,6 +72,7 @@ type Mailbox struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
QuotaMB int `json:"quotaMb"`
|
QuotaMB int `json:"quotaMb"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useMe } from "@/hooks/use-me"
|
|||||||
import { useLogout } from "@/hooks/use-logout"
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
import { AuthGuard } from "@/components/auth-guard"
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||||
import { hasAnyPermission } from "@/lib/permissions"
|
import { hasAnyPermission } from "@/lib/permissions"
|
||||||
@@ -110,9 +109,6 @@ function ProtectedContent() {
|
|||||||
<span className="truncate font-semibold">{user.displayName}</span>
|
<span className="truncate font-semibold">{user.displayName}</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
|
||||||
{user.role === "admin" ? "管理员" : "普通用户"}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const Card = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
@@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card-header"
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="card-title"
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -57,7 +60,7 @@ const CardContent = React.forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
))
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead ref={ref} data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
))
|
))
|
||||||
TableHeader.displayName = "TableHeader"
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ const TableRow = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className
|
className
|
||||||
@@ -72,6 +73,7 @@ const TableHead = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className
|
||||||
@@ -87,6 +89,7 @@ const TableCell = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className
|
||||||
|
|||||||
+40
-2
@@ -140,12 +140,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-sidebar="menu-button"][data-active="true"] {
|
[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));
|
color: hsl(var(--sidebar-accent-foreground));
|
||||||
|
box-shadow: inset 2px 0 0 hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sidebar="menu-button"][data-active="true"] svg {
|
[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 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 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 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 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 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 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 }
|
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ export const api = {
|
|||||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
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: { email: string; displayName: string; role: "user"; password: string; disabled: boolean; mailboxLimitOverride?: number }) => request<AdminUser>("/api/admin/users", { 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<AdminUser>("/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<AdminUser>(`/api/admin/users/${id}`, { 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<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"),
|
||||||
@@ -181,7 +181,7 @@ export const api = {
|
|||||||
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
|
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
|
||||||
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
|
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
|
||||||
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
createMailbox: (payload: { domainId: string; localPart: string; displayName?: string; userId: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
|
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
|
||||||
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
|
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
|
||||||
|
|||||||
+208
-181
@@ -32,10 +32,10 @@ type PendingConfirm = { title: string; description?: string; confirmText: string
|
|||||||
|
|
||||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||||
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
|
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
|
||||||
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
|
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
|
||||||
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
|
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
|
||||||
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
||||||
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
|
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
|
||||||
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
||||||
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
||||||
sendAudit: { 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 projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||||
const defaultMailboxLimitOverride = 9
|
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<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
|
const accountPrimaryEmail = (user: Pick<AdminUser, "email" | "loginName">) => user.email || user.loginName || ""
|
||||||
|
|
||||||
function compareAdminUsers(left: AdminUser, right: AdminUser) {
|
function compareAdminUsers(left: AdminUser, right: AdminUser) {
|
||||||
if (left.role === "admin" && right.role !== "admin") return -1
|
if (left.role === "admin" && right.role !== "admin") return -1
|
||||||
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 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 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 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 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 aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
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 mailboxItems = mailboxes.data?.items || []
|
||||||
const aliasItems = aliases.data?.items || []
|
const aliasItems = aliases.data?.items || []
|
||||||
const userItems = users.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 visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||||||
const rawSection = params.get("section") as Section | null
|
const rawSection = params.get("section") as Section | null
|
||||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||||
@@ -129,7 +133,7 @@ export function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
<main className="admin-page mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||||
|
|
||||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||||
@@ -144,7 +148,7 @@ export function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||||
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} />}
|
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
|
||||||
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
||||||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||||
@@ -175,7 +179,6 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
|
|||||||
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||||
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||||
</Button>
|
</Button>
|
||||||
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +255,7 @@ function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permissionGroups: PermissionGroup[] }) {
|
function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -279,7 +282,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<CardTitle>账号管理</CardTitle>
|
<CardTitle>账号管理</CardTitle>
|
||||||
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} domains={domains} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -317,8 +322,8 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<RoleBadge user={user} />
|
<RoleBadge user={user} />
|
||||||
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
|
<AccountStatus user={user} />
|
||||||
<Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
|
<span className="text-xs text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3"><UserPermissionGroupsCell user={user} /></div>
|
<div className="mt-3"><UserPermissionGroupsCell user={user} /></div>
|
||||||
<div className="mt-3"><UserMailboxCell user={user} /></div>
|
<div className="mt-3"><UserMailboxCell user={user} /></div>
|
||||||
@@ -338,7 +343,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
|||||||
<TableCell><RoleBadge user={user} /></TableCell>
|
<TableCell><RoleBadge user={user} /></TableCell>
|
||||||
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
||||||
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
|
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
|
||||||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
|
<TableCell><AccountStatus user={user} /></TableCell>
|
||||||
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountPrimaryEmail(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -360,6 +365,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [query, setQuery] = React.useState("")
|
const [query, setQuery] = React.useState("")
|
||||||
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
|
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
|
||||||
|
const [viewing, setViewing] = React.useState<PermissionGroup | null>(null)
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const canCreate = hasPermission(user, "admin.permission_groups.create")
|
const canCreate = hasPermission(user, "admin.permission_groups.create")
|
||||||
const canUpdate = hasPermission(user, "admin.permission_groups.update")
|
const canUpdate = hasPermission(user, "admin.permission_groups.update")
|
||||||
@@ -378,14 +384,16 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
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
|
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<CardTitle>权限配置</CardTitle>
|
<CardTitle>权限配置</CardTitle>
|
||||||
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -400,26 +408,21 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="font-medium">{group.name}</div>
|
<div className="font-medium">{group.name}</div>
|
||||||
{group.system && <Badge variant="outline">系统组</Badge>}
|
<span className="text-xs text-muted-foreground">{group.system ? "系统配置" : "自定义配置"} · {group.userCount} 个账号</span>
|
||||||
{!group.system && <Badge variant="secondary">自定义</Badge>}
|
|
||||||
<Badge variant="outline">{group.userCount} 人</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||||
</div>
|
</div>
|
||||||
{(canUpdate || canDelete) && <DropdownMenu>
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
<Button type="button" variant="outline" size="sm" onClick={() => setViewing(group)}>查看全部权限</Button>
|
||||||
<DropdownMenuContent align="end">
|
{((isEditable(group) && canUpdate) || (isDeletable(group) && canDelete)) && <DropdownMenu>
|
||||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
{isEditable(group) && canUpdate && <DropdownMenuItem onSelect={() => setEditing(group)}>{group.id === regularUserPermissionGroupId ? "编辑普通用户权限" : "编辑权限配置"}</DropdownMenuItem>}
|
||||||
className="text-destructive"
|
{isEditable(group) && canUpdate && isDeletable(group) && canDelete && <DropdownMenuSeparator />}
|
||||||
disabled={!isDeletable(group) || !canDelete}
|
{isDeletable(group) && canDelete && <DropdownMenuItem className="text-destructive" onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}>删除权限配置</DropdownMenuItem>}
|
||||||
onSelect={() => setPendingConfirm({ title: "删除权限配置?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配置", onConfirm: () => remove.mutate(group.id) })}
|
</DropdownMenuContent>
|
||||||
>
|
</DropdownMenu>}
|
||||||
删除权限配置
|
</div>
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>}
|
|
||||||
</div>
|
</div>
|
||||||
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
||||||
<PermissionLimitBadges limits={group.limits} />
|
<PermissionLimitBadges limits={group.limits} />
|
||||||
@@ -429,6 +432,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
{filtered.length === 0 && <Empty text="暂无匹配的权限配置" />}
|
{filtered.length === 0 && <Empty text="暂无匹配的权限配置" />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
|
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
|
||||||
|
{viewing && <PermissionGroupDetailsDialog group={viewing} catalog={catalog} open={!!viewing} onOpenChange={(open) => { if (!open) setViewing(null) }} />}
|
||||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -440,6 +444,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
|||||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||||
const dialogOpen = open ?? internalOpen
|
const dialogOpen = open ?? internalOpen
|
||||||
const setDialogOpen = onOpenChange ?? setInternalOpen
|
const setDialogOpen = onOpenChange ?? setInternalOpen
|
||||||
|
const fixedIdentity = group?.id === regularUserPermissionGroupId
|
||||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
|
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
|
||||||
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
|
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
|
||||||
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||||||
@@ -469,18 +474,18 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
|||||||
})
|
})
|
||||||
const trigger = group ? null : (
|
const trigger = group ? null : (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm">权限配置</Button>
|
<Button size="sm" className="self-start">添加权限配置</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger}
|
{trigger}
|
||||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||||
<DialogHeader><DialogTitle>{group ? "编辑权限配置" : "创建权限配置"}</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>{fixedIdentity ? "编辑普通用户权限" : group ? "编辑权限配置" : "创建权限配置"}</DialogTitle></DialogHeader>
|
||||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" readOnly={fixedIdentity} />
|
||||||
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
|
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} readOnly={fixedIdentity} />
|
||||||
</div>
|
</div>
|
||||||
<PermissionLimitEditor value={limits} onChange={setLimits} />
|
<PermissionLimitEditor value={limits} onChange={setLimits} />
|
||||||
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||||
|
<DialogHeader><DialogTitle>{group.name}的全部权限</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">共 {group.permissions.length} 项权限。{group.id === superAdminPermissionGroupId ? "管理员权限由账号身份决定,不能修改。" : ""}</p>
|
||||||
|
<PermissionLimitBadges limits={group.limits} />
|
||||||
|
<PermissionPicker catalog={catalog} value={group.permissions} onChange={() => undefined} readOnly />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionPicker({ catalog, value, onChange, readOnly = false }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void; readOnly?: boolean }) {
|
||||||
const grouped = groupPermissionCatalog(catalog)
|
const grouped = groupPermissionCatalog(catalog)
|
||||||
function toggle(permission: PermissionKey, checked: boolean) {
|
function toggle(permission: PermissionKey, checked: boolean) {
|
||||||
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
|
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
|
||||||
@@ -504,24 +522,25 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label>菜单与操作权限</Label>
|
<Label>菜单与操作权限</Label>
|
||||||
<Badge variant="outline">{value.length} 项</Badge>
|
<span className="text-xs tabular-nums text-muted-foreground">已选 {value.length} 项</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{grouped.map(({ category, items }) => {
|
{grouped.map(({ category, items }) => {
|
||||||
const allChecked = items.every((item) => value.includes(item.key))
|
const allChecked = items.every((item) => value.includes(item.key))
|
||||||
|
const selectedCount = items.filter((item) => value.includes(item.key)).length
|
||||||
return (
|
return (
|
||||||
<div key={category} className="rounded-lg border">
|
<div key={category} className="rounded-lg border">
|
||||||
<div className="flex items-center justify-between gap-3 border-b px-3 py-2">
|
<div className="flex items-center justify-between gap-3 border-b px-3 py-2">
|
||||||
<label className="flex items-center gap-2 font-medium">
|
<label className={cn("flex items-center gap-2 font-medium", readOnly && "cursor-default")}>
|
||||||
<Checkbox checked={allChecked} onCheckedChange={(next) => toggleCategory(items, next === true)} />
|
<Checkbox className={cn(readOnly && "disabled:opacity-100")} disabled={readOnly} checked={allChecked ? true : selectedCount > 0 ? "indeterminate" : false} onCheckedChange={(next) => toggleCategory(items, next === true)} />
|
||||||
{category}
|
{category}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted-foreground">{items.filter((item) => value.includes(item.key)).length}/{items.length}</span>
|
<span className="text-xs text-muted-foreground">{selectedCount}/{items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 p-3 md:grid-cols-2">
|
<div className="grid gap-2 p-3 md:grid-cols-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<label key={item.key} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
<label key={item.key} className={cn("flex min-h-16 items-start gap-3 rounded-md border px-3 py-2", readOnly && "cursor-default")}>
|
||||||
<Checkbox checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
|
<Checkbox className={cn(readOnly && "disabled:opacity-100")} disabled={readOnly} checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-sm font-medium">{item.label}</span>
|
<span className="block text-sm font-medium">{item.label}</span>
|
||||||
<span className="line-clamp-2 text-xs text-muted-foreground">{item.description}</span>
|
<span className="line-clamp-2 text-xs text-muted-foreground">{item.description}</span>
|
||||||
@@ -545,7 +564,7 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-lg border p-3">
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Label>账号配额</Label>
|
<Label>使用限制</Label>
|
||||||
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
@@ -581,29 +600,14 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
|
|||||||
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
||||||
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
|
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
|
||||||
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground">无后台权限</div>
|
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground">无后台权限</div>
|
||||||
return (
|
const visible = permissions.slice(0, 5).map((permission) => labelByKey.get(permission) || permission)
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
return <div className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">权限:{visible.join("、")}{permissions.length > visible.length ? ` 等 ${permissions.length} 项` : ""}</div>
|
||||||
{permissions.slice(0, 10).map((permission) => (
|
|
||||||
<Badge key={permission} variant="outline" className="font-normal">{labelByKey.get(permission) || permission}</Badge>
|
|
||||||
))}
|
|
||||||
{permissions.length > 10 && <Badge variant="secondary">+{permissions.length - 10}</Badge>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
||||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits })
|
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits })
|
||||||
const value = limits || defaultLimitsQuery.data || defaultPermissionLimits
|
const value = limits || defaultLimitsQuery.data || defaultPermissionLimits
|
||||||
return (
|
return <div className="mt-2 text-xs leading-5 text-muted-foreground">限制:邮箱 {limitText(value.maxMailboxCount, "个")} · 附件 {limitText(value.maxAttachmentMb, "MB")} · SMTP {limitText(value.smtpDailyLimit, "封/日")} · IMAP {limitText(value.imapMinuteLimit, "次/分钟")} · POP3 {limitText(value.pop3MinuteLimit, "次/分钟")}</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
||||||
<Badge variant="secondary" className="font-normal">附件 {limitText(value.maxAttachmentMb, "MB")}</Badge>
|
|
||||||
<Badge variant="secondary" className="font-normal">邮箱 {limitText(value.maxMailboxCount, "个")}</Badge>
|
|
||||||
<Badge variant="secondary" className="font-normal">SMTP 每日 {limitText(value.smtpDailyLimit, "封")}</Badge>
|
|
||||||
<Badge variant="secondary" className="font-normal">SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")}</Badge>
|
|
||||||
<Badge variant="secondary" className="font-normal">IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")}</Badge>
|
|
||||||
<Badge variant="secondary" className="font-normal">POP3 每分钟 {limitText(value.pop3MinuteLimit, "次")}</Badge>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function limitText(value: number, unit: string) {
|
function limitText(value: number, unit: string) {
|
||||||
@@ -651,8 +655,8 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
|||||||
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div>
|
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant={domain.status === "active" ? "default" : "secondary"}>{domain.status === "active" ? "启用" : "停用"}</Badge>
|
<StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" />
|
||||||
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
<StatusText active={domain.dnsStatus === "ok"} activeLabel="DNS 正常" inactiveLabel={domain.dnsStatus || "未检测"} />
|
||||||
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
||||||
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
|
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
|
||||||
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||||
@@ -685,56 +689,96 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const [query, setQuery] = React.useState("")
|
||||||
|
const [expandedOwners, setExpandedOwners] = React.useState<string[]>([])
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const canCreate = hasPermission(user, "admin.mailboxes.create")
|
const canCreate = hasPermission(user, "admin.mailboxes.create")
|
||||||
const canUpdate = hasPermission(user, "admin.mailboxes.update")
|
const canUpdate = hasPermission(user, "admin.mailboxes.update")
|
||||||
const canDelete = hasPermission(user, "admin.mailboxes.delete")
|
const canDelete = hasPermission(user, "admin.mailboxes.delete")
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({ mailbox, status }: { mailbox: MailboxType; status: "active" | "disabled" }) => api.updateMailbox(mailbox.id, { userId: mailbox.userId, displayName: mailbox.displayName || mailbox.address, quotaMb: mailbox.quotaMb, status }),
|
||||||
|
onSuccess: (mailbox) => { invalidateAdmin(qc); toast({ title: mailbox.status === "active" ? "邮箱已启用" : "邮箱已停用" }) },
|
||||||
|
onError: (e) => toast({ title: "状态更新失败", description: e.message }),
|
||||||
|
})
|
||||||
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||||
|
const keyword = query.trim().toLowerCase()
|
||||||
|
const knownOwnerIDs = new Set(users.map((item) => item.id))
|
||||||
|
const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [
|
||||||
|
...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort((left, right) => left.address.localeCompare(right.address, "en")) })),
|
||||||
|
...mailboxes.filter((mailbox) => !knownOwnerIDs.has(mailbox.userId)).map((mailbox) => ({ owner: undefined, mailboxes: [mailbox] })),
|
||||||
|
]
|
||||||
|
.filter((group) => group.mailboxes.length > 0)
|
||||||
|
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
|
||||||
|
const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID])
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<CardTitle>邮箱管理</CardTitle>
|
<CardTitle>邮箱管理</CardTitle>
|
||||||
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">{mailboxes.length} 个邮箱</span>
|
||||||
|
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-3 md:hidden">
|
<div className="relative">
|
||||||
{mailboxes.map((mailbox) => (
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<div key={mailbox.id} className="rounded-lg border p-4">
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" />
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">{mailbox.address}</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
|
|
||||||
</div>
|
|
||||||
<MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
|
|
||||||
<Badge variant="outline">{mailbox.quotaMb} MB</Badge>
|
|
||||||
<Badge variant="outline">{mailbox.displayName || "未命名"}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="divide-y overflow-hidden rounded-md border">
|
||||||
<Table>
|
<div className="hidden grid-cols-[minmax(0,1fr)_9rem_8rem] gap-4 bg-muted/40 px-11 py-2 text-xs font-medium text-muted-foreground sm:grid">
|
||||||
<TableHeader><TableRow><TableHead>地址</TableHead><TableHead>归属账号</TableHead><TableHead>名称</TableHead><TableHead>配额</TableHead><TableHead>状态</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
<span>归属账号</span><span>权限管理</span><span>子邮箱</span>
|
||||||
<TableBody>
|
</div>
|
||||||
{mailboxes.map((mailbox) => (
|
{mailboxGroups.map((group) => {
|
||||||
<TableRow key={mailbox.id}>
|
const ownerID = group.owner?.id || group.mailboxes[0].userId
|
||||||
<TableCell className="font-medium">{mailbox.address}</TableCell>
|
const expanded = expandedOwners.includes(ownerID) || !!keyword
|
||||||
<TableCell className="text-muted-foreground">{mailbox.userEmail || mailbox.userId}</TableCell>
|
return (
|
||||||
<TableCell>{mailbox.displayName}</TableCell>
|
<div key={ownerID}>
|
||||||
<TableCell>{mailbox.quotaMb} MB</TableCell>
|
<Button type="button" variant="ghost" className="grid h-auto min-h-16 w-full grid-cols-[auto_minmax(0,1fr)_auto] gap-3 rounded-none px-4 py-3 text-left font-normal sm:grid-cols-[auto_minmax(0,1fr)_9rem_8rem]" onClick={() => toggleOwner(ownerID)} aria-expanded={expanded}>
|
||||||
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
|
<ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", !expanded && "-rotate-90")} />
|
||||||
<TableCell><MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} /></TableCell>
|
<div className="min-w-0 flex-1">
|
||||||
</TableRow>
|
<div className="truncate font-medium">{group.owner ? accountPrimaryEmail(group.owner) : group.mailboxes[0].userEmail || "未知账号"}</div>
|
||||||
))}
|
<div className="truncate text-xs text-muted-foreground">{group.owner?.displayName || "账号信息不可用"}</div>
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
<span className="hidden sm:block">{group.owner ? <RoleBadge user={group.owner} /> : "-"}</span>
|
||||||
|
<span className="flex items-center justify-end gap-2 text-sm tabular-nums text-muted-foreground"><span>{group.mailboxes.length} 个</span><span className="hidden text-xs lg:inline">查看邮箱</span></span>
|
||||||
|
</Button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t bg-muted/20 px-4 py-2 sm:pl-11">
|
||||||
|
{group.mailboxes.map((mailbox) => (
|
||||||
|
<div key={mailbox.id} className="flex min-h-14 items-center gap-3 border-b py-2 last:border-b-0">
|
||||||
|
<Mail className="hidden h-4 w-4 shrink-0 text-muted-foreground sm:block" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{mailbox.address}</div>
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{mailbox.displayName || "未命名"}</span>
|
||||||
|
{mailbox.primary && <span className="shrink-0">默认邮箱</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn("w-10 shrink-0 text-right text-xs font-medium", mailbox.status === "active" ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>{mailbox.status === "active" ? "启用" : "停用"}</span>
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||||
|
{!mailbox.primary && (canUpdate || canDelete) && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon" className="h-8 w-8" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{canUpdate && <DropdownMenuItem disabled={mailbox.status === "active" || update.isPending} onSelect={() => update.mutate({ mailbox, status: "active" })}>启用</DropdownMenuItem>}
|
||||||
|
{canUpdate && <DropdownMenuItem disabled={mailbox.status === "disabled" || update.isPending} onSelect={() => update.mutate({ mailbox, status: "disabled" })}>停用</DropdownMenuItem>}
|
||||||
|
{canUpdate && canDelete && !mailbox.primary && <DropdownMenuSeparator />}
|
||||||
|
{canDelete && !mailbox.primary && <DropdownMenuItem className="text-destructive" onSelect={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。此操作无法撤销。`, confirmText: "确认删除", onConfirm: () => remove.mutate(mailbox.id) })}>删除邮箱</DropdownMenuItem>}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{mailboxes.length === 0 && <Empty text="暂无邮箱" />}
|
{mailboxGroups.length === 0 && <Empty text={query ? "没有匹配的账号或邮箱" : "暂无邮箱"} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||||
</Card>
|
</Card>
|
||||||
@@ -772,7 +816,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
|||||||
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
|
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
|
<StatusText active={alias.enabled} activeLabel="启用" inactiveLabel="停用" />
|
||||||
<Badge variant="outline">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</Badge>
|
<Badge variant="outline">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -787,7 +831,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
|||||||
<TableCell className="font-medium">{alias.source}</TableCell>
|
<TableCell className="font-medium">{alias.source}</TableCell>
|
||||||
<TableCell>{alias.destination}</TableCell>
|
<TableCell>{alias.destination}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
|
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
|
||||||
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
|
<TableCell><StatusText active={alias.enabled} activeLabel="启用" inactiveLabel="停用" /></TableCell>
|
||||||
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
|
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -1855,7 +1899,8 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
|
|||||||
return sortedMailboxes.filter((mailbox) => mailbox.toLowerCase().includes(normalizedQuery))
|
return sortedMailboxes.filter((mailbox) => mailbox.toLowerCase().includes(normalizedQuery))
|
||||||
}, [normalizedQuery, sortedMailboxes])
|
}, [normalizedQuery, sortedMailboxes])
|
||||||
const limit = user.role === "admin" ? "不限" : limitText(user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride, "个")
|
const limit = user.role === "admin" ? "不限" : limitText(user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride, "个")
|
||||||
const quota = <div className="text-[11px] text-muted-foreground">邮箱 {user.mailboxCount}/{limit}</div>
|
const storage = formatBytes((user.storageQuotaMb || defaultStorageQuotaMb(user.role)) * 1024 * 1024)
|
||||||
|
const quota = <div className="text-[11px] text-muted-foreground">邮箱 {user.mailboxCount}/{limit} · 共享存储容量 {storage}</div>
|
||||||
async function copyMailbox(address: string) {
|
async function copyMailbox(address: string) {
|
||||||
if (!address) return
|
if (!address) return
|
||||||
await navigator.clipboard.writeText(address)
|
await navigator.clipboard.writeText(address)
|
||||||
@@ -1872,7 +1917,6 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
|
|||||||
className="h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
|
className="h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
|
||||||
title={selectedAddress}
|
title={selectedAddress}
|
||||||
>
|
>
|
||||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{selectedAddress}</span>
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{selectedAddress}</span>
|
||||||
{sortedMailboxes.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{sortedMailboxes.length} 个</span>}
|
{sortedMailboxes.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{sortedMailboxes.length} 个</span>}
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
@@ -1898,7 +1942,6 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
|
|||||||
onSelect={() => setSelectedAddress(mailbox)}
|
onSelect={() => setSelectedAddress(mailbox)}
|
||||||
className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", selectedAddress === mailbox && "bg-accent text-accent-foreground")}
|
className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", selectedAddress === mailbox && "bg-accent text-accent-foreground")}
|
||||||
>
|
>
|
||||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="min-w-0 flex-1 truncate" title={mailbox}>{mailbox}</span>
|
<span className="min-w-0 flex-1 truncate" title={mailbox}>{mailbox}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -1916,17 +1959,10 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserPermissionGroupsCell({ user }: { user: AdminUser }) {
|
function UserPermissionGroupsCell({ user }: { user: AdminUser }) {
|
||||||
const groups = user.permissionGroups || []
|
const groups = (user.permissionGroups || []).filter((group) => group.id !== "pg_regular_user")
|
||||||
if (groups.length === 0) return <span className="text-muted-foreground">普通用户</span>
|
if (user.role === "admin") return <span className="text-sm text-muted-foreground">全部权限</span>
|
||||||
return (
|
if (groups.length === 0) return <span className="text-muted-foreground">默认权限</span>
|
||||||
<div className="flex max-w-md flex-wrap gap-1">
|
return <span className="line-clamp-2 text-sm text-muted-foreground">{groups.map((group) => group.name).join("、")}</span>
|
||||||
{groups.map((group) => (
|
|
||||||
<Badge key={group.id} variant={group.id === "pg_super_admin" ? "default" : "secondary"} className="font-normal">
|
|
||||||
{group.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignableUserGroupIDs(user: AdminUser) {
|
function assignableUserGroupIDs(user: AdminUser) {
|
||||||
@@ -1934,6 +1970,7 @@ function assignableUserGroupIDs(user: AdminUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
|
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
|
||||||
|
if (groups.length === 0) return null
|
||||||
function toggle(groupID: string, checked: boolean) {
|
function toggle(groupID: string, checked: boolean) {
|
||||||
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
|
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
|
||||||
}
|
}
|
||||||
@@ -1954,18 +1991,20 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{groups.length === 0 && <Empty text="暂无可分配自定义权限配置" />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoleBadge({ user }: { user: AdminUser }) {
|
function RoleBadge({ user }: { user: AdminUser }) {
|
||||||
return (
|
return <span className="text-sm font-medium">{user.role === "admin" ? "管理员" : "普通用户"}</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
}
|
||||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
|
||||||
{user.protected && <Badge variant="outline">默认账号</Badge>}
|
function AccountStatus({ user }: { user: AdminUser }) {
|
||||||
</div>
|
return <StatusText active={!user.disabled} activeLabel="正常" inactiveLabel="停用" />
|
||||||
)
|
}
|
||||||
|
|
||||||
|
function StatusText({ active, activeLabel, inactiveLabel }: { active: boolean; activeLabel: string; inactiveLabel: string }) {
|
||||||
|
return <span className="inline-flex items-center gap-2 whitespace-nowrap text-sm"><span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-600" : "bg-muted-foreground")} />{active ? activeLabel : inactiveLabel}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
|
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
|
||||||
@@ -1996,50 +2035,58 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
|||||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
function CreateUserDialog({ permissionGroups, domains }: { permissionGroups: PermissionGroup[]; domains: Domain[] }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [status, setStatus] = React.useState("active")
|
const [domainId, setDomainId] = React.useState("")
|
||||||
|
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
|
||||||
|
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domainId, domains])
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (form: FormData) => api.createUser({
|
mutationFn: (form: FormData) => {
|
||||||
email: String(form.get("email") || ""),
|
const domain = domains.find((item) => item.id === domainId)
|
||||||
displayName: String(form.get("displayName") || ""),
|
if (!domain) throw new Error("请选择邮箱域名")
|
||||||
password: String(form.get("password") || ""),
|
const localPart = String(form.get("localPart") || "").trim()
|
||||||
role: "user",
|
if (!localPart) throw new Error("请输入邮箱前缀")
|
||||||
disabled: status === "disabled",
|
const email = `${localPart}@${domain.name}`
|
||||||
mailboxLimitOverride: mailboxLimitFromForm(form),
|
const inputPassword = String(form.get("password") || "")
|
||||||
}),
|
const confirmPassword = String(form.get("confirmPassword") || "")
|
||||||
|
if (inputPassword !== confirmPassword) throw new Error("两次输入的密码不一致")
|
||||||
|
const password = inputPassword || email
|
||||||
|
if (password.length < 6) throw new Error("密码至少需要 6 位")
|
||||||
|
return api.createUser({ email, displayName: String(form.get("displayName") || ""), password, role: "user", disabled: false, mailboxLimitOverride: mailboxLimitFromForm(form), storageQuotaMb: Number(form.get("storageQuotaMb") || defaultUserStorageQuotaMb), permissionGroupIds })
|
||||||
|
},
|
||||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
|
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "账号已创建" }) },
|
||||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild><Button size="sm">账号</Button></DialogTrigger>
|
<DialogTrigger asChild><Button size="sm">添加账号</Button></DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>创建账号</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>创建用户账号</DialogTitle></DialogHeader>
|
||||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
||||||
<Field name="email" label="主登录邮箱" type="email" autoComplete="off" placeholder="user@example.com" />
|
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
|
||||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
|
||||||
<Field name="password" label="初始密码" type="password" minLength={6} />
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
|
<Field name="localPart" label="邮箱前缀" placeholder="user" />
|
||||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
<Field name="displayName" label="显示名称" placeholder="例如:SZX" />
|
||||||
</div>
|
</div>
|
||||||
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
<Field name="password" label="密码(留空则使用邮箱账号)" type="password" required={false} autoComplete="new-password" />
|
||||||
|
<Field name="confirmPassword" label="再次输入密码" type="password" required={false} autoComplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<SelectField label="身份" value="user" onValueChange={() => undefined} items={[["user", "普通用户"]]} disabled />
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<MailboxLimitField defaultValue={defaultMailboxLimitOverride} />
|
||||||
|
<Field name="storageQuotaMb" label="共享存储容量(MB)" type="number" min={100} defaultValue={String(defaultUserStorageQuotaMb)} />
|
||||||
|
</div>
|
||||||
|
<PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />
|
||||||
|
<DialogFooter><Button disabled={create.isPending || !domainId}>{create.isPending ? "创建中..." : "创建用户账号"}</Button></DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
|
||||||
const [open, setOpen] = React.useState(false)
|
|
||||||
if (!canUpdate && !onDelete) return null
|
|
||||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||||
if (!onToggle && !onDelete) return null
|
if (!onToggle && !onDelete) return null
|
||||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||||
@@ -2063,6 +2110,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
|||||||
role,
|
role,
|
||||||
disabled: disabled === "disabled",
|
disabled: disabled === "disabled",
|
||||||
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form, effectiveMailboxLimit(user)) : undefined,
|
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form, effectiveMailboxLimit(user)) : undefined,
|
||||||
|
storageQuotaMb: Number(form.get("storageQuotaMb") || user.storageQuotaMb || defaultStorageQuotaMb(user.role)),
|
||||||
permissionGroupIds: role === "user" ? permissionGroupIds : [],
|
permissionGroupIds: role === "user" ? permissionGroupIds : [],
|
||||||
}),
|
}),
|
||||||
onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "账号已更新" }) },
|
onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "账号已更新" }) },
|
||||||
@@ -2079,7 +2127,10 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
|||||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
|
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={user.role === "admin" ? [["admin", "管理员"]] : [["user", "普通用户"]]} disabled />
|
||||||
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
|
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected || user.role === "admin"} />
|
||||||
</div>
|
</div>
|
||||||
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
|
||||||
|
<Field name="storageQuotaMb" label="共享存储容量(MB)" type="number" min={100} defaultValue={String(user.storageQuotaMb || defaultStorageQuotaMb(user.role))} />
|
||||||
|
</div>
|
||||||
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||||
<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -2093,17 +2144,10 @@ function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; op
|
|||||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="主登录邮箱" value={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="主登录邮箱" value={accountPrimaryEmail(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
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 <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateDomainDialog() {
|
function CreateDomainDialog() {
|
||||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false)
|
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 }) })
|
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 <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline">域名</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>添加域名</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="name" label="域名" placeholder="example.com" /><DialogFooter><Button disabled={mut.isPending}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button className="self-start">添加域名</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>添加域名</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="name" label="域名" placeholder="example.com" /><DialogFooter><Button disabled={mut.isPending}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
|
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
|
||||||
@@ -2111,7 +2155,6 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [domainId, setDomainId] = React.useState("")
|
const [domainId, setDomainId] = React.useState("")
|
||||||
const [ownerMode, setOwnerMode] = React.useState("new")
|
|
||||||
const [userId, setUserId] = React.useState("")
|
const [userId, setUserId] = React.useState("")
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!domainId && domains[0]) setDomainId(domains[0].id)
|
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({
|
mutationFn: (form: FormData) => api.createMailbox({
|
||||||
domainId,
|
domainId,
|
||||||
localPart: String(form.get("localPart")),
|
localPart: String(form.get("localPart")),
|
||||||
displayName: String(form.get("displayName")),
|
userId,
|
||||||
password: String(form.get("password")),
|
|
||||||
quotaMb: Number(form.get("quotaMb") || 1024),
|
|
||||||
role: "user",
|
|
||||||
ownerEmail: String(form.get("ownerEmail") || ""),
|
|
||||||
userId: ownerMode === "existing" ? userId : "",
|
|
||||||
}),
|
}),
|
||||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
|
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) },
|
||||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild><Button>邮箱</Button></DialogTrigger>
|
<DialogTrigger asChild><Button>添加邮箱</Button></DialogTrigger>
|
||||||
<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)) }}>
|
||||||
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
|
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
|
||||||
<Field name="localPart" label="邮箱前缀" placeholder="alice" />
|
<SelectField label="绑定用户账号" value={userId} onValueChange={setUserId} items={users.filter((item) => !item.disabled).sort(compareAdminUsers).map((item) => [item.id, accountPrimaryEmail(item)])} />
|
||||||
<Field name="displayName" label="显示名" placeholder="Alice" />
|
<p className="text-xs text-muted-foreground">该邮箱使用绑定账号的登录密码;账号修改密码后会自动同步。</p>
|
||||||
</div>
|
<DialogFooter><Button disabled={mut.isPending || !domainId || !userId}>{mut.isPending ? "创建中..." : "创建邮箱"}</Button></DialogFooter>
|
||||||
<SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[["new", "新建/按主登录邮箱匹配账号"], ["existing", "追加到已有账号"]]} />
|
|
||||||
{ownerMode === "existing" ? (
|
|
||||||
<SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountPrimaryEmail(u)])} />
|
|
||||||
) : (
|
|
||||||
<Field name="ownerEmail" label="主登录邮箱" placeholder="留空则使用新邮箱地址" required={false} />
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Field name="password" label="密码" type="password" placeholder="至少 6 位" />
|
|
||||||
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
|
|
||||||
</div>
|
|
||||||
<DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -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("")
|
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])
|
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 }) })
|
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 <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline">转发</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮件转发</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button className="self-start">添加转发</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮件转发</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user