diff --git a/backend/internal/httpapi/auth_optional_email_test.go b/backend/internal/httpapi/auth_optional_email_test.go index b9e770e..1d3e22e 100644 --- a/backend/internal/httpapi/auth_optional_email_test.go +++ b/backend/internal/httpapi/auth_optional_email_test.go @@ -57,7 +57,7 @@ func TestAuthSessionsSupportOptionalEmail(t *testing.T) { if !payload.Authenticated || payload.Email != "" || payload.Role != role || payload.IsAdmin != (role == repository.UserRoleAdmin) { t.Fatalf("unexpected session identity: %+v", payload) } - // 普通管理员可读服务商目录,但不能读取超级管理员专属系统设置。 + // 服务商目录仅含公共字段定义,已登录用户可读;系统设置仍仅超级管理员可读。 settings := serveUserRequest(server, cookies[0], http.MethodGet, "/api/v1/settings", "") if settings.Code != http.StatusForbidden { t.Fatalf("非超级管理员读取系统设置应返回 403,实际为 %d", settings.Code) @@ -66,10 +66,7 @@ func TestAuthSessionsSupportOptionalEmail(t *testing.T) { request.AddCookie(cookies[0]) access := httptest.NewRecorder() server.Handler().ServeHTTP(access, request) - expectedStatus := http.StatusForbidden - if role == repository.UserRoleAdmin { - expectedStatus = http.StatusOK - } + expectedStatus := http.StatusOK if access.Code != expectedStatus { t.Fatalf("role boundary: status=%d want=%d", access.Code, expectedStatus) } diff --git a/backend/internal/httpapi/dns_account_ui_permissions_test.go b/backend/internal/httpapi/dns_account_ui_permissions_test.go new file mode 100644 index 0000000..c9f3822 --- /dev/null +++ b/backend/internal/httpapi/dns_account_ui_permissions_test.go @@ -0,0 +1,57 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestManagedDNSAccountCatalogForProjectUser(t *testing.T) { + f := newProjectMemberAPIFixture(t) + for _, actor := range []string{"", "manager", "viewer", "global-admin"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/dns/providers", nil) + if actor != "" { + req.AddCookie(f.cookies[actor]) + } + response := httptest.NewRecorder() + f.server.Handler().ServeHTTP(response, req) + status := http.StatusOK + if actor == "" { + status = http.StatusUnauthorized + } + if response.Code != status || response.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("服务商目录认证或缓存隔离错误:%s %d", actor, response.Code) + } + } +} + +func TestManagedDNSAccountProjectCapability(t *testing.T) { + f := newProjectMemberAPIFixture(t) + if err := f.members.GrantProjectRole(t.Context(), "primary-super-admin", "project-a", "target", domain.ProjectRoleDNSAdmin); err != nil { + t.Fatal(err) + } + for _, actor := range []string{"primary-super-admin", "manager", "viewer", "target", "global-admin"} { + page, err := f.members.ListVisibleProjects(t.Context(), actor, "", 30) + if err != nil { + t.Fatal(err) + } + for _, item := range page.Items { + expected := actor == "primary-super-admin" || actor == "manager" || actor == "target" + if item.CanManageDNS != expected { + t.Fatalf("DNS 管理提示与项目角色不符:%s %+v", actor, item) + } + } + } + if _, err := f.db.ExecContext(t.Context(), "DELETE FROM project_members WHERE user_id = 'target'"); err != nil { + t.Fatal(err) + } + response := serveUserRequest(f.server, f.cookies["target"], http.MethodGet, "/api/v1/projects", "") + var page repository.VisibleProjectPage + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || response.Code != 200 || len(page.Items) != 0 { + t.Fatal("撤权后的旧会话仍有项目管理提示", err) + } +} diff --git a/backend/internal/httpapi/middleware_test.go b/backend/internal/httpapi/middleware_test.go index f44d098..cc582b7 100644 --- a/backend/internal/httpapi/middleware_test.go +++ b/backend/internal/httpapi/middleware_test.go @@ -108,7 +108,8 @@ func TestAdminAPIRejectsAuthenticatedRegularUser(t *testing.T) { cfg.Admin.PasswordHash = string(hash) server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}}) cookie := httpUserSessionCookie(server, "user-1", repository.UserRoleUser) - response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/dns/providers", "") + // 目录已允许项目用户读取;实际系统 DNS 在线操作仍必须经过全局管理权限。 + response := serveUserRequest(server, cookie, http.MethodPost, "/api/v1/dns/test", `{}`) if response.Code != http.StatusForbidden { t.Fatalf("普通用户访问管理接口应返回 403,实际为 %d:%s", response.Code, response.Body.String()) } diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index 9588bd4..2e1bb4b 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -16,6 +16,8 @@ func (s *Server) registerProjectResourceRoutes() { api.GET("/domains/:id/dns-account", s.getDomainDNSAccount) api.PUT("/domains/:id/dns-account", s.requireSameOrigin, s.setDomainDNSAccount) api.GET("/dns/accounts", s.listDNSAccounts) + // 服务商目录不包含账户数据;项目 DNS 管理员不必拥有全局管理员身份。 + api.GET("/dns/providers", s.listAdminDNSProviders) api.GET("/dns/accounts/:id", s.getManagedDNSAccount) api.POST("/dns/accounts", s.requireSameOrigin, s.createManagedDNSAccount) api.PUT("/dns/accounts/:id", s.requireSameOrigin, s.renameManagedDNSAccount) diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 3de0bf5..ae4e1b7 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -252,7 +252,6 @@ func (s *Server) registerRoutes() { s.router.GET("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.getSettings) s.router.PUT("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.updateSettings) - adminAPI.GET("/dns/providers", s.listAdminDNSProviders) 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/backend/internal/repository/project_directory.go b/backend/internal/repository/project_directory.go index fc6b37c..e586809 100644 --- a/backend/internal/repository/project_directory.go +++ b/backend/internal/repository/project_directory.go @@ -19,6 +19,7 @@ type VisibleProject struct { CanManageMembers bool `json:"can_manage_members"` CanManageProject bool `json:"can_manage_project"` CanDeleteProject bool `json:"can_delete_project"` + CanManageDNS bool `json:"can_manage_dns"` } type VisibleProjectPage struct { @@ -45,6 +46,10 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac CASE WHEN a.role = 'super_admin' OR EXISTS ( SELECT 1 FROM project_members manager WHERE manager.project_id = p.id AND manager.user_id = a.id AND manager.role = 'project_admin' + ) THEN 1 ELSE 0 END, + CASE WHEN a.role = 'super_admin' OR EXISTS ( + SELECT 1 FROM project_members dns_manager WHERE dns_manager.project_id = p.id + AND dns_manager.user_id = a.id AND dns_manager.role IN ('project_admin', 'dns_admin') ) THEN 1 ELSE 0 END FROM users a LEFT JOIN projects p ON p.id > ? AND ( a.role = 'super_admin' OR EXISTS ( @@ -62,8 +67,8 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac for rows.Next() { var actor, globalRole string var id, name, description sql.NullString - var manager int - if err := rows.Scan(&actor, &globalRole, &id, &name, &description, &manager); err != nil { + var manager, dnsManager int + if err := rows.Scan(&actor, &globalRole, &id, &name, &description, &manager, &dnsManager); err != nil { return page, fmt.Errorf("读取可见项目失败:%w", err) } allowed = true @@ -72,6 +77,7 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac page.Items = append(page.Items, VisibleProject{ ID: id.String, Name: name.String, Description: description.String, CanManageMembers: manager == 1, CanManageProject: manager == 1, CanDeleteProject: manager == 1 && id.String != "default", + CanManageDNS: dnsManager == 1, }) } } diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index 709f407..a372ed7 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -1,5 +1,26 @@ # DNS 多账户整改任务清单 +## 2026年10月4日06:28 页面生命周期开发结果 + +- [✓] 第一台 `0610` 单元已 inactive/success、退出码零;`c80530f` 单轮三库竞态通过:数据库 81.666 秒、仓储 37.304 秒、证书 1.032 秒、HTTP 22.561 秒。包含历史排序规则与错误列排序规则用例,修复获得实库证据。不是原 `0555` 三轮通过。 +- [x] DNS 账户页接入新建、改名、启停、凭据轮换和指定授权域名在线验证;归属用户保持展示,归属项目显示名称;历史系统账户只读标记,尚未提供删除按钮。 +- [x] 项目列表增加 `can_manage_dns`,按超级管理员或项目管理员、DNS 管理员角色实时计算;界面按项目能力显示操作,服务端写入仍实时核权。服务商目录仅含公共字段定义,允许已认证项目用户读取,实际系统 DNS 测试及系统设置权限不放宽。 +- [x] 原生对话框、双列输入、鼠标悬浮说明、列表内滚动及固定表头;保留 5/10/15/30 分页。凭据不回填、不持久化,提交、取消、刷新、切换服务商和卸载时清空;409 不自动重放,网络中断提示结果未确认。 +- [✓] 新增 24 项前端专项;前端全量 310 项及生产构建通过。HTTP 全包 27.129 秒、仓储全包 7.944 秒及两包 vet 通过。本地外部数据库跳过不计实库。 +- [x] 初次后端回归发现两项旧测试把只读服务商目录当作全局管理员边界;已按新契约验证所有登录用户可读目录,并将管理员拒绝用例移到真实系统 DNS 写操作,未删除权限负向检查;复跑通过。 +- [~] 本批页面与权限代码等待第一台干净源码 Linux 复审和真实浏览器验收;当前没有升级正式服务,没有操作第二台或修改发信配置。 +- [ ] 域名表单账户选择、执行租约与签发续期重试、旧账户导入、安全删除回收和完整生产验收仍待完成,P1-03 不关闭。 + +## 2026年10月4日06:13 页面接入与实库复跑 + +- [!] `0555` 单元已实查失败,退出码一;全量和 vet 已完成,三库阶段明确出现磁盘空间不足,不能计为通过,也不能据此判定排序规则修复失败。 +- [✓] 已确认相关测试单元终止且不存在隔离数据库进程,只清理 `0538/mysql` 与 `0425/mysql-race`、`0425/mysql-review` 下隔离实例的 `data` 目录;保留日志、源码、工具链、生产数据库和第二台,释放约 3.5 GB。 +- [~] `scert-domain-dns-20261004-0610.service` 复用 `c80530f` 干净源码执行单轮三库竞态专项;不重复已完成全量,不将本轮表述为三轮通过。 +- [✓] 页面接入前检查点 `7f7b084` 已提交推送。 +- [~] 接入新建、改名、启停、轮换和在线验证;项目权限提示只用于界面,后端仍实时判权。凭据不回填、不持久化,旧版本冲突不自动重试写入。 +- [ ] 页面单元回归、构建和更新代码的第一台 Linux 复审。 +- [ ] 执行租约、实际签发续期、历史导入和安全回收仍未完成,不发布过渡版本,不关闭 P1-03。 + ## 2026年10月4日05:54 绑定接口与 MySQL 外键兼容修复 06:00(UTC+8)补充:修复已随 `c80530f` 提交推送。第一台 `scert-domain-dns-20261004-0555.service` 正在该提交的真实 Git 干净签出上执行全量、vet 和三库三轮竞态;已实查 active/running,尚无终态。不得重复启动或提前计为通过。源码和日志在 `/var/tmp/scert-domain-dns-20261004-0555`。 @@ -34,7 +55,7 @@ - [x] 域名绑定 HTTP 接口与严格参数、实时权限、版本竞争、跨源和审计回滚用例已实现;第一台回归待复审。 - [ ] 正式签发、续期、重试的账户解析与执行前权限复核。 - [x] 账户验证、增改、启停、轮换接口及无缓存和敏感字段隔离;本地全包与专项通过,第一台 Linux 回归待终态确认。 -- [ ] DNS 账户页、域名配置页及前端回归。 +- [~] DNS 账户页生命周期及前端回归已完成;域名配置页账户选择、浏览器复审仍待完成。 - [ ] 第一台 Linux 全量、竞态、三库实测与正式产物升级。 - [ ] 公共域名真实浏览器和授权 DNS/公共 CA 验收。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 8463993..6263dab 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,21 @@ # Linux 生产流程测试记录 +## 2026年10月4日06:28 磁盘故障终态、绑定复跑和页面开发 + +### 第一台绑定修复实库结果 + +- 原 `scert-domain-dns-20261004-0555.service` 已 failed/exit-code、退出一;全量与 vet 完成,三库阶段出现 `no space left on device`。06:08 现场根分区 100%,仅余 22 MB;不能把该任务算作通过。 +- 已确认 `0538`、`0555`、`0425` 及相关 `0430/0445/0458` 单元均终止,进程列表仅有生产 mysqld、无隔离服务。只删除 `/var/tmp/scert-domain-dns-20261004-0538/mysql` 和 `/var/tmp/scert-dns-accounts-20261004-0425/mysql-race`、`mysql-review` 下已停止实例的 `data`;先解析绝对路径并匹配固定允许目录,保留日志、源码、工具链及生产数据,清理后可用 3.5 GB。未触碰第二台。 +- 新单元 `scert-domain-dns-20261004-0610.service` 复用 `0555/source` 中 `c80530f` 的真实 Git 干净签出;隔离数据库使用新 `0610/mysql`、`0610/postgres` 目录,仅监听回环并严格 TLS。没有创建辅助验收脚本,也没有重复全量。 +- 06:17 实查 `ActiveState=inactive`、`SubState=dead`、`Result=success`、`ExecMainStatus=0`。单轮 `go test -race -p 1 -count=1` 专项四包通过:数据库 81.666 秒、仓储 37.304 秒、证书 1.032 秒、HTTP 22.561 秒,包含 MySQL 历史排序规则兼容和拒绝错误列定义。日志 `/var/tmp/scert-domain-dns-20261004-0610/race.log`。不表述为三轮通过。 + +### 页面增量本地回归 + +- 修改前检查点 `7f7b084` 已推送。独立 DNS 页面增加创建、改名、启停、轮换、在线验证及项目能力提示,使用已实现 API;凭据无持久化和回填,冲突与网络异常不重放写请求。 +- 新增 24 项前端专项;前端全量 310 项及 Vite 生产构建通过。后端 HTTP 全包 27.129 秒、仓储全包 7.944 秒及两包 vet 通过;本地外部库跳过不计实库验证。 +- 首次后端回归两项旧测试要求普通用户不可读服务商目录;新契约允许项目用户读取公共字段定义。已更新目录测试,并将原管理员拒绝测试改为实际系统 DNS 在线写操作,保留权限负向覆盖;复跑通过。 +- 本批第一台 Linux 干净源码复审及浏览器验证尚待完成。正式服务与邮件配置没有修改;域名表单、执行租约、真实签发续期、旧导入、安全回收及生产验收仍未完成。 + ## 2026年10月4日05:54 绑定接口及 MySQL 实库失败修复 ### 06:00(UTC+8)修复版原任务仍在运行 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index afad23a..df03d26 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,11 @@ # Scert 开发任务清单 +## 2026年10月4日06:28 DNS 页面开发进度 + +- [x] 独立账户页面生命周期及项目权限提示已接入,表格内滚动、原生弹窗和双列字段;凭据无持久化及回填,写入冲突不自动重试。 +- [✓] 前端 310 项和构建、后端两包全量及 vet 通过;前序绑定修复 `c80530f` 在第一台单轮三库竞态通过。 +- [~] 当前批次待 Linux 干净源码和浏览器复核。P1-03 剩余域名选择、执行租约、旧账户导入、安全删除回收与签发续期重试;线上未升级,完整开发与生产验收仍未完成。 + ## 2026年10月4日05:54 DNS 绑定开发进度 - [x] 独立账户域名绑定接口已完成编码及本地 API 回归;绑定和解除都必须提供当前令牌,拒绝隐式清空和旧版本覆盖。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 01dd788..c544a18 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,12 @@ # Scert 整改任务执行清单 +## 2026年10月4日06:28 DNS 页面生命周期 + +- [✓] 第一台 `0610` 修复版单轮三库竞态四包通过;原 `0555` 磁盘不足失败已记录,清理范围仅已停止的隔离 MySQL 数据,日志保留。 +- [x] 页面新建、改名、启停、轮换、验证与权限提示接入;保留用户归属、分页和历史账户只读展示,不增加未实现的删除入口。 +- [✓] 前端全量 310 项、生产构建、后端 HTTP/仓储全量及 vet 通过;新增 24 项前端专项覆盖权限、版本、敏感输入清空、竞态和异常。 +- [~] 本批代码第一台 Linux 干净源码复审、域名表单、执行租约与签发续期、旧导入和回收继续推进,不升级未完成版本,不关闭 P1-03。 + ## 2026年10月4日05:54 域名 DNS 绑定接口 - [x] 绑定读取、写入、解除路由及严格参数、权限、缓存、同源、版本、状态和审计回滚测试已实现;本地两包全量和 vet 通过。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 9933f33..3166211 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,11 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日06:28 DNS 页面与实库进度 + +- [✓] `c80530f` 第一台 `0610` 单轮三库竞态四包通过,MySQL 历史排序规则修复获得实库证据。`0555` 先前因磁盘不足失败;仅清理已终止隔离 MySQL 数据目录,保留日志和生产数据,不改写失败记录。 +- [x] DNS 账户页已接新建、改名、启停、轮换、授权域名验证;显示项目名称、用户归属和状态,历史账户只读。按项目 DNS 管理能力展示操作,凭据清空及版本冲突不重放;前端全量 310 项、构建、后端两包全量及 vet 通过。 +- [~] P1-03 仍缺域名表单选择、执行租约、旧账户导入、签发续期重试、安全删除回收和完整浏览器/实机复审;页面代码等待第一台 Linux 干净源码复核。正式服务未升级,第二台未操作,生产验收未完成。 + ## 2026年10月4日05:54 DNS 绑定接口及实库阻塞修复 - [x] 域名 DNS 绑定读取、修改和解除接口已接入,包含显式版本令牌、实时权限、在途任务拒绝、同源及缓存隔离;本地接口与数据库全量、vet 通过。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index c759648..d43c438 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -1,5 +1,13 @@ # 核心能力任务规格 +## 2026年10月4日06:28 DNS 页面权限契约 + +- `GET /api/v1/projects` 增加 `can_manage_dns`:超级管理员、当前项目管理员或 DNS 管理员为真;这是界面提示,不替代每次写入的实时授权。 +- `GET /api/v1/dns/providers` 只返回服务商字段定义,所有有效登录用户可读,匿名拒绝并禁止共享缓存;系统设置与系统 DNS 在线测试仍沿用原管理权限。 +- DNS 页面连接 `/api/v1/dns/accounts` 及 `/:id`、`/:id/status`、`/:id/credentials`、`/:id/verify`;写操作固定选中账户及版本,凭据仅当次提交。轮换与创建后提示重新在线验证,状态变更需要确认。 +- 原生对话框限制焦点,字段双列,补充说明使用悬浮提示;分页 5/10/15/30、表内滚动和固定表头。历史配置仅只读显示,不伪装已导入独立账户。 +- 前端 310 项和构建、HTTP/仓储全量与 vet 已通过;本批 Linux 和浏览器复审待完成。执行租约、实际签发续期、域名表单选择、旧导入与回收仍属未完成项。 + ## 2026年10月4日05:54 DNS 绑定接口契约 - [x] `GET /api/v1/domains/:id/dns-account` 返回已授权域名的账户绑定摘要;未绑定时标识为空,不读取秘密。 diff --git a/frontend/src/views/DNSAccounts/DNSAccountDialog.css b/frontend/src/views/DNSAccounts/DNSAccountDialog.css new file mode 100644 index 0000000..1da07a1 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccountDialog.css @@ -0,0 +1,93 @@ +.dns-account-dialog { + width: min(620px, calc(100vw - 48px)); + max-height: calc(100dvh - 48px); + box-sizing: border-box; + overflow-y: auto; + padding: 24px; + border: 1px solid #d5ddea; + border-radius: 16px; + color: #20314f; +} + +.dns-account-dialog::backdrop { + background: rgb(15 23 42 / 42%); +} + +header, +footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +h3 { + margin: 0; +} + +fieldset { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 20px 0; + margin: 0; + border: 0; +} + +label { + display: grid; + gap: 8px; + min-width: 0; + font-size: 14px; +} + +input, +select, +button { + box-sizing: border-box; + min-width: 0; + padding: 9px 12px; + border: 1px solid #d5ddea; + border-radius: 8px; + background: white; + color: inherit; + font: inherit; +} + +button { + cursor: pointer; +} + +button[type="submit"] { + color: white; + background: #315aaa; +} + +:disabled { + opacity: 0.6; + cursor: default; +} + +:focus-visible { + outline: 2px solid #315aaa; + outline-offset: 2px; +} + +.dns-dialog-confirm { + grid-column: 1 / -1; + overflow-wrap: anywhere; +} + +.dns-dialog-error { + color: #a92e2e; +} + +footer { + justify-content: flex-end; +} + +@media (max-width: 540px) { + fieldset { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/frontend/src/views/DNSAccounts/DNSAccountDialog.vue b/frontend/src/views/DNSAccounts/DNSAccountDialog.vue new file mode 100644 index 0000000..659952b --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccountDialog.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/frontend/src/views/DNSAccounts/DNSAccountEditor.js b/frontend/src/views/DNSAccounts/DNSAccountEditor.js new file mode 100644 index 0000000..23e6cf8 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccountEditor.js @@ -0,0 +1,131 @@ +import { computed, onBeforeUnmount, ref } from "vue"; +import { requestDNS } from "./DNSAccountRequest.js"; + +const titles = { + create: "新增 DNS 账户", rename: "修改账户名称", rotate: "轮换 DNS 凭据", + verify: "验证 DNS 权限", status: "变更账户状态", +}; + +export function useDNSAccountEditor(projects, providers, loading, refresh) { + const editor = ref(null); + const saving = ref(false); + const mutationError = ref(""); + const notice = ref(""); + const managedProjects = computed(() => projects.value.filter((item) => item.can_manage_dns === true)); + const selectedProvider = computed(() => providers.value.find((item) => item.id === editor.value?.provider)); + const editorTitle = computed(() => titles[editor.value?.action] || "DNS 账户"); + let mounted = true; + let target = null; + + function canManage(account) { + return account?.managed === true && Number.isSafeInteger(account.revision) && account.revision > 0 + && managedProjects.value.some((item) => item.id === account.project_id); + } + + function clearEditor() { + if (editor.value) editor.value.credentials = {}; + editor.value = null; + target = null; + } + + function closeEditor() { + if (!saving.value) clearEditor(); + } + + function openEditor(action, account = null) { + if (loading.value || saving.value || !titles[action]) return; + if (action === "create" ? managedProjects.value.length === 0 : !canManage(account)) return; + if (action === "verify" && account.status !== "active") return; + clearEditor(); + mutationError.value = ""; + notice.value = ""; + target = action === "create" ? null : { ...account }; + editor.value = { + action, name: account?.name || "", project_id: account?.project_id || managedProjects.value[0]?.id || "", + provider: account?.provider || providers.value[0]?.id || "", credentials: {}, test_domain: "", + enabled: account?.status !== "active", + }; + } + + function changeProvider() { + if (!saving.value && editor.value?.action === "create") editor.value.credentials = {}; + } + + async function saveEditor() { + const form = editor.value; + if (!mounted || !form || saving.value || loading.value) return; + mutationError.value = ""; + if (form.action === "create" ? !managedProjects.value.some((item) => item.id === form.project_id) : !canManage(target)) { + mutationError.value = "当前项目没有 DNS 管理权限,请刷新列表"; + return; + } + const body = form.action === "create" ? {} : { revision: target.revision }; + if (["create", "rename"].includes(form.action)) { + const name = form.name.trim(); + if (!name || [...name].length > 128 || /[\u0000-\u001f\u007f-\u009f]/u.test(name)) { + mutationError.value = "账户名称必须为不含控制字符的 1~128 个字符"; + return; + } + body.name = name; + } + if (["create", "rotate"].includes(form.action)) { + const provider = providers.value.find((item) => item.id === (target?.provider || form.provider)); + if (!provider?.available || !Array.isArray(provider.credential_fields)) { + mutationError.value = "请选择可用的 DNS 服务商"; + return; + } + body.credentials = {}; + for (const field of provider.credential_fields) { + const value = String(form.credentials[field.name] || "").trim(); + if (field.required && !value) { + mutationError.value = `请填写${field.label}`; + return; + } + if (value) body.credentials[field.name] = value; + } + if (form.action === "create") Object.assign(body, { provider: provider.id, project_id: form.project_id }); + } + if (form.action === "verify") { + body.test_domain = form.test_domain.trim().toLowerCase(); + if (!body.test_domain || /[\s/:*]/u.test(body.test_domain)) { + mutationError.value = "请填写不含协议、路径或通配符的测试域名"; + return; + } + } + if (form.action === "status") body.enabled = target.status !== "active"; + const suffix = { rename: "", rotate: "/credentials", verify: "/verify", status: "/status" }; + const path = "/api/v1/dns/accounts" + (form.action === "create" ? "" : `/${encodeURIComponent(target.id)}${suffix[form.action]}`); + saving.value = true; + notice.value = ""; + form.credentials = {}; + try { + await requestDNS(path, { + method: ["create", "verify"].includes(form.action) ? "POST" : "PUT", + body: JSON.stringify(body), + }); + if (!mounted) return; + notice.value = ["create", "rotate"].includes(form.action) + ? "账户已保存,请使用有权管理的测试域名执行在线验证" : "账户操作已完成"; + } catch (reason) { + if (!mounted) return; + mutationError.value = reason.message; + } finally { + // 写请求不自动重放;成功或失败均清空输入并重新读取服务器状态。 + if (body.credentials) body.credentials = {}; + if (mounted) { + clearEditor(); + await refresh({ internal: true }); + if (mounted) saving.value = false; + } + } + } + + onBeforeUnmount(() => { + mounted = false; + clearEditor(); + }); + return { + editor, saving, mutationError, notice, managedProjects, selectedProvider, editorTitle, + canManage, openEditor, closeEditor, changeProvider, saveEditor, + }; +} diff --git a/frontend/src/views/DNSAccounts/DNSAccountRequest.js b/frontend/src/views/DNSAccounts/DNSAccountRequest.js new file mode 100644 index 0000000..1cae154 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccountRequest.js @@ -0,0 +1,51 @@ +export async function requestDNS(path, options = {}) { + let response; + try { + response = await fetch(path, { + ...options, + credentials: "include", + cache: "no-store", + headers: options.body ? { "Content-Type": "application/json" } : {}, + }); + } catch (reason) { + if (reason.name === "AbortError") throw reason; + throw new Error("网络连接失败;操作结果可能尚未确认,请刷新列表后核对,勿重复提交"); + } + if (!response.ok) { + // 不展示任意服务端正文,避免代理错误或服务商响应带出敏感内容。 + const messages = { + 400: "参数无效,请检查账户名称、凭据和测试域名", + 401: "登录会话已过期,请重新登录", + 403: "当前账号没有操作权限,或目标账户已不可用", + 409: "账户已变更,请刷新后重新选择,不会自动重试此次操作", + 422: "DNS 在线验证失败,请检查凭据、测试域名和云端权限", + 429: "操作过于频繁,请稍后重试", + }; + const error = new Error(messages[response.status] || "DNS 账户请求失败,请稍后刷新核对"); + error.status = response.status; + throw error; + } + try { + return await response.json(); + } catch { + throw new Error("服务器响应格式不正确,请刷新核对操作结果"); + } +} + +export async function loadDNSProjects(signal) { + const projects = []; + const cursors = new Set(); + let after = ""; + while (true) { + const query = new URLSearchParams({ page_size: "30", after }); + const result = await requestDNS(`/api/v1/projects?${query}`, { signal }); + if (!Array.isArray(result.items)) throw new Error("项目列表格式不正确,请刷新重试"); + projects.push(...result.items); + if (!result.has_more) return projects; + if (typeof result.next_cursor !== "string" || !result.next_cursor || cursors.has(result.next_cursor)) { + throw new Error("项目分页游标无效,请刷新重试"); + } + after = result.next_cursor; + cursors.add(after); + } +} diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.css b/frontend/src/views/DNSAccounts/DNSAccounts.css index ccc3fa5..39d4057 100644 --- a/frontend/src/views/DNSAccounts/DNSAccounts.css +++ b/frontend/src/views/DNSAccounts/DNSAccounts.css @@ -1,4 +1,9 @@ .dns-accounts-panel { + display: flex; + flex-direction: column; + max-height: calc(100dvh - 130px); + min-height: 0; + box-sizing: border-box; padding: 24px; border: 1px solid #e2e8f0; border-radius: 20px; @@ -60,7 +65,8 @@ select:focus-visible, } .dns-table-scroll { - overflow-x: auto; + overflow: auto; + min-height: 0; } .dns-accounts-table { @@ -72,12 +78,14 @@ select:focus-visible, .dns-accounts-table th, .dns-accounts-table td { - padding: 16px 12px; + padding: 10px 12px; border-bottom: 1px solid #e8edf4; overflow-wrap: anywhere; } .dns-accounts-table th { + position: sticky; + top: 0; color: #66758d; background: #f7f9fc; font-weight: 600; @@ -126,6 +134,32 @@ select:focus-visible, margin: 0 5px; } +.dns-row-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dns-row-actions button { + padding: 4px 7px; + border: 1px solid #d5ddea; + border-radius: 6px; + color: #315aaa; + background: #fff; + white-space: nowrap; + cursor: pointer; +} + +.dns-notice { + color: #216044; + font-size: 14px; +} + +.dns-pagination, +.dns-accounts-heading { + flex-shrink: 0; +} + @media (max-width: 760px) { .dns-accounts-panel { padding: 16px; diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.js b/frontend/src/views/DNSAccounts/DNSAccounts.js index 6cee439..106aa17 100644 --- a/frontend/src/views/DNSAccounts/DNSAccounts.js +++ b/frontend/src/views/DNSAccounts/DNSAccounts.js @@ -1,8 +1,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; +import { loadDNSProjects, requestDNS } from "./DNSAccountRequest.js"; +import { useDNSAccountEditor } from "./DNSAccountEditor.js"; export function dnsVerificationLabel(item) { + if (item.status === "disabled") return "已停用"; if (item.credential_state === "unavailable") return "凭据不可读取"; if (item.credential_state === "invalid") return "凭据配置不完整"; + if (item.verification_state === "failed") return "验证失败"; return item.verification_state === "verified" ? "已验证" : "待验证"; } @@ -13,6 +17,8 @@ export function dnsProjectLabel(item) { export function useDNSAccounts() { const accounts = ref([]); + const projects = ref([]); + const providers = ref([]); const loading = ref(false); const error = ref(""); const pageSize = ref(10); @@ -21,31 +27,38 @@ export function useDNSAccounts() { const visibleAccounts = computed(() => accounts.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)); let requestVersion = 0; let controller; + const actions = useDNSAccountEditor(projects, providers, loading, loadAccounts); - async function loadAccounts() { + async function loadAccounts(options = {}) { + if (actions.saving.value && options?.internal !== true) return; + actions.closeEditor(); const version = ++requestVersion; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = ""; accounts.value = []; + projects.value = []; + providers.value = []; page.value = 1; try { - const response = await fetch("/api/v1/dns/accounts", { - credentials: "include", - cache: "no-store", - signal: controller.signal, - }); - if (response.status === 401) throw new Error("登录会话已过期,请重新登录"); - if (response.status === 403) throw new Error("当前账号没有查看 DNS 账户的权限"); - if (!response.ok) throw new Error("DNS 账户加载失败,请稍后重试"); - const data = await response.json(); + const signal = controller.signal; + const [data, projectItems, catalog] = await Promise.all([ + requestDNS("/api/v1/dns/accounts", { signal }), + loadDNSProjects(signal), + requestDNS("/api/v1/dns/providers", { signal }), + ]); if (!Array.isArray(data.items) || !data.items.every((item) => item && typeof item.id === "string")) { throw new Error("DNS 账户响应格式不正确,请刷新重试"); } - if (version === requestVersion) accounts.value = data.items; + if (!Array.isArray(catalog.items)) throw new Error("DNS 服务商目录格式不正确,请刷新重试"); + if (version === requestVersion) { + accounts.value = data.items; + projects.value = projectItems; + providers.value = catalog.items.filter((item) => item.available === true); + } } catch (reason) { - if (version === requestVersion) error.value = reason.message || "DNS 账户加载失败,请稍后重试"; + if (version === requestVersion && reason.name !== "AbortError") error.value = reason.message || "DNS 账户加载失败,请稍后重试"; } finally { if (version === requestVersion) loading.value = false; } @@ -61,5 +74,9 @@ export function useDNSAccounts() { controller?.abort(); }); - return { accounts, loading, error, pageSize, page, pageCount, visibleAccounts, loadAccounts, changePageSize }; + function projectLabel(item) { + return projects.value.find((project) => project.id === item.project_id)?.name || dnsProjectLabel(item); + } + + return { ...actions, accounts, providers, loading, error, pageSize, page, pageCount, visibleAccounts, loadAccounts, changePageSize, projectLabel }; } diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.test.js b/frontend/src/views/DNSAccounts/DNSAccounts.test.js new file mode 100644 index 0000000..1348268 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccounts.test.js @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { deferred, mountComposable } from "../../test-support/mount.js"; +import { dnsVerificationLabel, useDNSAccounts } from "./DNSAccounts.js"; +import { loadDNSProjects, requestDNS } from "./DNSAccountRequest.js"; + +const account = { + id: "account-a", name: "测试账户", project_id: "project-a", managed: true, + provider: "cloudflare", status: "active", revision: 7, verification_state: "verified", +}; +const provider = { + id: "cloudflare", name: "Cloudflare", available: true, + credential_fields: [{ name: "api_token", label: "访问令牌", required: true, secret: true }], +}; + +async function setup(t, mutate = () => Response.json({}), options = {}) { + const calls = []; + const { state, unmount } = await mountComposable(t, useDNSAccounts, async (path, init = {}) => { + calls.push({ path, ...init }); + if (init.method) return mutate(path, init); + if (path.startsWith("/api/v1/projects?")) { + return Response.json({ items: [{ id: "project-a", name: "项目甲", can_manage_dns: options.manage !== false }] }); + } + if (path.endsWith("/providers")) return Response.json({ items: [provider] }); + return Response.json({ items: options.accounts || [account] }); + }); + return { state, calls, unmount }; +} + +test("DNS 列表显示项目归属、分页及真实账户验证状态", async (t) => { + const { state, calls } = await setup(t, undefined, { accounts: Array.from({ length: 31 }, (_, index) => ({ ...account, id: `a-${index}` })) }); + assert.equal(state.accounts.value.length, 31); + assert.equal(state.visibleAccounts.value.length, 10); + assert.equal(state.projectLabel(account), "项目甲"); + for (const size of [5, 10, 15, 30]) { + state.page.value = 2; + state.pageSize.value = size; + state.changePageSize(); + assert.equal(state.page.value, 1); + assert.equal(state.visibleAccounts.value.length, size); + } + assert.equal(dnsVerificationLabel({ ...account, status: "disabled" }), "已停用"); + assert.equal(dnsVerificationLabel({ ...account, verification_state: "failed" }), "验证失败"); + assert.equal(dnsVerificationLabel({ ...account, credential_state: "stored" }), "已验证"); + assert.ok(calls.every((item) => item.credentials === "include" && item.cache === "no-store")); +}); + +test("DNS 新建只发送白名单字段并清空凭据", async (t) => { + const { state, calls } = await setup(t); + state.openEditor("create"); + Object.assign(state.editor.value, { name: " 新账户 ", owner_user_id: "forged", revision: 900 }); + state.editor.value.credentials = { api_token: "fixture-token", unknown: "must-not-send" }; + await state.saveEditor(); + const write = calls.find((item) => item.method); + assert.equal(write.method, "POST"); + assert.equal(write.path, "/api/v1/dns/accounts"); + assert.deepEqual(JSON.parse(write.body), { + name: "新账户", project_id: "project-a", provider: "cloudflare", credentials: { api_token: "fixture-token" }, + }); + assert.equal(state.editor.value, null); + assert.match(state.notice.value, /在线验证/); +}); + +for (const [action, suffix, body, method] of [ + ["rename", "", { revision: 7, name: "新名称" }, "PUT"], + ["rotate", "/credentials", { revision: 7, credentials: { api_token: "replacement-token" } }, "PUT"], + ["status", "/status", { revision: 7, enabled: false }, "PUT"], + ["verify", "/verify", { revision: 7, test_domain: "example.test" }, "POST"], +]) { + test(`DNS ${action} 使用选中账户的固定身份与版本`, async (t) => { + const { state, calls } = await setup(t); + state.openEditor(action, account); + Object.assign(state.editor.value, { + id: "forged", revision: 999, name: "新名称", test_domain: " EXAMPLE.TEST ", + credentials: { api_token: "replacement-token" }, + }); + await state.saveEditor(); + const write = calls.find((item) => item.method); + assert.equal(write.path, `/api/v1/dns/accounts/account-a${suffix}`); + assert.equal(write.method, method); + assert.deepEqual(JSON.parse(write.body), body); + }); +} + +test("只读项目和历史配置不能发起账户写操作", async (t) => { + const { state, calls } = await setup(t, undefined, { manage: false }); + for (const action of ["create", "rename", "rotate", "status", "verify"]) { + state.openEditor(action, account); + assert.equal(state.editor.value, null); + } + assert.equal(calls.filter((item) => item.method).length, 0); + assert.equal(state.canManage({ ...account, managed: false }), false); +}); + +test("停用账户不能验证,轮换和重新启用仍可用", async (t) => { + const { state, calls } = await setup(t); + const disabled = { ...account, status: "disabled" }; + state.openEditor("verify", disabled); + assert.equal(state.editor.value, null); + state.openEditor("status", disabled); + await state.saveEditor(); + assert.equal(JSON.parse(calls.find((item) => item.method).body).enabled, true); +}); + +test("切换服务商、取消和刷新清空未提交凭据", async (t) => { + const { state } = await setup(t); + state.openEditor("create"); + state.editor.value.credentials = { api_token: "discard-me" }; + state.changeProvider(); + assert.deepEqual(state.editor.value.credentials, {}); + const form = state.editor.value; + form.credentials.api_token = "discard-me"; + state.closeEditor(); + assert.deepEqual(form.credentials, {}); + state.openEditor("rotate", account); + const nextForm = state.editor.value; + nextForm.credentials.api_token = "discard-me"; + await state.loadAccounts(); + assert.deepEqual(nextForm.credentials, {}); + assert.equal(state.editor.value, null); +}); + +test("无效名称、缺失凭据和无效域名不发出写请求", async (t) => { + const { state, calls } = await setup(t); + state.openEditor("create"); + state.editor.value.name = "坏\u0000名称"; + await state.saveEditor(); + assert.match(state.mutationError.value, /名称/); + state.editor.value.name = "有效名称"; + await state.saveEditor(); + assert.match(state.mutationError.value, /访问令牌/); + state.openEditor("verify", account); + state.editor.value.test_domain = "https://example.test/path"; + await state.saveEditor(); + assert.match(state.mutationError.value, /域名/); + assert.equal(calls.filter((item) => item.method).length, 0); +}); + +for (const status of [401, 403, 409, 422, 503]) { + test(`DNS 写入 ${status} 清空输入、刷新状态且不自动重试或显示敏感响应`, async (t) => { + const { state, calls } = await setup(t, () => Response.json({ error: "secret://sensitive" }, { status })); + state.openEditor("rotate", account); + state.editor.value.credentials.api_token = "fixture-token"; + await state.saveEditor(); + assert.equal(state.editor.value, null); + assert.equal(state.saving.value, false); + assert.ok(state.mutationError.value); + assert.doesNotMatch(state.mutationError.value, /sensitive|fixture-token/); + assert.equal(calls.filter((item) => item.method).length, 1); + assert.equal(calls.filter((item) => item.path === "/api/v1/dns/accounts" && !item.method).length, 2); + }); +} + +test("DNS 写入中阻止双击、刷新、切换账户和关闭", async (t) => { + const pending = deferred(); + const { state, calls } = await setup(t, () => pending.promise); + state.openEditor("rotate", account); + state.editor.value.credentials.api_token = "fixture-token"; + const request = state.saveEditor(); + assert.deepEqual(state.editor.value.credentials, {}); + const count = calls.length; + await state.saveEditor(); + await state.loadAccounts(); + state.openEditor("rename", { ...account, id: "other" }); + state.closeEditor(); + assert.equal(calls.length, count); + assert.equal(state.editor.value.action, "rotate"); + pending.resolve(Response.json({})); + await request; + assert.equal(state.editor.value, null); +}); + +test("DNS 页面卸载后迟到的写响应不会重新加载或恢复凭据", async (t) => { + const pending = deferred(); + const { state, calls, unmount } = await setup(t, () => pending.promise); + state.openEditor("rotate", account); + state.editor.value.credentials.api_token = "fixture-token"; + const request = state.saveEditor(); + const count = calls.length; + unmount(); + pending.resolve(Response.json({})); + await request; + assert.equal(calls.length, count); + assert.equal(state.editor.value, null); + assert.equal(state.notice.value, ""); +}); + +test("DNS 项目选择遍历游标而非只保留第一页", async (t) => { + let pages = 0; + t.mock.method(globalThis, "fetch", async (path) => { + pages += 1; + return Response.json(path.includes("after=next") + ? { items: [{ id: "second" }], has_more: false } + : { items: [{ id: "first" }], has_more: true, next_cursor: "next" }); + }); + assert.deepEqual((await loadDNSProjects()).map((item) => item.id), ["first", "second"]); + assert.equal(pages, 2); +}); + +test("DNS 项目列表拒绝重复游标避免循环", async (t) => { + t.mock.method(globalThis, "fetch", async () => Response.json({ items: [], has_more: true, next_cursor: "repeat" })); + await assert.rejects(loadDNSProjects(), /游标/); +}); + +test("DNS 旧读取不能覆盖刷新后的权限和账户", async (t) => { + const { state } = await setup(t); + const pending = deferred(); + let reads = 0; + t.mock.method(globalThis, "fetch", async (path) => { + if (path === "/api/v1/dns/accounts" && reads++ === 0) return pending.promise; + return Response.json({ items: [] }); + }); + const old = state.loadAccounts(); + await state.loadAccounts(); + pending.resolve(Response.json({ items: [account] })); + await old; + assert.deepEqual(state.accounts.value, []); + assert.equal(state.managedProjects.value.length, 0); +}); + +test("DNS 网络中断提示结果未确认而不重试写入", async (t) => { + const { state, calls } = await setup(t, () => { throw new Error("sensitive-network-detail"); }); + state.openEditor("rename", account); + await state.saveEditor(); + assert.match(state.mutationError.value, /未确认/); + assert.doesNotMatch(state.mutationError.value, /sensitive/); + assert.equal(calls.filter((item) => item.method).length, 1); +}); + +test("DNS 非 JSON 成功响应也不能暴露原始服务端文本", async (t) => { + t.mock.method(globalThis, "fetch", async () => new Response("sensitive upstream content")); + await assert.rejects(requestDNS("/api/v1/dns/accounts"), (error) => { + assert.match(error.message, /响应格式/); + assert.doesNotMatch(error.message, /sensitive/); + return true; + }); +}); + +test("DNS 刷新失败立即移除旧账户和管理能力", async (t) => { + const { state } = await setup(t); + assert.equal(state.managedProjects.value.length, 1); + t.mock.method(globalThis, "fetch", async () => Response.json({ error: "sensitive" }, { status: 403 })); + await state.loadAccounts(null); + assert.deepEqual(state.accounts.value, []); + assert.deepEqual(state.managedProjects.value, []); + state.openEditor("rename", account); + assert.equal(state.editor.value, null); + assert.match(state.error.value, /权限/); +}); + +test("DNS 页面与原生对话框可编译,字段分栏且提示不占用常驻空间", async () => { + const { readFile } = await import("node:fs/promises"); + const { parse, compileScript, compileTemplate } = await import("@vue/compiler-sfc"); + for (const file of ["DNSAccounts.vue", "DNSAccountDialog.vue"]) { + const source = await readFile(new URL(file, import.meta.url), "utf8"); + const { descriptor, errors } = parse(source); + assert.deepEqual(errors, []); + assert.ok(compileScript(descriptor, { id: file })); + assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors, []); + assert.doesNotMatch(source, /&#x[0-9a-f]+;/i); + } + const dialog = await readFile(new URL("DNSAccountDialog.vue", import.meta.url), "utf8"); + assert.match(dialog, /showModal\(\)/); + assert.match(dialog, /aria-labelledby="dns-editor-title"/); + assert.match(dialog, /@cancel.prevent/); + assert.match(dialog, /field.secret \? 'password'/); + assert.match(dialog, /title="仅填写你有权管理的域名/); +}); diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.vue b/frontend/src/views/DNSAccounts/DNSAccounts.vue index 82004f6..194418a 100644 --- a/frontend/src/views/DNSAccounts/DNSAccounts.vue +++ b/frontend/src/views/DNSAccounts/DNSAccounts.vue @@ -1,11 +1,14 @@