From 31435c1e9704b11e77e8e2598ed72b784421b2f9 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Mon, 22 Jun 2026 14:31:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=BC=95=E5=85=A5=E7=BB=86?= =?UTF-8?q?=E7=B2=92=E5=BA=A6=E6=9D=83=E9=99=90=E7=BB=84=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增权限目录、系统权限组与用户权限组分配,后台路由改为按权限粒度控制。 - 支持创建、编辑、查看和删除自定义权限组,并为用户绑定权限组。 - 前端后台界面按权限动态展示菜单与操作项,补充用户/权限组相关类型与交互。 --- apps/api/internal/app/admin_handlers.go | 156 +++- apps/api/internal/app/app.go | 31 +- apps/api/internal/app/app_test.go | 225 ++++++ .../internal/app/permission_group_handlers.go | 201 +++++ apps/api/internal/app/permissions.go | 731 ++++++++++++++++++ apps/api/internal/app/router_auth.go | 85 +- apps/api/internal/app/two_factor.go | 3 + apps/api/internal/app/types.go | 18 +- apps/web/src/components/admin-only.tsx | 3 +- apps/web/src/components/protected-layout.tsx | 34 +- apps/web/src/lib/api-types.ts | 39 +- apps/web/src/lib/api.ts | 10 +- apps/web/src/lib/permissions.ts | 53 ++ apps/web/src/pages/admin.tsx | 481 ++++++++++-- apps/web/src/pages/profile.tsx | 2 +- 15 files changed, 1915 insertions(+), 157 deletions(-) create mode 100644 apps/api/internal/app/permission_group_handlers.go create mode 100644 apps/api/internal/app/permissions.go create mode 100644 apps/web/src/lib/permissions.ts diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index ff2601b..3a8d853 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -73,21 +73,37 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { item.Mailboxes = splitCSV(mailboxCSV) 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}) } func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { var req struct { - Email string `json:"email"` - DisplayName string `json:"displayName"` - Role string `json:"role"` - Password string `json:"password"` - Disabled bool `json:"disabled"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Password string `json:"password"` + Disabled bool `json:"disabled"` + PermissionGroupIDs []string `json:"permissionGroupIds"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } + actor := currentUser(r) email := normalizeEmail(req.Email) if email == "" || !strings.Contains(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")) 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 { badRequest(w, errors.New("password must be at least 8 characters")) return @@ -116,12 +136,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { } id := newID("usr") 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) - VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now) + tx, err := a.db.BeginTx(r.Context(), 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) 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) if err != nil { 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") current := currentUser(r) var req struct { - DisplayName string `json:"displayName"` - Role string `json:"role"` - Disabled *bool `json:"disabled"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Disabled *bool `json:"disabled"` + PermissionGroupIDs *[]string `json:"permissionGroupIds"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -155,21 +193,79 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("invalid role")) 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 { disabled = *req.Disabled } - if current != nil && current.ID == id && (disabled || role != "admin") { - badRequest(w, errors.New("cannot remove your own admin access")) + if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) { + badRequest(w, errors.New("default administrator must remain an active super administrator")) return } if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil { badRequest(w, err) return } - _, err := a.db.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) + shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil + 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 { + 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") 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) { 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 { 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")) 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 { badRequest(w, err) return @@ -409,6 +525,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("invalid role")) 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) if err != nil { @@ -836,6 +959,9 @@ func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) item.TwoFactorEnabled = intBool(twoFactorEnabled) item.CreatedAt = parseTime(created) item.Mailboxes = splitCSV(mailboxCSV) + if err := a.attachUserAuthorization(ctx, &item.User); err != nil { + return nil, err + } return &item, nil } diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 6d22003..617f91a 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -115,6 +115,22 @@ func (a *App) migrate(ctx context.Context) error { created_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 ( id TEXT PRIMARY KEY, 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 { return err } + if err := a.ensureDefaultPermissionGroups(ctx); err != nil { + return err + } return nil } @@ -679,7 +698,7 @@ func (a *App) seed(ctx context.Context) error { return err } if count > 0 { - return nil + return a.ensureConfiguredAdminSuperAdmin(ctx) } adminPassword := a.cfg.AdminPassword @@ -736,6 +755,16 @@ func (a *App) seed(ctx context.Context) error { 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) { name = normalizeDomain(name) if name == "" || !strings.Contains(name, ".") { diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 2d52860..1733fa5 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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) { a := newTestApp(t) ctx := context.Background() diff --git a/apps/api/internal/app/permission_group_handlers.go b/apps/api/internal/app/permission_group_handlers.go new file mode 100644 index 0000000..504eeaf --- /dev/null +++ b/apps/api/internal/app/permission_group_handlers.go @@ -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}) +} diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go new file mode 100644 index 0000000..445da3a --- /dev/null +++ b/apps/api/internal/app/permissions.go @@ -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) + }) +} diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 5d3f870..b715c27 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -87,36 +87,41 @@ func (a *App) Router() http.Handler { r.Group(func(r chi.Router) { r.Use(a.requireAuth) - r.Use(a.requireAdmin) - r.Get("/admin/overview", a.handleAdminOverview) - r.Get("/admin/users", a.handleListUsers) - r.Post("/admin/users", a.handleCreateUser) - r.Post("/admin/users/{id}", a.handleUpdateUser) - r.Post("/admin/users/{id}/password", a.handleResetUserPassword) - r.Delete("/admin/users/{id}", a.handleDeleteUser) - r.Get("/admin/domains", a.handleListDomains) - r.Post("/admin/domains", a.handleCreateDomain) - r.Post("/admin/domains/{id}", a.handleUpdateDomain) - r.Delete("/admin/domains/{id}", a.handleDeleteDomain) - r.Get("/admin/mailboxes", a.handleListMailboxes) - r.Post("/admin/mailboxes", a.handleCreateMailbox) - r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox) - r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox) - r.Get("/admin/aliases", a.handleListAliases) - r.Post("/admin/aliases", a.handleCreateAlias) - r.Post("/admin/aliases/{id}", a.handleUpdateAlias) - r.Delete("/admin/aliases/{id}", a.handleDeleteAlias) - r.Get("/admin/messages", a.handleAdminMessages) - r.Get("/admin/messages/{id}", a.handleAdminMessage) - r.Get("/admin/attachments/{id}", a.handleAdminAttachment) - r.Get("/admin/settings", a.handleGetSystemSettings) - r.Post("/admin/settings", a.handleUpdateSystemSettings) - r.Post("/admin/settings/test-smtp", a.handleTestSMTP) - r.Get("/admin/mail-templates", a.handleListMailTemplates) - r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate) - r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate) - r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords) - r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck) + r.Use(a.requireAdminAccess) + r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview) + r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers) + r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser) + r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser) + r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword) + r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser) + r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog) + r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups) + r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup) + r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup) + r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup) + r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains) + r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain) + r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain) + r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain) + r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes) + r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox) + r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox) + r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox) + r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases) + r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias) + r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias) + r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias) + r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages) + r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage) + r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment) + r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings) + r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings) + 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 { user, _ := r.Context().Value(userContextKey).(*User) return user @@ -188,6 +182,9 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) { if u.Disabled { return nil, errors.New("disabled") } + if err := a.attachUserAuthorization(r.Context(), &u); err != nil { + return nil, err + } return &u, nil } @@ -206,6 +203,9 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err u.Disabled = intBool(disabled) u.TwoFactorEnabled = intBool(twoFactorEnabled) u.CreatedAt = parseTime(created) + if err := a.attachUserAuthorization(ctx, &u); err != nil { + return nil, "", err + } return &u, passwordHash, nil } @@ -223,5 +223,8 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) { u.Disabled = intBool(disabled) u.TwoFactorEnabled = intBool(twoFactorEnabled) u.CreatedAt = parseTime(created) + if err := a.attachUserAuthorization(ctx, &u); err != nil { + return nil, err + } return &u, nil } diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go index b37e304..1d832d5 100644 --- a/apps/api/internal/app/two_factor.go +++ b/apps/api/internal/app/two_factor.go @@ -135,6 +135,9 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e u.Disabled = intBool(disabled) u.TwoFactorEnabled = intBool(twoFactorEnabled) u.CreatedAt = parseTime(created) + if err := a.attachUserAuthorization(ctx, &u); err != nil { + return nil, "", err + } return &u, secret, nil } diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 6cae86b..41b7b61 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -3,13 +3,17 @@ package app import "time" type User struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - Role string `json:"role"` - Disabled bool `json:"disabled"` - TwoFactorEnabled bool `json:"twoFactorEnabled"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Disabled bool `json:"disabled"` + Protected bool `json:"protected"` + TwoFactorEnabled bool `json:"twoFactorEnabled"` + Permissions []string `json:"permissions"` + PermissionGroupIDs []string `json:"permissionGroupIds"` + PermissionGroups []PermissionGroupSummary `json:"permissionGroups"` + CreatedAt time.Time `json:"createdAt"` } type AdminUser struct { diff --git a/apps/web/src/components/admin-only.tsx b/apps/web/src/components/admin-only.tsx index ea60390..ceb598d 100644 --- a/apps/web/src/components/admin-only.tsx +++ b/apps/web/src/components/admin-only.tsx @@ -1,11 +1,12 @@ import React from "react" import { Navigate } from "react-router-dom" import { useMe } from "@/hooks/use-me" +import { hasAdminAccess } from "@/lib/permissions" export function AdminOnly({ children }: { children: React.ReactNode }) { const me = useMe() if (me.isLoading) return null if (!me.data?.user) return - if (me.data.user.role !== "admin") return + if (!hasAdminAccess(me.data.user)) return return <>{children} } diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index 16abe19..29fb9f8 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -1,12 +1,14 @@ import * as React from "react" 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 { useLogout } from "@/hooks/use-logout" import { AuthGuard } from "@/components/auth-guard" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { hasAnyPermission } from "@/lib/permissions" +import type { PermissionKey } from "@/lib/api-types" import { Sidebar, SidebarContent, @@ -24,14 +26,15 @@ import { useSidebar, } from "@/components/ui/sidebar" -const adminSections = [ - { key: "overview", label: "概览", icon: }, - { key: "users", label: "用户", icon: }, - { key: "domains", label: "域名", icon: }, - { key: "mailboxes", label: "邮箱账号", icon: }, - { key: "aliases", label: "别名转发", icon: }, - { key: "messages", label: "全部邮件", icon: }, - { key: "settings", label: "系统设置", icon: }, +const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ + { key: "overview", label: "概览", icon: , permissions: ["admin.overview.view"] }, + { key: "users", label: "用户", icon: , permissions: ["admin.users.view"] }, + { key: "permissionGroups", label: "权限组", icon: , permissions: ["admin.permission_groups.view"] }, + { key: "domains", label: "域名", icon: , permissions: ["admin.domains.view", "admin.dns.view"] }, + { key: "mailboxes", label: "邮箱账号", icon: , permissions: ["admin.mailboxes.view"] }, + { key: "aliases", label: "别名转发", icon: , permissions: ["admin.aliases.view"] }, + { key: "messages", label: "全部邮件", icon: , permissions: ["admin.messages.view"] }, + { key: "settings", label: "系统设置", icon: , permissions: ["admin.settings.view", "admin.templates.view"] }, ] export function ProtectedLayout() { @@ -52,6 +55,7 @@ function ProtectedContent() { const isProfileRoute = location.pathname.startsWith("/profile") const isAdminRoute = location.pathname.startsWith("/admin") const adminSection = new URLSearchParams(location.search).get("section") || "overview" + const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions)) if (isMailRoute || isProfileRoute) { return @@ -77,11 +81,11 @@ function ProtectedContent() { - {user.role === "admin" && isAdminRoute && ( + {isAdminRoute && visibleAdminSections.length > 0 && ( - + @@ -102,7 +106,7 @@ function ProtectedContent() { {user.email} - {user.role === "admin" ? "管理员" : "用户"} + {user.role === "admin" ? "超级管理员" : "普通用户"} @@ -121,7 +125,7 @@ function ProtectedContent() {
- {isAdminRoute ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"} + {isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
@@ -131,13 +135,13 @@ function ProtectedContent() { ) } -function AdminSectionItems({ activeSection }: { activeSection: string }) { +function AdminSectionItems({ activeSection, sections }: { activeSection: string; sections: typeof adminSections }) { const { isMobile, setOpenMobile } = useSidebar() function closeMobile() { if (isMobile) setOpenMobile(false) } - return adminSections.map((item) => ( + return sections.map((item) => ( diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 14e335f..52e982c 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -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 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 } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f11c585..ae016cf 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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" const REQUEST_TIMEOUT_MS = 15_000 @@ -63,8 +63,12 @@ export const api = { applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }), adminOverview: () => request("/api/admin/overview"), users: () => request>("/api/admin/users"), - createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), - updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), + permissionGroups: () => request & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), + createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[] }) => request("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }), + updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[] }) => request(`/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("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), + updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), domains: () => request>("/api/admin/domains"), diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts new file mode 100644 index 0000000..eb29f2f --- /dev/null +++ b/apps/web/src/lib/permissions.ts @@ -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) +} diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 1d3121a..06f8fd2 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -3,7 +3,7 @@ import DOMPurify from "dompurify" import { useSearchParams } from "react-router-dom" 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 { 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 { Button } from "@/components/ui/button" 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 { Textarea } from "@/components/ui/textarea" import { ConfirmDialog } from "@/components/confirm-dialog" +import { useMe } from "@/hooks/use-me" 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 } const sectionLabels: Record = { overview: "概览", users: "用户", + permissionGroups: "权限组", domains: "域名", mailboxes: "邮箱账号", aliases: "别名转发", @@ -35,25 +39,50 @@ const sectionLabels: Record = { settings: "系统设置", } const sectionKeys = Object.keys(sectionLabels) as Section[] +const sectionPermissions: Record = { + 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 projectTag = import.meta.env.VITE_APP_VERSION || "" const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "") export function AdminPage() { - const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview }) - const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users }) - const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains }) - const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes }) - const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases }) - const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings }) + const me = useMe() + const user = me.data?.user + const canOverview = hasPermission(user, "admin.overview.view") + const canUsersView = hasPermission(user, "admin.users.view") + const canPermissionGroupsView = hasPermission(user, "admin.permission_groups.view") + 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 domainItems = domains.data?.items || [] const mailboxItems = mailboxes.data?.items || [] const aliasItems = aliases.data?.items || [] const userItems = users.data?.items || [] + const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== "pg_super_admin" && group.id !== "pg_regular_user") + const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key])) 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 ( @@ -62,7 +91,7 @@ export function AdminPage() {

{sectionLabels[section]}

- {section === "overview" && ( + {section === "overview" && canOverview && (
} label="用户" value={overview.data?.users || 0} /> } label="域名" value={overview.data?.domains || 0} /> @@ -71,8 +100,9 @@ export function AdminPage() {
)} - {section === "overview" && setParams(next === "overview" ? {} : { section: next })} />} - {section === "users" && } + {section === "overview" && setParams(next === "overview" ? {} : { section: next })} />} + {section === "users" && } + {section === "permissionGroups" && } {section === "domains" && } {section === "mailboxes" && } {section === "aliases" && } @@ -82,8 +112,8 @@ export function AdminPage() {
) } -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 }) { - const checklist = setupChecklist(overview, domains, settings) +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).filter((item) => visibleSections.includes(item.section)) return (
@@ -142,7 +172,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number return [ { 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: "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: "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
{label}{value}
} -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 { toast } = useToast() const [query, setQuery] = React.useState("") const [roleFilter, setRoleFilter] = React.useState("all") const [statusFilter, setStatusFilter] = React.useState("all") const [pendingConfirm, setPendingConfirm] = React.useState(null) + const canCreate = hasPermission(user, "admin.users.create") + const canDelete = hasPermission(user, "admin.users.delete") const filteredUsers = users.filter((user) => { const keyword = query.trim().toLowerCase() const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) @@ -172,7 +206,7 @@ function UsersSection({ users }: { users: AdminUser[] }) {
用户管理 - + {canCreate && }
@@ -185,7 +219,7 @@ function UsersSection({ users }: { users: AdminUser[] }) { 全部角色 - 管理员 + 超级管理员 普通用户 @@ -206,20 +240,21 @@ function UsersSection({ users }: { users: AdminUser[] }) {
{user.displayName}
{user.email}
- setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /> + setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
- {user.role === "admin" ? "管理员" : "普通用户"} + {user.disabled ? "停用" : "正常"} {new Date(user.createdAt).toLocaleDateString()}
+
))}
- 用户角色邮箱状态创建时间 + 用户身份权限组邮箱状态创建时间 {filteredUsers.map((user) => ( @@ -227,11 +262,12 @@ function UsersSection({ users }: { users: AdminUser[] }) {
{user.displayName}
{user.email}
- {user.role === "admin" ? "管理员" : "普通用户"} + + {user.disabled ? "停用" : "正常"} {new Date(user.createdAt).toLocaleDateString()} - setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /> + setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
))}
@@ -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(null) + const [pendingConfirm, setPendingConfirm] = React.useState(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 ( + + +
+ 权限组管理 + {canCreate && } +
+
+ +
+ + setQuery(event.target.value)} placeholder="搜索权限组、说明或权限键" className="pl-9" /> +
+
+ {filtered.map((group) => ( +
+
+
+
+
{group.name}
+ {group.system && 系统组} + {!group.system && 自定义} + {group.userCount} 人 +
+
{group.description || "未填写说明"}
+
+ {(canUpdate || canDelete) && + + + setEditing(group)}>编辑权限组 + + 0 || !canDelete} + onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })} + > + 删除权限组 + + + } +
+ +
+ ))} +
+ {filtered.length === 0 && } +
+ {editing && { if (!open) setEditing(null) }} />} + { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> +
+ ) +} + +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(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 : ( + + + + ) + return ( + + {trigger} + + {group ? "编辑权限组" : "创建权限组"} +
{ event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}> +
+ + +
+ + + +
+
+ ) +} + +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 ( +
+
+ + {value.length} 项 +
+
+ {grouped.map(({ category, items }) => { + const allChecked = items.every((item) => value.includes(item.key)) + return ( +
+
+ + {items.filter((item) => value.includes(item.key)).length}/{items.length} +
+
+ {items.map((item) => ( + + ))} +
+
+ ) + })} +
+
+ ) +} + +function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) { + const labelByKey = new Map(catalog.map((item) => [item.key, item.label])) + if (permissions.length === 0) return
无后台权限
+ return ( +
+ {permissions.slice(0, 10).map((permission) => ( + {labelByKey.get(permission) || permission} + ))} + {permissions.length > 10 && +{permissions.length - 10}} +
+ ) +} + +function groupPermissionCatalog(catalog: PermissionInfo[]) { + const order: string[] = [] + const grouped = new Map() + 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[] }) { + const me = useMe() + const user = me.data?.user const qc = useQueryClient() const { toast } = useToast() const [pendingConfirm, setPendingConfirm] = React.useState(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 remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) return ( @@ -255,7 +496,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
域名管理 - + {canCreate && }
@@ -268,9 +509,9 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
{domain.status === "active" ? "启用" : "停用"} {domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus} - - - + {canViewDNS && } + {canUpdate && } + {canDelete && }
))} @@ -296,16 +537,21 @@ function DomainDNSDialog({ domain }: { domain: Domain }) { } function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxType[]; users: AdminUser[]; domains: Domain[] }) { + const me = useMe() + const user = me.data?.user const qc = useQueryClient() const { toast } = useToast() const [pendingConfirm, setPendingConfirm] = React.useState(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 }) }) return (
邮箱账号管理 - + {canCreate && }
@@ -317,7 +563,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
{mailbox.address}
{mailbox.userEmail || mailbox.userId}
- setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /> + setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} />
{mailbox.status === "active" ? "启用" : "停用"} @@ -338,7 +584,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp {mailbox.displayName} {mailbox.quotaMb} MB {mailbox.status === "active" ? "启用" : "停用"} - setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /> + setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} /> ))} @@ -352,9 +598,14 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp } function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) { + const me = useMe() + const user = me.data?.user const qc = useQueryClient() const { toast } = useToast() const [pendingConfirm, setPendingConfirm] = React.useState(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 remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) return ( @@ -362,7 +613,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
别名/转发管理 - + {canCreate && }
@@ -374,7 +625,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
{alias.source}
{alias.destination}
- 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) })} /> + 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} />
{alias.enabled ? "启用" : "停用"} @@ -393,7 +644,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai {alias.destination} {domains.find((d) => d.id === alias.domainId)?.name || alias.domainId} {alias.enabled ? "启用" : "停用"} - 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) })} /> + 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} /> ))} @@ -534,9 +785,17 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { } function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) { + const me = useMe() + const user = me.data?.user const qc = useQueryClient() 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 [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) @@ -617,16 +876,22 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting settings.reservedMailboxPrefixes, ].join("|") : "loading" const tabs: { key: typeof settingsTab; label: string }[] = [ - { key: "base", label: "基础" }, - { key: "smtp", label: "SMTP" }, - { key: "storage", label: "存储" }, - { key: "mail", label: "邮件" }, - { key: "templates", label: "模板" }, - { key: "security", label: "安全" }, + ...(canSettingsView ? [ + { key: "base" as const, label: "基础" }, + { key: "smtp" as const, label: "SMTP" }, + { key: "storage" as const, label: "存储" }, + { key: "mail" as const, label: "邮件" }, + ] : []), + ...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []), + ...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []), { key: "about", label: "关于" }, ] + React.useEffect(() => { + if (tabs.some((tab) => tab.key === settingsTab)) return + setSettingsTab(tabs[0]?.key || "about") + }, [settingsTab, tabs]) return ( -
{ event.preventDefault(); save.mutate(new FormData(event.currentTarget)) }} className="space-y-6"> + { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
{tabs.map((tab) => (
@@ -721,7 +986,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting } - {settingsTab === "templates" && } + {settingsTab === "templates" && canViewTemplates && } {settingsTab === "security" && 安全设置 @@ -742,7 +1007,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting {settingsTab === "about" && } - {settingsTab !== "about" &&
+ {settingsTab !== "about" && canUpdateSettings &&
} @@ -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 { toast } = useToast() const [selectedKey, setSelectedKey] = React.useState("") @@ -906,14 +1171,14 @@ function MailTemplatesPanel({ templates, loading }: { templates: MailTemplate[];