Clarify Feishu permissions and verify name lookup scope

This commit is contained in:
Codex committed 2026-10-23 02:25:15 +08:00
1 parent 46390194ee
commit 4096b93da3
3 files changed
+18 -6

No files matched your search

+2 -2
View File
@@ -577,8 +577,8 @@
}, },
"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": "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": "", "guideTitle": "Feishu app setup", "guideApp": "Enter the enterprise app ID and secret; grant the three scopes below as needed.", "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", "tenantPermission": "tenant:tenant:readonly · enterprise lookup", "userPermission": "contact:user.base:readonly · browser sign-in", "basicProfilePermission": "contact:user.basic_profile:readonly · name lookup by open_id (app identity)", "permissionSteps": "Grant the app identity scope and publish the app; after a member signs in, check their name below.", "openFeishuPermission": "Open Feishu permissions ↗", "checkNamePending": "Check name below", "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", "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.", "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",
+2 -2
View File
@@ -577,8 +577,8 @@
}, },
"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": "", "guidePublish": "发布应用,并将 VPN 用户加入应用可用范围。", "guideCallback": "安全设置 → 重定向 URL:", "guideCallbackPending": "请先配置 HTTPS 域名。", "guideVerify": "", "guideManual": "", "guideScope": "", "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": "首次登录后验证", "tenantPermission": "tenant:tenant:readonly · 查询企业信息", "userPermission": "contact:user.base:readonly · 浏览器登录用户", "basicProfilePermission": "contact:user.basic_profile:readonly · 按 open_id 查询姓名(应用身份)", "permissionSteps": "在飞书开通应用身份权限并发布应用;成员登录后,到下方“飞书姓名查询”检查。", "openFeishuPermission": "打开飞书权限管理 ↗", "checkNamePending": "待检查姓名", "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 管理入口", "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/;请同步公网入口。", "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",
@@ -17,7 +17,9 @@
<div class="identity-checks" aria-live="polite"> <div class="identity-checks" aria-live="polite">
<div><span>{{ $t('identity.tenantPermission') }}</span><el-tag :type="tenantError ? 'danger' : tenantLoading ? 'info' : tenantChecked ? 'success' : 'info'" size="small">{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}</el-tag></div> <div><span>{{ $t('identity.tenantPermission') }}</span><el-tag :type="tenantError ? 'danger' : tenantLoading ? 'info' : tenantChecked ? 'success' : 'info'" size="small">{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}</el-tag></div>
<div><span>{{ $t('identity.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div> <div><span>{{ $t('identity.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
<div><span>{{ $t('identity.basicProfilePermission') }}</span><el-tag :type="basicProfileVerified ? 'success' : 'info'" size="small">{{ basicProfileVerified ? $t('identity.checked') : $t('identity.checkNamePending') }}</el-tag></div>
</div> </div>
<p class="tls-hint">{{ $t('identity.permissionSteps') }} <a v-if="feishuPermissionUrl" :href="feishuPermissionUrl" target="_blank" rel="noopener noreferrer">{{ $t('identity.openFeishuPermission') }}</a></p>
<el-form-item :label="$t('identity.tenant')"> <el-form-item :label="$t('identity.tenant')">
<el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" /> <el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" />
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button> <el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button>
@@ -69,6 +71,9 @@ const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' }) const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:' const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '') const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '')
const feishuPermissionUrl = computed(() => /^cli_[a-zA-Z0-9]+$/.test(identityForm.value.app_id)
? `https://open.feishu.cn/app/${identityForm.value.app_id}/auth?q=contact:user.basic_profile:readonly&op_from=openapi&token_type=tenant`
: '')
const identityLoading = ref(false) const identityLoading = ref(false)
const identitySaving = ref(false) const identitySaving = ref(false)
const identityGatewayLoading = ref(false) const identityGatewayLoading = ref(false)
@@ -82,6 +87,7 @@ const profilesLoading = ref(false)
const directoryLoading = ref(false) const directoryLoading = ref(false)
const directoryResult = ref('') const directoryResult = ref('')
const directoryAvailable = ref(false) const directoryAvailable = ref(false)
const basicProfileVerified = 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 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) const tenantSeconds = ref(0)
let tenantTimer let tenantTimer
@@ -95,6 +101,7 @@ watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId,
tenantLoading.value = false tenantLoading.value = false
tenantChecked.value = false tenantChecked.value = false
tenantError.value = '' tenantError.value = ''
basicProfileVerified.value = false
clearTimeout(identityCheckTimer) clearTimeout(identityCheckTimer)
if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) { if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) {
identityForm.value.tenant_key = '' identityForm.value.tenant_key = ''
@@ -104,6 +111,7 @@ watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId,
identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700) identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700)
} }
}) })
watch(selectedProfile, () => { basicProfileVerified.value = false; directoryResult.value = '' })
async function fetchIdentityTenant(silent = false) { async function fetchIdentityTenant(silent = false) {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly')) if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
const request = ++tenantRequest const request = ++tenantRequest
@@ -139,6 +147,7 @@ async function loadIdentity() {
tenantName.value = '' tenantName.value = ''
tenantError.value = '' tenantError.value = ''
tenantChecked.value = false tenantChecked.value = false
basicProfileVerified.value = false
} 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 }
} }
@@ -151,11 +160,14 @@ async function loadProfiles() {
async function checkDirectory() { async function checkDirectory() {
directoryLoading.value = true directoryLoading.value = true
directoryResult.value = '' directoryResult.value = ''
const subject = selectedProfile.value
try { try {
const { data } = await api.post('/system/identity/directory-check', { subject: selectedProfile.value }) const { data } = await api.post('/system/identity/directory-check', { subject })
if (subject !== selectedProfile.value) return
directoryAvailable.value = data.available directoryAvailable.value = data.available
basicProfileVerified.value = data.available && data.source === 'basic_batch'
directoryResult.value = data.available ? t('feishuDirectory.found', { name: data.name }) + (data.source === 'contact_user' ? ` ${t('feishuDirectory.basicUnavailable')}` : '') : data.hint directoryResult.value = data.available ? t('feishuDirectory.found', { name: data.name }) + (data.source === 'contact_user' ? ` ${t('feishuDirectory.basicUnavailable')}` : '') : data.hint
} catch (e) { directoryAvailable.value = false; directoryResult.value = e.response?.data?.error || t('feishuDirectory.failed') } } catch (e) { if (subject === selectedProfile.value) { directoryAvailable.value = false; basicProfileVerified.value = false; directoryResult.value = e.response?.data?.error || t('feishuDirectory.failed') } }
finally { directoryLoading.value = false } finally { directoryLoading.value = false }
} }
async function saveIdentity() { async function saveIdentity() {