Resolve Feishu names by verified open ID and scope VPN member access
This commit is contained in:
1 parent
578a58a129
commit
d4eddf7365
19 files changed
+837
-35
No files matched your search
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<section class="feishu-admission">
|
||||
<el-divider content-position="left">{{ $t('feishuAdmission.title') }}</el-divider>
|
||||
<el-form-item :label="$t('feishuAdmission.mode')">
|
||||
<el-radio-group v-model="config.feishu_access_mode">
|
||||
<el-radio value="all">{{ $t('feishuAdmission.all') }}</el-radio>
|
||||
<el-radio value="allowlist">{{ $t('feishuAdmission.allowlist') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="hint">{{ $t('feishuAdmission.modeHint') }}</p>
|
||||
</el-form-item>
|
||||
<template v-if="editing && isAdmin">
|
||||
<el-form-item :label="$t('feishuAdmission.members')">
|
||||
<div class="members">
|
||||
<div class="member-add">
|
||||
<el-select v-model="selected" filterable :placeholder="$t('feishuAdmission.choose')" style="flex:1" :loading="loading">
|
||||
<el-option v-for="profile in candidates" :key="profile.subject" :label="`${profile.name || profile.subject} · ${profile.subject}`" :value="profile.subject" />
|
||||
</el-select>
|
||||
<el-button :disabled="!selected" :loading="working" @click="add">{{ $t('feishuAdmission.add') }}</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!grants.length" :description="$t('feishuAdmission.empty')" :image-size="64" />
|
||||
<div v-for="grant in grants" :key="grant.id" class="member-row">
|
||||
<span>{{ profiles.find(p => p.subject === grant.subject)?.name || grant.subject }} <small>{{ grant.subject }}</small></span>
|
||||
<el-button type="danger" link :loading="working" @click="remove(grant)">{{ $t('feishuAdmission.remove') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-alert v-else :title="isAdmin ? $t('feishuAdmission.saveFirst') : $t('feishuAdmission.adminOnly')" type="info" :closable="false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
|
||||
const props = defineProps({ config: { type: Object, required: true }, tunnelId: { type: [String, Number], required: true }, editing: Boolean, isAdmin: Boolean })
|
||||
const { t } = useI18n()
|
||||
const profiles = ref([])
|
||||
const grants = ref([])
|
||||
const selected = ref('')
|
||||
const loading = ref(false)
|
||||
const working = ref(false)
|
||||
const candidates = computed(() => profiles.value.filter(p => !grants.value.some(g => g.subject === p.subject)))
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [users, permissions] = await Promise.all([api.get('/system/identity/profiles'), api.get(`/tunnels/${props.tunnelId}/feishu-grants`)])
|
||||
profiles.value = users.data.profiles || []
|
||||
grants.value = permissions.data.grants || []
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('feishuAdmission.loadFailed')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function add() {
|
||||
working.value = true
|
||||
try { await api.post(`/tunnels/${props.tunnelId}/feishu-grants`, { subject: selected.value }); selected.value = ''; await refresh() }
|
||||
catch (e) { ElMessage.error(e.response?.data?.error || t('common.saveFailed')) }
|
||||
finally { working.value = false }
|
||||
}
|
||||
async function remove(grant) {
|
||||
working.value = true
|
||||
try { await api.delete(`/tunnels/${props.tunnelId}/feishu-grants/${grant.id}`); await refresh() }
|
||||
catch (e) { ElMessage.error(e.response?.data?.error || t('common.saveFailed')) }
|
||||
finally { working.value = false }
|
||||
}
|
||||
onMounted(() => { if (props.editing && props.isAdmin) refresh() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { width: 100%; margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
|
||||
.members { width: 100%; }
|
||||
.member-add { display: flex; gap: 8px; }
|
||||
.member-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.member-row small { display: block; color: var(--text-secondary); line-height: 1.2; }
|
||||
</style>
|
||||
@@ -793,6 +793,30 @@
|
||||
"setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server",
|
||||
"commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled"
|
||||
},
|
||||
"feishuDirectory": {
|
||||
"title": "Feishu directory lookup",
|
||||
"hint": "After a sign-in, check the directory name. The app needs published user basic info permission and visibility.",
|
||||
"choose": "Select a signed-in member",
|
||||
"check": "Check name",
|
||||
"found": "Directory name: {name}",
|
||||
"basicUnavailable": "The basic name API did not return a result. Check app permissions and publication in Feishu.",
|
||||
"failed": "Lookup failed"
|
||||
},
|
||||
"feishuAdmission": {
|
||||
"title": "Feishu member access",
|
||||
"mode": "Who can connect",
|
||||
"all": "All coworkers in this tenant",
|
||||
"allowlist": "Selected members only",
|
||||
"modeHint": "Members appear here after their first Feishu sign-in. Add them before enabling the allowlist; revocation applies to their next login.",
|
||||
"members": "Authorized members",
|
||||
"choose": "Select a member who has signed in",
|
||||
"add": "Add",
|
||||
"remove": "Revoke",
|
||||
"empty": "No authorized members",
|
||||
"saveFirst": "Save the built-in Feishu tunnel before managing members.",
|
||||
"adminOnly": "Only administrators can manage member access.",
|
||||
"loadFailed": "Could not load Feishu members"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
"name": "WireGuard",
|
||||
|
||||
@@ -793,6 +793,30 @@
|
||||
"setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续",
|
||||
"commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启"
|
||||
},
|
||||
"feishuDirectory": {
|
||||
"title": "飞书姓名查询",
|
||||
"hint": "完成登录后可检查通讯录姓名。需要飞书应用已发布、获取用户基本信息权限和可见范围。",
|
||||
"choose": "选择已登录成员",
|
||||
"check": "检查姓名",
|
||||
"found": "通讯录姓名:{name}",
|
||||
"basicUnavailable": "基础姓名接口未返回结果;请在飞书开放平台核对接口权限和应用发布状态。",
|
||||
"failed": "检查失败"
|
||||
},
|
||||
"feishuAdmission": {
|
||||
"title": "飞书成员准入",
|
||||
"mode": "允许连接的成员",
|
||||
"all": "同企业所有同事",
|
||||
"allowlist": "仅指定成员",
|
||||
"modeHint": "成员首次通过飞书登录后出现在下方。先添加成员,再切换为仅指定成员;撤销后下次登录生效。",
|
||||
"members": "已授权成员",
|
||||
"choose": "选择已完成飞书登录的成员",
|
||||
"add": "添加",
|
||||
"remove": "撤销",
|
||||
"empty": "暂无授权成员",
|
||||
"saveFirst": "先保存内置飞书专线,再管理授权成员。",
|
||||
"adminOnly": "仅管理员可管理授权成员。",
|
||||
"loadFailed": "无法加载飞书成员"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
"name": "WireGuard",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
|
||||
</el-form-item>
|
||||
<OpenVPNOIDCGuide v-if="isOpenVPN && form.config.auth_mode === 'oidc'" :config="form.config" :editing="originalAuthMode === 'oidc'" />
|
||||
<FeishuAdmission v-if="isOpenVPN && form.config.auth_mode === 'oidc' && form.config.oidc_provider === 'builtin'" :config="form.config" :tunnel-id="route.params.id" :editing="originalAuthMode === 'oidc' && originalOIDCProvider === 'builtin'" :is-admin="auth.role === 'admin'" />
|
||||
<template v-if="isOpenVPN">
|
||||
<el-form-item :label="$t('tunnelEditor.trafficMode')">
|
||||
<el-radio-group v-model="trafficMode">
|
||||
@@ -76,9 +77,10 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
import api, { auth } from '../api'
|
||||
import RouteListInput from '../components/RouteListInput.vue'
|
||||
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
||||
import FeishuAdmission from '../components/FeishuAdmission.vue'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -140,6 +142,7 @@ async function load() {
|
||||
const config = parseConfig(tunnel.config)
|
||||
originalAuthMode.value = config.auth_mode || ''
|
||||
originalOIDCProvider.value = config.oidc_provider || 'external'
|
||||
if (config.feishu_access_mode === undefined) config.feishu_access_mode = 'all'
|
||||
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
|
||||
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
|
||||
}
|
||||
|
||||
@@ -81,22 +81,24 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.operation')" width="205" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
|
||||
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
||||
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<div class="row-actions">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
|
||||
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)">
|
||||
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -279,6 +281,8 @@ onUnmounted(() => clearInterval(timer))
|
||||
|
||||
<style scoped>
|
||||
.tunnel-toolbar, .tunnel-filters, .tunnel-actions { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.tunnel-toolbar { justify-content: space-between; flex-wrap: wrap; }
|
||||
.tunnel-filters { flex: 1; min-width: 0; flex-wrap: wrap; }
|
||||
.tunnel-search { width: 200px; }
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
|
||||
<el-button :loading="identityGatewayLoading" :disabled="!identitySecure || !identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
|
||||
</el-card>
|
||||
<el-card v-if="identityStatus?.enabled && identitySecure" class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>{{ $t('feishuDirectory.title') }}</b></template>
|
||||
<p class="tls-hint">{{ $t('feishuDirectory.hint') }}</p>
|
||||
<div class="directory-check">
|
||||
<el-select v-model="selectedProfile" filterable :placeholder="$t('feishuDirectory.choose')" :loading="profilesLoading" style="flex:1">
|
||||
<el-option v-for="profile in identityProfiles" :key="profile.subject" :label="`${profile.name || profile.subject} · ${profile.subject}`" :value="profile.subject" />
|
||||
</el-select>
|
||||
<el-button :disabled="!selectedProfile" :loading="directoryLoading" @click="checkDirectory">{{ $t('feishuDirectory.check') }}</el-button>
|
||||
</div>
|
||||
<el-alert v-if="directoryResult" :title="directoryResult" :type="directoryAvailable ? 'success' : 'warning'" :closable="false" style="margin-top:12px" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +76,12 @@ const tenantLoading = ref(false)
|
||||
const tenantName = ref('')
|
||||
const tenantError = ref('')
|
||||
const tenantChecked = ref(false)
|
||||
const identityProfiles = ref([])
|
||||
const selectedProfile = ref('')
|
||||
const profilesLoading = ref(false)
|
||||
const directoryLoading = ref(false)
|
||||
const directoryResult = ref('')
|
||||
const directoryAvailable = 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
|
||||
@@ -117,6 +134,7 @@ async function loadIdentity() {
|
||||
try {
|
||||
const { data } = await api.get('/system/identity')
|
||||
identityStatus.value = data
|
||||
if (data.enabled && identitySecure) loadProfiles()
|
||||
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
||||
tenantName.value = ''
|
||||
tenantError.value = ''
|
||||
@@ -124,6 +142,22 @@ async function loadIdentity() {
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
||||
finally { identityLoading.value = false }
|
||||
}
|
||||
async function loadProfiles() {
|
||||
profilesLoading.value = true
|
||||
try { identityProfiles.value = (await api.get('/system/identity/profiles')).data.profiles || [] }
|
||||
catch { identityProfiles.value = [] }
|
||||
finally { profilesLoading.value = false }
|
||||
}
|
||||
async function checkDirectory() {
|
||||
directoryLoading.value = true
|
||||
directoryResult.value = ''
|
||||
try {
|
||||
const { data } = await api.post('/system/identity/directory-check', { subject: selectedProfile.value })
|
||||
directoryAvailable.value = data.available
|
||||
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') }
|
||||
finally { directoryLoading.value = false }
|
||||
}
|
||||
async function saveIdentity() {
|
||||
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
|
||||
identitySaving.value = true
|
||||
@@ -158,4 +192,5 @@ onMounted(() => { if (!auth.mustChange) loadIdentity() })
|
||||
.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; }
|
||||
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
|
||||
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0 12px; color: var(--el-color-primary); font-size: 13px; }
|
||||
.directory-check { display: flex; gap: 8px; }
|
||||
</style>
|
||||
Reference in new issue
Block a user