Guide Feishu app setup and surface tenant lookup codes
This commit is contained in:
1 parent
eb3861bbe8
commit
7e50443bba
6 files changed
+76
-5
No files matched your search
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +20,14 @@ import (
|
|||||||
const feishuTenantToken = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
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"
|
const feishuTenantQuery = "https://open.feishu.cn/open-apis/tenant/v2/tenant/query"
|
||||||
|
|
||||||
|
// Do not include upstream msg: it is untrusted and may contain request data.
|
||||||
|
func feishuFailure(stage string, status int, code *int) error {
|
||||||
|
if code != nil {
|
||||||
|
return fmt.Errorf("%s失败(飞书错误码 %d,HTTP %d);请按错误码检查应用权限、发布状态及接口配置", stage, *code, status)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s失败(HTTP %d,未获得飞书错误码);请检查飞书接口配置", stage, status)
|
||||||
|
}
|
||||||
|
|
||||||
func secureAdmin(c *gin.Context) bool {
|
func secureAdmin(c *gin.Context) bool {
|
||||||
if c.Request.TLS != nil {
|
if c.Request.TLS != nil {
|
||||||
return true
|
return true
|
||||||
@@ -68,8 +77,12 @@ func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, st
|
|||||||
Code *int `json:"code"`
|
Code *int `json:"code"`
|
||||||
TenantAccessToken string `json:"tenant_access_token"`
|
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 == "" {
|
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token)
|
||||||
return "", "", errors.New("获取飞书访问凭据失败,请检查 App ID 和 App Secret")
|
if resp.StatusCode != http.StatusOK || decodeErr != nil || token.Code == nil || *token.Code != 0 {
|
||||||
|
return "", "", feishuFailure("获取飞书访问凭据", resp.StatusCode, token.Code)
|
||||||
|
}
|
||||||
|
if token.TenantAccessToken == "" {
|
||||||
|
return "", "", errors.New("飞书未返回 tenant_access_token")
|
||||||
}
|
}
|
||||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, feishuTenantQuery, nil)
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, feishuTenantQuery, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,8 +103,9 @@ func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, st
|
|||||||
} `json:"tenant"`
|
} `json:"tenant"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || result.Code == nil || *result.Code != 0 {
|
decodeErr = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result)
|
||||||
return "", "", errors.New("查询飞书企业信息失败,请确认应用已开通企业信息读取权限")
|
if resp.StatusCode != http.StatusOK || decodeErr != nil || result.Code == nil || *result.Code != 0 {
|
||||||
|
return "", "", feishuFailure("查询飞书企业信息", resp.StatusCode, result.Code)
|
||||||
}
|
}
|
||||||
if !validTenant(result.Data.Tenant.TenantKey) {
|
if !validTenant(result.Data.Tenant.TenantKey) {
|
||||||
return "", "", errors.New("飞书未返回有效的企业标识")
|
return "", "", errors.New("飞书未返回有效的企业标识")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package identityprovider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -123,6 +124,34 @@ func TestQueryTenantRejectsWrongAppAndUpstreamFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTenantErrorsIdentifyStageWithoutLeakingFeishuMessage(t *testing.T) {
|
||||||
|
previous := upstreamClient
|
||||||
|
defer func() { upstreamClient = previous }()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, endpoint, payload, want string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"tenant denied", feishuTenantQuery, `{"code":99991672,"msg":"secret in upstream error"}`, "99991672", 403},
|
||||||
|
{"unexpected tenant endpoint", feishuTenantQuery, `not json`, "HTTP 404", 404},
|
||||||
|
{"token denied", feishuTenantToken, `{"code":10003,"msg":"secret in upstream error"}`, "10003", 401},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body := `{"code":0,"tenant_access_token":"temporary-token"}`
|
||||||
|
status := 200
|
||||||
|
if req.URL.String() == tc.endpoint {
|
||||||
|
body, status = tc.payload, tc.status
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
_, _, err := queryFeishuTenant(context.Background(), tenantCredentials{AppID: "cli_test", AppSecret: "private-value"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "secret in upstream error") || strings.Contains(err.Error(), "private-value") {
|
||||||
|
t.Fatalf("unsafe or unhelpful error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
|
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("VPN_DATA_DIR", dir)
|
t.Setenv("VPN_DATA_DIR", dir)
|
||||||
|
|||||||
@@ -67,3 +67,9 @@
|
|||||||
| AC-07-04 | 启用时企业标识留空可自动获取;查询失败不保存;手工输入兼容 | 管理接口测试与前端构建 |
|
| AC-07-04 | 启用时企业标识留空可自动获取;查询失败不保存;手工输入兼容 | 管理接口测试与前端构建 |
|
||||||
|
|
||||||
发布前完成 Go 测试与前端构建;部署后检查新路由被托管 Nginx 白名单接受、未授权返回 401,并在轮换飞书 Secret 后进行真实查询。恢复可回退二进制与前端资源;不需要数据迁移,也不修改已运行专线。
|
发布前完成 Go 测试与前端构建;部署后检查新路由被托管 Nginx 白名单接受、未授权返回 401,并在轮换飞书 Secret 后进行真实查询。恢复可回退二进制与前端资源;不需要数据迁移,也不修改已运行专线。
|
||||||
|
|
||||||
|
## 8. 飞书应用引导与查询错误诊断(S3)
|
||||||
|
|
||||||
|
当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权准备 `user_profile` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。
|
||||||
|
|
||||||
|
后端只回显飞书返回的数字错误码与 HTTP 状态,不回显第三方原始 `msg`、访问令牌或凭据;页面保留错误提示以便管理员根据飞书错误码排查,不再把所有错误归因于权限。验收:模拟 `code != 0`、非 2xx、无响应码,区分令牌与企业查询阶段且不泄露上游原始信息;前端引导能看到精确回调、自动查询权限与发布要求;构建和身份模块测试通过。发布先构建和测试,再按现有 `/tmp` 双备份流程部署并检查 HTTPS、401 和 Nginx;恢复为上一版二进制,保留数据库与身份配置。
|
||||||
@@ -575,6 +575,7 @@
|
|||||||
},
|
},
|
||||||
"identity": {
|
"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.",
|
"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": "In Permissions, request read access to enterprise information (search for tenant:tenant:readonly or 'get enterprise information'; use the actual name shown by Feishu) for automatic tenant lookup. Allow the user_profile scope for browser sign-in user info.", "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 here, then sync its public routes and try a user in the app's availability scope. The Feishu callback differs from the VPN client callback below.", "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.",
|
||||||
"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",
|
"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.",
|
"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",
|
"addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID",
|
||||||
|
|||||||
@@ -575,6 +575,7 @@
|
|||||||
},
|
},
|
||||||
"identity": {
|
"identity": {
|
||||||
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
|
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
|
||||||
|
"guideTitle": "飞书开放平台配置步骤", "guideApp": "在飞书开放平台 → 企业自建应用中打开你的应用;在「凭证与基础信息」复制 App ID 和 App Secret,填入下方。不要使用商店应用或智能体的其他凭据。", "guidePermission": "在「权限管理」申请企业信息读取权限(搜索 tenant:tenant:readonly 或“获取企业信息”,以飞书控制台实际名称为准),供“自动获取企业标识”使用;网页授权还需允许 user_profile 范围以读取登录用户信息。", "guidePublish": "按企业要求发布/启用新版本,等待管理员审核,并在「应用可用范围」加入需要登录 VPN 的用户;只创建应用或添加权限不代表已生效。", "guideCallback": "在飞书应用的「安全设置 → 重定向 URL」添加飞书登录回调(完整地址):", "guideCallbackPending": "先在「公网入口」完成 HTTPS 域名配置,页面才会显示回调地址。", "guideVerify": "保存并启用本页身份服务,再同步身份公网路由;用应用可用范围内的账号试登录。飞书回调地址与下方 VPN 客户端回调地址不同。", "guideManual": "自动获取失败时可手填 tenant_key;请从同一企业的飞书用户信息核对。错误码会显示在输入框下方,凭据或权限变更后可重试。",
|
||||||
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "可自动获取,也可手动填写", "fetchTenant": "自动获取企业标识", "tenantFetched": "已读取飞书企业标识", "tenantFailed": "读取飞书企业信息失败", "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/;请同步公网入口。",
|
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。",
|
||||||
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",
|
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",
|
||||||
|
|||||||
@@ -42,6 +42,17 @@
|
|||||||
<el-card class="page-card" shadow="never">
|
<el-card class="page-card" shadow="never">
|
||||||
<template #header><b>{{ $t('identity.title') }}</b></template>
|
<template #header><b>{{ $t('identity.title') }}</b></template>
|
||||||
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
|
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
|
||||||
|
<el-card shadow="never" class="identity-guide">
|
||||||
|
<template #header><b>{{ $t('identity.guideTitle') }}</b></template>
|
||||||
|
<ol>
|
||||||
|
<li>{{ $t('identity.guideApp') }}</li>
|
||||||
|
<li>{{ $t('identity.guidePermission') }}</li>
|
||||||
|
<li>{{ $t('identity.guidePublish') }}</li>
|
||||||
|
<li>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></li>
|
||||||
|
<li>{{ $t('identity.guideVerify') }}</li>
|
||||||
|
</ol>
|
||||||
|
<p class="tls-hint">{{ $t('identity.guideManual') }}</p>
|
||||||
|
</el-card>
|
||||||
<el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
|
<el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
|
||||||
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
|
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
|
||||||
<el-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
|
<el-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
|
||||||
@@ -52,6 +63,7 @@
|
|||||||
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant">{{ $t('identity.fetchTenant') }}</el-button>
|
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant">{{ $t('identity.fetchTenant') }}</el-button>
|
||||||
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
|
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-alert v-if="tenantError" type="warning" :closable="false" :title="tenantError" style="margin-bottom:16px" />
|
||||||
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" :disabled="!identityStatus?.tls_ready" /></el-form-item>
|
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" :disabled="!identityStatus?.tls_ready" /></el-form-item>
|
||||||
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
|
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
|
||||||
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
|
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
|
||||||
@@ -212,7 +224,9 @@ const identitySaving = ref(false)
|
|||||||
const identityGatewayLoading = ref(false)
|
const identityGatewayLoading = ref(false)
|
||||||
const tenantLoading = ref(false)
|
const tenantLoading = ref(false)
|
||||||
const tenantName = ref('')
|
const tenantName = ref('')
|
||||||
|
const tenantError = ref('')
|
||||||
function onIdentityAppChange() {
|
function onIdentityAppChange() {
|
||||||
|
tenantError.value = ''
|
||||||
if (identityForm.value.app_id !== identityStatus.value?.app_id) {
|
if (identityForm.value.app_id !== identityStatus.value?.app_id) {
|
||||||
identityForm.value.tenant_key = ''
|
identityForm.value.tenant_key = ''
|
||||||
tenantName.value = ''
|
tenantName.value = ''
|
||||||
@@ -220,6 +234,7 @@ function onIdentityAppChange() {
|
|||||||
}
|
}
|
||||||
async function fetchIdentityTenant() {
|
async function fetchIdentityTenant() {
|
||||||
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
|
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
|
||||||
|
tenantError.value = ''
|
||||||
tenantLoading.value = true
|
tenantLoading.value = true
|
||||||
try {
|
try {
|
||||||
const appId = identityForm.value.app_id
|
const appId = identityForm.value.app_id
|
||||||
@@ -229,7 +244,7 @@ async function fetchIdentityTenant() {
|
|||||||
tenantName.value = data.name || ''
|
tenantName.value = data.name || ''
|
||||||
ElMessage.success(t('identity.tenantFetched'))
|
ElMessage.success(t('identity.tenantFetched'))
|
||||||
}
|
}
|
||||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.tenantFailed')) }
|
} catch (e) { tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
|
||||||
finally { tenantLoading.value = false }
|
finally { tenantLoading.value = false }
|
||||||
}
|
}
|
||||||
async function loadIdentity() {
|
async function loadIdentity() {
|
||||||
@@ -239,6 +254,7 @@ async function loadIdentity() {
|
|||||||
identityStatus.value = data
|
identityStatus.value = data
|
||||||
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
||||||
tenantName.value = ''
|
tenantName.value = ''
|
||||||
|
tenantError.value = ''
|
||||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
||||||
finally { identityLoading.value = false }
|
finally { identityLoading.value = false }
|
||||||
}
|
}
|
||||||
@@ -523,5 +539,9 @@ function copyBackup() {
|
|||||||
background: var(--bg-card); border-radius: 4px;
|
background: var(--bg-card); border-radius: 4px;
|
||||||
}
|
}
|
||||||
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
|
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
|
||||||
|
.identity-guide { margin-bottom: 16px; }
|
||||||
|
.identity-guide ol { margin: 0; padding-left: 22px; line-height: 1.8; color: var(--text-secondary); font-size: 13px; }
|
||||||
|
.identity-guide li + li { margin-top: 6px; }
|
||||||
|
.identity-guide code { display: inline-block; padding: 1px 5px; overflow-wrap: anywhere; color: var(--el-color-primary); background: var(--el-fill-color-light); border-radius: 4px; }
|
||||||
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
|
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user