From 177e74c22e9ad2f3cb992f9502a35cdc3a706b26 Mon Sep 17 00:00:00 2001 From: hsn Date: Fri, 4 Sep 2026 06:03:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(keys):=20=E6=94=AF=E6=8C=81=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=20TF=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/config/config.go | 2 +- .../server/middleware/security_headers.go | 11 + .../middleware/security_headers_test.go | 15 +- deploy/config.example.yaml | 2 +- docs/index.md | 2 +- docs/interfaces/configuration.md | 2 +- docs/interfaces/index.md | 1 + docs/interfaces/tf_cli_web_import.md | 53 +++ .../src/components/keys/KeyActionMenu.vue | 19 +- .../src/components/keys/TfCliImportDialog.vue | 331 ++++++++++++++++++ .../keys/__tests__/KeyActionMenu.spec.ts | 23 +- .../keys/__tests__/TfCliImportDialog.spec.ts | 179 ++++++++++ frontend/src/i18n/locales/en/dashboard.ts | 33 ++ frontend/src/i18n/locales/zh/dashboard.ts | 33 ++ .../__tests__/tfCliImportSession.spec.ts | 68 ++++ frontend/src/router/index.ts | 4 + .../src/utils/__tests__/tfCliImport.spec.ts | 281 +++++++++++++++ frontend/src/utils/tfCliImport.ts | 294 ++++++++++++++++ frontend/src/views/user/KeysView.vue | 25 +- .../src/views/user/__tests__/KeysView.spec.ts | 30 ++ 20 files changed, 1395 insertions(+), 13 deletions(-) create mode 100644 docs/interfaces/tf_cli_web_import.md create mode 100644 frontend/src/components/keys/TfCliImportDialog.vue create mode 100644 frontend/src/components/keys/__tests__/TfCliImportDialog.spec.ts create mode 100644 frontend/src/router/__tests__/tfCliImportSession.spec.ts create mode 100644 frontend/src/utils/__tests__/tfCliImport.spec.ts create mode 100644 frontend/src/utils/tfCliImport.ts diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 881c6ae6d848..8202aeb91d95 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,7 +32,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://accounts.google.com/gsi/client https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://accounts.google.com/gsi/style https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://accounts.google.com/gsi/ https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://accounts.google.com/gsi/ https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://accounts.google.com/gsi/client https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://accounts.google.com/gsi/style https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://127.0.0.1:43110 http://127.0.0.1:43111 http://127.0.0.1:43112 http://127.0.0.1:43113 http://127.0.0.1:43114 http://127.0.0.1:43115 http://127.0.0.1:43116 http://127.0.0.1:43117 http://127.0.0.1:43118 http://127.0.0.1:43119 https://accounts.google.com/gsi/ https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://accounts.google.com/gsi/ https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index 203f0e4609ab..3f904cac7035 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -94,6 +94,17 @@ var requiredCSPDirectiveValues = []struct { {"style-src", AirwallexDemoStaticDomain}, {"style-src", AirwallexDemoCheckoutDomain}, {"frame-src", AirwallexDemoCheckoutDomain}, + // @project-doc docs/interfaces/tf_cli_web_import.md#browser_security_headers + {"connect-src", "http://127.0.0.1:43110"}, + {"connect-src", "http://127.0.0.1:43111"}, + {"connect-src", "http://127.0.0.1:43112"}, + {"connect-src", "http://127.0.0.1:43113"}, + {"connect-src", "http://127.0.0.1:43114"}, + {"connect-src", "http://127.0.0.1:43115"}, + {"connect-src", "http://127.0.0.1:43116"}, + {"connect-src", "http://127.0.0.1:43117"}, + {"connect-src", "http://127.0.0.1:43118"}, + {"connect-src", "http://127.0.0.1:43119"}, } // GenerateNonce generates a cryptographically secure random nonce. diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index f337462c71ae..70588a32926f 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -2,6 +2,7 @@ package middleware import ( "encoding/base64" + "fmt" "net/http" "net/http/httptest" "strings" @@ -346,7 +347,19 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource)) }) - t.Run("default_policy_already_carries_tencent_captcha_domains", func(t *testing.T) { + t.Run("adds_only_the_tf_cli_loopback_ports", func(t *testing.T) { + policy := "default-src 'self'; connect-src 'self'" + enhanced := enhanceCSPPolicy(policy) + + for port := 43110; port <= 43119; port++ { + origin := fmt.Sprintf("http://127.0.0.1:%d", port) + assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", origin)) + } + assert.NotContains(t, enhanced, "http://127.0.0.1:*") + assert.NotContains(t, enhanced, "http://localhost") + }) + + t.Run("default_policy_already_carries_required_domains", func(t *testing.T) { // 默认策略与中间件强制注入表必须同形,否则 config.example.yaml 会误导自建用户 for _, required := range requiredCSPDirectiveValues { assert.Equal(t, 1, countDirectiveValue(config.DefaultCSPPolicy, required.directive, required.value), diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 7401f015eb3f..555d346a0acc 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -176,7 +176,7 @@ security: # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) # Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security # 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全 - policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://accounts.google.com/gsi/client https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://accounts.google.com/gsi/style https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://accounts.google.com/gsi/ https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://accounts.google.com/gsi/ https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://accounts.google.com/gsi/client https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://accounts.google.com/gsi/style https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://127.0.0.1:43110 http://127.0.0.1:43111 http://127.0.0.1:43112 http://127.0.0.1:43113 http://127.0.0.1:43114 http://127.0.0.1:43115 http://127.0.0.1:43116 http://127.0.0.1:43117 http://127.0.0.1:43118 http://127.0.0.1:43119 https://accounts.google.com/gsi/ https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://accounts.google.com/gsi/ https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) diff --git a/docs/index.md b/docs/index.md index 9b1e7667ab00..eec4846083a1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,5 +14,5 @@ - [架构](architecture/index.md):系统组成、进程启动、网关请求链路、账号调度和缓存一致性。读取时机:修改应用装配、服务边界、请求转发、调度快照或故障转移时读取。 - [领域](domains/index.md):身份租户、网关策略、计费额度、支付推广、内容审核和后台任务规则。读取时机:修改核心实体、业务不变量、状态转换、风险策略或结算规则时读取。 -- [接口](interfaces/index.md):HTTP 路由、配置来源、账号能力矩阵、模型目录和六类上游平台适配契约。读取时机:修改外部 API、配置项、协议转换、错误响应或第三方集成时读取。 +- [接口](interfaces/index.md):HTTP 路由、配置来源、浏览器回环导入、账号能力矩阵、模型目录和上游平台适配契约。读取时机:修改外部 API、配置项、协议转换、错误响应或第三方集成时读取。 - [运维](operations/index.md):部署迁移、账号/传输维护、监控告警、数据共享、开发流程和入口安全。读取时机:构建、发布、迁移、排障、数据维护或同步上游时读取。 diff --git a/docs/interfaces/configuration.md b/docs/interfaces/configuration.md index 4a12936e7239..5279dcb717f1 100644 --- a/docs/interfaces/configuration.md +++ b/docs/interfaces/configuration.md @@ -97,7 +97,7 @@ Grok 文本转发有三项数据库运行时设置:`grok_default_text_model` `gateway.grok` 属于启动时进程配置。`password_auth_enabled` 默认关闭并控制邮箱密码到 SSO/OAuth 的敏感入口;Free OAuth 本地软门禁由 `free_quota_soft_gate_enabled`、`free_quota_token_limit`、`free_quota_soft_gate_percent`、`free_quota_window_hours` 和 `free_quota_stats_cache_seconds` 控制。所有数值在启动时校验,修改后需要重启;统计缓存 miss 或查询故障按 fail-open 处理,但不能放宽 OAuth state 一次性消费、凭据持久化或 URL 信任边界。 -验证码同样属于数据库运行时设置。Turnstile、腾讯天御与阿里云验证码 2.0 三者互斥。腾讯天御启用时必须同时具备正整数 `CaptchaAppId`、`AppSecretKey`、腾讯云 `SecretId` 和 `SecretKey`,并选择 `cn` 中国站或 `intl` 国际站;站点决定前端 SDK、构造函数形式、控制台入口和服务端票据校验 endpoint,`CaptchaAppId` 与云密钥必须来自同一站点,缺失或非法站点按 `cn` 回退。阿里云启用时必须具备 Scene ID、Prefix、AccessKey ID、AccessKey Secret 及 `cn` 或 `sgp` 地域。公开设置只返回各提供方的启用状态、站点和渲染所需的非敏感参数;管理响应只返回 secret 的“已配置”标记,空白更新保留原值,审计仅记录字段发生写入而不记录内容。腾讯与阿里云 Web SDK 所需的脚本、连接、iframe、worker 和样式来源由默认 CSP 与运行时 CSP 补全逻辑共同维护,覆盖自定义旧策略时也不能遗漏,其中阿里云静态资源允许 `https://*.alicdn.com`。Google GIS 同样由默认策略与旧自定义策略增强共同允许:`script-src` 仅加入 `https://accounts.google.com/gsi/client`,`frame-src`/`connect-src` 加入 `https://accounts.google.com/gsi/`,`style-src` 加入 `https://accounts.google.com/gsi/style`。 +验证码同样属于数据库运行时设置。Turnstile、腾讯天御与阿里云验证码 2.0 三者互斥。腾讯天御启用时必须同时具备正整数 `CaptchaAppId`、`AppSecretKey`、腾讯云 `SecretId` 和 `SecretKey`,并选择 `cn` 中国站或 `intl` 国际站;站点决定前端 SDK、构造函数形式、控制台入口和服务端票据校验 endpoint,`CaptchaAppId` 与云密钥必须来自同一站点,缺失或非法站点按 `cn` 回退。阿里云启用时必须具备 Scene ID、Prefix、AccessKey ID、AccessKey Secret 及 `cn` 或 `sgp` 地域。公开设置只返回各提供方的启用状态、站点和渲染所需的非敏感参数;管理响应只返回 secret 的“已配置”标记,空白更新保留原值,审计仅记录字段发生写入而不记录内容。腾讯与阿里云 Web SDK 所需的脚本、连接、iframe、worker 和样式来源由默认 CSP 与运行时 CSP 补全逻辑共同维护,覆盖自定义旧策略时也不能遗漏,其中阿里云静态资源允许 `https://*.alicdn.com`。Google GIS 同样由默认策略与旧自定义策略增强共同允许:`script-src` 仅加入 `https://accounts.google.com/gsi/client`,`frame-src`/`connect-src` 加入 `https://accounts.google.com/gsi/`,`style-src` 加入 `https://accounts.google.com/gsi/style`。tf CLI 网页导入在 `connect-src` 中只允许 `http://127.0.0.1:43110` 到 `43119` 十个精确 Origin;代码默认策略、旧自定义策略增强和 `deploy/config.example.yaml` 必须同步,不能扩大为端口或局域网通配符。完整边界见 [tf CLI 网页导入](tf_cli_web_import.md)。 SMTP 的测试连接与实际发送共用同一建连路径和超时。`smtp_use_tls=true` 先按隐式 TLS 连接;仅当服务端以明文 SMTP 问候响应时改用强制 STARTTLS,服务端不支持升级时直接失败,不能明文发送认证。`smtp_use_tls=false` 保留机会式 STARTTLS,并在服务端不提供扩展时允许现有明文语义。两条路径都在认证成功后忽略非标准 QUIT 响应,因此后台连接测试与实际发信能力保持一致。 diff --git a/docs/interfaces/index.md b/docs/interfaces/index.md index 0ea0181fb31a..da732dc59351 100644 --- a/docs/interfaces/index.md +++ b/docs/interfaces/index.md @@ -10,6 +10,7 @@ - [HTTP 接口边界](http_api.md):公共、用户、管理员、支付和网关路由族及认证/错误边界。读取时机:新增或移动路由、调整中间件、认证方式或公共响应语义时读取。 - [配置边界](configuration.md):默认值、YAML、环境变量、数据库运行时设置和首次初始化之间的边界。读取时机:新增配置项、修改加载优先级、设置页面或部署变量时读取。 +- [tf CLI 网页导入](tf_cli_web_import.md):Keys 页、本机回环协议、会话证明、双重确认和浏览器安全头。读取时机:修改 Keys 导入入口、URL fragment、localhost fetch、CSP 或 tf-cli 协议时读取。 - [上游账号能力矩阵](upstream_account_matrix.md):九个平台、七类账号和全部公开网关协议的正式支持、兼容保留与不支持边界。读取时机:新增平台/账号类型、修改创建导入校验、路由分派或能力承诺时读取。 - [API Key 上游用量查询](upstream_usage.md):API Key 账号的适配器、管理员查询接口、归一化结果和浏览器缓存边界。读取时机:修改 API Key 用量查询、适配器协议、账号用量展示或查询安全策略时读取。 - [Anthropic 上游](anthropic_upstream.md):OAuth、Setup Token、API Key、Bedrock、Vertex,以及 Messages/OpenAI 兼容转换和缓存/限流契约。读取时机:修改 Anthropic 认证、协议、beta、thinking、缓存或错误分类时读取。 diff --git a/docs/interfaces/tf_cli_web_import.md b/docs/interfaces/tf_cli_web_import.md new file mode 100644 index 000000000000..9a207ac77719 --- /dev/null +++ b/docs/interfaces/tf_cli_web_import.md @@ -0,0 +1,53 @@ +# tf CLI 网页导入 + +本文只记录 TokenRouter 对 [tf-cli 网页导入协议](https://github.com/TokenFlux/tf-cli/blob/main/docs/integrations/web-import.md) 的接入责任。端口、字段、错误码和签名格式以上游文档为准。 + +TokenRouter 后端不接收导入请求,也不新增授权接口。Keys 页把当前用户已加载到内存的 API Key 直接发给本机 `tf`;Key 不进入 URL、浏览器持久化、日志或分析事件。 + + +## 会话片段 + +`tf login --from-web` 打开 `/keys#tf=1..`。`frontend/src/router/index.ts` 在 Router 创建和登录守卫运行前调用 `initializeTfCliImportSession()`: + +- 任何 `#tf=` fragment 都立即通过 `history.replaceState` 删除,畸形值也删除。 +- 只接受 Keys 路径、`43110` 至 `43119` 端口和 16 字节规范无 padding base64url secret。 +- session 只在模块内存保留十分钟;过期、终端取消或请求被终端接受后清零。 +- 同一 SPA 登录跳转可继续使用 session;刷新和外部认证整页跳转会丢失证明,之后只能走未验证兼容路径。 + + +## 回环协议 + +页面只访问 `http://127.0.0.1:43110` 至 `43119`。发现使用 `GET /ping`;有 session 时携带随机 challenge,并用 Web Crypto 校验 tf 返回的 HMAC。导入使用 `POST /import`,只在发现证明有效时发送 `X-TF-Session-Proof`。 + +页面只称“已验证当前 tf 会话”。proof 不验证网页、API Key、本机程序来源或后续网关结果,也不提供 freshness 或防重放。没有 session、proof 不匹配或 Web Crypto 不可用时仍可导入,但发送前必须显示未验证警告。若已验证后导入 proof 过期或计算失败,本次不发送 Key;页面先降级为未验证状态,再要求用户确认。 + +回环 fetch 固定使用 CORS、`credentials: omit`、`cache: no-store`、`redirect: error`、`referrerPolicy: no-referrer` 和 `targetAddressSpace: loopback`。发现总预算为 30 秒。浏览器自动完成 `OPTIONS` 预检,前端不手动发送。 + +## 字段映射 + +- `host` 使用页面 Origin。tf 校验同源后会恢复 CLI 自己的完整服务地址。 +- `key_name` 取 Keys 页名称。tf 会保存该来源元数据;未显式指定本地名称时,合法值还会成为终端命名候选。 +- 普通 Key 发送其 `group_id` 和 `group_name`。 +- 复合 Key 不发送单一分组元数据;实际能力由 tf 查询模型目录识别。 + +HTTP `202 Accepted` 只表示终端已确认。tf 之后才校验网关,并在未显式指定名称时让用户选择自动识别、网页名称或自订名称;页面只能提示最终结果以终端为准,不能显示“导入成功”或“Key 已保存”。 + + +## 页面交互 + +Keys 行的更多菜单提供“导入 TF CLI”,不增加第二套入口或 Key 选择状态。弹窗先发现服务,再显示已验证状态或未验证警告;用户点击“发送到 TF CLI”后才发送 Key。POST 等待期间提示用户在终端核对来源并确认。网页确认决定是否发送 Key,终端确认决定 tf 是否继续处理,两者不能互相替代。 + + +## 浏览器安全头 + +默认 CSP、旧自定义 CSP 的运行时补全和 `deploy/config.example.yaml` 都在 `connect-src` 中精确列出十个回环 Origin。不得扩大为端口通配符、`localhost` 或局域网地址。 + +TokenRouter 默认不限制 `local-network-access`。若反向代理自行设置 `Permissions-Policy`,必须允许顶层页面访问本地网络。现代 Chromium 首次访问可能请求本地网络权限;拒绝时页面显示未找到本机会话并允许重试。 + +## 验证 + +- `tfCliImport.spec.ts`:fragment、过期、固定 HMAC 向量、验证降级、请求选项和 proof。 +- `TfCliImportDialog.spec.ts`、`KeysView.spec.ts`、`KeyActionMenu.spec.ts`:发送确认、状态文案、字段映射和菜单入口。 +- `security_headers_test.go`:十个精确 CSP Origin 及默认策略同步。 + +相关文档:[接口目录](index.md)、[配置边界](configuration.md)、[复合 API Key](../domains/composite_api_keys.md)。 diff --git a/frontend/src/components/keys/KeyActionMenu.vue b/frontend/src/components/keys/KeyActionMenu.vue index 3174fe8be103..061dade85b1c 100644 --- a/frontend/src/components/keys/KeyActionMenu.vue +++ b/frontend/src/components/keys/KeyActionMenu.vue @@ -1,23 +1,30 @@