Refresh tunnel actions promptly and clarify Feishu OIDC setup

This commit is contained in:
opsx committed 2026-10-22 00:01:12 +08:00
1 parent 41fb9d5631
commit 6a1bace3e9
10 files changed
+136 -32

No files matched your search

@@ -14,12 +14,14 @@
<p>{{ $t('oidcGuide.scopeTip') }}</p>
</template>
</el-alert>
<el-alert type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" />
<el-form-item :label="$t('oidcGuide.issuer')" required>
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
</el-form-item>
<el-form-item :label="$t('oidcGuide.clientId')" required>
<el-input v-model.trim="config.oidc_client_id" />
<div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div>
</el-form-item>
<el-form-item :label="$t('oidcGuide.clientSecret')" :required="!editing">
<el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" />
+5 -3
View File
@@ -241,16 +241,18 @@
"appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.",
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
"scopeTip": "Explicitly restrict who can use the app. Existing LDAP dial-in users are not automatically linked to Feishu identities. If your tenant lacks a standard OIDC discovery endpoint, use a compatible Keycloak / Authentik broker.",
"scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.",
"providerTip": "A Feishu app alone is not an OIDC issuer. Verify that your provider publishes standard discovery and ID tokens; otherwise use an identity broker such as Keycloak or Authentik. OpsX VPN does not currently issue OIDC identity tokens.",
"issuer": "OIDC issuer URL",
"issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.",
"clientId": "OIDC Client ID",
"clientIdTip": "Get the credentials from the provider named by the issuer. With a broker, create a VPN client in the broker; the Feishu App ID/Secret belong in its upstream Feishu configuration.",
"clientSecret": "OIDC Client Secret",
"copySecret": "Copy from the app; hidden after saving",
"copySecret": "Copy from the OIDC provider; hidden after saving",
"keepSecret": "Leave blank to retain current secret",
"secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.",
"baseUrl": "Public sign-in URL",
"baseTip": "Use a dedicated HTTPS root URL reachable before VPN connection. Give each OIDC tunnel its own endpoint.",
"baseTip": "Use a public HTTPS root URL, not the current HTTP admin address. The auth component handles browser callbacks; its entry site may share the admin server.",
"listenPort": "Local auth service port",
"portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.",
"portLookupFailed": "Could not find an available port. Retry or enter one manually.",
+5 -3
View File
@@ -241,16 +241,18 @@
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
"scopeTip": "在飞书侧明确设置应用可用人员与授权范围。现有 LDAP 拨号用户不会自动关联飞书身份;如飞书未提供兼容的标准 OIDC 发现地址,请先部署 Keycloak / Authentik 身份代理。",
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
"providerTip": "这张飞书应用页面只证明已创建应用,不能作为 OIDC 发行者。先确认飞书是否提供标准发现文档和 ID Token;否则需要 Keycloak / Authentik 等身份代理。当前 opsx-vpn 不签发 OIDC 身份令牌。",
"issuer": "OIDC 发行者地址",
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
"clientId": "OIDC Client ID",
"clientIdTip": "从上方 issuer 对应的 OIDC 提供方获取:使用身份代理时填代理中创建的 VPN 客户端凭据,飞书 App ID/Secret 只填在代理的飞书上游配置中。",
"clientSecret": "OIDC Client Secret",
"copySecret": "从应用凭据复制,保存后不回显",
"copySecret": "从 OIDC 提供方复制,保存后不回显",
"keepSecret": "留空保持原密钥",
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
"baseUrl": "认证入口公网地址",
"baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。",
"baseTip": "填写可公网访问的 HTTPS 域名根地址;不能用当前 HTTP 管理地址。浏览器回调由认证组件处理,入口站点可与管理后台同机部署。",
"listenPort": "本机认证服务端口",
"portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。",
"portLookupFailed": "无法获取可用端口,请稍后重试或手动选择",
+31 -22
View File
@@ -75,24 +75,24 @@
<el-table-column :label="$t('tunnels.node')" width="110">
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="285" fixed="right">
<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 @click="start(row)">{{ $t('tunnels.start') }}</el-button>
<el-button v-else type="warning" size="small" plain @click="stop(row)">{{ $t('tunnels.stop') }}</el-button>
<el-button size="small" link @click="showDetail(row)">{{ $t('tunnels.detail') }}</el-button>
<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>
<el-button type="danger" size="small" link @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
<template #empty>
@@ -251,6 +251,8 @@ const { t } = useI18n()
const router = useRouter()
const list = ref([])
const loading = ref(false)
const changingIds = ref(new Set())
let loadRevision = 0
const keyword = ref('')
const protocolFilter = ref('')
const stateFilter = ref('')
@@ -330,6 +332,7 @@ function peerOnline(row) {
}
async function load() {
const revision = ++loadRevision
loading.value = true
try {
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
@@ -337,13 +340,15 @@ async function load() {
if (stateFilter.value) {
result = result.filter(r => stateOf(r) === stateFilter.value)
}
list.value = result
} finally { loading.value = false }
if (revision === loadRevision) list.value = result
} finally { if (revision === loadRevision) loading.value = false }
}
// 更多操作下拉命令分发
function onMoreCommand(cmd, row) {
if (cmd === 'download') download(row)
if (cmd === 'detail') showDetail(row)
else if (cmd === 'delete') remove(row)
else if (cmd === 'download') download(row)
else if (cmd === 'qrcode') showQr(row)
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
else if (cmd === 'logs') openLogStream(row)
@@ -446,23 +451,27 @@ async function savePolicy(tunnelId) {
} finally { policySaving.value = false }
}
async function start(row) {
async function changeState(row, action) {
if (changingIds.value.has(row.id)) return
changingIds.value = new Set([...changingIds.value, row.id])
++loadRevision // discard a poll started before this operation
try {
await api.post(`/tunnels/${row.id}/start`)
ElMessage.success(t('tunnels.started'))
const { data } = await api.post(`/tunnels/${row.id}/${action}`)
row.state = data.state
row.runtime = null // discard the monitor's older snapshot immediately
if (stateFilter.value && stateFilter.value !== data.state) {
list.value = list.value.filter(item => item.id !== row.id)
}
ElMessage.success(t(action === 'start' ? 'tunnels.started' : 'tunnels.stopped'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.startFailed'))
ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed'))
} finally {
try { await load() } finally {
const next = new Set(changingIds.value)
next.delete(row.id)
changingIds.value = next
}
}
load()
}
async function stop(row) {
try {
await api.post(`/tunnels/${row.id}/stop`)
ElMessage.success(t('tunnels.stopped'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.stopFailed'))
}
load()
}
async function remove(row) {
try {