diff --git a/backend/internal/identityprovider/admin.go b/backend/internal/identityprovider/admin.go index 84b9e2a..fa3f4c1 100644 --- a/backend/internal/identityprovider/admin.go +++ b/backend/internal/identityprovider/admin.go @@ -162,6 +162,7 @@ func (p *Provider) Status(c *gin.Context) { "tenant_key": cfg.TenantKey, "client_id": cfg.ClientID, "client_secret_configured": cfg.ClientSecret != "", "issuer": base, "feishu_callback": callback, "vpn_callback": vpnCallback, "tls_ready": tlsError == nil, "gateway_ready": issuerRoute, + "user_scope_verified": cfg.UserScopeVerifiedAt != nil, }) } @@ -218,7 +219,7 @@ func (p *Provider) Configure(c *gin.Context) { return } } - if cfg.AppID != req.AppID && req.Enabled && !autoTenant { + if (cfg.AppID != req.AppID || req.AppSecret != "") && req.Enabled && !autoTenant { creds, err := resolveTenantCredentials(tenantCredentials{req.AppID, req.AppSecret}, cfg.AppID, cfg.AppSecret) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) @@ -245,6 +246,9 @@ func (p *Provider) Configure(c *gin.Context) { return } } + if cfg.AppID != req.AppID || req.AppSecret != "" || cfg.TenantKey != req.TenantKey { + cfg.UserScopeVerifiedAt = nil + } if req.Enabled && cfg.AppSecret == "" { c.JSON(400, gin.H{"error": "飞书 App Secret 未配置"}) return diff --git a/backend/internal/identityprovider/handlers.go b/backend/internal/identityprovider/handlers.go index e719a0e..96a9b58 100644 --- a/backend/internal/identityprovider/handlers.go +++ b/backend/internal/identityprovider/handlers.go @@ -222,6 +222,9 @@ func (p *Provider) FeishuCallback(c *gin.Context) { oidcError(c, 503) return } + // A successful user-info lookup confirms this application's browser scope. + // Do not store user identifiers; reset this evidence when credentials change. + _ = store.DB.Model(&model.IdentityProvider{}).Where("id = ? AND app_id = ? AND tenant_key = ?", cfg.ID, cfg.AppID, cfg.TenantKey).Update("user_scope_verified_at", time.Now()).Error u, _ := url.Parse(state.Redirect) q := u.Query() q.Set("code", code) diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index a29febc..c775603 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -6,14 +6,15 @@ import "time" // IdentityProvider is the opt-in, single-tenant Feishu OIDC issuer configuration. // Credentials are encrypted with the platform key and never serialized in API responses. type IdentityProvider struct { - ID uint `gorm:"primaryKey" json:"-"` - Enabled bool `json:"enabled"` - AppID string `gorm:"size:128" json:"app_id"` - AppSecret string `gorm:"type:text" json:"-"` - TenantKey string `gorm:"size:128" json:"tenant_key"` - ClientID string `gorm:"size:128" json:"client_id"` - ClientSecret string `gorm:"type:text" json:"-"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"-"` + Enabled bool `json:"enabled"` + AppID string `gorm:"size:128" json:"app_id"` + AppSecret string `gorm:"type:text" json:"-"` + TenantKey string `gorm:"size:128" json:"tenant_key"` + ClientID string `gorm:"size:128" json:"client_id"` + ClientSecret string `gorm:"type:text" json:"-"` + UserScopeVerifiedAt *time.Time `json:"-"` // Set only after a successful Feishu user-info callback. + UpdatedAt time.Time `json:"updated_at"` } // IdentityGrant stores only a hash of each browser state, authorization code, diff --git a/backend/internal/service/openvpn/auth_result.go b/backend/internal/service/openvpn/auth_result.go new file mode 100644 index 0000000..07a66eb --- /dev/null +++ b/backend/internal/service/openvpn/auth_result.go @@ -0,0 +1,17 @@ +package openvpn + +// This page is intentionally outcome-neutral: the auth component can render its +// end template for different outcomes. Only the VPN client knows connection state. +const authResultPage = ` + +返回 VPN 客户端 · OPSX VPN +
OPSX VPN
+ +

请返回 VPN 客户端

浏览器认证步骤已结束。连接状态请以客户端显示为准。

+
此页面可以关闭
` diff --git a/backend/internal/service/openvpn/oidc.go b/backend/internal/service/openvpn/oidc.go index 12f529c..cb9a9d7 100644 --- a/backend/internal/service/openvpn/oidc.go +++ b/backend/internal/service/openvpn/oidc.go @@ -62,10 +62,11 @@ func yamlString(s string) string { func oidcConfig(t *model.Tunnel, cfg map[string]interface{}) string { dir := service.InstanceDir(t) - return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\noauth2:\n issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n", + return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n", yamlString(service.Str(cfg, "oidc_base_url")), yamlString(fmt.Sprintf("127.0.0.1:%d", int(service.Num(cfg, "oidc_http_port", 19090)))), yamlString(service.Str(cfg, "oidc_http_secret")), + yamlString(filepath.Join(dir, "auth-result.html")), yamlString(service.Str(cfg, "oidc_issuer")), yamlString(service.Str(cfg, "oidc_client_id")), yamlString(service.Str(cfg, "oidc_client_secret")), @@ -140,6 +141,13 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error { if err := os.Chmod(passwordPath, 0o600); err != nil { return err } + pagePath, err := service.WriteConfig(t, "auth-result.html", authResultPage) + if err != nil { + return err + } + if err := os.Chmod(pagePath, 0o600); err != nil { + return err + } configPath, err := service.WriteConfig(t, "openvpn-auth-oauth2.yaml", oidcConfig(t, cfg)) if err != nil { return err diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md index 823d9fe..f920d42 100644 --- a/docs/specs/SDD-007-built-in-feishu-issuer.md +++ b/docs/specs/SDD-007-built-in-feishu-issuer.md @@ -87,3 +87,9 @@ 生产中 OpenVPN 客户端已接入 TCP 1194,认证组件发送 WebAuth 并等待 60 秒;此前 `/oauth2/` 入口的 444 已修复。隔离浏览器对内置发行者的合成请求跳到飞书错误页 `20043`:`user_profile 有误`,实际返回 `invalid_scope`。代码在 `handlers.go` 将 `user_profile` 写入飞书授权链接,设置页也错误地指导管理员启用它。参考项目 `/Users/logan/devops/demo/id/Feishu-OIDC-Workers` 使用飞书权限 `contact:user.base:readonly` 获取用户基础资料,替换无效范围,并在页面清楚区分企业信息查询权限与浏览器登录授权权限。OpenVPN OIDC 模式通过飞书 `tenant_key` 和 `open_id` 识别用户,不自动读取 VPN 拨号用户目录;如需逐用户允许名单须另行设计,不在本次导入飞书用户。 验收:单元测试验证生成的飞书授权链接包含新的权限范围、不再包含 `user_profile`;现有令牌/JWKS/用户信息测试、前端构建通过;上线后授权链接跳转路径和无 state 入口验证通过。真实飞书授权仍需应用批准该权限、发布并由用户本人完成登录;未经本人授权不得宣称已跑通完整 VPN 连接。发布前备份旧二进制、发布后检查健康与 Nginx;可回退到旧二进制但将恢复无效范围,须停止测试专线或等待修复。 + +## 11. 权限检测与浏览器结果页(S3) + +真实飞书授权及 OpenVPN 建链现已在实例日志中验证。配置页输入 App ID/Secret 后延迟调用已有 HTTPS 管理员查询接口:成功读取企业信息才显示 `tenant:tenant:readonly` 已验证,并自动填入 tenant_key;失败仅展示脱敏错误并允许重试。`contact:user.base:readonly` 是用户授权范围,不能仅靠应用凭据证明;首次成功换取用户资料并校验租户后记录无用户标识的验证时间,换应用凭据或租户时清除,设置页只展示是否曾通过。服务端启动或页面刷新不重复发起 OAuth,也不把手填 tenant_key 当作验证通过。密钥只经 HTTPS 管理接口传输,页面异步请求需忽略被新输入覆盖的旧响应。 + +认证组件通过原生 `http.template` 加载每实例的 0600 HTML 页面;页面只提示返回客户端查看连接状态,不在可能复用的终态模板上错误宣称 VPN 已连接。不额外放宽 Nginx 路径。验收:凭据变更后状态清零,查询正确/错误与旧响应均不误报;首次真实登录后状态可见;插件能正常加载模板并保持浏览器认证、OpenVPN 建链。发布时先构建测试,再替换二进制并重启受影响的 OIDC 实例以应用新模板;若插件不兼容模板,回退之前的配置与二进制、重启实例,旧认证仍可使用。数据库新列由 AutoMigrate 添加,回退版本忽略该列。 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index c9207c8..8478927 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -575,8 +575,9 @@ }, "identity": { "tab": "Feishu identity", "title": "Built-in Feishu identity service", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.", - "guideTitle": "Configure your Feishu app", "guideApp": "In Feishu Open Platform → Enterprise apps, open your app. Copy the App ID and App Secret from Credentials & Basic Information into the fields below. Use enterprise app credentials.", "guidePermission": "For automatic tenant lookup, request enterprise information read access (search for tenant:tenant:readonly or 'get enterprise information'; use the actual name shown by Feishu). For browser login, request the basic user information permission contact:user.base:readonly (use Feishu's actual console label).", "guidePublish": "Publish or enable the new app version, complete administrator review if required, and add VPN users to the app's availability scope. Creating the app or adding a permission alone does not activate it.", "guideCallback": "In Security Settings → Redirect URLs, add this exact Feishu sign-in callback:", "guideCallbackPending": "Configure the HTTPS domain under Public Gateway first to see the callback URL.", "guideVerify": "Save and enable the identity service, then sync its public routes. Configure and start an OIDC OpenVPN tunnel, verify openvpn-auth-oauth2 is installed, sync the routes again, and test with a user in the app's availability scope.", "guideManual": "If automatic lookup fails, enter a tenant_key verified from user information in the same enterprise. The Feishu error code appears below the field; retry after changing credentials or permissions.", "guideScope": "Finding tenant_key only proves the enterprise information API works; it does not confirm Feishu browser sign-in or VPN access. Browser login also needs the basic user information permission contact:user.base:readonly, the callback URL, app publication, and user availability. VPN also needs a tunnel and auth component. The Feishu callback differs from the VPN callback.", - "appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank to retain the secret; use a rotated secret for new setup", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Fetch automatically or enter manually", "fetchTenant": "Fetch tenant ID", "fetchingTenant": "Requesting Feishu ({seconds}s)…", "tenantFetched": "Feishu tenant ID retrieved", "tenantFailed": "Could not fetch Feishu tenant", "tenantNotEnabled": "Tenant ID is set; enable the identity service and finish the callback, app publication, and tunnel configuration.", "enabled": "Enable built-in issuer", "httpsOnly": "Enter app credentials only through the HTTPS management site. Switch to its secure address.", "openHttps": "Open HTTPS management site", + "guideTitle": "Feishu app setup", "guideApp": "Enter the enterprise app ID and secret; enable both permissions below.", "guidePermission": "", "guidePublish": "Publish the app and include VPN users in its availability scope.", "guideCallback": "Security Settings → Redirect URL:", "guideCallbackPending": "Configure an HTTPS domain first.", "guideVerify": "", "guideManual": "", "guideScope": "", + "tenantPermission": "tenant:tenant:readonly · enterprise", "userPermission": "contact:user.base:readonly · user", "checking": "Checking", "checked": "Verified", "checkFailed": "Check failed", "checkPending": "Not checked", "checkedByLogin": "Login verified", "loginPending": "Verify on first login", + "appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank if saved; enter a new secret to rotate", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Fetched automatically or enter manually", "fetchTenant": "Check again", "fetchingTenant": "Checking ({seconds}s)…", "tenantFetched": "Enterprise permission verified", "tenantFailed": "Enterprise permission check failed", "tenantNotEnabled": "", "enabled": "Enable built-in issuer", "httpsOnly": "Use HTTPS to enter app credentials.", "openHttps": "Open HTTPS management site", "httpsRequired": "Configure and activate your public HTTPS site and certificate first.", "gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.", "addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID", "oneTimeSecret": "New VPN Client Secret is displayed only once. Store it safely; rotation of active tunnel credentials requires separate planning.", "routeHint": "Sync identity routes first. After starting the one OpenVPN OIDC tunnel using this domain, sync again to route /oauth2/ to its local auth component.", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 6cd45a7..f2aaf54 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -575,8 +575,9 @@ }, "identity": { "tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。", - "guideTitle": "飞书开放平台配置步骤", "guideApp": "在飞书开放平台 → 企业自建应用中打开你的应用;在「凭证与基础信息」复制 App ID 和 App Secret,填入下方。不要使用商店应用或智能体的其他凭据。", "guidePermission": "自动获取企业标识:在「权限管理」申请企业信息读取权限(搜索 tenant:tenant:readonly 或“获取企业信息”,以飞书控制台实际名称为准);浏览器登录:申请用户基础信息权限 contact:user.base:readonly(以飞书后台实际名称为准),本系统通过该授权读取登录用户。", "guidePublish": "按企业要求发布/启用新版本,等待管理员审核,并在「应用可用范围」加入需要登录 VPN 的用户;只创建应用或添加权限不代表已生效。", "guideCallback": "在飞书应用的「安全设置 → 重定向 URL」添加飞书登录回调(完整地址):", "guideCallbackPending": "先在「公网入口」完成 HTTPS 域名配置,页面才会显示回调地址。", "guideVerify": "保存并启用本页身份服务,再同步身份公网路由;配置并启动支持 OIDC 的 OpenVPN 专线,确认 openvpn-auth-oauth2 已安装后再次同步公网路由,最后用应用可用范围内的账号试登录。", "guideManual": "自动获取失败时可手填 tenant_key;请从同一企业的飞书用户信息核对。错误码会显示在输入框下方,凭据或权限变更后可重试。", "guideScope": "查到 tenant_key 只说明企业信息接口可用,不代表飞书网页登录或 VPN 已可连接。网页登录还需要用户基础信息权限 contact:user.base:readonly、回调、发布与用户可用范围;VPN 还需配置专线和认证组件。飞书回调与 VPN 客户端回调是两个地址。", - "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "可自动获取,也可手动填写", "fetchTenant": "自动获取企业标识", "fetchingTenant": "正在请求飞书({seconds} 秒)…", "tenantFetched": "已读取飞书企业标识", "tenantFailed": "读取飞书企业信息失败", "tenantNotEnabled": "企业标识已填写;仍需启用身份服务并完成回调、应用发布及专线配置。", "enabled": "启用内置发行者", "httpsOnly": "应用密钥只能通过 HTTPS 管理入口填写;请切换到安全地址。", "openHttps": "打开 HTTPS 管理入口", + "guideTitle": "飞书应用准备", "guideApp": "填入企业自建应用的 App ID 和 App Secret;在权限管理开通下方两项权限。", "guidePermission": "", "guidePublish": "发布应用,并将 VPN 用户加入应用可用范围。", "guideCallback": "安全设置 → 重定向 URL:", "guideCallbackPending": "请先配置 HTTPS 域名。", "guideVerify": "", "guideManual": "", "guideScope": "", + "tenantPermission": "tenant:tenant:readonly · 企业信息", "userPermission": "contact:user.base:readonly · 用户信息", "checking": "检测中", "checked": "已验证", "checkFailed": "检测失败", "checkPending": "待检测", "checkedByLogin": "登录已验证", "loginPending": "首次登录后验证", + "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "已保存可留空;更换密钥请填写", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "自动读取,也可手填", "fetchTenant": "重新检测", "fetchingTenant": "正在检测({seconds} 秒)…", "tenantFetched": "企业信息权限已验证", "tenantFailed": "检测企业信息权限失败", "tenantNotEnabled": "", "enabled": "启用内置发行者", "httpsOnly": "请通过 HTTPS 管理入口填写密钥。", "openHttps": "打开 HTTPS 管理入口", "httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。", "addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID", "oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。", diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 2eee4c8..799bf71 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -42,30 +42,27 @@ - - -
    -
  1. {{ $t('identity.guideApp') }}
  2. -
  3. {{ $t('identity.guidePermission') }}
  4. -
  5. {{ $t('identity.guidePublish') }}
  6. -
  7. {{ $t('identity.guideCallback') }} {{ identityStatus.feishu_callback }}{{ $t('identity.guideCallbackPending') }}
  8. -
  9. {{ $t('identity.guideVerify') }}
  10. -
-

{{ $t('identity.guideManual') }}

- -
+
+ {{ $t('identity.guideTitle') }} +

{{ $t('identity.guideApp') }}

+

{{ $t('identity.guidePublish') }}

+

{{ $t('identity.guideCallback') }} {{ identityStatus.feishu_callback }}{{ $t('identity.guideCallbackPending') }}

+
{{ $t('identity.openHttps') }} - + +
+
{{ $t('identity.tenantPermission') }}{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}
+
{{ $t('identity.userPermission') }}{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}
+
- {{ $t('identity.fetchTenant') }} + {{ $t('identity.fetchTenant') }} {{ tenantName }} {{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }} - {{ $t('common.save') }} @@ -229,19 +226,33 @@ const identityGatewayLoading = ref(false) const tenantLoading = ref(false) const tenantName = ref('') const tenantError = ref('') +const tenantChecked = ref(false) +const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key) const tenantSeconds = ref(0) let tenantTimer -onUnmounted(() => clearInterval(tenantTimer)) -function onIdentityAppChange() { +let identityCheckTimer +let tenantRequest = 0 +onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ }) +watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => { + if (appId === oldId && secret === oldSecret) return + tenantRequest++ + clearInterval(tenantTimer) + tenantLoading.value = false + tenantChecked.value = false tenantError.value = '' - if (identityForm.value.app_id !== identityStatus.value?.app_id) { + clearTimeout(identityCheckTimer) + if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) { identityForm.value.tenant_key = '' tenantName.value = '' } -} -async function fetchIdentityTenant() { + if (identitySecure && appId && (secret || (appId === identityStatus.value?.app_id && identityStatus.value?.app_secret_configured))) { + identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700) + } +}) +async function fetchIdentityTenant(silent = false) { if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly')) - if (tenantLoading.value) return + const request = ++tenantRequest + clearInterval(tenantTimer) tenantError.value = '' tenantLoading.value = true tenantSeconds.value = 0 @@ -249,16 +260,21 @@ async function fetchIdentityTenant() { tenantTimer = setInterval(() => { tenantSeconds.value = Math.floor((Date.now() - started) / 1000) }, 1000) try { const appId = identityForm.value.app_id - const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: identityForm.value.app_secret }) - if (identityForm.value.app_id === appId) { + const secret = identityForm.value.app_secret + const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: secret }) + if (request === tenantRequest && identityForm.value.app_id === appId && identityForm.value.app_secret === secret) { identityForm.value.tenant_key = data.tenant_key tenantName.value = data.name || '' - ElMessage.success(t('identity.tenantFetched')) + tenantChecked.value = true + if (!silent) ElMessage.success(t('identity.tenantFetched')) } - } catch (e) { tenantError.value = e.response?.data?.error || t('identity.tenantFailed') } - finally { clearInterval(tenantTimer); tenantLoading.value = false } + } catch (e) { if (request === tenantRequest) tenantError.value = e.response?.data?.error || t('identity.tenantFailed') } + finally { if (request === tenantRequest) { clearInterval(tenantTimer); tenantLoading.value = false } } } async function loadIdentity() { + tenantRequest++ + clearInterval(tenantTimer) + tenantLoading.value = false identityLoading.value = true try { const { data } = await api.get('/system/identity') @@ -266,6 +282,7 @@ async function loadIdentity() { identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' } tenantName.value = '' tenantError.value = '' + tenantChecked.value = false } catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) } finally { identityLoading.value = false } } @@ -539,6 +556,12 @@ function copyBackup() { .settings-column-wide { max-width: 850px; } .settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; } .settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; } +.identity-guide { padding: 16px 18px; margin-bottom: 20px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; } +.identity-guide b { display: block; margin-bottom: 6px; color: #244368; } +.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; } +.identity-guide code { color: #246cb7; } +.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; } +.identity-checks > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid #e6edf6; border-radius: 10px; font-size: 13px; color: #435b75; } @media (max-width: 640px) { .settings-tabs { padding: 0 14px 16px; } .settings-tabs :deep(.el-tabs__item) { font-size: 12px; padding: 0 10px; } } .backup-codes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;