Bind Feishu identity to OpenVPN sessions and refine management UI

This commit is contained in:
opsx committed 2026-10-22 23:24:41 +08:00
1 parent 347799cb1e
commit 8fd8c0cd73
27 files changed
+1556 -1011

No files matched your search

+1
View File
@@ -531,6 +531,7 @@
"connectedDuration": "Duration",
"kickOffline": "Kick",
"noOnlineUsers": "No online users",
"identityUnknown": "Identity unknown",
"trafficAlert": "⚠ Abnormal Traffic Alert",
"userTunnel": "User/Tunnel",
"alertType": "Type",
+1
View File
@@ -531,6 +531,7 @@
"connectedDuration": "已连接时长",
"kickOffline": "踢下线",
"noOnlineUsers": "当前无在线用户",
"identityUnknown": "身份未识别",
"trafficAlert": "⚠ 异常流量告警",
"userTunnel": "用户/隧道",
"alertType": "类型",
+14 -4
View File
@@ -13,7 +13,9 @@
</div>
</template>
<el-table :data="sessions" size="default" v-loading="loading">
<el-table-column prop="username" :label="$t('common.username')" min-width="120" />
<el-table-column :label="$t('common.username')" min-width="120">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('common.protocol')" width="100">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
@@ -64,7 +66,9 @@
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="username" :label="$t('common.username')" width="100" />
<el-table-column :label="$t('common.username')" min-width="180">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('sessions.event')" width="100">
<template #default="{ row }">
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
@@ -92,6 +96,12 @@ const alerts = ref([])
const loading = ref(false)
const loadError = ref('')
function displayUsername(value) {
return typeof value === 'string' && value.trim() && value.trim().toUpperCase() !== 'UNDEF'
? value
: t('sessions.identityUnknown')
}
function fmtDuration(dur) {
if (!dur) return '-'
const sec = Math.floor((Date.now() - new Date(dur).getTime()) / 1000)
@@ -125,7 +135,7 @@ async function load() {
async function disconnect(row) {
try {
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.username }), t('common.confirm'), { type: 'warning' })
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.display_name || displayUsername(row.username) }), t('common.confirm'), { type: 'warning' })
await api.post(`/sessions/${row.id}/disconnect`)
ElMessage.success(t('sessions.kicked'))
load()
@@ -137,4 +147,4 @@ onMounted(() => { load(); timer = setInterval(load, 15000) })
onUnmounted(() => clearInterval(timer))
</script>
<style scoped>.session-error { margin-bottom: var(--space-4); }</style>
<style scoped>.identity-subject { font-size: 11px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; } .session-error { margin-bottom: var(--space-4); }</style>
+6 -372
View File
@@ -38,122 +38,10 @@
</div>
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('identity.tab')" name="identity">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('identity.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
<div class="identity-guide">
<b>{{ $t('identity.guideTitle') }}</b>
<p>{{ $t('identity.guideApp') }}</p>
<p>{{ $t('identity.guidePublish') }}</p>
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
</div>
<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-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
<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.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
</div>
<el-form-item :label="$t('identity.tenant')">
<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>
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
<span v-if="tenantLoading" class="tenant-progress" role="status" aria-live="polite"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
</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-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
</el-form>
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('identity.addresses') }}</b></template>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
</el-descriptions>
<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>
</div>
<IdentitySettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
<el-step :title="$t('tls.setupStepCheck')" />
<el-step :title="$t('tls.setupStepInstall')" />
<el-step :title="$t('tls.setupStepConfigure')" />
</el-steps>
<el-card shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.setupTitle') }}</b></template>
<template v-if="nginxSetup">
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
<el-alert v-if="!nginxSetup.supported || !nginxSetup.root" type="warning" :closable="false" :title="$t('tls.setupUnsupported')" style="margin-bottom:12px" />
</template>
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
</el-card>
<el-form @submit.prevent="saveTLSDomain" label-position="top">
<el-form-item :label="$t('tls.domain')">
<el-input v-model.trim="tlsDomain" placeholder="test-link.appx365.com" maxlength="253" />
</el-form-item>
<el-button type="primary" :loading="tlsSaving" @click="saveTLSDomain">{{ $t('tls.saveDomain') }}</el-button>
</el-form>
<el-form label-position="top" style="margin-top:16px" @submit.prevent="saveTLSMode">
<el-form-item :label="$t('tls.mode')">
<el-select v-model="tlsMode" style="width:100%">
<el-option value="autocert" :label="$t('tls.goMode')" />
<el-option value="nginx" :label="$t('tls.nginxMode')" />
</el-select>
</el-form-item>
<el-button :loading="tlsSavingMode" @click="saveTLSMode">{{ $t('tls.saveMode') }}</el-button>
</el-form>
<el-alert v-if="tlsMode === 'nginx'" type="warning" :closable="false" :title="$t('tls.nginxHint')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.mode')">{{ tlsStatus.mode === 'nginx' ? $t('tls.nginxMode') : $t('tls.goMode') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.mode === 'nginx' && nginxSetup?.site_stage !== 'https' ? $t('tls.notConfigured') : tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.certificate')">
<el-tag :type="tlsStatus.certificate_valid ? 'success' : 'warning'">{{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.issuer')">{{ tlsStatus.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.expires')">{{ new Date(tlsStatus.not_after).toLocaleString() }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : tlsStatus.mode === 'nginx' ? $t('tls.externalRenewal') : '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.mode === 'nginx' ? $t('tls.externalPorts') : `${tlsStatus.http_address || ':80'} / ${tlsStatus.https_address || ':443'}` }}</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('tls.setupHint') }}</p>
<el-button :loading="tlsLoading" @click="loadTLS">{{ $t('tls.refresh') }}</el-button>
<el-button v-if="tlsStatus?.enabled || (tlsStatus?.certificate_valid && nginxSetup?.site_stage === 'https')" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
<el-button type="primary" :loading="nginxActivating" :disabled="!nginxSetup?.root || !nginxSetup?.nginx_installed || !nginxSetup?.certbot_installed || !nginxSetup?.webroot_ready || nginxSetup?.running || tlsStatus?.mode !== 'nginx' || tlsStatus?.restart_required || !tlsStatus?.configured_domain" @click="activateNginx">{{ $t('tls.activateSite') }}</el-button>
<p class="tls-hint">{{ $t('tls.activateHint') }}</p>
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxActivateLog }}</pre>
<el-button :loading="nginxLoading" :disabled="!tlsStatus?.configured_domain" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
<template v-if="nginxPreview">
<p class="tls-hint">{{ $t('tls.previewOnly') }}</p>
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
</template>
</el-card>
</div>
<GatewaySettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.hostTab')" name="host">
<div class="settings-column settings-column-wide">
@@ -198,12 +86,13 @@
</template>
<script setup>
import { computed, ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { ref, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import { Loading } from '@element-plus/icons-vue'
import IdentitySettings from './settings/components/IdentitySettings.vue'
import GatewaySettings from './settings/components/GatewaySettings.vue'
import api, { auth } from '../api'
const { t } = useI18n()
@@ -216,109 +105,12 @@ function changeTab(name) {
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
}
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '')
const identityLoading = ref(false)
const identitySaving = ref(false)
const identityGatewayLoading = ref(false)
const tenantLoading = ref(false)
const tenantName = ref('')
const tenantError = ref('')
const tenantChecked = 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
let identityCheckTimer
let tenantRequest = 0
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ })
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
if (appId === oldId && secret === oldSecret) return
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
tenantChecked.value = false
tenantError.value = ''
clearTimeout(identityCheckTimer)
if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) {
identityForm.value.tenant_key = ''
tenantName.value = ''
}
if (identitySecure && appId && (secret || (appId === identityStatus.value?.app_id && identityStatus.value?.app_secret_configured))) {
identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700)
}
})
async function fetchIdentityTenant(silent = false) {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
const request = ++tenantRequest
clearInterval(tenantTimer)
tenantError.value = ''
tenantLoading.value = true
tenantSeconds.value = 0
const started = Date.now()
tenantTimer = setInterval(() => { tenantSeconds.value = Math.floor((Date.now() - started) / 1000) }, 1000)
try {
const appId = identityForm.value.app_id
const secret = identityForm.value.app_secret
const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: secret })
if (request === tenantRequest && identityForm.value.app_id === appId && identityForm.value.app_secret === secret) {
identityForm.value.tenant_key = data.tenant_key
tenantName.value = data.name || ''
tenantChecked.value = true
if (!silent) ElMessage.success(t('identity.tenantFetched'))
}
} catch (e) { if (request === tenantRequest) tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
finally { if (request === tenantRequest) { clearInterval(tenantTimer); tenantLoading.value = false } }
}
async function loadIdentity() {
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
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 = ''
tenantError.value = ''
tenantChecked.value = false
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
finally { identityLoading.value = false }
}
async function saveIdentity() {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
identitySaving.value = true
try {
await api.put('/system/identity', identityForm.value)
await loadIdentity()
ElMessage.success(t('identity.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.saveFailed')) }
finally { identitySaving.value = false }
}
async function syncIdentityGateway() {
identityGatewayLoading.value = true
try {
await api.post('/system/identity/gateway')
await loadIdentity()
ElMessage.success(t('identity.gatewaySynced'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
finally { identityGatewayLoading.value = false }
}
const pwdLoading = ref(false)
const mfa = ref({ enabled: false, uri: '', code: '' })
const mfaCanvas = ref(null)
const mfaLoading = ref(false)
const backupVisible = ref(false)
const backupCodes = ref([])
const tlsStatus = ref(null)
const tlsLoading = ref(false)
const tlsSaving = ref(false)
const tlsDomain = ref('')
const tlsMode = ref('autocert')
const tlsSavingMode = ref(false)
const nginxLoading = ref(false)
const nginxPreview = ref(null)
const hostReadiness = ref(null)
const hostLoading = ref(false)
const hostFixing = ref(false)
@@ -341,152 +133,6 @@ async function enableForwarding() {
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { hostFixing.value = false }
}
const nginxSetup = ref(null)
const nginxSetupLoading = ref(false)
const nginxInstalling = ref(false)
const nginxInstallLog = ref('')
const nginxLogView = ref(null)
const nginxActivating = ref(false)
const nginxActivateLog = ref('')
const nginxActivateLogView = ref(null)
async function activateNginx() {
nginxActivating.value = true
nginxActivateLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-activate', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.siteReady'))
} catch (e) {
nginxActivateLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.siteFailed'))
} finally {
nginxActivating.value = false
await Promise.all([loadNginxSetup(), loadTLS()])
}
}
async function loadNginxSetup() {
nginxSetupLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-setup')
nginxSetup.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxSetupLoading.value = false }
}
async function initializeNginx() {
nginxInstalling.value = true
nginxInstallLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-setup', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.setupComplete'))
} catch (e) {
nginxInstallLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.setupFailed'))
} finally {
nginxInstalling.value = false
await loadNginxSetup()
}
}
async function loadTLS() {
tlsLoading.value = true
try {
const { data } = await api.get('/system/tls')
tlsStatus.value = data
tlsDomain.value = data.configured_domain || data.domain || ''
tlsMode.value = data.configured_mode || data.mode || 'autocert'
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { tlsLoading.value = false }
}
async function saveTLSMode() {
tlsSavingMode.value = true
try {
const { data } = await api.put('/system/tls/mode', { mode: tlsMode.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSavingMode.value = false }
}
async function loadNginxPreview() {
nginxLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-preview')
nginxPreview.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxLoading.value = false }
}
async function saveTLSDomain() {
tlsSaving.value = true
try {
const { data } = await api.put('/system/tls/domain', { domain: tlsDomain.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSaving.value = false }
}
async function changePwd() {
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning(t('settings.passwordMismatch'))
if (pwd.value.new_password.length < 8) return ElMessage.warning(t('settings.passwordMin8'))
@@ -502,7 +148,7 @@ async function changePwd() {
}
onMounted(async () => {
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness(); loadIdentity() }
if (auth.isAdmin() && !auth.mustChange) { loadHostReadiness() }
try {
const status = await api.get('/mfa/status')
mfa.value.enabled = status.data.enabled
@@ -554,12 +200,6 @@ function copyBackup() {
.settings-column-wide { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.identity-guide { padding: 16px 18px; margin-bottom: 20px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; }
.identity-guide b { display: block; margin-bottom: 6px; color: #244368; }
.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
.identity-guide code { color: #246cb7; }
.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; }
.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; }
@media (max-width: 640px) { .settings-tabs { padding: 0 14px 16px; } .settings-tabs :deep(.el-tabs__item) { font-size: 12px; padding: 0 10px; } }
.backup-codes {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;
@@ -571,10 +211,4 @@ function copyBackup() {
background: var(--bg-card); border-radius: 4px;
}
.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; }
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0 12px; color: var(--el-color-primary); font-size: 13px; }
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
</style>
+9 -371
View File
@@ -106,132 +106,10 @@
</template>
</el-table>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
</el-descriptions>
<h4>{{ $t('tunnels.runningParams') }}</h4>
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
<h4>{{ $t('tunnels.clientConfig') }}</h4>
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
@click="$router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
<!-- 流量策略 -->
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
<el-form label-width="90px" size="small" v-loading="policyLoading">
<el-form-item :label="$t('tunnels.quotaGB')">
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.rateLimitMbps')">
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.exceedAction')">
<el-select v-model="policy.action_on_exceed" style="width:100%">
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
<el-option value="block" :label="$t('tunnels.actionBlock')" />
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
</el-select>
</el-form-item>
<el-form-item :label="$t('tunnels.resetCycle')">
<el-select v-model="policy.reset_cycle" style="width:100%">
<el-option value="none" :label="$t('tunnels.resetNone')" />
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>
<!-- WireGuard Peer 管理 -->
<template v-if="detail.tunnel.protocol === 'wireguard'">
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
</div>
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="name" :label="$t('common.name')" width="100" />
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="150">
<template #default="{ row }">
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 流量趋势图 -->
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
</template>
</el-drawer>
<!-- WireGuard 二维码 -->
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
<div style="text-align:center">
<canvas ref="qrCanvas"></canvas>
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
</div>
</el-dialog>
<!-- 添加 Peer -->
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
<el-form label-width="110px">
<el-form-item :label="$t('tunnels.peerId')">
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
</el-form-item>
<el-form-item :label="$t('common.name')">
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
</el-form-item>
<el-form-item :label="$t('tunnels.clientAddress')" required>
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
</el-form-item>
<el-form-item label="AllowedIPs">
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
</el-form-item>
<el-form-item :label="$t('tunnels.externalPublicKey')">
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
:placeholder="$t('tunnels.externalKeyHint')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
</template>
</el-dialog>
<TunnelDetailDrawer ref="detailDrawer" @updated="load" />
</el-card>
<!-- 日志实时查看弹窗 -->
<el-dialog v-model="logVisible" :title="$t('tunnels.logTitle', { name: logTunnel?.name || '' })" width="780px" align-center
@close="closeLogStream">
<div class="log-toolbar">
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
{{ logStreaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}
</el-tag>
<el-button v-if="logStreaming" size="small" @click="stopLogStream">{{ $t('tunnels.pause') }}</el-button>
<el-button v-else size="small" type="primary" @click="resumeLogStream">{{ $t('tunnels.resume') }}</el-button>
<el-button size="small" @click="logContent = ''">{{ $t('tunnels.clearScreen') }}</el-button>
</div>
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
</el-dialog>
<TunnelLogDialog ref="logDialog" />
<!-- 批量任务进度 -->
<TaskProgress ref="taskProgressRef" />
@@ -242,13 +120,13 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { useRouter } from 'vue-router'
import api from '../api'
import { fmtBytes } from '../utils/format'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import TaskProgress from '../components/TaskProgress.vue'
import TrafficChart from '../components/TrafficChart.vue'
import TunnelDetailDrawer from './tunnels/components/TunnelDetailDrawer.vue'
import TunnelLogDialog from './tunnels/components/TunnelLogDialog.vue'
import { protocolDisplay, protocolColor } from '../protocols'
const { t } = useI18n()
@@ -262,24 +140,11 @@ const keyword = ref('')
const protocolFilter = ref('')
const stateFilter = ref('')
const protocolOptions = ref([])
const detailVisible = ref(false)
const detail = ref(null)
const qrVisible = ref(false)
// 流量策略
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
const policyLoading = ref(false)
const policySaving = ref(false)
const qrCanvas = ref(null)
const wgPeers = ref([])
const runtimePeers = ref([])
const peersLoading = ref(false)
const peerDialog = ref(false)
const peerSaving = ref(false)
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
// 批量操作
const selectedRows = ref([])
const taskProgressRef = ref(null)
const logDialog = ref(null)
const detailDrawer = ref(null)
function handleSelectionChange(rows) {
selectedRows.value = rows
@@ -328,14 +193,6 @@ function authMethod(row) {
}
return t('tunnels.authUnknown')
}
function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
}
function peerOnline(row) {
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
return !!rt?.connected
}
async function load() {
const revision = ++loadRevision
loading.value = true
@@ -353,109 +210,12 @@ async function load() {
// 更多操作下拉命令分发
function onMoreCommand(cmd, row) {
if (cmd === 'detail') showDetail(row)
if (cmd === 'detail') detailDrawer.value?.open(row)
else if (cmd === 'delete') remove(row)
else if (cmd === 'download') download(row)
else if (cmd === 'qrcode') showQr(row)
else if (cmd === 'qrcode') detailDrawer.value?.showQr(row)
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
else if (cmd === 'logs') openLogStream(row)
}
// ---- 实时日志流(SSE via fetch + ReadableStream,避免 token 暴露在 URL)----
const logVisible = ref(false)
const logTunnel = ref(null)
const logContent = ref('')
const logStreaming = ref(false)
const logView = ref(null)
let logAbortController = null
function openLogStream(row) {
logTunnel.value = row
logContent.value = ''
logVisible.value = true
resumeLogStream()
}
async function resumeLogStream() {
if (!logTunnel.value) return
stopLogStream()
const token = localStorage.getItem('vpn_token')
logAbortController = new AbortController()
logStreaming.value = true
try {
const resp = await fetch(`/api/tunnels/${logTunnel.value.id}/logs`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: logAbortController.signal
})
if (!resp.ok) { logStreaming.value = false; return }
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) {
logContent.value += line.slice(6) + '\n'
}
}
nextTick(() => {
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
})
if (logContent.value.length > 100000) {
logContent.value = logContent.value.slice(-50000)
}
}
} catch (e) {
if (e.name !== 'AbortError') logContent.value += `\n${t('common.loadFailed')}\n`
}
logStreaming.value = false
}
function stopLogStream() {
logStreaming.value = false
logAbortController?.abort()
logAbortController = null
}
function closeLogStream() {
stopLogStream()
logContent.value = ''
}
// ---- 流量策略 ----
async function loadPolicy(tunnelId) {
policyLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
policy.value = {
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
rate_limit_mbps: data.rate_limit_mbps || 0,
action_on_exceed: data.action_on_exceed || 'block',
reset_cycle: data.reset_cycle || 'none',
used_bytes: data.used_bytes || 0,
}
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
finally { policyLoading.value = false }
}
async function savePolicy(tunnelId) {
policySaving.value = true
try {
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
await api.put(`/tunnels/${tunnelId}/policy`, {
quota_bytes: quotaBytes,
rate_limit_mbps: policy.value.rate_limit_mbps,
action_on_exceed: policy.value.action_on_exceed,
reset_cycle: policy.value.reset_cycle,
})
ElMessage.success(t('tunnels.policySaved'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { policySaving.value = false }
else if (cmd === 'logs') logDialog.value?.open(row)
}
async function changeState(row, action) {
@@ -491,110 +251,6 @@ async function remove(row) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
async function showDetail(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
}
loadPolicy(row.id)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
}
}
async function loadPeers(tunnelId) {
peersLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
wgPeers.value = data.peers || []
runtimePeers.value = data.runtime_peers || []
} catch {
wgPeers.value = []
} finally {
peersLoading.value = false
}
}
function openAddPeer() {
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
peerDialog.value = true
}
async function addPeer() {
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
peerSaving.value = true
try {
const body = { ...peerForm.value }
if (!body.id) delete body.id
if (!body.public_key) delete body.public_key
if (!body.allowed_ips) delete body.allowed_ips
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
ElMessage.success(t('tunnels.peerAdded'))
peerDialog.value = false
await loadPeers(detail.value.tunnel.id)
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
detail.value = data
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
} finally {
peerSaving.value = false
}
}
async function removePeer(row) {
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
try {
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
await loadPeers(detail.value.tunnel.id)
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
function downloadPeer(row) {
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
}).then(async r => {
if (!r.ok) {
const d = await r.json()
throw new Error(d.error)
}
return r.blob()
}).then(b => {
const a = document.createElement('a')
a.href = URL.createObjectURL(b)
a.download = `wireguard-${row.id}.conf`
a.click()
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function qrPeer(row) {
try {
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
})
if (!res.ok) {
const d = await res.json()
throw new Error(d.error)
}
const conf = await res.text()
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.message || t('tunnels.qrFailed'))
}
}
function download(row) {
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
@@ -609,18 +265,6 @@ function download(row) {
})
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function showQr(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
}
}
let timer
onMounted(async () => {
load()
@@ -643,12 +287,6 @@ onUnmounted(() => clearInterval(timer))
@media (max-width: 640px) { .tunnel-search { flex: 1 1 100%; width: 100%; } .tunnel-filter { flex: 1 1 135px; width: auto; } .tunnel-actions { width: 100%; justify-content: flex-end; } }
.remark { font-size: 12px; color: var(--text-secondary); }
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.log-view {
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
}
.batch-bar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; margin-bottom: 12px;
+6 -222
View File
@@ -123,57 +123,21 @@
</template>
</el-dialog>
<!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" width="620px" class="mfa-dialog" destroy-on-close>
<template #header>
<div class="mfa-dialog-title">
<div class="mfa-title-icon"><el-icon><Key /></el-icon></div>
<div><div class="mfa-title-text">{{ $t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' }) }}</div><div class="mfa-title-subtitle">{{ tunnelName(otpUser?.tunnel_id) }}</div></div>
</div>
</template>
<div v-if="otpStatus" class="mfa-content">
<div class="mfa-status-strip" :class="{ enabled: otpStatus.mfa_enabled }">
<el-icon class="mfa-status-icon"><CircleCheckFilled v-if="otpStatus.mfa_enabled" /><Key v-else /></el-icon>
<div class="mfa-status-copy"><strong>{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.mfaNotEnabled') }}</strong><span>{{ otpStatus.mfa_enabled ? $t('vpnUsers.mfaEnabledHint') : $t('vpnUsers.mfaDisabledHint') }}</span></div>
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" effect="plain">{{ otpStatus.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}</el-tag>
</div>
<template v-if="!otpStatus.mfa_enabled">
<div v-if="!bindStep" class="mfa-empty-state"><div class="mfa-empty-icon"><el-icon><Key /></el-icon></div><p>{{ $t('vpnUsers.mfaSetupIntro') }}</p><el-button type="primary" size="large" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button></div>
<div v-else class="mfa-enrollment">
<div class="mfa-qr-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.scanHint') }}</div><div class="mfa-qr"><canvas ref="mfaCanvas"></canvas></div><el-collapse v-if="mfaSecret" v-model="manualSecretOpen" class="mfa-manual-secret"><el-collapse-item :title="$t('vpnUsers.manualSetup')" name="secret"><code>{{ mfaSecret }}</code></el-collapse-item></el-collapse></div>
<div class="mfa-verify-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.verifySetup') }}</div><p>{{ $t('vpnUsers.verifySetupHint') }}</p><el-input v-model="mfaCode" inputmode="numeric" maxlength="6" :placeholder="$t('vpnUsers.input6DigitCode')" /><div class="mfa-action-row"><el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button><el-button @click="cancelMFASetup">{{ $t('common.cancel') }}</el-button></div></div>
</div>
</template>
<template v-else>
<div class="mfa-metrics"><div class="mfa-metric"><span>{{ $t('vpnUsers.staticPin') }}</span><strong>{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lockStatus') }}</span><strong :class="{ danger: otpStatus.mfa_locked }">{{ otpStatus.mfa_locked ? $t('vpnUsers.locked') : $t('common.no') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lastOtpSuccess') }}</span><strong>{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}</strong></div></div>
<div class="mfa-actions"><el-button @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button><el-button @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button><el-button v-if="otpStatus.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button><el-button @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button><el-button type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button></div>
<el-collapse class="mfa-details"><el-collapse-item :title="$t('vpnUsers.securityDetails')" name="details"><div class="mfa-detail-line"><span>{{ $t('vpnUsers.failCount') }}</span><b>{{ otpStatus.mfa_fail_count || 0 }}</b></div><div class="mfa-detail-line"><span>{{ $t('vpnUsers.compatible') }}</span><b>{{ otpStatus.compatible }}</b></div></el-collapse-item></el-collapse>
</template>
</div>
</el-dialog>
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
:title="$t('vpnUsers.pinHint')" />
<el-input v-model="pinValue" type="password" show-password :placeholder="$t('vpnUsers.pinPlaceholder')" />
<template #footer>
<el-button @click="pinVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="savePin">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
<VpnUserMfaDialog ref="mfaDialog" :tunnels="tunnels" @updated="load" />
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key, Refresh, ArrowDown } from '@element-plus/icons-vue'
import { Refresh, ArrowDown } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import VpnUserMfaDialog from './vpn-users/components/VpnUserMfaDialog.vue'
import api from '../api'
import { fmtBytes } from '../utils/format'
import { protocolDisplay } from '../protocols'
const { t } = useI18n()
const mfaDialog = ref(null)
const users = ref([])
const tunnels = ref([])
const loading = ref(false)
@@ -192,24 +156,11 @@ const platformUsers = ref([])
const platformSelected = ref([])
const importing = ref(false)
const otpVisible = ref(false)
const otpUser = ref(null)
const otpStatus = ref(null)
const bindStep = ref(false)
const mfaCode = ref('')
const mfaSecret = ref('')
const mfaCanvas = ref(null)
const mfaSaving = ref(false)
const manualSecretOpen = ref([])
const pinVisible = ref(false)
const pinValue = ref('')
const tunnelName = id => tunnels.value.find(t => t.id === id)?.name || `#${id}`
const tunnelProto = id => {
const tun = tunnels.value.find(x => x.id === id)
return tun ? t(protocolDisplay(tun.protocol)) : ''
}
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
const supportsMFA = id => ['openvpn', 'wireguard'].includes(tunnels.value.find(x => x.id === id)?.protocol)
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
const filteredUsers = computed(() => users.value.filter(user => {
@@ -230,7 +181,7 @@ async function load() {
}
function userCommand(command, row) {
if (command === 'otp') openOTPManager(row)
if (command === 'otp') mfaDialog.value?.open(row)
else if (command === 'toggle') toggle(row)
else if (command === 'delete') remove(row)
}
@@ -329,143 +280,10 @@ async function download(row) {
}
}
async function openOTPManager(row) {
otpUser.value = row
bindStep.value = false
manualSecretOpen.value = []
otpVisible.value = true
await refreshOTPStatus()
}
async function refreshOTPStatus() {
if (!otpUser.value) return
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa`)
otpStatus.value = data
} catch (e) {
otpStatus.value = {
mfa_enabled: otpUser.value.mfa_enabled,
mfa_has_pin: otpUser.value.mfa_has_pin,
mfa_fail_count: otpUser.value.mfa_fail_count || 0,
mfa_locked: isLocked(otpUser.value),
mfa_locked_until: otpUser.value.mfa_locked_until,
mfa_last_used_at: otpUser.value.mfa_last_used_at,
compatible: t('vpnUsers.totpCompatible')
}
}
}
async function setupMFA() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
mfaCode.value = ''
mfaSecret.value = data.secret || ''
manualSecretOpen.value = []
bindStep.value = true
await nextTick()
if (mfaCanvas.value && data.otpauth_url) {
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
}
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.mfaSetupFailed'))
}
}
function cancelMFASetup() {
bindStep.value = false
mfaCode.value = ''
mfaSecret.value = ''
manualSecretOpen.value = []
}
async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
mfaSaving.value = true
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
bindStep.value = false
const codes = (data.backup_codes || []).join(' ')
ElMessageBox.alert(
t('vpnUsers.mfaEnabledMsg', { codes }),
t('vpnUsers.bindSuccess'),
{ type: 'success' }
)
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.verifyFailed'))
} finally {
mfaSaving.value = false
}
}
async function disableMFA() {
await ElMessageBox.confirm(t('vpnUsers.disableMfaConfirm', { username: otpUser.value.username }), t('common.confirm'), { type: 'warning' })
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
ElMessage.success(t('vpnUsers.mfaClosed'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
async function regenBackup() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
ElMessageBox.alert(t('vpnUsers.newBackupCodes', { codes: (data.backup_codes || []).join(' ') }), t('vpnUsers.backupCodesTitle'), { type: 'success' })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.generateFailed'))
}
}
function showPinDialog() {
pinValue.value = ''
pinVisible.value = true
}
async function savePin() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
pinVisible.value = false
ElMessage.success(pinValue.value ? t('vpnUsers.pinSetSuccess') : t('vpnUsers.pinCleared'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
}
}
async function unlockMFA() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
ElMessage.success(t('vpnUsers.unlocked'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.unlockFailed'))
}
}
async function exportOtpSecrets() {
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
ElMessageBox.alert(
t('vpnUsers.otpSecretsMsg', { line: data.line, format: data.format }),
t('vpnUsers.exportKeysTitle'),
{ type: 'warning' }
)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.exportFailed'))
}
}
onMounted(load)
</script>
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.vpn-header, .vpn-header-actions, .vpn-toolbar, .vpn-batch-bar { display:flex; align-items:center; gap:8px; }
.vpn-header { justify-content:space-between; flex-wrap:wrap; }
.vpn-header-actions { flex-wrap:wrap; }
@@ -473,38 +291,4 @@ onMounted(load)
.vpn-load-error { margin-bottom: var(--space-4); }
@media (max-width: 640px) { .vpn-toolbar { flex-wrap: wrap; } .vpn-toolbar > * { flex: 1 1 180px; max-width: 100%; } }
.vpn-batch-bar { justify-content:space-between; margin: 0 0 10px; padding: 8px 10px; background:#fef3c7; color:#92400e; border:1px solid #fde68a; font-size:13px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }
.mfa-title-text { font-size: 16px; font-weight: 650; color: #1f2937; }
.mfa-title-subtitle { margin-top: 2px; color: #6b7280; font-size: 12px; }
.mfa-content { display: grid; gap: 16px; }
.mfa-status-strip { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d1d5db; background: #f9fafb; }
.mfa-status-strip.enabled { border-color: #a7f3d0; background: #ecfdf5; }
.mfa-status-icon { color: #64748b; font-size: 22px; }
.mfa-status-strip.enabled .mfa-status-icon { color: #16a34a; }
.mfa-status-copy { display: grid; gap: 2px; flex: 1; }
.mfa-status-copy strong { color: #1f2937; font-size: 14px; }
.mfa-status-copy span { color: #6b7280; font-size: 12px; }
.mfa-empty-state { display: grid; justify-items: center; gap: 12px; padding: 26px 16px 20px; text-align: center; }
.mfa-empty-icon { width: 48px; height: 48px; border-radius: 50%; font-size: 22px; }
.mfa-empty-state p { margin: 0; color: #4b5563; font-size: 13px; }
.mfa-enrollment { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); border: 1px solid #e5e7eb; }
.mfa-qr-panel, .mfa-verify-panel { padding: 18px; }
.mfa-qr-panel { display: grid; justify-items: center; border-right: 1px solid #e5e7eb; }
.mfa-panel-label { color: #374151; font-weight: 600; font-size: 13px; }
.mfa-qr { margin: 14px 0 8px; padding: 10px; background: #fff; border: 1px solid #e5e7eb; }
.mfa-manual-secret { width: 100%; }
.mfa-manual-secret code { display: block; overflow-wrap: anywhere; color: #334155; font-size: 12px; }
.mfa-verify-panel { display: grid; align-content: center; gap: 12px; }
.mfa-verify-panel p { margin: 0; color: #6b7280; font-size: 12px; line-height: 1.55; }
.mfa-action-row, .mfa-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mfa-metrics { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #e5e7eb; }
.mfa-metric { display: grid; gap: 5px; padding: 13px; border-right: 1px solid #e5e7eb; }
.mfa-metric:last-child { border-right: 0; }
.mfa-metric span { color: #6b7280; font-size: 12px; }
.mfa-metric strong { color: #1f2937; font-size: 13px; word-break: break-word; }
.mfa-metric .danger { color: #dc2626; }
.mfa-details { margin-top: -4px; }
.mfa-detail-line { display: flex; justify-content: space-between; padding: 4px 0; color: #4b5563; font-size: 13px; }
@media (max-width: 640px) { .mfa-enrollment { grid-template-columns: 1fr; } .mfa-qr-panel { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metrics { grid-template-columns: 1fr; } .mfa-metric { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metric:last-child { border-bottom: 0; } }
</style>
@@ -0,0 +1,243 @@
<template>
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
<el-step :title="$t('tls.setupStepCheck')" />
<el-step :title="$t('tls.setupStepInstall')" />
<el-step :title="$t('tls.setupStepConfigure')" />
</el-steps>
<el-card shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.setupTitle') }}</b></template>
<template v-if="nginxSetup">
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
<el-alert v-if="!nginxSetup.supported || !nginxSetup.root" type="warning" :closable="false" :title="$t('tls.setupUnsupported')" style="margin-bottom:12px" />
</template>
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
</el-card>
<el-form @submit.prevent="saveTLSDomain" label-position="top">
<el-form-item :label="$t('tls.domain')">
<el-input v-model.trim="tlsDomain" placeholder="test-link.appx365.com" maxlength="253" />
</el-form-item>
<el-button type="primary" :loading="tlsSaving" @click="saveTLSDomain">{{ $t('tls.saveDomain') }}</el-button>
</el-form>
<el-form label-position="top" style="margin-top:16px" @submit.prevent="saveTLSMode">
<el-form-item :label="$t('tls.mode')">
<el-select v-model="tlsMode" style="width:100%">
<el-option value="autocert" :label="$t('tls.goMode')" />
<el-option value="nginx" :label="$t('tls.nginxMode')" />
</el-select>
</el-form-item>
<el-button :loading="tlsSavingMode" @click="saveTLSMode">{{ $t('tls.saveMode') }}</el-button>
</el-form>
<el-alert v-if="tlsMode === 'nginx'" type="warning" :closable="false" :title="$t('tls.nginxHint')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.mode')">{{ tlsStatus.mode === 'nginx' ? $t('tls.nginxMode') : $t('tls.goMode') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.mode === 'nginx' && nginxSetup?.site_stage !== 'https' ? $t('tls.notConfigured') : tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.certificate')">
<el-tag :type="tlsStatus.certificate_valid ? 'success' : 'warning'">{{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.issuer')">{{ tlsStatus.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.expires')">{{ new Date(tlsStatus.not_after).toLocaleString() }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : tlsStatus.mode === 'nginx' ? $t('tls.externalRenewal') : '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.mode === 'nginx' ? $t('tls.externalPorts') : `${tlsStatus.http_address || ':80'} / ${tlsStatus.https_address || ':443'}` }}</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('tls.setupHint') }}</p>
<el-button :loading="tlsLoading" @click="loadTLS">{{ $t('tls.refresh') }}</el-button>
<el-button v-if="tlsStatus?.enabled || (tlsStatus?.certificate_valid && nginxSetup?.site_stage === 'https')" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
<el-button type="primary" :loading="nginxActivating" :disabled="!nginxSetup?.root || !nginxSetup?.nginx_installed || !nginxSetup?.certbot_installed || !nginxSetup?.webroot_ready || nginxSetup?.running || tlsStatus?.mode !== 'nginx' || tlsStatus?.restart_required || !tlsStatus?.configured_domain" @click="activateNginx">{{ $t('tls.activateSite') }}</el-button>
<p class="tls-hint">{{ $t('tls.activateHint') }}</p>
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxActivateLog }}</pre>
<el-button :loading="nginxLoading" :disabled="!tlsStatus?.configured_domain" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
<template v-if="nginxPreview">
<p class="tls-hint">{{ $t('tls.previewOnly') }}</p>
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
</template>
</el-card>
</div>
</template>
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api, { auth } from '../../../api'
const { t } = useI18n()
const tlsStatus = ref(null)
const tlsLoading = ref(false)
const tlsSaving = ref(false)
const tlsDomain = ref('')
const tlsMode = ref('autocert')
const tlsSavingMode = ref(false)
const nginxLoading = ref(false)
const nginxPreview = ref(null)
const nginxSetup = ref(null)
const nginxSetupLoading = ref(false)
const nginxInstalling = ref(false)
const nginxInstallLog = ref('')
const nginxLogView = ref(null)
const nginxActivating = ref(false)
const nginxActivateLog = ref('')
const nginxActivateLogView = ref(null)
async function activateNginx() {
nginxActivating.value = true
nginxActivateLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-activate', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.siteReady'))
} catch (e) {
nginxActivateLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.siteFailed'))
} finally {
nginxActivating.value = false
await Promise.all([loadNginxSetup(), loadTLS()])
}
}
async function loadNginxSetup() {
nginxSetupLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-setup')
nginxSetup.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxSetupLoading.value = false }
}
async function initializeNginx() {
nginxInstalling.value = true
nginxInstallLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-setup', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.setupComplete'))
} catch (e) {
nginxInstallLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.setupFailed'))
} finally {
nginxInstalling.value = false
await loadNginxSetup()
}
}
async function loadTLS() {
tlsLoading.value = true
try {
const { data } = await api.get('/system/tls')
tlsStatus.value = data
tlsDomain.value = data.configured_domain || data.domain || ''
tlsMode.value = data.configured_mode || data.mode || 'autocert'
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { tlsLoading.value = false }
}
async function saveTLSMode() {
tlsSavingMode.value = true
try {
const { data } = await api.put('/system/tls/mode', { mode: tlsMode.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSavingMode.value = false }
}
async function loadNginxPreview() {
nginxLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-preview')
nginxPreview.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxLoading.value = false }
}
async function saveTLSDomain() {
tlsSaving.value = true
try {
const { data } = await api.put('/system/tls/domain', { domain: tlsDomain.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSaving.value = false }
}
onMounted(() => { if (!auth.mustChange) { loadTLS(); loadNginxSetup() } })
</script>
<style scoped>
.settings-column { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
</style>
@@ -0,0 +1,161 @@
<template>
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('identity.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
<div class="identity-guide">
<b>{{ $t('identity.guideTitle') }}</b>
<p>{{ $t('identity.guideApp') }}</p>
<p>{{ $t('identity.guidePublish') }}</p>
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
</div>
<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-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
<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.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
</div>
<el-form-item :label="$t('identity.tenant')">
<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>
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
<span v-if="tenantLoading" class="tenant-progress" role="status" aria-live="polite"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
</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-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
</el-form>
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('identity.addresses') }}</b></template>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
</el-descriptions>
<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>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import api, { auth } from '../../../api'
const { t } = useI18n()
const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '')
const identityLoading = ref(false)
const identitySaving = ref(false)
const identityGatewayLoading = ref(false)
const tenantLoading = ref(false)
const tenantName = ref('')
const tenantError = ref('')
const tenantChecked = 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
let identityCheckTimer
let tenantRequest = 0
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ })
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
if (appId === oldId && secret === oldSecret) return
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
tenantChecked.value = false
tenantError.value = ''
clearTimeout(identityCheckTimer)
if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) {
identityForm.value.tenant_key = ''
tenantName.value = ''
}
if (identitySecure && appId && (secret || (appId === identityStatus.value?.app_id && identityStatus.value?.app_secret_configured))) {
identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700)
}
})
async function fetchIdentityTenant(silent = false) {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
const request = ++tenantRequest
clearInterval(tenantTimer)
tenantError.value = ''
tenantLoading.value = true
tenantSeconds.value = 0
const started = Date.now()
tenantTimer = setInterval(() => { tenantSeconds.value = Math.floor((Date.now() - started) / 1000) }, 1000)
try {
const appId = identityForm.value.app_id
const secret = identityForm.value.app_secret
const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: secret })
if (request === tenantRequest && identityForm.value.app_id === appId && identityForm.value.app_secret === secret) {
identityForm.value.tenant_key = data.tenant_key
tenantName.value = data.name || ''
tenantChecked.value = true
if (!silent) ElMessage.success(t('identity.tenantFetched'))
}
} catch (e) { if (request === tenantRequest) tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
finally { if (request === tenantRequest) { clearInterval(tenantTimer); tenantLoading.value = false } }
}
async function loadIdentity() {
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
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 = ''
tenantError.value = ''
tenantChecked.value = false
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
finally { identityLoading.value = false }
}
async function saveIdentity() {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
identitySaving.value = true
try {
await api.put('/system/identity', identityForm.value)
await loadIdentity()
ElMessage.success(t('identity.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.saveFailed')) }
finally { identitySaving.value = false }
}
async function syncIdentityGateway() {
identityGatewayLoading.value = true
try {
await api.post('/system/identity/gateway')
await loadIdentity()
ElMessage.success(t('identity.gatewaySynced'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
finally { identityGatewayLoading.value = false }
}
onMounted(() => { if (!auth.mustChange) loadIdentity() })
</script>
<style scoped>
.settings-column { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.identity-guide { padding: 16px 18px; margin-bottom: 16px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; }
.identity-guide b { display: block; margin-bottom: 6px; color: #244368; }
.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
.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; }
.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; }
.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; }
</style>
@@ -0,0 +1,337 @@
<template>
<div>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
</el-descriptions>
<h4>{{ $t('tunnels.runningParams') }}</h4>
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
<h4>{{ $t('tunnels.clientConfig') }}</h4>
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
@click="router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
<!-- 流量策略 -->
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
<el-form label-width="90px" size="small" v-loading="policyLoading">
<el-form-item :label="$t('tunnels.quotaGB')">
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.rateLimitMbps')">
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.exceedAction')">
<el-select v-model="policy.action_on_exceed" style="width:100%">
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
<el-option value="block" :label="$t('tunnels.actionBlock')" />
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
</el-select>
</el-form-item>
<el-form-item :label="$t('tunnels.resetCycle')">
<el-select v-model="policy.reset_cycle" style="width:100%">
<el-option value="none" :label="$t('tunnels.resetNone')" />
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>
<!-- WireGuard Peer 管理 -->
<template v-if="detail.tunnel.protocol === 'wireguard'">
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
</div>
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="name" :label="$t('common.name')" width="100" />
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="150">
<template #default="{ row }">
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 流量趋势图 -->
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
</template>
</el-drawer>
<!-- WireGuard 二维码 -->
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
<div style="text-align:center">
<canvas ref="qrCanvas"></canvas>
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
</div>
</el-dialog>
<!-- 添加 Peer -->
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
<el-form label-width="110px">
<el-form-item :label="$t('tunnels.peerId')">
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
</el-form-item>
<el-form-item :label="$t('common.name')">
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
</el-form-item>
<el-form-item :label="$t('tunnels.clientAddress')" required>
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
</el-form-item>
<el-form-item label="AllowedIPs">
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
</el-form-item>
<el-form-item :label="$t('tunnels.externalPublicKey')">
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
:placeholder="$t('tunnels.externalKeyHint')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { nextTick, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import api from '../../../api'
import { fmtBytes } from '../../../utils/format'
import { protocolDisplay } from '../../../protocols'
import TrafficChart from '../../../components/TrafficChart.vue'
const { t } = useI18n()
const router = useRouter()
const emit = defineEmits(['updated'])
const detailVisible = ref(false)
const detail = ref(null)
const qrVisible = ref(false)
// 流量策略
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
const policyLoading = ref(false)
const policySaving = ref(false)
const qrCanvas = ref(null)
const wgPeers = ref([])
const runtimePeers = ref([])
const peersLoading = ref(false)
const peerDialog = ref(false)
const peerSaving = ref(false)
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
function fmtTime(time) { return time ? new Date(time).toLocaleString() : '-' }
function stateText(row) {
return { running: t('tunnels.stateRunning'), stopped: t('tunnels.stateStopped'), error: t('tunnels.stateError'), undeployed: t('tunnels.stateUndeployed') }[stateOf(row)] || stateOf(row)
}
function stateOf(row) { return row.runtime?.state || row.state }
function authMethod(row) {
let config = {}
try { config = typeof row.config === 'string' ? JSON.parse(row.config) : (row.config || {}) } catch { /* old malformed config */ }
if (row.protocol === 'wireguard') return !config.auth_mode || config.auth_mode === 'password' ? t('tunnels.wgAuthPassword') : t('tunnels.authUnknown')
if (row.protocol === 'openvpn') {
const mode = config.auth_mode || 'password'
const key = `protocols.openvpn.fields.auth_mode.options.${mode}`
return ['certificate', 'password', 'certificate+password', 'oidc'].includes(mode) ? t(key) : t('tunnels.authUnknown')
}
return t('tunnels.authUnknown')
}
function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
}
function peerOnline(row) {
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
return !!rt?.connected
}
// ---- 流量策略 ----
async function loadPolicy(tunnelId) {
policyLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
policy.value = {
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
rate_limit_mbps: data.rate_limit_mbps || 0,
action_on_exceed: data.action_on_exceed || 'block',
reset_cycle: data.reset_cycle || 'none',
used_bytes: data.used_bytes || 0,
}
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
finally { policyLoading.value = false }
}
async function savePolicy(tunnelId) {
policySaving.value = true
try {
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
await api.put(`/tunnels/${tunnelId}/policy`, {
quota_bytes: quotaBytes,
rate_limit_mbps: policy.value.rate_limit_mbps,
action_on_exceed: policy.value.action_on_exceed,
reset_cycle: policy.value.reset_cycle,
})
ElMessage.success(t('tunnels.policySaved'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { policySaving.value = false }
}
async function showDetail(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
}
loadPolicy(row.id)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
}
}
async function loadPeers(tunnelId) {
peersLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
wgPeers.value = data.peers || []
runtimePeers.value = data.runtime_peers || []
} catch {
wgPeers.value = []
} finally {
peersLoading.value = false
}
}
function openAddPeer() {
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
peerDialog.value = true
}
async function addPeer() {
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
peerSaving.value = true
try {
const body = { ...peerForm.value }
if (!body.id) delete body.id
if (!body.public_key) delete body.public_key
if (!body.allowed_ips) delete body.allowed_ips
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
ElMessage.success(t('tunnels.peerAdded'))
peerDialog.value = false
await loadPeers(detail.value.tunnel.id)
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
detail.value = data
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
} finally {
peerSaving.value = false
}
}
async function removePeer(row) {
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
try {
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
await loadPeers(detail.value.tunnel.id)
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
function downloadPeer(row) {
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
}).then(async r => {
if (!r.ok) {
const d = await r.json()
throw new Error(d.error)
}
return r.blob()
}).then(b => {
const a = document.createElement('a')
a.href = URL.createObjectURL(b)
a.download = `wireguard-${row.id}.conf`
a.click()
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function qrPeer(row) {
try {
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
})
if (!res.ok) {
const d = await res.json()
throw new Error(d.error)
}
const conf = await res.text()
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.message || t('tunnels.qrFailed'))
}
}
function download(row) {
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
.then(b => {
const a = document.createElement('a')
const url = URL.createObjectURL(b)
a.href = url
const ext = row.protocol === 'openvpn' ? 'ovpn' : 'conf'
a.download = `${row.protocol}-${row.name}.${ext}`
a.click()
URL.revokeObjectURL(url)
})
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function showQr(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
}
}
defineExpose({ open: showDetail, showQr })
</script>
<style scoped>
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
</style>
@@ -0,0 +1,84 @@
<template>
<el-dialog v-model="visible" :title="$t('tunnels.logTitle', { name: tunnel?.name || '' })" width="780px" align-center @close="closeStream">
<div class="log-toolbar">
<el-tag size="small" :type="streaming ? 'success' : 'info'">{{ streaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}</el-tag>
<el-button v-if="streaming" size="small" @click="stopStream">{{ $t('tunnels.pause') }}</el-button>
<el-button v-else size="small" type="primary" @click="resumeStream">{{ $t('tunnels.resume') }}</el-button>
<el-button size="small" @click="content = ''">{{ $t('tunnels.clearScreen') }}</el-button>
</div>
<div ref="logView" class="log-view mono">{{ content || $t('tunnels.waitingLog') }}</div>
</el-dialog>
</template>
<script setup>
import { nextTick, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const visible = ref(false)
const tunnel = ref(null)
const content = ref('')
const streaming = ref(false)
const logView = ref(null)
let controller = null
function open(row) {
tunnel.value = row
content.value = ''
visible.value = true
resumeStream()
}
async function resumeStream() {
if (!tunnel.value) return
stopStream()
const current = new AbortController()
controller = current
streaming.value = true
try {
const resp = await fetch(`/api/tunnels/${tunnel.value.id}/logs`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` },
signal: current.signal
})
if (!resp.ok) return
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) content.value += line.slice(6) + '\n'
}
nextTick(() => { if (logView.value) logView.value.scrollTop = logView.value.scrollHeight })
if (content.value.length > 100000) content.value = content.value.slice(-50000)
}
} catch (e) {
if (e.name !== 'AbortError') content.value += `\n${t('common.loadFailed')}\n`
} finally {
if (controller === current) streaming.value = false
}
}
function stopStream() {
streaming.value = false
controller?.abort()
controller = null
}
function closeStream() {
stopStream()
content.value = ''
}
onUnmounted(stopStream)
defineExpose({ open })
</script>
<style scoped>
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.log-view { background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px; height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
</style>
@@ -0,0 +1,238 @@
<template>
<!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" width="620px" class="mfa-dialog" destroy-on-close>
<template #header>
<div class="mfa-dialog-title">
<div class="mfa-title-icon"><el-icon><Key /></el-icon></div>
<div><div class="mfa-title-text">{{ $t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' }) }}</div><div class="mfa-title-subtitle">{{ tunnelName(otpUser?.tunnel_id) }}</div></div>
</div>
</template>
<div v-if="otpStatus" class="mfa-content">
<div class="mfa-status-strip" :class="{ enabled: otpStatus.mfa_enabled }">
<el-icon class="mfa-status-icon"><CircleCheckFilled v-if="otpStatus.mfa_enabled" /><Key v-else /></el-icon>
<div class="mfa-status-copy"><strong>{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.mfaNotEnabled') }}</strong><span>{{ otpStatus.mfa_enabled ? $t('vpnUsers.mfaEnabledHint') : $t('vpnUsers.mfaDisabledHint') }}</span></div>
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" effect="plain">{{ otpStatus.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}</el-tag>
</div>
<template v-if="!otpStatus.mfa_enabled">
<div v-if="!bindStep" class="mfa-empty-state"><div class="mfa-empty-icon"><el-icon><Key /></el-icon></div><p>{{ $t('vpnUsers.mfaSetupIntro') }}</p><el-button type="primary" size="large" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button></div>
<div v-else class="mfa-enrollment">
<div class="mfa-qr-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.scanHint') }}</div><div class="mfa-qr"><canvas ref="mfaCanvas"></canvas></div><el-collapse v-if="mfaSecret" v-model="manualSecretOpen" class="mfa-manual-secret"><el-collapse-item :title="$t('vpnUsers.manualSetup')" name="secret"><code>{{ mfaSecret }}</code></el-collapse-item></el-collapse></div>
<div class="mfa-verify-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.verifySetup') }}</div><p>{{ $t('vpnUsers.verifySetupHint') }}</p><el-input v-model="mfaCode" inputmode="numeric" maxlength="6" :placeholder="$t('vpnUsers.input6DigitCode')" /><div class="mfa-action-row"><el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button><el-button @click="cancelMFASetup">{{ $t('common.cancel') }}</el-button></div></div>
</div>
</template>
<template v-else>
<div class="mfa-metrics"><div class="mfa-metric"><span>{{ $t('vpnUsers.staticPin') }}</span><strong>{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lockStatus') }}</span><strong :class="{ danger: otpStatus.mfa_locked }">{{ otpStatus.mfa_locked ? $t('vpnUsers.locked') : $t('common.no') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lastOtpSuccess') }}</span><strong>{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}</strong></div></div>
<div class="mfa-actions"><el-button @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button><el-button @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button><el-button v-if="otpStatus.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button><el-button @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button><el-button type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button></div>
<el-collapse class="mfa-details"><el-collapse-item :title="$t('vpnUsers.securityDetails')" name="details"><div class="mfa-detail-line"><span>{{ $t('vpnUsers.failCount') }}</span><b>{{ otpStatus.mfa_fail_count || 0 }}</b></div><div class="mfa-detail-line"><span>{{ $t('vpnUsers.compatible') }}</span><b>{{ otpStatus.compatible }}</b></div></el-collapse-item></el-collapse>
</template>
</div>
</el-dialog>
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
:title="$t('vpnUsers.pinHint')" />
<el-input v-model="pinValue" type="password" show-password :placeholder="$t('vpnUsers.pinPlaceholder')" />
<template #footer>
<el-button @click="pinVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="savePin">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { nextTick, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api from '../../../api'
const props = defineProps({ tunnels: { type: Array, required: true } })
const emit = defineEmits(['updated'])
const { t } = useI18n()
const otpVisible = ref(false)
const otpUser = ref(null)
const otpStatus = ref(null)
const bindStep = ref(false)
const mfaCode = ref('')
const mfaSecret = ref('')
const mfaCanvas = ref(null)
const mfaSaving = ref(false)
const manualSecretOpen = ref([])
const pinVisible = ref(false)
const pinValue = ref('')
const tunnelName = id => props.tunnels.find(tunnel => tunnel.id === id)?.name || `#${id}`
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
async function open(row) {
otpUser.value = row
bindStep.value = false
manualSecretOpen.value = []
otpVisible.value = true
await refreshOTPStatus()
}
async function refreshOTPStatus() {
if (!otpUser.value) return
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa`)
otpStatus.value = data
} catch (e) {
otpStatus.value = {
mfa_enabled: otpUser.value.mfa_enabled,
mfa_has_pin: otpUser.value.mfa_has_pin,
mfa_fail_count: otpUser.value.mfa_fail_count || 0,
mfa_locked: isLocked(otpUser.value),
mfa_locked_until: otpUser.value.mfa_locked_until,
mfa_last_used_at: otpUser.value.mfa_last_used_at,
compatible: t('vpnUsers.totpCompatible')
}
}
}
async function setupMFA() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
mfaCode.value = ''
mfaSecret.value = data.secret || ''
manualSecretOpen.value = []
bindStep.value = true
await nextTick()
if (mfaCanvas.value && data.otpauth_url) {
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
}
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.mfaSetupFailed'))
}
}
function cancelMFASetup() {
bindStep.value = false
mfaCode.value = ''
mfaSecret.value = ''
manualSecretOpen.value = []
}
async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
mfaSaving.value = true
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
bindStep.value = false
const codes = (data.backup_codes || []).join(' ')
ElMessageBox.alert(
t('vpnUsers.mfaEnabledMsg', { codes }),
t('vpnUsers.bindSuccess'),
{ type: 'success' }
)
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.verifyFailed'))
} finally {
mfaSaving.value = false
}
}
async function disableMFA() {
await ElMessageBox.confirm(t('vpnUsers.disableMfaConfirm', { username: otpUser.value.username }), t('common.confirm'), { type: 'warning' })
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
ElMessage.success(t('vpnUsers.mfaClosed'))
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
async function regenBackup() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
ElMessageBox.alert(t('vpnUsers.newBackupCodes', { codes: (data.backup_codes || []).join(' ') }), t('vpnUsers.backupCodesTitle'), { type: 'success' })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.generateFailed'))
}
}
function showPinDialog() {
pinValue.value = ''
pinVisible.value = true
}
async function savePin() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
pinVisible.value = false
ElMessage.success(pinValue.value ? t('vpnUsers.pinSetSuccess') : t('vpnUsers.pinCleared'))
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
}
}
async function unlockMFA() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
ElMessage.success(t('vpnUsers.unlocked'))
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.unlockFailed'))
}
}
async function exportOtpSecrets() {
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
ElMessageBox.alert(
t('vpnUsers.otpSecretsMsg', { line: data.line, format: data.format }),
t('vpnUsers.exportKeysTitle'),
{ type: 'warning' }
)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.exportFailed'))
}
}
defineExpose({ open })
</script>
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }
.mfa-title-text { font-size: 16px; font-weight: 650; color: #1f2937; }
.mfa-title-subtitle { margin-top: 2px; color: #6b7280; font-size: 12px; }
.mfa-content { display: grid; gap: 16px; }
.mfa-status-strip { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d1d5db; background: #f9fafb; }
.mfa-status-strip.enabled { border-color: #a7f3d0; background: #ecfdf5; }
.mfa-status-icon { color: #64748b; font-size: 22px; }
.mfa-status-strip.enabled .mfa-status-icon { color: #16a34a; }
.mfa-status-copy { display: grid; gap: 2px; flex: 1; }
.mfa-status-copy strong { color: #1f2937; font-size: 14px; }
.mfa-status-copy span { color: #6b7280; font-size: 12px; }
.mfa-empty-state { display: grid; justify-items: center; gap: 12px; padding: 26px 16px 20px; text-align: center; }
.mfa-empty-icon { width: 48px; height: 48px; border-radius: 50%; font-size: 22px; }
.mfa-empty-state p { margin: 0; color: #4b5563; font-size: 13px; }
.mfa-enrollment { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); border: 1px solid #e5e7eb; }
.mfa-qr-panel, .mfa-verify-panel { padding: 18px; }
.mfa-qr-panel { display: grid; justify-items: center; border-right: 1px solid #e5e7eb; }
.mfa-panel-label { color: #374151; font-weight: 600; font-size: 13px; }
.mfa-qr { margin: 14px 0 8px; padding: 10px; background: #fff; border: 1px solid #e5e7eb; }
.mfa-manual-secret { width: 100%; }
.mfa-manual-secret code { display: block; overflow-wrap: anywhere; color: #334155; font-size: 12px; }
.mfa-verify-panel { display: grid; align-content: center; gap: 12px; }
.mfa-verify-panel p { margin: 0; color: #6b7280; font-size: 12px; line-height: 1.55; }
.mfa-action-row, .mfa-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mfa-metrics { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #e5e7eb; }
.mfa-metric { display: grid; gap: 5px; padding: 13px; border-right: 1px solid #e5e7eb; }
.mfa-metric:last-child { border-right: 0; }
.mfa-metric span { color: #6b7280; font-size: 12px; }
.mfa-metric strong { color: #1f2937; font-size: 13px; word-break: break-word; }
.mfa-metric .danger { color: #dc2626; }
.mfa-details { margin-top: -4px; }
.mfa-detail-line { display: flex; justify-content: space-between; padding: 4px 0; color: #4b5563; font-size: 13px; }
@media (max-width: 640px) { .mfa-enrollment { grid-template-columns: 1fr; } .mfa-qr-panel { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metrics { grid-template-columns: 1fr; } .mfa-metric { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metric:last-child { border-bottom: 0; } }
</style>