diff --git a/backend/internal/httpapi/dns_accounts_api.go b/backend/internal/httpapi/dns_accounts_api.go index 91a170b..3da5561 100644 --- a/backend/internal/httpapi/dns_accounts_api.go +++ b/backend/internal/httpapi/dns_accounts_api.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) @@ -28,7 +29,6 @@ type dnsAccountView struct { // listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。 func (s *Server) listDNSAccounts(c *gin.Context) { - c.Header("Cache-Control", "no-store") s.configMu.RLock() cfg := s.config s.configMu.RUnlock() @@ -37,16 +37,36 @@ func (s *Server) listDNSAccounts(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items}) return } + projectID := strings.TrimSpace(cfg.DNS.ProjectID) + if projectID == "" { + projectID = domain.DefaultProjectID + } + // 在查询账户归属或解密凭据之前,按配置中的真实项目实时判权。 + // 全局管理员身份不能代替项目授权,撤权后的旧会话也不能继续读取。 + authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer) + if !ok { + authorizer, ok = s.projectDomains.(repository.ProjectAuthorizer) + } + if !ok { + s.writeError(c, http.StatusServiceUnavailable, "项目 DNS 权限服务尚未就绪", nil) + return + } + if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionDNSRead); err != nil { + if errors.Is(err, repository.ErrProjectAccessDenied) { + // 列表只返回可见账户,不泄露其他项目是否配置了凭据。 + c.JSON(http.StatusOK, gin.H{"items": items}) + return + } + s.writeError(c, http.StatusServiceUnavailable, "读取项目 DNS 权限失败,请稍后重试", nil) + return + } item := dnsAccountView{ ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider, - ProjectID: strings.TrimSpace(cfg.DNS.ProjectID), + ProjectID: projectID, ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识", OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配", VerificationState: cfg.DNS.VerificationState, CredentialState: "available", } - if item.ProjectID == "" { - item.ProjectID = "default" - } var owner repository.User ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID) if ownerID == "" { diff --git a/backend/internal/httpapi/dns_accounts_scope_test.go b/backend/internal/httpapi/dns_accounts_scope_test.go new file mode 100644 index 0000000..845393b --- /dev/null +++ b/backend/internal/httpapi/dns_accounts_scope_test.go @@ -0,0 +1,136 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +func newDNSAccountScopeFixture(t *testing.T) (projectMemberAPIFixture, *fakeSecretReaderStore) { + t.Helper() + f := newProjectMemberAPIFixture(t) + f.server.config.DNS.Provider = "aliyun" + f.server.config.DNS.ProjectID = "project-a" + f.server.config.DNS.CredentialRef = "secret://dns/scope-test" + secrets := &fakeSecretReaderStore{data: map[string]map[string]string{ + f.server.config.DNS.CredentialRef: { + "access_key_id": "test-access-key-1234", "access_key_secret": "test-secret-never-return", + }, + }} + f.server.secrets = secrets + return f, secrets +} + +func TestDNSAccountScopePermissions(t *testing.T) { + f, secrets := newDNSAccountScopeFixture(t) + for _, tc := range []struct { + actor string + count int + }{ + {"primary-super-admin", 1}, {"manager", 1}, {"viewer", 1}, {"global-admin", 0}, {"target", 0}, + } { + t.Run(tc.actor, func(t *testing.T) { + secrets.getCalls = 0 + response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodGet, "/api/v1/dns/accounts", "") + var page struct { + Items []dnsAccountView `json:"items"` + } + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil || page.Items == nil || len(page.Items) != tc.count { + t.Fatalf("DNS 账户项目可见范围错误:%s,%d,%s", tc.actor, response.Code, response.Body.String()) + } + if secrets.getCalls != tc.count { + t.Fatalf("无权访问的请求读取了凭据仓库:%s,%d", tc.actor, secrets.getCalls) + } + if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" { + t.Fatal("DNS 账户响应缺少身份缓存隔离") + } + for _, forbidden := range []string{"test-access-key-1234", "test-secret-never-return", "secret://dns/scope-test"} { + if strings.Contains(response.Body.String(), forbidden) { + t.Fatal("DNS 账户列表泄露完整凭据或内部引用") + } + } + if tc.count == 1 && (page.Items[0].ProjectID != "project-a" || page.Items[0].AccountIdentifier != "test****1234") { + t.Fatalf("授权响应丢失真实归属或脱敏标识:%s", response.Body.String()) + } + }) + } + // 请求参数不能冒充其他身份或改变配置中的真实项目归属。 + f.server.config.DNS.ProjectID = "project-b" + secrets.getCalls = 0 + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/dns/accounts?project_id=project-a&actor_id=primary-super-admin", "") + if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"items":[]}` || secrets.getCalls != 0 { + t.Fatalf("跨项目请求没有在读取凭据之前拒绝:%d,%s", response.Code, response.Body.String()) + } +} + +func TestDNSAccountScopeRevocation(t *testing.T) { + f, secrets := newDNSAccountScopeFixture(t) + if err := f.members.GrantProjectRole(t.Context(), "primary-super-admin", "project-a", "global-admin", domain.ProjectRoleDNSAdmin); err != nil { + t.Fatal(err) + } + response := serveUserRequest(f.server, f.cookies["global-admin"], http.MethodGet, "/api/v1/dns/accounts", "") + if response.Code != http.StatusOK || secrets.getCalls != 1 { + t.Fatalf("显式授予 DNS 角色后不可读取:%d,%s", response.Code, response.Body.String()) + } + if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "global-admin", domain.ProjectRoleDNSAdmin); err != nil { + t.Fatal(err) + } + response = serveUserRequest(f.server, f.cookies["global-admin"], http.MethodGet, "/api/v1/dns/accounts", "") + if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"items":[]}` || secrets.getCalls != 1 { + t.Fatalf("撤权后原会话仍可读取 DNS 账户:%d,%s", response.Code, response.Body.String()) + } +} + +func TestDNSAccountScopeDefaultsAndDependencies(t *testing.T) { + for _, scenario := range []string{"default", "missing_project", "missing_authorizer", "authorizer_error", "database_error", "anonymous"} { + t.Run(scenario, func(t *testing.T) { + f, secrets := newDNSAccountScopeFixture(t) + cookie := f.cookies["primary-super-admin"] + want := http.StatusOK + switch scenario { + case "default": + f.server.config.DNS.ProjectID = " " + case "missing_project": + f.server.config.DNS.ProjectID = "missing" + case "missing_authorizer": + f.server.projectMembers, f.server.projectDomains = nil, nil + want = http.StatusServiceUnavailable + case "authorizer_error": + f.server.projectMembers = nil + f.server.projectDomains = projectDomainRepositoryStub{authorizeErr: errors.New("internal-sensitive-diagnostic")} + want = http.StatusServiceUnavailable + case "database_error": + // 认证仍使用正常账户仓储,只让项目授权的数据库读取失败。 + f.server.projectMembers = nil + f.server.projectDomains = projectDomainRepositoryStub{authorize: func(ctx context.Context, actor, project string, permission domain.ProjectPermission) error { + if err := f.db.Close(); err != nil { + return err + } + return f.members.AuthorizeProject(ctx, actor, project, permission) + }} + want = http.StatusServiceUnavailable + case "anonymous": + cookie = nil + want = http.StatusUnauthorized + } + response := serveUserRequest(f.server, cookie, http.MethodGet, "/api/v1/dns/accounts", "") + if response.Code != want || response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" { + t.Fatalf("DNS 列表依赖或缓存隔离错误:%d,%s", response.Code, response.Body.String()) + } + if scenario == "default" { + if secrets.getCalls != 1 || !strings.Contains(response.Body.String(), `"project_id":"default"`) { + t.Fatal("旧配置没有按默认项目授权") + } + return + } + if secrets.getCalls != 0 || strings.Contains(response.Body.String(), "project-a") || strings.Contains(response.Body.String(), "internal-sensitive-diagnostic") || strings.Contains(response.Body.String(), "account_identifier") { + t.Fatalf("未获可靠授权仍读取凭据或泄露元数据:%s", response.Body.String()) + } + }) + } +} diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index deb8588..2db24fb 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -91,7 +91,7 @@ func TestAdminDNSAccountsExposeProjectScope(t *testing.T) { secrets := &fakeSecretReaderStore{data: map[string]map[string]string{ cfg.DNS.CredentialRef: {"api_token": "test-token"}, }} - server := New(Dependencies{Config: cfg, Secrets: secrets}) + server := New(Dependencies{Config: cfg, Secrets: secrets, ProjectDomains: projectDomainRepositoryStub{}}) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/dns/accounts", nil) serveAdminHTTP(server, response, request) diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index 5948c94..7e89fa0 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -13,6 +13,7 @@ import ( func (s *Server) registerProjectResourceRoutes() { api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated) api.GET("/domains", s.listDomains) + api.GET("/dns/accounts", s.listDNSAccounts) api.GET("/audit-logs", s.listAuditLogs) api.GET("/servers", s.listServers) api.GET("/servers/:id", s.getServer) diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index d78b82c..4e586d5 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -65,10 +65,10 @@ type Dependencies struct { ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) // DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。 - DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error) - RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error) + DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error) + RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error) RestoreCertificateVersionTask func(context.Context, string, string) (certificate.IssueResult, error) - ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) + ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) // ValidateCertificateTask 直接校验当前证书,不重复创建任务。 ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) @@ -246,7 +246,6 @@ func (s *Server) registerRoutes() { adminAPI.GET("/settings", s.getSettings) adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings) adminAPI.GET("/dns/providers", s.listAdminDNSProviders) - adminAPI.GET("/dns/accounts", s.listDNSAccounts) adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS) adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain) adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow) diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index be9f051..7bb6183 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,18 @@ # Linux 生产流程测试记录 +## 2026年10月4日01:35 DNS 账户项目隔离红绿测试 + +修改前中文检查点 `e060205` 已推送。旧源码基线的 DNS 账户实现摘要为 `9bcb1dbe1ca60dc0dfcbd8dfecbb1ceead1dfe92d54bc501f5c2432e0bcdf585`,本机与第一台既有隔离源码一致。新测试使用正式 Go 用例,不创建辅助验收脚本、不修改生产配置或数据库,不使用第二台。 + +- [x] 隔离目录为第一台 `/var/tmp/scert-dns-scope-20261004-0130/backend`,使用原生 Linux Go 工具链、nobody 账户、独立临时 SQLite;CPU、内存及运行时间有限制。 +- [x] 单元 `scert-dns-scope-red-20261004-0130.service` 在旧源码运行新用例,终态 failed、退出一:无项目角色的全局管理员仍返回 system-dns,撤销项目 DNS 角色后仍可读取。项目角色反被全局管理员门槛拒绝;缺授权依赖未拒绝读取。该失败为缺陷复现,不隐去。 +- [x] 修复后单元 `scert-dns-scope-green-20261004-0134.service` 完成,终态 inactive、Result=success、退出零;DNS 账户、原有 DNS API、项目资源与项目目录专项共用正式 HTTP 路由及隔离 SQL 权限仓储,包耗时 2.910 秒。 +- [x] 用例覆盖真实归属、管理员无隐式权限、项目管理员及只读角色、跨项目伪造参数、撤权后旧会话、默认项目、不存在项目、缺依赖、数据库读失败、匿名缓存隔离和凭据不回显;拒绝路径断言凭据 Get 次数为零。 +- [x] 前端正式测试共 276 项通过,无失败或跳过;新增项目角色 DNS 入口及退出后导航复位用例。 +- [~] 后端全量、竞态及最新正式包升级尚未完成;当前结果不是线上或浏览器验收,也不代表 P0-07 全部完成。 + +本次读取源码还确认:当前 ACME 入口仅调用 DNS-01 Provider 设置,没有 HTTP-01 接入;DNS 列表仅包含当前 system-dns,没有独立多账户增删管理。完整矩阵必须区分开发未完成与已有能力缺实机证据,不再无限复跑已通过的 DNS-01 或证书回滚来替代这些缺项。 + ## 2026年10月4日01:10 首次 Web 前提与系统通知授权核查 - [✓] 第一台 Scert、Nginx 正常运行;正确名称的回环严格 TLS 前端首页返回 200,后端就绪已通过。本轮未启动新的隔离 Web 实例、未清空现有业务库。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index e14a7dc..3d61e30 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,12 @@ # Scert 开发任务清单 +## 2026年10月4日01:35 DNS 账户隔离补漏 + +- [x] 已修复 DNS 列表仅按全局管理员校验的问题,接入实时项目权限及先授权后读凭据;前端项目角色入口同步。 +- [x] 第一台 Linux 旧源码红测确认越权及撤权缺陷,新源码专项通过;前端 276 项通过。 +- [~] 后端全量、竞态、正式 Linux 包及第一台部署待完成,不将隔离测试当生产验收。 +- [!] HTTP-01 尚未接入,完整 DNS 账户生命周期与委派管理仍需开发和验证;域名切至第一台、系统通知授权与三数据库首次 Web 仍未关闭。 + ## 2026年10月4日01:04 不同版本实机验收进度 - [✓] 未改产品源码,沿用第一台正式包 `e00a757` 验证历史版本恢复及双目标推送;第二台仅检查证书和 HTTPS 生效,不运行重复业务测试。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 712bbd6..b9bd0b5 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -5,9 +5,9 @@ 沿用已确认的 P0-07、P1-03 要求:第一台进行主程序与隔离回归,第二台仅验证证书推送;不重复磁盘检查、签发和已通过的恢复测试。 - [x] 核对源码:DNS 账户列表目前只校验全局管理员身份,返回的项目归属没有用于读取授权;需修复跨项目元数据暴露。 -- [~] 先补正式回归用例,验证没有项目权限的管理员不获知账户、归属或凭据状态,且拒绝路径不读取凭据仓库。 -- [ ] 将列表接入实时项目 DNS 读取权限;项目内授权角色可读取脱敏列表,撤权后原会话立即失效,依赖异常拒绝返回数据。 -- [ ] 覆盖匿名缓存隔离、默认项目、跨项目、缺依赖及数据库失败;只在第一台隔离 Linux 源码目录运行正式 Go 测试。 +- [x] 正式回归已在旧源码复现越权及撤权失效:无项目角色的管理员收到完整账户元数据;失败记录保留,不与修复后的通过混用。 +- [x] 列表接入实时项目 DNS 读取权限;项目内授权角色可读取脱敏列表,撤权后原会话立即失效,依赖异常拒绝返回数据;导航开放项目角色入口,不放开系统设置写权限。 +- [x] 第一台隔离 Linux 专项通过,覆盖匿名缓存隔离、默认项目、跨项目、缺依赖及数据库失败,拒绝路径没有读取凭据;前端 276 项通过。后端全量与竞态仍待完成。 - [ ] 回归通过后构建、部署第一台正式包并复核;未部署前不声称线上修复。 - [!] 源码仅接入 DNS-01,HTTP-01 尚未接入,属于开发缺项而非仅缺实机证据。账户独立增删、禁用轮换和委派管理的完整矩阵继续保留,不能由已有单系统账户或 Provider 适配器代替。 - [!] 管理域名仍指第二台,用户要求真实域名入口;不使用本地映射或隧道冒充公共验收,不在第二台配置发信。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 51d009c..9e455cf 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,15 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日01:35 DNS 账户读取隔离补漏 + +- [x] 第一台隔离 Linux 正式用例在旧源码复现全局管理员跨项目读取 DNS 账户、撤权后旧会话继续读取的问题;这不是仅有待验收项,已据实补修。 +- [x] DNS 列表改为真实项目的实时 DNS 读取授权,先授权再查询归属及凭据;无权返回空列表、依赖异常拒绝返回数据,匿名及授权响应均禁止共享缓存。项目角色导航同步开放,系统写权限不变。 +- [x] 第一台 Linux 专项通过,前端 276 项通过;后端全量、竞态、正式新包与第一台升级待完成。当前线上仍为旧发布包,不提前宣称线上缺口已关闭。 +- [!] DNS/ACME 源码核对发现 HTTP-01 尚未接入;完整账户生命周期与委派管理仍不能由单系统账户和 Provider 适配器替代。这些属于开发及验收缺项,不再笼统写作只差实机矩阵。 +- [!] 域名页面已经按用户要求通过正常域名打开,但当前仍指第二台;未在那里配置发信,未修改 DNS。公网入口、系统通知授权和三数据库首次 Web 前提仍保留。 + +仅处理 Linux;第二台不重复主程序测试,无新增辅助脚本、备份或磁盘检查。详细用例及执行记录见《Linux生产流程测试记录》。 + ## 2026年10月4日01:10 外部前提补充 不同版本回滚与重试的通过结论见下节,不重复执行第二台业务测试。第一台内部前后端正常,但公共入口定向诊断仍为 HTTP 403、HTTPS 握手失败,服务器侧管理域名 DNS 仍指第二台。Smail 网站可达,但 Scert 系统 SMTP 未启用、未配置授权;匿名状态查询的权限拒绝不是发信故障。完整首次 Web 和邮件闭环需要可用且指向第一台的管理域名,以及 Smail 系统通知专用授权;未满足前不清空现有业务数据、不猜凭据、不改用普通邮箱。整体目标及其余门禁保持不变。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index 32805d4..c92c216 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -32,6 +32,8 @@ ## P1-03 完善 ACME 和 DNS 管理 +2026年10月4日源码复核:HTTP-01 尚未接入;system-dns 单账户列表不能代表独立多账户生命周期完成。已补 DNS 列表实时项目读取权限及第一台 Linux 隔离红绿测试,正式新包部署仍待完成。原六阶段未完成状态保留,不把开发缺项改写成仅待验收。 + - 任务编号:P1-03;任务名称:完善 ACME 和 DNS 管理;优先级:P1。 - 当前状态:`[~] 进行中`。 - 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[~]`;最终验收 `[ ]`。 diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index b6b47ae..4d69db6 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -65,6 +65,18 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", () assert.equal(navigation.sections.value.length, 5); }); +test("DNS 账户入口允许项目角色访问,数据范围由后端实时授权", () => { + const user = ref({ role: "user" }); + const navigation = useDashboardNavigation(user); + assert.equal(navigation.sections.value.some((section) => section.id === "dns-accounts"), true); + assert.equal(navigation.sections.value.some((section) => section.id === "users"), false); + navigation.selectSection("dns-accounts"); + assert.equal(navigation.activeSection.value, "dns-accounts"); + user.value = null; + assert.equal(navigation.activeSection.value, "overview"); + assert.equal(navigation.sections.value.some((section) => section.id === "dns-accounts"), false); +}); + test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入口", async () => { const { readFile } = await import("node:fs/promises"); const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8"); diff --git a/frontend/src/views/Dashboard/DashboardNavigation.js b/frontend/src/views/Dashboard/DashboardNavigation.js index 41ee77c..6c39306 100644 --- a/frontend/src/views/Dashboard/DashboardNavigation.js +++ b/frontend/src/views/Dashboard/DashboardNavigation.js @@ -12,9 +12,11 @@ export const dashboardSections = [ export function useDashboardNavigation(currentUser) { const selectedSection = ref("overview"); - const sections = computed(() => dashboardSections.filter( - (section) => !["users", "dns-accounts"].includes(section.id) || ["super_admin", "admin"].includes(currentUser.value?.role), - )); + const sections = computed(() => dashboardSections.filter((section) => { + if (section.id === "users") return ["super_admin", "admin"].includes(currentUser.value?.role); + if (section.id === "dns-accounts") return Boolean(currentUser.value); + return true; + })); const activeSection = computed(() => sections.value.some( (section) => section.id === selectedSection.value, ) ? selectedSection.value : "overview");