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
@@ -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>
|
||||
Reference in new issue
Block a user