feat: 新增 GRE 隧道协议,管理端口调整为 18080
- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2 - backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动 - backend: OpenVPN 认证辅助与用户口令同步逻辑完善 - frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行) - frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化 - i18n: 中英文文案同步更新 - test: 新增 binmgr/common/gre 单测,openvpn 测试补充 - docs: README 补充 GRE 说明,默认监听端口 8080 → 18080 - chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
This commit is contained in:
1 parent
c7f59f2cbf
commit
ef7eb378d2
28 files changed
+1363
-439
No files matched your search
+150
-84
@@ -1,17 +1,32 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="vpn-header">
|
||||
<div>
|
||||
<b>{{ $t('vpnUsers.title') }}</b>
|
||||
<span style="margin-left:8px;color:#909399;font-size:12px">
|
||||
{{ $t('vpnUsers.mfaHint') }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
|
||||
<div class="vpn-header-actions">
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
|
||||
<el-button @click="openBulkImport">{{ $t('vpnUsers.bulkImport') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="users" size="small">
|
||||
<div class="vpn-toolbar">
|
||||
<el-input v-model="keyword" :placeholder="$t('vpnUsers.searchPlaceholder')" clearable style="width:220px" />
|
||||
<el-select v-model="tunnelFilter" clearable :placeholder="$t('vpnUsers.allTunnels')" style="width:210px">
|
||||
<el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="selected.length" class="vpn-batch-bar">
|
||||
<span>{{ $t('vpnUsers.selectedUsers', { count: selected.length }) }}</span>
|
||||
<el-button type="danger" size="small" @click="batchRemove">{{ $t('vpnUsers.batchDelete') }}</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredUsers" size="small" v-loading="loading" @selection-change="rows => selected = rows">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="username" :label="$t('common.username')" />
|
||||
<el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" />
|
||||
<el-table-column :label="$t('vpnUsers.tunnel')" min-width="160">
|
||||
@@ -64,24 +79,11 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.username')" required>
|
||||
<el-input v-model="form.username">
|
||||
<template #append>
|
||||
<el-button @click="openPlatformPicker">{{ $t('vpnUsers.pickFromUsers') }}</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('vpnUsers.displayNameLabel')"><el-input v-model="form.display_name" /></el-form-item>
|
||||
<el-form-item :label="$t('vpnUsers.authSource')">
|
||||
<el-radio-group v-model="form.source">
|
||||
<el-radio-button value="local">{{ $t('common.local') }}</el-radio-button>
|
||||
<el-radio-button value="ldap">{{ $t('common.ldap') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.source === 'local'" :label="$t('common.password')" required>
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else :label="$t('vpnUsers.ldapDn')" required>
|
||||
<el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" />
|
||||
<el-form-item :label="$t('common.password')" required>
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.email')"><el-input v-model="form.email" /></el-form-item>
|
||||
</el-form>
|
||||
@@ -91,10 +93,14 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从平台用户选择对话框 -->
|
||||
<el-dialog v-model="pickerVisible" :title="$t('vpnUsers.pickUserTitle')" width="560px" align-center>
|
||||
<el-dialog v-model="pickerVisible" :title="$t('vpnUsers.bulkImportTitle')" width="680px" align-center>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px" :title="$t('vpnUsers.bulkImportHint')" />
|
||||
<el-select v-model="form.tunnel_id" style="width:100%;margin-bottom:12px" :placeholder="$t('vpnUsers.selectOpenVPNTunnel')">
|
||||
<el-option v-for="t in tunnels.filter(item => item.protocol === 'openvpn')" :key="t.id" :value="t.id" :label="t.name" />
|
||||
</el-select>
|
||||
<el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading"
|
||||
@row-click="pickPlatformUser">
|
||||
@selection-change="rows => platformSelected = rows">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="username" :label="$t('common.username')" width="120" />
|
||||
<el-table-column prop="email" :label="$t('common.email')" min-width="160" />
|
||||
<el-table-column :label="$t('common.source')" width="80">
|
||||
@@ -104,58 +110,39 @@
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="$t('vpnUsers.noPlatformUsers')" /></template>
|
||||
</el-table>
|
||||
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">{{ $t('vpnUsers.clickRowHint') }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="pickerVisible = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="importPlatformUsers">{{ $t('vpnUsers.importSelected') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- OTP 双因素认证管理 -->
|
||||
<el-dialog v-model="otpVisible" :title="$t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' })" width="520px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
:title="$t('vpnUsers.otpHint')" />
|
||||
|
||||
<el-descriptions :column="1" border size="small" v-if="otpStatus">
|
||||
<el-descriptions-item :label="$t('vpnUsers.bindStatus')">
|
||||
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" size="small">
|
||||
{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.unbound') }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.staticPin')">{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.failCount')">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.lockStatus')">
|
||||
<span v-if="otpStatus.mfa_locked" style="color:#f56c6c">
|
||||
{{ $t('vpnUsers.lockedUntil', { time: otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString() : '' }) }}
|
||||
</span>
|
||||
<span v-else>{{ $t('common.no') }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.lastOtpSuccess')">
|
||||
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.compatible')">{{ otpStatus.compatible }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px">
|
||||
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button>
|
||||
<el-button v-else type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="bindStep" style="margin-top:16px;text-align:center;border-top:1px solid #ebeef5;padding-top:16px">
|
||||
<p style="font-size:13px;color:#606266">{{ $t('vpnUsers.scanHint') }}</p>
|
||||
<canvas ref="mfaCanvas"></canvas>
|
||||
<div v-if="mfaSecret" style="margin-top:8px;font-size:12px;color:#909399;word-break:break-all">
|
||||
{{ $t('vpnUsers.base32Key', { key: mfaSecret }) }}
|
||||
<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>
|
||||
<el-input v-model="mfaCode" :placeholder="$t('vpnUsers.input6DigitCode')" style="margin-top:12px;max-width:280px" maxlength="8" />
|
||||
<div style="margin-top:10px">
|
||||
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button>
|
||||
<el-button @click="bindStep = false">{{ $t('common.cancel') }}</el-button>
|
||||
</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-alert v-if="otpStatus?.mfa_enabled" type="success" :closable="false" style="margin-top:16px"
|
||||
:title="$t('vpnUsers.clientConnectHint')" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
|
||||
@@ -170,8 +157,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { CircleCheckFilled, Key, Refresh } from '@element-plus/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
@@ -181,13 +169,19 @@ import { protocolDisplay } from '../protocols'
|
||||
const { t } = useI18n()
|
||||
const users = ref([])
|
||||
const tunnels = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const tunnelFilter = ref(null)
|
||||
const selected = ref([])
|
||||
const visible = ref(false)
|
||||
const form = ref({ tunnel_id: null, username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true })
|
||||
const form = ref({ tunnel_id: null, username: '', display_name: '', password: '', email: '', enabled: true })
|
||||
|
||||
// 平台用户选择器
|
||||
const pickerVisible = ref(false)
|
||||
const pickerLoading = ref(false)
|
||||
const platformUsers = ref([])
|
||||
const platformSelected = ref([])
|
||||
const importing = ref(false)
|
||||
|
||||
const otpVisible = ref(false)
|
||||
const otpUser = ref(null)
|
||||
@@ -197,6 +191,7 @@ const mfaCode = ref('')
|
||||
const mfaSecret = ref('')
|
||||
const mfaCanvas = ref(null)
|
||||
const mfaSaving = ref(false)
|
||||
const manualSecretOpen = ref([])
|
||||
const pinVisible = ref(false)
|
||||
const pinValue = ref('')
|
||||
|
||||
@@ -207,23 +202,31 @@ const tunnelProto = id => {
|
||||
}
|
||||
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
|
||||
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
|
||||
const filteredUsers = computed(() => users.value.filter(user => {
|
||||
const matchKeyword = !keyword.value || [user.username, user.display_name, user.email].some(v => (v || '').toLowerCase().includes(keyword.value.toLowerCase()))
|
||||
return matchKeyword && (!tunnelFilter.value || user.tunnel_id === tunnelFilter.value)
|
||||
}))
|
||||
|
||||
async function load() {
|
||||
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
|
||||
users.value = u.data || []
|
||||
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
|
||||
loading.value = true
|
||||
try {
|
||||
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
|
||||
users.value = u.data || []
|
||||
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = {
|
||||
tunnel_id: tunnels.value[0]?.id || null,
|
||||
username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true
|
||||
username: '', display_name: '', password: '', email: '', enabled: true
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 从平台用户选择导入
|
||||
async function openPlatformPicker() {
|
||||
async function openBulkImport() {
|
||||
platformSelected.value = []
|
||||
form.value.tunnel_id = tunnels.value.find(tunnel => tunnel.protocol === 'openvpn')?.id || null
|
||||
pickerVisible.value = true
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
@@ -231,14 +234,16 @@ async function openPlatformPicker() {
|
||||
platformUsers.value = data || []
|
||||
} catch { platformUsers.value = [] } finally { pickerLoading.value = false }
|
||||
}
|
||||
function pickPlatformUser(row) {
|
||||
form.value.username = row.username
|
||||
form.value.display_name = row.email ? row.email.split('@')[0] : row.username
|
||||
form.value.email = row.email || ''
|
||||
form.value.source = row.source === 'ldap' ? 'ldap' : 'local'
|
||||
form.value.ldap_dn = row.source === 'ldap' ? `uid=${row.username}` : ''
|
||||
pickerVisible.value = false
|
||||
ElMessage.success(t('vpnUsers.userFilled', { username: row.username }))
|
||||
|
||||
async function importPlatformUsers() {
|
||||
if (!form.value.tunnel_id || !platformSelected.value.length) return ElMessage.warning(t('vpnUsers.selectTunnelAndUsers'))
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await api.post('/vpn-users/import-platform', { tunnel_id: form.value.tunnel_id, platform_user_ids: platformSelected.value.map(row => row.id) })
|
||||
ElMessage.success(t('vpnUsers.importedCount', { count: data.created || 0 }))
|
||||
pickerVisible.value = false
|
||||
await load()
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('vpnUsers.importFailed')) } finally { importing.value = false }
|
||||
}
|
||||
|
||||
async function create() {
|
||||
@@ -271,6 +276,16 @@ async function remove(row) {
|
||||
}
|
||||
}
|
||||
|
||||
async function batchRemove() {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('vpnUsers.batchDeleteConfirm', { count: selected.value.length }), t('common.confirm'), { type: 'warning' })
|
||||
await api.post('/vpn-users/batch-delete', { ids: selected.value.map(row => row.id) })
|
||||
selected.value = []
|
||||
ElMessage.success(t('vpnUsers.batchDeleteDone'))
|
||||
await load()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed')) }
|
||||
}
|
||||
|
||||
async function download(row) {
|
||||
try {
|
||||
const res = await fetch(`/api/vpn-users/${row.id}/config`, {
|
||||
@@ -296,6 +311,7 @@ async function download(row) {
|
||||
async function openOTPManager(row) {
|
||||
otpUser.value = row
|
||||
bindStep.value = false
|
||||
manualSecretOpen.value = []
|
||||
otpVisible.value = true
|
||||
await refreshOTPStatus()
|
||||
}
|
||||
@@ -323,6 +339,7 @@ async function setupMFA() {
|
||||
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) {
|
||||
@@ -333,6 +350,13 @@ async function setupMFA() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -418,3 +442,45 @@ async function exportOtpSecrets() {
|
||||
|
||||
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; }
|
||||
.vpn-toolbar { padding: 0 0 12px; }
|
||||
.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>
|
||||
Reference in new issue
Block a user