feat(admin): 引入细粒度权限组管理
- 新增权限目录、系统权限组与用户权限组分配,后台路由改为按权限粒度控制。 - 支持创建、编辑、查看和删除自定义权限组,并为用户绑定权限组。 - 前端后台界面按权限动态展示菜单与操作项,补充用户/权限组相关类型与交互。
This commit is contained in:
@@ -73,21 +73,37 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
item.Mailboxes = splitCSV(mailboxCSV)
|
item.Mailboxes = splitCSV(mailboxCSV)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if err := a.attachUserAuthorization(r.Context(), &items[i].User); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load user permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
|
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actor := currentUser(r)
|
||||||
email := normalizeEmail(req.Email)
|
email := normalizeEmail(req.Email)
|
||||||
if email == "" || !strings.Contains(email, "@") {
|
if email == "" || !strings.Contains(email, "@") {
|
||||||
badRequest(w, errors.New("invalid email"))
|
badRequest(w, errors.New("invalid email"))
|
||||||
@@ -105,6 +121,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid role"))
|
badRequest(w, errors.New("invalid role"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||||
|
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(req.Password) < 8 {
|
if len(req.Password) < 8 {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||||
return
|
return
|
||||||
@@ -116,12 +136,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id := newID("usr")
|
id := newID("usr")
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
permissionGroupIDs := req.PermissionGroupIDs
|
||||||
|
if role == "admin" {
|
||||||
|
permissionGroupIDs = nil
|
||||||
|
}
|
||||||
|
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, actor); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
user, err := a.adminUserByID(r.Context(), id)
|
user, err := a.adminUserByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||||
@@ -134,9 +171,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
current := currentUser(r)
|
current := currentUser(r)
|
||||||
var req struct {
|
var req struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Disabled *bool `json:"disabled"`
|
Disabled *bool `json:"disabled"`
|
||||||
|
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
@@ -155,21 +193,79 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid role"))
|
badRequest(w, errors.New("invalid role"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
disabled := false
|
existing, err := a.userByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||||
|
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disabled := existing.Disabled
|
||||||
if req.Disabled != nil {
|
if req.Disabled != nil {
|
||||||
disabled = *req.Disabled
|
disabled = *req.Disabled
|
||||||
}
|
}
|
||||||
if current != nil && current.ID == id && (disabled || role != "admin") {
|
if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) {
|
||||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil
|
||||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id)
|
var permissionGroupIDs []string
|
||||||
|
if role == "user" {
|
||||||
|
if req.PermissionGroupIDs != nil {
|
||||||
|
permissionGroupIDs = *req.PermissionGroupIDs
|
||||||
|
} else if existing.Role == "user" {
|
||||||
|
for _, groupID := range existing.PermissionGroupIDs {
|
||||||
|
if isAssignablePermissionGroupID(groupID) {
|
||||||
|
permissionGroupIDs = append(permissionGroupIDs, groupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != nil && current.ID == id {
|
||||||
|
next := *existing
|
||||||
|
next.Role = role
|
||||||
|
next.Disabled = disabled
|
||||||
|
if shouldUpdatePermissionGroups {
|
||||||
|
if role == "admin" {
|
||||||
|
next.Permissions = allPermissionKeys()
|
||||||
|
} else {
|
||||||
|
permissions, err := a.permissionsForGroupIDs(r.Context(), nil, permissionGroupIDs)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.Permissions = permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.Disabled || !userHasAdminAccess(&next) {
|
||||||
|
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||||
|
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldUpdatePermissionGroups {
|
||||||
|
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, current); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -183,6 +279,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
} else if target.Role == "admin" {
|
||||||
|
current := currentUser(r)
|
||||||
|
if current == nil || current.Role != "admin" {
|
||||||
|
respondError(w, http.StatusForbidden, "only administrators can reset administrator passwords")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
@@ -234,6 +340,16 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("cannot delete your own user"))
|
badRequest(w, errors.New("cannot delete your own user"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
} else if a.isDefaultAdminUser(target) {
|
||||||
|
badRequest(w, errors.New("default administrator cannot be deleted"))
|
||||||
|
return
|
||||||
|
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
||||||
|
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -409,6 +525,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("invalid role"))
|
badRequest(w, errors.New("invalid role"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if role == "admin" {
|
||||||
|
current := currentUser(r)
|
||||||
|
if current == nil || current.Role != "admin" {
|
||||||
|
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -836,6 +959,9 @@ func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error)
|
|||||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||||
item.CreatedAt = parseTime(created)
|
item.CreatedAt = parseTime(created)
|
||||||
item.Mailboxes = splitCSV(mailboxCSV)
|
item.Mailboxes = splitCSV(mailboxCSV)
|
||||||
|
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS permission_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
permissions_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_permission_groups (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id TEXT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, group_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_group ON user_permission_groups(group_id, user_id)`,
|
||||||
`CREATE TABLE IF NOT EXISTS sessions (
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -327,6 +343,9 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +698,7 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil
|
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPassword := a.cfg.AdminPassword
|
adminPassword := a.cfg.AdminPassword
|
||||||
@@ -736,6 +755,16 @@ func (a *App) seed(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||||
|
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||||
|
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||||
|
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
|
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
|
||||||
name = normalizeDomain(name)
|
name = normalizeDomain(name)
|
||||||
if name == "" || !strings.Contains(name, ".") {
|
if name == "" || !strings.Contains(name, ".") {
|
||||||
|
|||||||
@@ -886,6 +886,231 @@ func TestDNSRecords(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(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("admin login code=%d body=%v", code, login)
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups struct {
|
||||||
|
Items []PermissionGroup `json:"items"`
|
||||||
|
}
|
||||||
|
if code := admin.do("GET", "/api/admin/permission-groups", nil, &groups); code != http.StatusOK || len(groups.Items) != len(defaultPermissionGroups()) {
|
||||||
|
t.Fatalf("fixed permission groups code=%d groups=%+v", code, groups.Items)
|
||||||
|
}
|
||||||
|
groupByID := map[string]PermissionGroup{}
|
||||||
|
for _, group := range groups.Items {
|
||||||
|
groupByID[group.ID] = group
|
||||||
|
}
|
||||||
|
for _, group := range defaultPermissionGroups() {
|
||||||
|
if _, ok := groupByID[group.ID]; !ok {
|
||||||
|
t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular || groupByID[PermissionGroupMailboxAdmin].UserCount != 0 {
|
||||||
|
t.Fatalf("unexpected fixed permission groups: %+v", groups.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBody map[string]any
|
||||||
|
var users struct {
|
||||||
|
Items []AdminUser `json:"items"`
|
||||||
|
}
|
||||||
|
var customGroup PermissionGroup
|
||||||
|
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
|
||||||
|
"name": "Mailbox Viewers",
|
||||||
|
"description": "Can view mailboxes only",
|
||||||
|
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
|
||||||
|
}, &customGroup); code != http.StatusCreated {
|
||||||
|
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup)
|
||||||
|
}
|
||||||
|
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
|
||||||
|
t.Fatalf("custom permission group permissions=%+v", customGroup)
|
||||||
|
}
|
||||||
|
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupMailboxAdmin, map[string]any{
|
||||||
|
"name": "Changed",
|
||||||
|
"description": "Should not change",
|
||||||
|
"permissions": []string{PermissionMailboxesView},
|
||||||
|
}, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupMailboxAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "invalid-group@lanqin.local",
|
||||||
|
"displayName": "Invalid Group",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{PermissionGroupSuperAdmin},
|
||||||
|
}, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("assigning super admin group should be rejected code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailboxUser AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "mailbox-admin@lanqin.local",
|
||||||
|
"displayName": "Mailbox Admin",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{PermissionGroupMailboxAdmin},
|
||||||
|
}, &mailboxUser); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create mailbox admin user code=%d user=%+v", code, mailboxUser)
|
||||||
|
}
|
||||||
|
if mailboxUser.Role != "user" || len(mailboxUser.PermissionGroupIDs) != 1 || mailboxUser.PermissionGroupIDs[0] != PermissionGroupMailboxAdmin || !userHasPermission(&mailboxUser.User, PermissionMailboxesManage) || userHasPermission(&mailboxUser.User, PermissionSystemSettings) {
|
||||||
|
t.Fatalf("mailbox admin authorization=%+v", mailboxUser.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
var customUser AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "mailbox-viewer@lanqin.local",
|
||||||
|
"displayName": "Mailbox Viewer",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{customGroup.ID},
|
||||||
|
}, &customUser); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create custom group user code=%d user=%+v", code, customUser)
|
||||||
|
}
|
||||||
|
if !userHasPermission(&customUser.User, PermissionMailboxesView) || userHasPermission(&customUser.User, PermissionMailboxesCreate) {
|
||||||
|
t.Fatalf("custom group user authorization=%+v", customUser.User)
|
||||||
|
}
|
||||||
|
if code := admin.do("DELETE", "/api/admin/permission-groups/"+customGroup.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("assigned custom permission group delete should be rejected code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
mailboxAdmin := &testClient{t: t, server: ts}
|
||||||
|
if code := mailboxAdmin.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("mailbox admin login code=%d", code)
|
||||||
|
}
|
||||||
|
var mailboxList struct {
|
||||||
|
Items []Mailbox `json:"items"`
|
||||||
|
}
|
||||||
|
if code := mailboxAdmin.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
|
||||||
|
t.Fatalf("mailbox admin should access mailboxes code=%d", code)
|
||||||
|
}
|
||||||
|
if code := mailboxAdmin.do("GET", "/api/admin/settings", nil, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("mailbox admin settings should be forbidden code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := mailboxAdmin.do("GET", "/api/admin/users", nil, &errBody); code != http.StatusOK {
|
||||||
|
t.Fatalf("mailbox admin should read users for mailbox ownership code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
viewer := &testClient{t: t, server: ts}
|
||||||
|
if code := viewer.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-viewer@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("mailbox viewer login code=%d", code)
|
||||||
|
}
|
||||||
|
if code := viewer.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
|
||||||
|
t.Fatalf("mailbox viewer should read mailboxes code=%d", code)
|
||||||
|
}
|
||||||
|
if code := viewer.do("POST", "/api/admin/mailboxes", map[string]any{
|
||||||
|
"domainId": mustDefaultDomainID(t, a),
|
||||||
|
"localPart": "blocked-create",
|
||||||
|
"displayName": "Blocked Create",
|
||||||
|
"password": "Password123!",
|
||||||
|
"quotaMb": 1024,
|
||||||
|
"role": "user",
|
||||||
|
}, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("mailbox viewer should not create mailboxes code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := mailboxAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "blocked-by-mailbox-admin@lanqin.local",
|
||||||
|
"displayName": "Blocked",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{PermissionGroupMailboxAdmin},
|
||||||
|
}, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("mailbox admin should not create users code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userManager AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "user-admin@lanqin.local",
|
||||||
|
"displayName": "User Admin",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{PermissionGroupUserAdmin},
|
||||||
|
}, &userManager); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create user admin code=%d user=%+v", code, userManager)
|
||||||
|
}
|
||||||
|
userAdmin := &testClient{t: t, server: ts}
|
||||||
|
if code := userAdmin.do("POST", "/api/auth/login", map[string]string{"email": "user-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("user admin login code=%d", code)
|
||||||
|
}
|
||||||
|
if code := userAdmin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
|
||||||
|
t.Fatalf("user admin users code=%d body=%v", code, users)
|
||||||
|
}
|
||||||
|
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "delegated-mailbox@lanqin.local",
|
||||||
|
"displayName": "Delegated Mailbox",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{PermissionGroupMailboxAdmin},
|
||||||
|
}, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("user admin should not assign mailbox admin group code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
var regularUser AdminUser
|
||||||
|
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "delegated-user@lanqin.local",
|
||||||
|
"displayName": "Delegated User",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{PermissionGroupUserAdmin},
|
||||||
|
}, ®ularUser); code != http.StatusCreated {
|
||||||
|
t.Fatalf("user admin should assign own group code=%d user=%+v", code, regularUser)
|
||||||
|
}
|
||||||
|
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "delegated-super@lanqin.local",
|
||||||
|
"displayName": "Delegated Super",
|
||||||
|
"role": "admin",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{},
|
||||||
|
}, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("user admin should not create super admin code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK || len(users.Items) == 0 {
|
||||||
|
t.Fatalf("admin users code=%d items=%d", code, len(users.Items))
|
||||||
|
}
|
||||||
|
var defaultAdmin AdminUser
|
||||||
|
for _, user := range users.Items {
|
||||||
|
if user.Email == "admin@lanqin.local" {
|
||||||
|
defaultAdmin = user
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if defaultAdmin.ID == "" || !defaultAdmin.Protected || defaultAdmin.Role != "admin" {
|
||||||
|
t.Fatalf("default admin should be protected super admin: %+v", defaultAdmin.User)
|
||||||
|
}
|
||||||
|
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
|
||||||
|
"displayName": "LanQin Admin",
|
||||||
|
"role": "user",
|
||||||
|
"disabled": false,
|
||||||
|
}, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("default admin downgrade should be rejected code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
|
||||||
|
"displayName": "LanQin Admin",
|
||||||
|
"role": "admin",
|
||||||
|
"disabled": true,
|
||||||
|
}, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("default admin disable should be rejected code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
if code := admin.do("DELETE", "/api/admin/users/"+defaultAdmin.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("default admin delete should be rejected code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaildirSyncImportsRFC822(t *testing.T) {
|
func TestMaildirSyncImportsRFC822(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,system,created_at,updated_at
|
||||||
|
FROM permission_groups
|
||||||
|
ORDER BY created_at ASC,name ASC`)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []PermissionGroup{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item PermissionGroup
|
||||||
|
var raw, created, updated string
|
||||||
|
var system int
|
||||||
|
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &raw, &system, &created, &updated); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Permissions = decodeStoredPermissions(raw)
|
||||||
|
item.System = intBool(system)
|
||||||
|
item.CreatedAt = parseTime(created)
|
||||||
|
item.UpdatedAt = parseTime(updated)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var adminCount, regularCount int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
|
||||||
|
WHERE u.role<>'admin'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM user_permission_groups upg
|
||||||
|
WHERE upg.user_id=u.id AND upg.group_id NOT IN (?,?)
|
||||||
|
)`, PermissionGroupSuperAdmin, PermissionGroupRegular,
|
||||||
|
).Scan(®ularCount); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
switch items[i].ID {
|
||||||
|
case PermissionGroupSuperAdmin:
|
||||||
|
items[i].UserCount = adminCount
|
||||||
|
case PermissionGroupRegular:
|
||||||
|
items[i].UserCount = regularCount
|
||||||
|
default:
|
||||||
|
var count int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, items[i].ID).Scan(&count); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items[i].UserCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortPermissionGroups(items)
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"items": items, "catalog": permissionCatalog()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
badRequest(w, errors.New("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissions, err := normalizePermissionList(req.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||||
|
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newID("pg")
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), now, now); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group, err := a.permissionGroupByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusCreated, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var existingSystem int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if intBool(existingSystem) {
|
||||||
|
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
badRequest(w, errors.New("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissions, err := normalizePermissionList(req.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||||
|
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,updated_at=? WHERE id=?`,
|
||||||
|
name, strings.TrimSpace(req.Description), encodePermissions(permissions), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group, err := a.permissionGroupByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var system int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&system); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if intBool(system) {
|
||||||
|
respondError(w, http.StatusForbidden, "system permission groups cannot be deleted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userCount int
|
||||||
|
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, id).Scan(&userCount); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to check permission group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if userCount > 0 {
|
||||||
|
badRequest(w, errors.New("permission group is assigned to users"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `DELETE FROM permission_groups WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to delete permission group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermissionAdminOverview = "admin.overview.view"
|
||||||
|
|
||||||
|
PermissionUsersView = "admin.users.view"
|
||||||
|
PermissionUsersCreate = "admin.users.create"
|
||||||
|
PermissionUsersUpdate = "admin.users.update"
|
||||||
|
PermissionUsersDelete = "admin.users.delete"
|
||||||
|
PermissionUsersResetPassword = "admin.users.reset_password"
|
||||||
|
|
||||||
|
PermissionGroupsView = "admin.permission_groups.view"
|
||||||
|
PermissionGroupsCreate = "admin.permission_groups.create"
|
||||||
|
PermissionGroupsUpdate = "admin.permission_groups.update"
|
||||||
|
PermissionGroupsDelete = "admin.permission_groups.delete"
|
||||||
|
|
||||||
|
PermissionDomainsView = "admin.domains.view"
|
||||||
|
PermissionDomainsCreate = "admin.domains.create"
|
||||||
|
PermissionDomainsUpdate = "admin.domains.update"
|
||||||
|
PermissionDomainsDelete = "admin.domains.delete"
|
||||||
|
|
||||||
|
PermissionDNSView = "admin.dns.view"
|
||||||
|
PermissionDNSCheck = "admin.dns.check"
|
||||||
|
|
||||||
|
PermissionMailboxesView = "admin.mailboxes.view"
|
||||||
|
PermissionMailboxesCreate = "admin.mailboxes.create"
|
||||||
|
PermissionMailboxesUpdate = "admin.mailboxes.update"
|
||||||
|
PermissionMailboxesDelete = "admin.mailboxes.delete"
|
||||||
|
|
||||||
|
PermissionAliasesView = "admin.aliases.view"
|
||||||
|
PermissionAliasesCreate = "admin.aliases.create"
|
||||||
|
PermissionAliasesUpdate = "admin.aliases.update"
|
||||||
|
PermissionAliasesDelete = "admin.aliases.delete"
|
||||||
|
|
||||||
|
PermissionMessagesView = "admin.messages.view"
|
||||||
|
PermissionMessagesRead = "admin.messages.read"
|
||||||
|
PermissionMessagesAttachment = "admin.messages.attachments"
|
||||||
|
|
||||||
|
PermissionSettingsView = "admin.settings.view"
|
||||||
|
PermissionSettingsUpdate = "admin.settings.update"
|
||||||
|
PermissionSettingsTestSMTP = "admin.settings.test_smtp"
|
||||||
|
|
||||||
|
PermissionTemplatesView = "admin.templates.view"
|
||||||
|
PermissionTemplatesUpdate = "admin.templates.update"
|
||||||
|
PermissionTemplatesReset = "admin.templates.reset"
|
||||||
|
|
||||||
|
PermissionUsersManage = PermissionUsersUpdate
|
||||||
|
PermissionGroupsManage = PermissionGroupsUpdate
|
||||||
|
PermissionDomainsManage = PermissionDomainsUpdate
|
||||||
|
PermissionDNSManage = PermissionDNSCheck
|
||||||
|
PermissionMailboxesManage = PermissionMailboxesUpdate
|
||||||
|
PermissionAliasesManage = PermissionAliasesUpdate
|
||||||
|
PermissionSystemSettings = PermissionSettingsUpdate
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermissionGroupSuperAdmin = "pg_super_admin"
|
||||||
|
PermissionGroupRegular = "pg_regular_user"
|
||||||
|
PermissionGroupUserAdmin = "pg_user_admin"
|
||||||
|
PermissionGroupPermissionAdmin = "pg_permission_group_admin"
|
||||||
|
PermissionGroupDomainAdmin = "pg_domain_admin"
|
||||||
|
PermissionGroupDNSAdmin = "pg_dns_admin"
|
||||||
|
PermissionGroupMailboxAdmin = "pg_mailbox_admin"
|
||||||
|
PermissionGroupAliasAdmin = "pg_alias_admin"
|
||||||
|
PermissionGroupMessageAudit = "pg_message_audit"
|
||||||
|
PermissionGroupSystemAdmin = "pg_system_admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PermissionInfo struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionGroupSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionGroup struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
System bool `json:"system"`
|
||||||
|
UserCount int `json:"userCount"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var legacyPermissionExpansions = map[string][]string{
|
||||||
|
"admin.overview": {PermissionAdminOverview},
|
||||||
|
"admin.users": {PermissionUsersView, PermissionUsersCreate, PermissionUsersUpdate, PermissionUsersDelete, PermissionUsersResetPassword, PermissionGroupsView},
|
||||||
|
"admin.permission_groups": {PermissionGroupsView, PermissionGroupsCreate, PermissionGroupsUpdate, PermissionGroupsDelete},
|
||||||
|
"admin.domains": {PermissionDomainsView, PermissionDomainsCreate, PermissionDomainsUpdate, PermissionDomainsDelete},
|
||||||
|
"admin.dns": {PermissionDomainsView, PermissionDNSView, PermissionDNSCheck},
|
||||||
|
"admin.mailboxes": {PermissionUsersView, PermissionDomainsView, PermissionMailboxesView, PermissionMailboxesCreate, PermissionMailboxesUpdate, PermissionMailboxesDelete},
|
||||||
|
"admin.aliases": {PermissionDomainsView, PermissionAliasesView, PermissionAliasesCreate, PermissionAliasesUpdate, PermissionAliasesDelete},
|
||||||
|
"admin.messages": {PermissionMailboxesView, PermissionMessagesView, PermissionMessagesRead, PermissionMessagesAttachment},
|
||||||
|
"admin.settings": {PermissionDomainsView, PermissionSettingsView, PermissionSettingsUpdate, PermissionSettingsTestSMTP, PermissionTemplatesView, PermissionTemplatesUpdate, PermissionTemplatesReset},
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissionCatalogItems = []PermissionInfo{
|
||||||
|
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||||
|
|
||||||
|
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
|
||||||
|
{Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限组。", Category: "用户"},
|
||||||
|
{Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限组。", Category: "用户"},
|
||||||
|
{Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"},
|
||||||
|
{Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"},
|
||||||
|
|
||||||
|
{Key: PermissionGroupsView, Label: "查看权限组", Description: "查看权限组、权限目录和使用人数。", Category: "权限组"},
|
||||||
|
{Key: PermissionGroupsCreate, Label: "创建权限组", Description: "创建自定义权限组。", Category: "权限组"},
|
||||||
|
{Key: PermissionGroupsUpdate, Label: "编辑权限组", Description: "修改自定义权限组名称、说明和权限。", Category: "权限组"},
|
||||||
|
{Key: PermissionGroupsDelete, Label: "删除权限组", Description: "删除未被用户使用的自定义权限组。", Category: "权限组"},
|
||||||
|
|
||||||
|
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||||
|
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||||
|
{Key: PermissionDomainsUpdate, Label: "启停域名", Description: "启用或停用邮件域名。", Category: "域名"},
|
||||||
|
{Key: PermissionDomainsDelete, Label: "删除域名", Description: "删除未被邮箱使用的域名。", Category: "域名"},
|
||||||
|
|
||||||
|
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
|
||||||
|
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
|
||||||
|
|
||||||
|
{Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"},
|
||||||
|
{Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"},
|
||||||
|
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"},
|
||||||
|
{Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"},
|
||||||
|
|
||||||
|
{Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"},
|
||||||
|
{Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"},
|
||||||
|
{Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"},
|
||||||
|
{Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"},
|
||||||
|
|
||||||
|
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
|
||||||
|
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
|
||||||
|
{Key: PermissionMessagesAttachment, Label: "下载邮件附件", Description: "下载全局邮件中的附件。", Category: "邮件审计"},
|
||||||
|
|
||||||
|
{Key: PermissionSettingsView, Label: "查看系统设置", Description: "查看系统、SMTP、安全和邮件设置。", Category: "系统设置"},
|
||||||
|
{Key: PermissionSettingsUpdate, Label: "修改系统设置", Description: "保存系统、SMTP、安全和邮件设置。", Category: "系统设置"},
|
||||||
|
{Key: PermissionSettingsTestSMTP, Label: "测试 SMTP", Description: "发送 SMTP 测试邮件。", Category: "系统设置"},
|
||||||
|
{Key: PermissionTemplatesView, Label: "查看邮件模板", Description: "查看系统邮件模板。", Category: "邮件模板"},
|
||||||
|
{Key: PermissionTemplatesUpdate, Label: "编辑邮件模板", Description: "修改系统邮件模板内容。", Category: "邮件模板"},
|
||||||
|
{Key: PermissionTemplatesReset, Label: "恢复邮件模板", Description: "将系统邮件模板恢复默认。", Category: "邮件模板"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionCatalog() []PermissionInfo {
|
||||||
|
out := make([]PermissionInfo, len(permissionCatalogItems))
|
||||||
|
copy(out, permissionCatalogItems)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func allPermissionKeys() []string {
|
||||||
|
out := make([]string, 0, len(permissionCatalogItems))
|
||||||
|
for _, item := range permissionCatalogItems {
|
||||||
|
out = append(out, item.Key)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionSet() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, item := range permissionCatalogItems {
|
||||||
|
out[item.Key] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPermissionToSet(item string, seen map[string]bool) bool {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if permissionSet()[item] {
|
||||||
|
seen[item] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if expanded, ok := legacyPermissionExpansions[item]; ok {
|
||||||
|
for _, permission := range expanded {
|
||||||
|
seen[permission] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePermissionList(items []string) ([]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, item := range items {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if !addPermissionToSet(item, seen) {
|
||||||
|
return nil, fmt.Errorf("invalid permission: %s", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, item := range allPermissionKeys() {
|
||||||
|
if seen[item] {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStoredPermissions(value string) []string {
|
||||||
|
var raw []string
|
||||||
|
if err := json.Unmarshal([]byte(value), &raw); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, item := range raw {
|
||||||
|
addPermissionToSet(item, seen)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, item := range allPermissionKeys() {
|
||||||
|
if seen[item] {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodePermissions(items []string) string {
|
||||||
|
normalized, err := normalizePermissionList(items)
|
||||||
|
if err != nil {
|
||||||
|
normalized = nil
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(normalized)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPermissionGroups() []PermissionGroup {
|
||||||
|
return []PermissionGroup{
|
||||||
|
{
|
||||||
|
ID: PermissionGroupSuperAdmin,
|
||||||
|
Name: "超级管理员",
|
||||||
|
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
|
||||||
|
Permissions: allPermissionKeys(),
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupRegular,
|
||||||
|
Name: "普通用户",
|
||||||
|
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
|
||||||
|
Permissions: []string{},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupUserAdmin,
|
||||||
|
Name: "用户管理员",
|
||||||
|
Description: "查看并管理用户账号、状态、密码和用户权限组分配。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionUsersView,
|
||||||
|
PermissionUsersCreate,
|
||||||
|
PermissionUsersUpdate,
|
||||||
|
PermissionUsersDelete,
|
||||||
|
PermissionUsersResetPassword,
|
||||||
|
PermissionGroupsView,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupPermissionAdmin,
|
||||||
|
Name: "权限组管理员",
|
||||||
|
Description: "管理自定义权限组和权限目录。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionGroupsView,
|
||||||
|
PermissionGroupsCreate,
|
||||||
|
PermissionGroupsUpdate,
|
||||||
|
PermissionGroupsDelete,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupDomainAdmin,
|
||||||
|
Name: "域名管理员",
|
||||||
|
Description: "查看、添加、启停和删除邮件域名。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionDomainsView,
|
||||||
|
PermissionDomainsCreate,
|
||||||
|
PermissionDomainsUpdate,
|
||||||
|
PermissionDomainsDelete,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupDNSAdmin,
|
||||||
|
Name: "DNS 检测员",
|
||||||
|
Description: "查看 DNS 记录并执行 DNS 检测。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionDomainsView,
|
||||||
|
PermissionDNSView,
|
||||||
|
PermissionDNSCheck,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupMailboxAdmin,
|
||||||
|
Name: "邮箱账号管理员",
|
||||||
|
Description: "查看用户和域名,创建、编辑和删除邮箱账号。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionUsersView,
|
||||||
|
PermissionDomainsView,
|
||||||
|
PermissionMailboxesView,
|
||||||
|
PermissionMailboxesCreate,
|
||||||
|
PermissionMailboxesUpdate,
|
||||||
|
PermissionMailboxesDelete,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupAliasAdmin,
|
||||||
|
Name: "别名转发管理员",
|
||||||
|
Description: "查看域名,创建、编辑和删除别名转发。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionDomainsView,
|
||||||
|
PermissionAliasesView,
|
||||||
|
PermissionAliasesCreate,
|
||||||
|
PermissionAliasesUpdate,
|
||||||
|
PermissionAliasesDelete,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupMessageAudit,
|
||||||
|
Name: "邮件审计员",
|
||||||
|
Description: "查看全局邮件列表、正文和附件。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionMailboxesView,
|
||||||
|
PermissionMessagesView,
|
||||||
|
PermissionMessagesRead,
|
||||||
|
PermissionMessagesAttachment,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PermissionGroupSystemAdmin,
|
||||||
|
Name: "系统设置管理员",
|
||||||
|
Description: "查看并修改系统设置、SMTP 测试和邮件模板。",
|
||||||
|
Permissions: []string{
|
||||||
|
PermissionAdminOverview,
|
||||||
|
PermissionDomainsView,
|
||||||
|
PermissionSettingsView,
|
||||||
|
PermissionSettingsUpdate,
|
||||||
|
PermissionSettingsTestSMTP,
|
||||||
|
PermissionTemplatesView,
|
||||||
|
PermissionTemplatesUpdate,
|
||||||
|
PermissionTemplatesReset,
|
||||||
|
},
|
||||||
|
System: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedPermissionGroupIDs() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, group := range defaultPermissionGroups() {
|
||||||
|
out[group.ID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignablePermissionGroupIDs() map[string]bool {
|
||||||
|
out := fixedPermissionGroupIDs()
|
||||||
|
delete(out, PermissionGroupSuperAdmin)
|
||||||
|
delete(out, PermissionGroupRegular)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAssignablePermissionGroupID(groupID string) bool {
|
||||||
|
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionGroupOrder() map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for index, group := range defaultPermissionGroups() {
|
||||||
|
out[group.ID] = index
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionGroupNames() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, group := range defaultPermissionGroups() {
|
||||||
|
out[group.ID] = group.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for _, item := range defaultPermissionGroups() {
|
||||||
|
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at`,
|
||||||
|
item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupIDs, groups, err := a.permissionGroupsForUser(ctx, u.ID, u.Role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Permissions = permissions
|
||||||
|
u.PermissionGroupIDs = groupIDs
|
||||||
|
u.PermissionGroups = groups
|
||||||
|
u.Protected = a.isDefaultAdminUser(u)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]string, error) {
|
||||||
|
if role == "admin" {
|
||||||
|
return allPermissionKeys(), nil
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.permissions_json
|
||||||
|
FROM permission_groups pg
|
||||||
|
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||||
|
WHERE upg.user_id=?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var groupID, raw string
|
||||||
|
if err := rows.Scan(&groupID, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isAssignablePermissionGroupID(groupID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, permission := range decodeStoredPermissions(raw) {
|
||||||
|
seen[permission] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, item := range allPermissionKeys() {
|
||||||
|
if seen[item] {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||||
|
if role == "admin" {
|
||||||
|
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||||
|
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.name
|
||||||
|
FROM permission_groups pg
|
||||||
|
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||||
|
WHERE upg.user_id=?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
order := permissionGroupOrder()
|
||||||
|
ids := []string{}
|
||||||
|
groups := []PermissionGroupSummary{}
|
||||||
|
for rows.Next() {
|
||||||
|
var group PermissionGroupSummary
|
||||||
|
if err := rows.Scan(&group.ID, &group.Name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !isAssignablePermissionGroupID(group.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, group.ID)
|
||||||
|
groups = append(groups, group)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
sort.SliceStable(groups, func(i, j int) bool {
|
||||||
|
left, leftOK := order[groups[i].ID]
|
||||||
|
right, rightOK := order[groups[j].ID]
|
||||||
|
if leftOK && rightOK {
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name)
|
||||||
|
})
|
||||||
|
sort.SliceStable(ids, func(i, j int) bool {
|
||||||
|
left, leftOK := order[ids[i]]
|
||||||
|
right, rightOK := order[ids[j]]
|
||||||
|
if leftOK && rightOK {
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
return ids[i] < ids[j]
|
||||||
|
})
|
||||||
|
if len(groups) == 0 {
|
||||||
|
group := PermissionGroupSummary{ID: PermissionGroupRegular, Name: "普通用户"}
|
||||||
|
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
||||||
|
}
|
||||||
|
return ids, groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userHasPermission(user *User, permission string) bool {
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, item := range user.Permissions {
|
||||||
|
if item == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func userHasAnyPermission(user *User, permissions ...string) bool {
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if userHasPermission(user, permission) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func userHasAdminAccess(user *User) bool {
|
||||||
|
return userHasAnyPermission(user, allPermissionKeys()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) requireAdminAccess(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !userHasAdminAccess(currentUser(r)) {
|
||||||
|
respondError(w, http.StatusForbidden, "admin permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) requirePermission(permission string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !userHasPermission(currentUser(r), permission) {
|
||||||
|
respondError(w, http.StatusForbidden, "permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) requireAnyPermission(permissions ...string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !userHasAnyPermission(currentUser(r), permissions...) {
|
||||||
|
respondError(w, http.StatusForbidden, "permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actorCanGrantPermissions(actor *User, permissions []string) bool {
|
||||||
|
if actor == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if actor.Role == "admin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if !userHasPermission(actor, permission) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs []string) ([]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, groupID := range cleanIDList(groupIDs) {
|
||||||
|
if !isAssignablePermissionGroupID(groupID) {
|
||||||
|
return nil, fmt.Errorf("permission group not assignable: %s", groupID)
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
query := `SELECT permissions_json FROM permission_groups WHERE id=?`
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
err = tx.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||||
|
} else {
|
||||||
|
err = a.db.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("permission group not found: %s", groupID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, permission := range decodeStoredPermissions(raw) {
|
||||||
|
seen[permission] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, permission := range allPermissionKeys() {
|
||||||
|
if seen[permission] {
|
||||||
|
out = append(out, permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID string, groupIDs []string, actor *User) error {
|
||||||
|
groupIDs = cleanIDList(groupIDs)
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
if !isAssignablePermissionGroupID(groupID) {
|
||||||
|
return fmt.Errorf("permission group not assignable: %s", groupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupPermissions, err := a.permissionsForGroupIDs(ctx, tx, groupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !actorCanGrantPermissions(actor, groupPermissions) {
|
||||||
|
return errors.New("cannot assign permissions you do not hold")
|
||||||
|
}
|
||||||
|
exec := func(query string, args ...any) error {
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, args...)
|
||||||
|
} else {
|
||||||
|
_, err = a.db.ExecContext(ctx, query, args...)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := exec(`DELETE FROM user_permission_groups WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
if err := exec(`INSERT INTO user_permission_groups(user_id,group_id,created_at) VALUES(?,?,?)`, userID, groupID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGroup, error) {
|
||||||
|
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
|
||||||
|
FROM permission_groups pg
|
||||||
|
LEFT JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||||
|
WHERE pg.id=?
|
||||||
|
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at`, id)
|
||||||
|
var group PermissionGroup
|
||||||
|
var raw, created, updated string
|
||||||
|
var system int
|
||||||
|
if err := row.Scan(&group.ID, &group.Name, &group.Description, &raw, &system, &created, &updated, &group.UserCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group.Permissions = decodeStoredPermissions(raw)
|
||||||
|
group.System = intBool(system)
|
||||||
|
group.CreatedAt = parseTime(created)
|
||||||
|
group.UpdatedAt = parseTime(updated)
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||||
|
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortPermissionGroups(items []PermissionGroup) {
|
||||||
|
order := permissionGroupOrder()
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
left, leftOK := order[items[i].ID]
|
||||||
|
right, rightOK := order[items[j].ID]
|
||||||
|
if leftOK && rightOK {
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -87,36 +87,41 @@ func (a *App) Router() http.Handler {
|
|||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(a.requireAuth)
|
r.Use(a.requireAuth)
|
||||||
r.Use(a.requireAdmin)
|
r.Use(a.requireAdminAccess)
|
||||||
r.Get("/admin/overview", a.handleAdminOverview)
|
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||||
r.Get("/admin/users", a.handleListUsers)
|
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||||
r.Post("/admin/users", a.handleCreateUser)
|
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||||
r.Post("/admin/users/{id}", a.handleUpdateUser)
|
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
|
||||||
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||||
r.Delete("/admin/users/{id}", a.handleDeleteUser)
|
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||||
r.Get("/admin/domains", a.handleListDomains)
|
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
|
||||||
r.Post("/admin/domains", a.handleCreateDomain)
|
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
|
||||||
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
|
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
|
||||||
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup)
|
||||||
r.Get("/admin/mailboxes", a.handleListMailboxes)
|
r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup)
|
||||||
r.Post("/admin/mailboxes", a.handleCreateMailbox)
|
r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains)
|
||||||
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain)
|
||||||
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||||
r.Get("/admin/aliases", a.handleListAliases)
|
r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||||
r.Post("/admin/aliases", a.handleCreateAlias)
|
r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes)
|
||||||
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||||
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||||
r.Get("/admin/messages", a.handleAdminMessages)
|
r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||||
r.Get("/admin/messages/{id}", a.handleAdminMessage)
|
r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases)
|
||||||
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias)
|
||||||
r.Get("/admin/settings", a.handleGetSystemSettings)
|
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||||
r.Post("/admin/settings", a.handleUpdateSystemSettings)
|
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||||
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||||
r.Get("/admin/mail-templates", a.handleListMailTemplates)
|
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||||
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||||
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||||
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||||
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||||
|
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||||
|
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||||
|
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||||
|
r.With(a.requirePermission(PermissionDNSView)).Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||||
|
r.With(a.requirePermission(PermissionDNSCheck)).Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,17 +157,6 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) requireAdmin(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := currentUser(r)
|
|
||||||
if user == nil || user.Role != "admin" {
|
|
||||||
respondError(w, http.StatusForbidden, "admin role required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentUser(r *http.Request) *User {
|
func currentUser(r *http.Request) *User {
|
||||||
user, _ := r.Context().Value(userContextKey).(*User)
|
user, _ := r.Context().Value(userContextKey).(*User)
|
||||||
return user
|
return user
|
||||||
@@ -188,6 +182,9 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
|||||||
if u.Disabled {
|
if u.Disabled {
|
||||||
return nil, errors.New("disabled")
|
return nil, errors.New("disabled")
|
||||||
}
|
}
|
||||||
|
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +203,9 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
|||||||
u.Disabled = intBool(disabled)
|
u.Disabled = intBool(disabled)
|
||||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||||
u.CreatedAt = parseTime(created)
|
u.CreatedAt = parseTime(created)
|
||||||
|
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
return &u, passwordHash, nil
|
return &u, passwordHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,5 +223,8 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
|||||||
u.Disabled = intBool(disabled)
|
u.Disabled = intBool(disabled)
|
||||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||||
u.CreatedAt = parseTime(created)
|
u.CreatedAt = parseTime(created)
|
||||||
|
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
|||||||
u.Disabled = intBool(disabled)
|
u.Disabled = intBool(disabled)
|
||||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||||
u.CreatedAt = parseTime(created)
|
u.CreatedAt = parseTime(created)
|
||||||
|
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
return &u, secret, nil
|
return &u, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ package app
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
Protected bool `json:"protected"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||||
|
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Navigate } from "react-router-dom"
|
import { Navigate } from "react-router-dom"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
|
import { hasAdminAccess } from "@/lib/permissions"
|
||||||
|
|
||||||
export function AdminOnly({ children }: { children: React.ReactNode }) {
|
export function AdminOnly({ children }: { children: React.ReactNode }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
if (me.isLoading) return null
|
if (me.isLoading) return null
|
||||||
if (!me.data?.user) return <Navigate to="/login" replace />
|
if (!me.data?.user) return <Navigate to="/login" replace />
|
||||||
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
|
if (!hasAdminAccess(me.data.user)) return <Navigate to="/" replace />
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
|
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } from "lucide-react"
|
||||||
import { useMe } from "@/hooks/use-me"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { hasAnyPermission } from "@/lib/permissions"
|
||||||
|
import type { PermissionKey } from "@/lib/api-types"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -24,14 +26,15 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
const adminSections = [
|
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||||
{ key: "overview", label: "概览", icon: <BarChart3 /> },
|
{ key: "overview", label: "概览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||||
{ key: "users", label: "用户", icon: <Users /> },
|
{ key: "users", label: "用户", icon: <Users />, permissions: ["admin.users.view"] },
|
||||||
{ key: "domains", label: "域名", icon: <Globe2 /> },
|
{ key: "permissionGroups", label: "权限组", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||||
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox /> },
|
{ key: "domains", label: "域名", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||||
{ key: "aliases", label: "别名转发", icon: <Copy /> },
|
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||||
{ key: "messages", label: "全部邮件", icon: <Inbox /> },
|
{ key: "aliases", label: "别名转发", icon: <Copy />, permissions: ["admin.aliases.view"] },
|
||||||
{ key: "settings", label: "系统设置", icon: <Settings /> },
|
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||||
|
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ProtectedLayout() {
|
export function ProtectedLayout() {
|
||||||
@@ -52,6 +55,7 @@ function ProtectedContent() {
|
|||||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||||
|
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
||||||
|
|
||||||
if (isMailRoute || isProfileRoute) {
|
if (isMailRoute || isProfileRoute) {
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
@@ -77,11 +81,11 @@ function ProtectedContent() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{user.role === "admin" && isAdminRoute && (
|
{isAdminRoute && visibleAdminSections.length > 0 && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<AdminSectionItems activeSection={adminSection} />
|
<AdminSectionItems activeSection={adminSection} sections={visibleAdminSections} />
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -102,7 +106,7 @@ function ProtectedContent() {
|
|||||||
<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]">
|
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
||||||
{user.role === "admin" ? "管理员" : "用户"}
|
{user.role === "admin" ? "超级管理员" : "普通用户"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -121,7 +125,7 @@ function ProtectedContent() {
|
|||||||
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
||||||
<SidebarTrigger aria-label="打开导航" />
|
<SidebarTrigger aria-label="打开导航" />
|
||||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
{isAdminRoute ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -131,13 +135,13 @@ function ProtectedContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminSectionItems({ activeSection }: { activeSection: string }) {
|
function AdminSectionItems({ activeSection, sections }: { activeSection: string; sections: typeof adminSections }) {
|
||||||
const { isMobile, setOpenMobile } = useSidebar()
|
const { isMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
function closeMobile() {
|
function closeMobile() {
|
||||||
if (isMobile) setOpenMobile(false)
|
if (isMobile) setOpenMobile(false)
|
||||||
}
|
}
|
||||||
return adminSections.map((item) => (
|
return sections.map((item) => (
|
||||||
<SidebarMenuItem key={item.key}>
|
<SidebarMenuItem key={item.key}>
|
||||||
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
|
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
|
||||||
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
|
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
|
||||||
|
|||||||
@@ -1,4 +1,41 @@
|
|||||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
|
export type PermissionKey =
|
||||||
|
| "admin.overview.view"
|
||||||
|
| "admin.users.view"
|
||||||
|
| "admin.users.create"
|
||||||
|
| "admin.users.update"
|
||||||
|
| "admin.users.delete"
|
||||||
|
| "admin.users.reset_password"
|
||||||
|
| "admin.permission_groups.view"
|
||||||
|
| "admin.permission_groups.create"
|
||||||
|
| "admin.permission_groups.update"
|
||||||
|
| "admin.permission_groups.delete"
|
||||||
|
| "admin.domains.view"
|
||||||
|
| "admin.domains.create"
|
||||||
|
| "admin.domains.update"
|
||||||
|
| "admin.domains.delete"
|
||||||
|
| "admin.dns.view"
|
||||||
|
| "admin.dns.check"
|
||||||
|
| "admin.mailboxes.view"
|
||||||
|
| "admin.mailboxes.create"
|
||||||
|
| "admin.mailboxes.update"
|
||||||
|
| "admin.mailboxes.delete"
|
||||||
|
| "admin.aliases.view"
|
||||||
|
| "admin.aliases.create"
|
||||||
|
| "admin.aliases.update"
|
||||||
|
| "admin.aliases.delete"
|
||||||
|
| "admin.messages.view"
|
||||||
|
| "admin.messages.read"
|
||||||
|
| "admin.messages.attachments"
|
||||||
|
| "admin.settings.view"
|
||||||
|
| "admin.settings.update"
|
||||||
|
| "admin.settings.test_smtp"
|
||||||
|
| "admin.templates.view"
|
||||||
|
| "admin.templates.update"
|
||||||
|
| "admin.templates.reset"
|
||||||
|
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
|
||||||
|
export type PermissionGroupSummary = { id: string; name: string }
|
||||||
|
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||||
|
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
||||||
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 }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
|
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey } from "./api-types"
|
||||||
export * from "./api-types"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -63,8 +63,12 @@ export const api = {
|
|||||||
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
|
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
|
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
|
||||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[] }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[] }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||||
|
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; 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"),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { PermissionKey, User } from "@/lib/api-types"
|
||||||
|
|
||||||
|
export const ADMIN_PERMISSIONS: PermissionKey[] = [
|
||||||
|
"admin.overview.view",
|
||||||
|
"admin.users.view",
|
||||||
|
"admin.users.create",
|
||||||
|
"admin.users.update",
|
||||||
|
"admin.users.delete",
|
||||||
|
"admin.users.reset_password",
|
||||||
|
"admin.permission_groups.view",
|
||||||
|
"admin.permission_groups.create",
|
||||||
|
"admin.permission_groups.update",
|
||||||
|
"admin.permission_groups.delete",
|
||||||
|
"admin.domains.view",
|
||||||
|
"admin.domains.create",
|
||||||
|
"admin.domains.update",
|
||||||
|
"admin.domains.delete",
|
||||||
|
"admin.dns.view",
|
||||||
|
"admin.dns.check",
|
||||||
|
"admin.mailboxes.view",
|
||||||
|
"admin.mailboxes.create",
|
||||||
|
"admin.mailboxes.update",
|
||||||
|
"admin.mailboxes.delete",
|
||||||
|
"admin.aliases.view",
|
||||||
|
"admin.aliases.create",
|
||||||
|
"admin.aliases.update",
|
||||||
|
"admin.aliases.delete",
|
||||||
|
"admin.messages.view",
|
||||||
|
"admin.messages.read",
|
||||||
|
"admin.messages.attachments",
|
||||||
|
"admin.settings.view",
|
||||||
|
"admin.settings.update",
|
||||||
|
"admin.settings.test_smtp",
|
||||||
|
"admin.templates.view",
|
||||||
|
"admin.templates.update",
|
||||||
|
"admin.templates.reset",
|
||||||
|
]
|
||||||
|
|
||||||
|
export function hasPermission(user: User | undefined | null, permission: PermissionKey) {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.role === "admin") return true
|
||||||
|
return (user.permissions || []).includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnyPermission(user: User | undefined | null, permissions: PermissionKey[]) {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.role === "admin") return true
|
||||||
|
return permissions.some((permission) => (user.permissions || []).includes(permission))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAdminAccess(user: User | undefined | null) {
|
||||||
|
return hasAnyPermission(user, ADMIN_PERMISSIONS)
|
||||||
|
}
|
||||||
+409
-72
@@ -3,7 +3,7 @@ import DOMPurify from "dompurify"
|
|||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api"
|
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, SystemSettings } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -20,14 +20,18 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||||
|
import type { PermissionKey } from "@/lib/api-types"
|
||||||
|
|
||||||
type Section = "overview" | "users" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
|
|
||||||
const sectionLabels: Record<Section, string> = {
|
const sectionLabels: Record<Section, string> = {
|
||||||
overview: "概览",
|
overview: "概览",
|
||||||
users: "用户",
|
users: "用户",
|
||||||
|
permissionGroups: "权限组",
|
||||||
domains: "域名",
|
domains: "域名",
|
||||||
mailboxes: "邮箱账号",
|
mailboxes: "邮箱账号",
|
||||||
aliases: "别名转发",
|
aliases: "别名转发",
|
||||||
@@ -35,25 +39,50 @@ const sectionLabels: Record<Section, string> = {
|
|||||||
settings: "系统设置",
|
settings: "系统设置",
|
||||||
}
|
}
|
||||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||||
|
const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||||
|
overview: ["admin.overview.view"],
|
||||||
|
users: ["admin.users.view"],
|
||||||
|
permissionGroups: ["admin.permission_groups.view"],
|
||||||
|
domains: ["admin.domains.view", "admin.dns.view"],
|
||||||
|
mailboxes: ["admin.mailboxes.view"],
|
||||||
|
aliases: ["admin.aliases.view"],
|
||||||
|
messages: ["admin.messages.view"],
|
||||||
|
settings: ["admin.settings.view", "admin.templates.view"],
|
||||||
|
}
|
||||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||||||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview })
|
const me = useMe()
|
||||||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users })
|
const user = me.data?.user
|
||||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains })
|
const canOverview = hasPermission(user, "admin.overview.view")
|
||||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes })
|
const canUsersView = hasPermission(user, "admin.users.view")
|
||||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases })
|
const canPermissionGroupsView = hasPermission(user, "admin.permission_groups.view")
|
||||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings })
|
const canDomainsView = hasPermission(user, "admin.domains.view")
|
||||||
|
const canDNSView = hasPermission(user, "admin.dns.view")
|
||||||
|
const canMailboxesView = hasPermission(user, "admin.mailboxes.view")
|
||||||
|
const canAliasesView = hasPermission(user, "admin.aliases.view")
|
||||||
|
const canMessagesView = hasPermission(user, "admin.messages.view")
|
||||||
|
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||||||
|
const canTemplatesView = hasPermission(user, "admin.templates.view")
|
||||||
|
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview })
|
||||||
|
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
|
||||||
|
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
|
||||||
|
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
|
||||||
|
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView) })
|
||||||
|
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 [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
|
|
||||||
const domainItems = domains.data?.items || []
|
const domainItems = domains.data?.items || []
|
||||||
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 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 && sectionKeys.includes(rawSection) ? rawSection : "overview"
|
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||||
@@ -62,7 +91,7 @@ export function AdminPage() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{section === "overview" && (
|
{section === "overview" && canOverview && (
|
||||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||||
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} />
|
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} />
|
||||||
<Stat icon={<Globe2 />} label="域名" value={overview.data?.domains || 0} />
|
<Stat icon={<Globe2 />} label="域名" value={overview.data?.domains || 0} />
|
||||||
@@ -71,8 +100,9 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} 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} />}
|
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} />}
|
||||||
|
{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} />}
|
||||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||||
@@ -82,8 +112,8 @@ export function AdminPage() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
function OverviewSection({ overview, domains, settings, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; onSectionChange: (section: Section) => void }) {
|
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||||||
const checklist = setupChecklist(overview, domains, settings)
|
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
@@ -142,7 +172,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
|
|||||||
return [
|
return [
|
||||||
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
|
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
|
||||||
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
|
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
|
||||||
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给管理员或用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给超级管理员或普通用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
||||||
{ key: "smtp", title: "确认发信链路", detail: settings?.smtpHost ? `内置 Postfix:${settings.smtpHost}:${settings.smtpPort}` : "默认使用内置 Postfix", done: true, section: "settings" as Section },
|
{ key: "smtp", title: "确认发信链路", detail: settings?.smtpHost ? `内置 Postfix:${settings.smtpHost}:${settings.smtpPort}` : "默认使用内置 Postfix", done: true, section: "settings" as Section },
|
||||||
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
|
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
|
||||||
]
|
]
|
||||||
@@ -152,13 +182,17 @@ 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 }: { users: AdminUser[] }) {
|
function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permissionGroups: PermissionGroup[] }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [query, setQuery] = React.useState("")
|
const [query, setQuery] = React.useState("")
|
||||||
const [roleFilter, setRoleFilter] = React.useState("all")
|
const [roleFilter, setRoleFilter] = React.useState("all")
|
||||||
const [statusFilter, setStatusFilter] = React.useState("all")
|
const [statusFilter, setStatusFilter] = React.useState("all")
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
|
const canCreate = hasPermission(user, "admin.users.create")
|
||||||
|
const canDelete = hasPermission(user, "admin.users.delete")
|
||||||
const filteredUsers = users.filter((user) => {
|
const filteredUsers = users.filter((user) => {
|
||||||
const keyword = query.trim().toLowerCase()
|
const keyword = query.trim().toLowerCase()
|
||||||
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||||||
@@ -172,7 +206,7 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
|||||||
<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>
|
||||||
<CreateUserDialog />
|
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -185,7 +219,7 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
|||||||
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部角色</SelectItem>
|
<SelectItem value="all">全部角色</SelectItem>
|
||||||
<SelectItem value="admin">管理员</SelectItem>
|
<SelectItem value="admin">超级管理员</SelectItem>
|
||||||
<SelectItem value="user">普通用户</SelectItem>
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -206,20 +240,21 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
|||||||
<div className="truncate font-medium">{user.displayName}</div>
|
<div className="truncate font-medium">{user.displayName}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} />
|
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
<RoleBadge user={user} />
|
||||||
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
|
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
|
||||||
<Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
|
<Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>用户</TableHead><TableHead>角色</TableHead><TableHead>邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建时间</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
<TableHeader><TableRow><TableHead>用户</TableHead><TableHead>身份</TableHead><TableHead>权限组</TableHead><TableHead>邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建时间</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredUsers.map((user) => (
|
{filteredUsers.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
@@ -227,11 +262,12 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
|||||||
<div className="font-medium">{user.displayName}</div>
|
<div className="font-medium">{user.displayName}</div>
|
||||||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge></TableCell>
|
<TableCell><RoleBadge user={user} /></TableCell>
|
||||||
|
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
||||||
<TableCell><UserMailboxCell user={user} /></TableCell>
|
<TableCell><UserMailboxCell user={user} /></TableCell>
|
||||||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
|
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></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} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /></TableCell>
|
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -244,10 +280,215 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[]; catalog: PermissionInfo[] }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [query, setQuery] = React.useState("")
|
||||||
|
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
|
||||||
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
|
const canCreate = hasPermission(user, "admin.permission_groups.create")
|
||||||
|
const canUpdate = hasPermission(user, "admin.permission_groups.update")
|
||||||
|
const canDelete = hasPermission(user, "admin.permission_groups.delete")
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: api.deletePermissionGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
setPendingConfirm(null)
|
||||||
|
invalidateAdmin(qc)
|
||||||
|
toast({ title: "权限组已删除" })
|
||||||
|
},
|
||||||
|
onError: (e) => toast({ title: "删除失败", description: e.message }),
|
||||||
|
})
|
||||||
|
const filtered = groups.filter((group) => {
|
||||||
|
const keyword = query.trim().toLowerCase()
|
||||||
|
if (!keyword) return true
|
||||||
|
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<CardTitle>权限组管理</CardTitle>
|
||||||
|
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限组、说明或权限键" className="pl-9" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{filtered.map((group) => (
|
||||||
|
<div key={group.id} className="rounded-lg border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="font-medium">{group.name}</div>
|
||||||
|
{group.system && <Badge variant="outline">系统组</Badge>}
|
||||||
|
{!group.system && <Badge variant="secondary">自定义</Badge>}
|
||||||
|
<Badge variant="outline">{group.userCount} 人</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||||
|
</div>
|
||||||
|
{(canUpdate || canDelete) && <DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={group.system || !canUpdate} onSelect={() => setEditing(group)}>编辑权限组</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
disabled={group.system || group.userCount > 0 || !canDelete}
|
||||||
|
onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
|
||||||
|
>
|
||||||
|
删除权限组
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>}
|
||||||
|
</div>
|
||||||
|
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && <Empty text="暂无匹配的权限组" />}
|
||||||
|
</CardContent>
|
||||||
|
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(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()} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: PermissionGroup; catalog: PermissionInfo[]; open?: boolean; onOpenChange?: (open: boolean) => void }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||||
|
const dialogOpen = open ?? internalOpen
|
||||||
|
const setDialogOpen = onOpenChange ?? setInternalOpen
|
||||||
|
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (dialogOpen) setPermissions(group?.permissions || [])
|
||||||
|
}, [dialogOpen, group])
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (form: FormData) => {
|
||||||
|
const payload = {
|
||||||
|
name: String(form.get("name") || ""),
|
||||||
|
description: String(form.get("description") || ""),
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
return group ? api.updatePermissionGroup(group.id, payload) : api.createPermissionGroup(payload)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAdmin(qc)
|
||||||
|
setDialogOpen(false)
|
||||||
|
toast({ title: group ? "权限组已更新" : "权限组已创建" })
|
||||||
|
},
|
||||||
|
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
|
||||||
|
})
|
||||||
|
const trigger = group ? null : (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm"><Plus className="h-4 w-4" />权限组</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
{trigger}
|
||||||
|
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||||
|
<DialogHeader><DialogTitle>{group ? "编辑权限组" : "创建权限组"}</DialogTitle></DialogHeader>
|
||||||
|
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||||||
|
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
|
||||||
|
</div>
|
||||||
|
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
||||||
|
<DialogFooter><Button disabled={mutation.isPending}>{mutation.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void }) {
|
||||||
|
const grouped = groupPermissionCatalog(catalog)
|
||||||
|
function toggle(permission: PermissionKey, checked: boolean) {
|
||||||
|
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
|
||||||
|
}
|
||||||
|
function toggleCategory(items: PermissionInfo[], checked: boolean) {
|
||||||
|
const keys = items.map((item) => item.key)
|
||||||
|
onChange(checked ? Array.from(new Set([...value, ...keys])) : value.filter((item) => !keys.includes(item)))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label>菜单与操作权限</Label>
|
||||||
|
<Badge variant="outline">{value.length} 项</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{grouped.map(({ category, items }) => {
|
||||||
|
const allChecked = items.every((item) => value.includes(item.key))
|
||||||
|
return (
|
||||||
|
<div key={category} className="rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b px-3 py-2">
|
||||||
|
<label className="flex items-center gap-2 font-medium">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(next) => toggleCategory(items, next === true)} />
|
||||||
|
{category}
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-muted-foreground">{items.filter((item) => value.includes(item.key)).length}/{items.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 p-3 md:grid-cols-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<label key={item.key} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
||||||
|
<Checkbox checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">{item.label}</span>
|
||||||
|
<span className="line-clamp-2 text-xs text-muted-foreground">{item.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
||||||
|
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>
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{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 groupPermissionCatalog(catalog: PermissionInfo[]) {
|
||||||
|
const order: string[] = []
|
||||||
|
const grouped = new Map<string, PermissionInfo[]>()
|
||||||
|
for (const item of catalog) {
|
||||||
|
if (!grouped.has(item.category)) {
|
||||||
|
grouped.set(item.category, [])
|
||||||
|
order.push(item.category)
|
||||||
|
}
|
||||||
|
grouped.get(item.category)!.push(item)
|
||||||
|
}
|
||||||
|
return order.map((category) => ({ category, items: grouped.get(category)! }))
|
||||||
|
}
|
||||||
|
|
||||||
function DomainsSection({ domains }: { domains: Domain[] }) {
|
function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
|
const canCreate = hasPermission(user, "admin.domains.create")
|
||||||
|
const canUpdate = hasPermission(user, "admin.domains.update")
|
||||||
|
const canDelete = hasPermission(user, "admin.domains.delete")
|
||||||
|
const canViewDNS = hasPermission(user, "admin.dns.view")
|
||||||
const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||||
const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||||
return (
|
return (
|
||||||
@@ -255,7 +496,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
|||||||
<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>
|
||||||
<CreateDomainDialog />
|
{canCreate && <CreateDomainDialog />}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@@ -268,9 +509,9 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
|||||||
<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>
|
<Badge variant={domain.status === "active" ? "default" : "secondary"}>{domain.status === "active" ? "启用" : "停用"}</Badge>
|
||||||
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
||||||
<DomainDNSDialog domain={domain} />
|
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
||||||
<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>}
|
||||||
<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>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -296,16 +537,21 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxType[]; users: AdminUser[]; domains: Domain[] }) {
|
function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxType[]; users: AdminUser[]; domains: Domain[] }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
|
const canCreate = hasPermission(user, "admin.mailboxes.create")
|
||||||
|
const canUpdate = hasPermission(user, "admin.mailboxes.update")
|
||||||
|
const canDelete = hasPermission(user, "admin.mailboxes.delete")
|
||||||
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 }) })
|
||||||
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>
|
||||||
<CreateMailboxDialog domains={domains} users={users} />
|
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -317,7 +563,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
<div className="truncate font-medium">{mailbox.address}</div>
|
<div className="truncate font-medium">{mailbox.address}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
|
<div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
|
||||||
</div>
|
</div>
|
||||||
<MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} />
|
<MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
|
<Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
|
||||||
@@ -338,7 +584,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
<TableCell>{mailbox.displayName}</TableCell>
|
<TableCell>{mailbox.displayName}</TableCell>
|
||||||
<TableCell>{mailbox.quotaMb} MB</TableCell>
|
<TableCell>{mailbox.quotaMb} MB</TableCell>
|
||||||
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
|
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
|
||||||
<TableCell><MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /></TableCell>
|
<TableCell><MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -352,9 +598,14 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) {
|
function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
|
const canCreate = hasPermission(user, "admin.aliases.create")
|
||||||
|
const canUpdate = hasPermission(user, "admin.aliases.update")
|
||||||
|
const canDelete = hasPermission(user, "admin.aliases.delete")
|
||||||
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||||
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||||
return (
|
return (
|
||||||
@@ -362,7 +613,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
|||||||
<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>
|
||||||
<CreateAliasDialog domains={domains} />
|
{canCreate && <CreateAliasDialog domains={domains} />}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -374,7 +625,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
|||||||
<div className="truncate font-medium">{alias.source}</div>
|
<div className="truncate font-medium">{alias.source}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
|
<div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
|
||||||
</div>
|
</div>
|
||||||
<AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} />
|
<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>
|
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
|
||||||
@@ -393,7 +644,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
|||||||
<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><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
|
||||||
<TableCell><AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} /></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>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -534,9 +785,17 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates })
|
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||||||
|
const canUpdateSettings = hasPermission(user, "admin.settings.update")
|
||||||
|
const canTestSMTP = hasPermission(user, "admin.settings.test_smtp")
|
||||||
|
const canViewTemplates = hasPermission(user, "admin.templates.view")
|
||||||
|
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||||
|
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||||
|
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||||
@@ -617,16 +876,22 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
settings.reservedMailboxPrefixes,
|
settings.reservedMailboxPrefixes,
|
||||||
].join("|") : "loading"
|
].join("|") : "loading"
|
||||||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||||||
{ key: "base", label: "基础" },
|
...(canSettingsView ? [
|
||||||
{ key: "smtp", label: "SMTP" },
|
{ key: "base" as const, label: "基础" },
|
||||||
{ key: "storage", label: "存储" },
|
{ key: "smtp" as const, label: "SMTP" },
|
||||||
{ key: "mail", label: "邮件" },
|
{ key: "storage" as const, label: "存储" },
|
||||||
{ key: "templates", label: "模板" },
|
{ key: "mail" as const, label: "邮件" },
|
||||||
{ key: "security", label: "安全" },
|
] : []),
|
||||||
|
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||||
|
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
|
||||||
{ key: "about", label: "关于" },
|
{ key: "about", label: "关于" },
|
||||||
]
|
]
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tabs.some((tab) => tab.key === settingsTab)) return
|
||||||
|
setSettingsTab(tabs[0]?.key || "about")
|
||||||
|
}, [settingsTab, tabs])
|
||||||
return (
|
return (
|
||||||
<form key={formKey} onSubmit={(event) => { event.preventDefault(); save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
<form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
||||||
<div className="flex flex-wrap gap-2 rounded-lg border bg-card p-2">
|
<div className="flex flex-wrap gap-2 rounded-lg border bg-card p-2">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Button key={tab.key} type="button" variant={settingsTab === tab.key ? "default" : "ghost"} size="sm" onClick={() => setSettingsTab(tab.key)}>
|
<Button key={tab.key} type="button" variant={settingsTab === tab.key ? "default" : "ghost"} size="sm" onClick={() => setSettingsTab(tab.key)}>
|
||||||
@@ -652,7 +917,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>发信通道</CardTitle>
|
<CardTitle>发信通道</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<TestSMTPDialog disabled={!settings} />
|
{canTestSMTP && <TestSMTPDialog disabled={!settings} />}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -721,7 +986,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
{settingsTab === "templates" && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} />}
|
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||||
|
|
||||||
{settingsTab === "security" && <Card>
|
{settingsTab === "security" && <Card>
|
||||||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||||||
@@ -742,7 +1007,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
|
|
||||||
{settingsTab === "about" && <AboutProjectCard />}
|
{settingsTab === "about" && <AboutProjectCard />}
|
||||||
|
|
||||||
{settingsTab !== "about" && <div className="flex justify-end">
|
{settingsTab !== "about" && canUpdateSettings && <div className="flex justify-end">
|
||||||
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
|
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
</form>
|
</form>
|
||||||
@@ -852,7 +1117,7 @@ function TestSMTPDialog({ disabled }: { disabled?: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailTemplatesPanel({ templates, loading }: { templates: MailTemplate[]; loading: boolean }) {
|
function MailTemplatesPanel({ templates, loading, canUpdate, canReset }: { templates: MailTemplate[]; loading: boolean; canUpdate: boolean; canReset: boolean }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [selectedKey, setSelectedKey] = React.useState("")
|
const [selectedKey, setSelectedKey] = React.useState("")
|
||||||
@@ -906,14 +1171,14 @@ function MailTemplatesPanel({ templates, loading }: { templates: MailTemplate[];
|
|||||||
<Textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} className="min-h-64 font-mono text-sm" />
|
<Textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} className="min-h-64 font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
{(canUpdate || canReset) && <div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" disabled={reset.isPending || save.isPending} onClick={() => reset.mutate()}>
|
{canReset && <Button type="button" variant="outline" disabled={reset.isPending || save.isPending} onClick={() => reset.mutate()}>
|
||||||
{reset.isPending ? "恢复中..." : "恢复默认"}
|
{reset.isPending ? "恢复中..." : "恢复默认"}
|
||||||
</Button>
|
</Button>}
|
||||||
<Button type="button" disabled={save.isPending || reset.isPending} onClick={() => save.mutate()}>
|
{canUpdate && <Button type="button" disabled={save.isPending || reset.isPending} onClick={() => save.mutate()}>
|
||||||
{save.isPending ? "保存中..." : "保存模板"}
|
{save.isPending ? "保存中..." : "保存模板"}
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -1004,31 +1269,96 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserActions({ user, onDelete }: { user: AdminUser; onDelete: () => void }) {
|
function UserPermissionGroupsCell({ user }: { user: AdminUser }) {
|
||||||
|
const groups = user.permissionGroups || []
|
||||||
|
if (groups.length === 0) return <span className="text-muted-foreground">普通用户</span>
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-md flex-wrap gap-1">
|
||||||
|
{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) {
|
||||||
|
return (user.permissionGroupIds || []).filter((id) => id !== "pg_super_admin" && id !== "pg_regular_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
|
||||||
|
function toggle(groupID: string, checked: boolean) {
|
||||||
|
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>权限组</Label>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const checked = value.includes(group.id)
|
||||||
|
return (
|
||||||
|
<label key={group.id} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={(next) => toggle(group.id, next === true)} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">{group.name}</span>
|
||||||
|
<span className="line-clamp-2 text-xs text-muted-foreground">{group.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{groups.length === 0 && <Empty text="暂无可分配权限组" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleBadge({ user }: { user: AdminUser }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||||||
|
{user.protected && <Badge variant="outline">默认账号</Badge>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
|
||||||
|
const me = useMe()
|
||||||
|
const currentUser = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [editOpen, setEditOpen] = React.useState(false)
|
const [editOpen, setEditOpen] = React.useState(false)
|
||||||
const [passwordOpen, setPasswordOpen] = React.useState(false)
|
const [passwordOpen, setPasswordOpen] = React.useState(false)
|
||||||
|
const canUpdate = hasPermission(currentUser, "admin.users.update")
|
||||||
|
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => api.updateUser(user.id, payload),
|
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
|
||||||
onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已更新" }) },
|
onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已更新" }) },
|
||||||
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
||||||
})
|
})
|
||||||
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
|
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
|
||||||
update.mutate({ displayName: user.displayName, role: patch.role || user.role, disabled: patch.disabled ?? user.disabled })
|
const role = patch.role || user.role
|
||||||
|
update.mutate({
|
||||||
|
displayName: user.displayName,
|
||||||
|
role,
|
||||||
|
disabled: patch.disabled ?? user.disabled,
|
||||||
|
permissionGroupIds: role === "user" ? assignableUserGroupIDs(user) : [],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑用户</DropdownMenuItem><DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除用户</DropdownMenuItem></DropdownMenuContent></DropdownMenu><EditUserDialog user={user} open={editOpen} onOpenChange={setEditOpen} /><ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} /></>
|
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||||
|
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><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 && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为超级管理员"}</DropdownMenuItem></>}{!user.protected && 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() {
|
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
|
||||||
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 [role, setRole] = React.useState<"admin" | "user">("user")
|
const [role, setRole] = React.useState<"admin" | "user">("user")
|
||||||
const [status, setStatus] = React.useState("active")
|
const [status, setStatus] = React.useState("active")
|
||||||
|
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (form: FormData) => api.createUser({ email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), role, disabled: status === "disabled" }),
|
mutationFn: (form: FormData) => api.createUser({ email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), role, disabled: status === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }),
|
||||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "用户已创建" }) },
|
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "用户已创建" }) },
|
||||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
@@ -1041,9 +1371,10 @@ function CreateUserDialog() {
|
|||||||
<Field name="displayName" label="显示名称" placeholder="用户名称" />
|
<Field name="displayName" label="显示名称" placeholder="用户名称" />
|
||||||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<SelectField label="角色" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "超级管理员"]]} />
|
||||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||||||
</div>
|
</div>
|
||||||
|
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||||
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1051,20 +1382,22 @@ function CreateUserDialog() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailboxActions({ mailbox, users, onDelete }: { mailbox: MailboxType; users: AdminUser[]; onDelete: () => void }) {
|
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem></DropdownMenuContent></DropdownMenu><EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} /></>
|
if (!canUpdate && !onDelete) return null
|
||||||
|
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><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 }) {
|
||||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除别名</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
|
if (!onToggle && !onDelete) return null
|
||||||
|
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><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>
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditUserDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
const qc = useQueryClient(); const { toast } = useToast(); const [role, setRole] = React.useState(user.role); const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active")
|
const qc = useQueryClient(); const { toast } = useToast(); const [role, setRole] = React.useState(user.role); const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active"); const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>(assignableUserGroupIDs(user))
|
||||||
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active") }, [user, open])
|
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active"); setPermissionGroupIds(assignableUserGroupIDs(user)) }, [user, open])
|
||||||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled" }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "用户已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }), 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="email" label="登录邮箱" value={user.email} readOnly /><Field name="displayName" label="显示名称" defaultValue={user.displayName} /><div className="grid grid-cols-2 gap-3"><SelectField label="角色" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','管理员']]} /><SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[['active','正常'],['disabled','停用']]} /></div><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)) }}><Field name="email" label="登录邮箱" value={user.email} readOnly /><Field name="displayName" label="显示名称" defaultValue={user.displayName} /><div className="grid grid-cols-2 gap-3"><SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','超级管理员']]} disabled={user.protected} /><SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[['active','正常'],['disabled','停用']]} disabled={user.protected} /></div>{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
@@ -1089,7 +1422,7 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
|
|||||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
||||||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
||||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</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} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><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, u.email])} /> : <Field name="ownerEmail" label="归属用户邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="角色" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</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} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><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, u.email])} /> : <Field name="ownerEmail" label="归属用户邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','超级管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||||
@@ -1100,6 +1433,9 @@ function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data?.user
|
||||||
|
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
||||||
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
||||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) } })
|
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) } })
|
||||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||||
@@ -1111,8 +1447,9 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
|||||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
||||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
|
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
|
||||||
</>}</>
|
</>}</>
|
||||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle><Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button></div>
|
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||||||
if (embedded) return <div className="space-y-4"><div className="flex items-center justify-between"><div className="font-medium">DNS 记录</div><Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button></div>{content}</div>
|
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||||||
|
if (embedded) return <div className="space-y-4"><div className="flex items-center justify-between"><div className="font-medium">DNS 记录</div>{checkButton}</div>{content}</div>
|
||||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,7 +1502,7 @@ function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
||||||
function SelectField({ label, value, onValueChange, items }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][] }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||||||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
|||||||
<ShieldCheck className="h-4 w-4" />
|
<ShieldCheck className="h-4 w-4" />
|
||||||
角色
|
角色
|
||||||
</div>
|
</div>
|
||||||
<Badge>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
<Badge>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
|
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
|
||||||
<span>账号状态</span>
|
<span>账号状态</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user