diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go
index ea579dc..aafe433 100644
--- a/backend/internal/api/api.go
+++ b/backend/internal/api/api.go
@@ -312,6 +312,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
admin.POST("/system/fix", s.systemFix)
admin.GET("/system/tls", s.tlsStatus)
admin.GET("/system/identity", idp.Status)
+ admin.POST("/system/identity/tenant", idp.QueryTenant)
admin.PUT("/system/identity", idp.Configure)
admin.POST("/system/identity/gateway", identityprovider.SecureGateway(), s.identityGateway)
admin.PUT("/system/tls/domain", s.setTLSDomain)
diff --git a/backend/internal/identityprovider/admin.go b/backend/internal/identityprovider/admin.go
index 7219274..077491c 100644
--- a/backend/internal/identityprovider/admin.go
+++ b/backend/internal/identityprovider/admin.go
@@ -1,6 +1,11 @@
package identityprovider
import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
"net"
"net/http"
"strings"
@@ -11,6 +16,118 @@ import (
"vpn-platform/backend/internal/store"
)
+const feishuTenantToken = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
+const feishuTenantQuery = "https://open.feishu.cn/open-apis/tenant/v2/tenant/query"
+
+func secureAdmin(c *gin.Context) bool {
+ if c.Request.TLS != nil {
+ return true
+ }
+ peer, _, err := net.SplitHostPort(c.Request.RemoteAddr)
+ return err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() && c.GetHeader("X-Forwarded-Proto") == "https"
+}
+
+type tenantCredentials struct {
+ AppID string `json:"app_id"`
+ AppSecret string `json:"app_secret"`
+}
+
+func resolveTenantCredentials(input tenantCredentials, cfgAppID, encryptedSecret string) (tenantCredentials, error) {
+ input.AppID = strings.TrimSpace(input.AppID)
+ if input.AppID == "" || len(input.AppID) > 128 || strings.ContainsAny(input.AppID, " \r\n\t") || len(input.AppSecret) > 512 {
+ return input, errors.New("请输入有效的飞书 App ID 和 App Secret")
+ }
+ if input.AppSecret == "" && input.AppID == cfgAppID && encryptedSecret != "" {
+ secret, err := store.DecryptSecret(encryptedSecret)
+ if err != nil {
+ return input, errors.New("读取已保存的飞书密钥失败")
+ }
+ input.AppSecret = secret
+ }
+ if input.AppSecret == "" {
+ return input, errors.New("请填写当前 App ID 对应的 App Secret")
+ }
+ return input, nil
+}
+
+// queryFeishuTenant requests app-scoped tenant information without exposing the
+// upstream access token or raw upstream errors to the management UI.
+func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, string, error) {
+ payload, _ := json.Marshal(map[string]string{"app_id": creds.AppID, "app_secret": creds.AppSecret})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuTenantToken, bytes.NewReader(payload))
+ if err != nil {
+ return "", "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := upstreamClient.Do(req)
+ if err != nil {
+ return "", "", errors.New("飞书企业信息请求失败,请稍后重试")
+ }
+ defer resp.Body.Close()
+ var token struct {
+ Code *int `json:"code"`
+ TenantAccessToken string `json:"tenant_access_token"`
+ }
+ if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.Code == nil || *token.Code != 0 || token.TenantAccessToken == "" {
+ return "", "", errors.New("获取飞书访问凭据失败,请检查 App ID 和 App Secret")
+ }
+ req, err = http.NewRequestWithContext(ctx, http.MethodGet, feishuTenantQuery, nil)
+ if err != nil {
+ return "", "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+token.TenantAccessToken)
+ resp, err = upstreamClient.Do(req)
+ if err != nil {
+ return "", "", errors.New("飞书企业信息请求失败,请稍后重试")
+ }
+ defer resp.Body.Close()
+ var result struct {
+ Code *int `json:"code"`
+ Data struct {
+ Tenant struct {
+ TenantKey string `json:"tenant_key"`
+ Name string `json:"name"`
+ } `json:"tenant"`
+ } `json:"data"`
+ }
+ if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || result.Code == nil || *result.Code != 0 {
+ return "", "", errors.New("查询飞书企业信息失败,请确认应用已开通企业信息读取权限")
+ }
+ if !validTenant(result.Data.Tenant.TenantKey) {
+ return "", "", errors.New("飞书未返回有效的企业标识")
+ }
+ return result.Data.Tenant.TenantKey, result.Data.Tenant.Name, nil
+}
+
+func (p *Provider) QueryTenant(c *gin.Context) {
+ noStore(c)
+ if !secureAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口查询飞书企业信息"})
+ return
+ }
+ var input tenantCredentials
+ if c.ShouldBindJSON(&input) != nil {
+ c.JSON(400, gin.H{"error": "配置格式错误"})
+ return
+ }
+ cfg, err := current()
+ if err != nil && err != gorm.ErrRecordNotFound {
+ c.JSON(500, gin.H{"error": "读取身份配置失败"})
+ return
+ }
+ input, err = resolveTenantCredentials(input, cfg.AppID, cfg.AppSecret)
+ if err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+ key, name, err := queryFeishuTenant(c.Request.Context(), input)
+ if err != nil {
+ c.JSON(502, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{"tenant_key": key, "name": name})
+}
+
func (p *Provider) Status(c *gin.Context) {
cfg, err := current()
if err != nil && err != gorm.ErrRecordNotFound {
@@ -35,12 +152,9 @@ func (p *Provider) Status(c *gin.Context) {
}
func (p *Provider) Configure(c *gin.Context) {
- if c.Request.TLS == nil {
- peer, _, err := net.SplitHostPort(c.Request.RemoteAddr)
- if err != nil || net.ParseIP(peer) == nil || !net.ParseIP(peer).IsLoopback() || c.GetHeader("X-Forwarded-Proto") != "https" {
- c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口保存飞书凭据"})
- return
- }
+ if !secureAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口保存飞书凭据"})
+ return
}
var req struct {
Enabled bool `json:"enabled"`
@@ -54,7 +168,7 @@ func (p *Provider) Configure(c *gin.Context) {
}
req.AppID = strings.TrimSpace(req.AppID)
req.TenantKey = strings.TrimSpace(req.TenantKey)
- if len(req.AppID) > 128 || strings.ContainsAny(req.AppID, " \r\n\t") || (req.Enabled && (req.AppID == "" || !validTenant(req.TenantKey))) {
+ if len(req.AppID) > 128 || strings.ContainsAny(req.AppID, " \r\n\t") || (req.Enabled && req.AppID == "") || (req.TenantKey != "" && !validTenant(req.TenantKey)) {
c.JSON(400, gin.H{"error": "请输入有效的飞书 App ID 和租户标识"})
return
}
@@ -69,6 +183,43 @@ func (p *Provider) Configure(c *gin.Context) {
c.JSON(500, gin.H{"error": "读取身份配置失败"})
return
}
+ if cfg.AppID != "" && cfg.AppID != req.AppID && req.AppSecret == "" {
+ c.JSON(400, gin.H{"error": "更换 App ID 时必须填写对应的 App Secret"})
+ return
+ }
+ if cfg.AppID != "" && cfg.AppID != req.AppID && !req.Enabled {
+ // A disabled replacement must not retain the previous application's tenant.
+ req.TenantKey = ""
+ }
+ autoTenant := req.Enabled && req.TenantKey == ""
+ if req.Enabled && req.TenantKey == "" {
+ creds, err := resolveTenantCredentials(tenantCredentials{req.AppID, req.AppSecret}, cfg.AppID, cfg.AppSecret)
+ if err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+ req.TenantKey, _, err = queryFeishuTenant(c.Request.Context(), creds)
+ if err != nil {
+ c.JSON(502, gin.H{"error": err.Error()})
+ return
+ }
+ }
+ if cfg.AppID != req.AppID && 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()})
+ return
+ }
+ actual, _, err := queryFeishuTenant(c.Request.Context(), creds)
+ if err != nil {
+ c.JSON(502, gin.H{"error": err.Error()})
+ return
+ }
+ if actual != req.TenantKey {
+ c.JSON(400, gin.H{"error": "企业标识与当前飞书应用不一致"})
+ return
+ }
+ }
if req.AppSecret != "" {
if len(req.AppSecret) > 512 {
c.JSON(400, gin.H{"error": "密钥过长"})
diff --git a/backend/internal/identityprovider/admin_test.go b/backend/internal/identityprovider/admin_test.go
index 278162f..fef2466 100644
--- a/backend/internal/identityprovider/admin_test.go
+++ b/backend/internal/identityprovider/admin_test.go
@@ -3,6 +3,7 @@ package identityprovider
import (
"bytes"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"strings"
@@ -12,6 +13,116 @@ import (
"vpn-platform/backend/internal/store"
)
+func TestQueryTenantUsesAppCredentialsWithoutPersistingOrLeaking(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("VPN_DATA_DIR", dir)
+ if err := store.Init(dir); err != nil {
+ t.Fatal(err)
+ }
+ previous := upstreamClient
+ defer func() { upstreamClient = previous }()
+ upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
+ response := `{"code":0,"tenant_access_token":"temporary-token"}`
+ switch req.URL.String() {
+ case feishuTenantToken:
+ if req.Method != http.MethodPost || req.Header.Get("Content-Type") != "application/json" {
+ t.Fatal("invalid token request")
+ }
+ var creds tenantCredentials
+ if json.NewDecoder(req.Body).Decode(&creds) != nil || creds.AppID != "cli_test" || creds.AppSecret != "private-value" {
+ t.Fatal("invalid credentials")
+ }
+ case feishuTenantQuery:
+ if req.Method != http.MethodGet || req.Header.Get("Authorization") != "Bearer temporary-token" {
+ t.Fatal("invalid tenant request")
+ }
+ response = `{"code":0,"data":{"tenant":{"tenant_key":"tenant_123","name":"Example"}}}`
+ default:
+ t.Fatalf("unexpected endpoint: %s", req.URL)
+ }
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil
+ })}
+ gin.SetMode(gin.TestMode)
+ server := gin.New()
+ p := New()
+ server.POST("/tenant", p.QueryTenant)
+ server.PUT("/config", p.Configure)
+ query := []byte(`{"app_id":"cli_test","app_secret":"private-value"}`)
+ request := httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query))
+ request.RemoteAddr = "127.0.0.1:12345"
+ request.Header.Set("X-Forwarded-Proto", "https")
+ resp := httptest.NewRecorder()
+ server.ServeHTTP(resp, request)
+ if resp.Code != 200 || !strings.Contains(resp.Body.String(), `"tenant_key":"tenant_123"`) || strings.Contains(resp.Body.String(), "temporary-token") || strings.Contains(resp.Body.String(), "private-value") {
+ t.Fatalf("unexpected query response: %d %s", resp.Code, resp.Body.String())
+ }
+ if _, err := current(); err == nil {
+ t.Fatal("query unexpectedly persisted configuration")
+ }
+ request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_test","app_secret":"private-value","enabled":false}`))
+ request.RemoteAddr = "127.0.0.1:12345"
+ request.Header.Set("X-Forwarded-Proto", "https")
+ resp = httptest.NewRecorder()
+ server.ServeHTTP(resp, request)
+ if resp.Code != 200 {
+ t.Fatalf("save failed: %d %s", resp.Code, resp.Body.String())
+ }
+ request = httptest.NewRequest(http.MethodPost, "/tenant", strings.NewReader(`{"app_id":"cli_test"}`))
+ request.RemoteAddr = "127.0.0.1:12345"
+ request.Header.Set("X-Forwarded-Proto", "https")
+ resp = httptest.NewRecorder()
+ server.ServeHTTP(resp, request)
+ if resp.Code != 200 {
+ t.Fatalf("saved credentials not reused: %d %s", resp.Code, resp.Body.String())
+ }
+ request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_other","tenant_key":"tenant_123","enabled":false}`))
+ request.RemoteAddr = "127.0.0.1:12345"
+ request.Header.Set("X-Forwarded-Proto", "https")
+ resp = httptest.NewRecorder()
+ server.ServeHTTP(resp, request)
+ if resp.Code != 400 {
+ t.Fatalf("new app reused old secret: %d %s", resp.Code, resp.Body.String())
+ }
+ cfg, err := current()
+ if err != nil || cfg.AppID != "cli_test" {
+ t.Fatal("rejected app change modified saved configuration")
+ }
+ request = httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query))
+ resp = httptest.NewRecorder()
+ server.ServeHTTP(resp, request)
+ if resp.Code != 403 {
+ t.Fatalf("plaintext request accepted: %d", resp.Code)
+ }
+}
+
+func TestQueryTenantRejectsWrongAppAndUpstreamFailure(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("VPN_DATA_DIR", dir)
+ if err := store.Init(dir); err != nil {
+ t.Fatal(err)
+ }
+ secret, err := store.EncryptSecret("private-value")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_other"}, "cli_test", secret); err == nil {
+ t.Fatal("old secret reused for another app")
+ }
+ creds, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_test"}, "cli_test", secret)
+ if err != nil || creds.AppSecret != "private-value" {
+ t.Fatal("saved secret not reused for same app")
+ }
+ previous := upstreamClient
+ defer func() { upstreamClient = previous }()
+ upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":999,"msg":"private-value"}`)), Header: make(http.Header)}, nil
+ })}
+ _, _, err = queryFeishuTenant(httptest.NewRequest(http.MethodGet, "/", nil).Context(), creds)
+ if err == nil || strings.Contains(err.Error(), "private-value") {
+ t.Fatalf("unsafe upstream error: %v", err)
+ }
+}
+
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md
index 0971c05..f31bccd 100644
--- a/docs/specs/SDD-007-built-in-feishu-issuer.md
+++ b/docs/specs/SDD-007-built-in-feishu-issuer.md
@@ -52,3 +52,18 @@
线上新增 `/api/system/identity` 后托管 Nginx 仍使用旧白名单,浏览器看到 `ERR_EMPTY_RESPONSE`(Nginx 444);本机管理进程可正常返回 401。已有 `/api/system/tls/domain` 路由在无凭据时返回 401,不能把用户同时报告的域名保存失败归结于同一缺失路由。Vue 侧栏在 `route` 创建前注册 watch,导致初始化时引用尚未声明的变量;WireGuard SVG 含截断的路径数据。
每次启动且确认当前是有效 Nginx HTTPS 托管站点时,按已注册 Gin 路由重建站点白名单;文件内容相同则不重载,冲突或 `nginx -t` 失败时保留旧站点并记录日志,不阻止本机服务启动。公网只同步已注册 API、固定身份端点与当前单条运行的同域认证组件,未知路径仍 444。验收:新身份管理 API 不再返回空连接,未经认证返回 401;既有域名 PUT 也仍返回 401,授权后的保存需单独实测;设置和编辑页无布局初始化异常;SVG 路径有效;Nginx 测试通过。恢复时使用 `/tmp` 的上一版二进制和原 Nginx 站点快照重新加载,不修改专线或飞书凭据。
+
+## 7. 自动读取飞书企业标识(2026-10-22,S3)
+
+当前管理员必须手填 `tenant_key` 才能启用身份服务;用户提供的飞书 SDK `tenant.v2.tenant.query` 可通过应用凭据获取企业信息。新增仅管理员可调用的 `POST /api/system/identity/tenant`:沿用 HTTPS 管理入口限制,使用表单中 App ID/Secret 或同一 App ID 已保存的加密 Secret,向飞书换取 tenant access token 后查询企业名称和 tenant_key。仅返回这两个字段,不保存临时输入的凭据、令牌或查询结果;响应禁止缓存。页面提供「自动获取」并回填可编辑字段;保存启用配置且企业标识为空时也尝试自动获取。更换 App ID 时绝不复用原 App Secret 或旧企业标识;查询失败时不启用或改变已保存的配置。
+
+接口限定固定飞书域名、短超时、受限响应体和无重定向;错误不回显密钥或 token。飞书授权需要应用具备企业信息读取权限(按飞书后台实际授权提示配置)。管理员仍可手填企业标识以兼容既有配置;真实飞书 API 联调在获得轮换后的凭据时进行。
+
+| 验收 | 操作与预期 | 验证 |
+|---|---|---|
+| AC-07-01 | HTTPS 管理员查询正确凭据,返回企业标识与名称;未保存凭据不落库 | 模拟上游请求/响应 |
+| AC-07-02 | 同一 App ID 可复用加密 Secret;换 App ID 不复用旧密钥或旧租户 | 管理接口测试 |
+| AC-07-03 | 飞书拒绝授权、响应异常、无标识或 HTTP 管理请求时,安全失败且不泄露凭据 | 负向测试 |
+| AC-07-04 | 启用时企业标识留空可自动获取;查询失败不保存;手工输入兼容 | 管理接口测试与前端构建 |
+
+发布前完成 Go 测试与前端构建;部署后检查新路由被托管 Nginx 白名单接受、未授权返回 401,并在轮换飞书 Secret 后进行真实查询。恢复可回退二进制与前端资源;不需要数据迁移,也不修改已运行专线。
diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json
index 0da5ff7..8f97ae5 100644
--- a/frontend/src/locales/en-US.json
+++ b/frontend/src/locales/en-US.json
@@ -575,7 +575,7 @@
},
"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.",
- "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": "Verify the enterprise tenant_key from Feishu user info", "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",
+ "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", "tenantFetched": "Feishu tenant ID retrieved", "tenantFailed": "Could not fetch Feishu tenant", "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",
"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 ac1fdca..ce6709a 100644
--- a/frontend/src/locales/zh-CN.json
+++ b/frontend/src/locales/zh-CN.json
@@ -575,7 +575,7 @@
},
"identity": {
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
- "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "从飞书用户信息确认企业 tenant_key", "enabled": "启用内置发行者", "httpsOnly": "应用密钥只能通过 HTTPS 管理入口填写;请切换到安全地址。", "openHttps": "打开 HTTPS 管理入口",
+ "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "可自动获取,也可手动填写", "fetchTenant": "自动获取企业标识", "tenantFetched": "已读取飞书企业标识", "tenantFailed": "读取飞书企业信息失败", "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 863420c..e7140cb 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -45,9 +45,13 @@
{{ $t('identity.openHttps') }}
-
+
-
+
+
+ {{ $t('identity.fetchTenant') }}
+ {{ tenantName }}
+
{{ $t('common.save') }}
{{ $t('tls.refresh') }}
@@ -206,12 +210,35 @@ const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identi
const identityLoading = ref(false)
const identitySaving = ref(false)
const identityGatewayLoading = ref(false)
+const tenantLoading = ref(false)
+const tenantName = ref('')
+function onIdentityAppChange() {
+ if (identityForm.value.app_id !== identityStatus.value?.app_id) {
+ identityForm.value.tenant_key = ''
+ tenantName.value = ''
+ }
+}
+async function fetchIdentityTenant() {
+ if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
+ tenantLoading.value = true
+ 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) {
+ identityForm.value.tenant_key = data.tenant_key
+ tenantName.value = data.name || ''
+ ElMessage.success(t('identity.tenantFetched'))
+ }
+ } catch (e) { ElMessage.error(e.response?.data?.error || t('identity.tenantFailed')) }
+ finally { tenantLoading.value = false }
+}
async function loadIdentity() {
identityLoading.value = true
try {
const { data } = await api.get('/system/identity')
identityStatus.value = data
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
+ tenantName.value = ''
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
finally { identityLoading.value = false }
}