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 @@
{{ $t('identity.title') }}
-
- {{ $t('identity.guideTitle') }}
-
- - {{ $t('identity.guideApp') }}
- - {{ $t('identity.guidePermission') }}
- - {{ $t('identity.guidePublish') }}
- - {{ $t('identity.guideCallback') }}
{{ identityStatus.feishu_callback }}{{ $t('identity.guideCallbackPending') }}
- - {{ $t('identity.guideVerify') }}
-
- {{ $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;