Bind Feishu identity to OpenVPN sessions and refine management UI
This commit is contained in:
1 parent
347799cb1e
commit
8fd8c0cd73
27 files changed
+1556
-1011
No files matched your search
@@ -531,6 +531,7 @@
|
||||
"connectedDuration": "Duration",
|
||||
"kickOffline": "Kick",
|
||||
"noOnlineUsers": "No online users",
|
||||
"identityUnknown": "Identity unknown",
|
||||
"trafficAlert": "⚠ Abnormal Traffic Alert",
|
||||
"userTunnel": "User/Tunnel",
|
||||
"alertType": "Type",
|
||||
|
||||
@@ -531,6 +531,7 @@
|
||||
"connectedDuration": "已连接时长",
|
||||
"kickOffline": "踢下线",
|
||||
"noOnlineUsers": "当前无在线用户",
|
||||
"identityUnknown": "身份未识别",
|
||||
"trafficAlert": "⚠ 异常流量告警",
|
||||
"userTunnel": "用户/隧道",
|
||||
"alertType": "类型",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user