295 lines
14 KiB
Vue
295 lines
14 KiB
Vue
<template>
|
|
<el-card class="page-card" shadow="never">
|
|
<template #header>
|
|
<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>
|
|
<div class="vpn-header-actions">
|
|
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @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>
|
|
<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-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="vpn-load-error"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert>
|
|
<el-table :data="filteredUsers" size="default" 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">
|
|
<template #default="{ row }">
|
|
{{ tunnelName(row.tunnel_id) }}
|
|
<el-tag size="small" style="margin-left:6px" type="info">{{ tunnelProto(row.tunnel_id) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="source" :label="$t('common.source')" width="90" />
|
|
<el-table-column :label="$t('vpnUsers.otpMfa')" width="130">
|
|
<template #default="{ row }">
|
|
<template v-if="supportsMFA(row.tunnel_id)">
|
|
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
|
|
{{ row.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}
|
|
</el-tag>
|
|
<el-tag v-if="row.mfa_has_pin" size="small" type="warning" style="margin-left:4px">PIN</el-tag>
|
|
<el-tag v-if="isLocked(row)" size="small" type="danger" style="margin-left:4px">{{ $t('vpnUsers.locked') }}</el-tag>
|
|
</template>
|
|
<span v-else style="color:#c0c4cc">—</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="$t('common.traffic')" width="170" align="right" header-align="right">
|
|
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="$t('vpnUsers.lastOnline')" width="180">
|
|
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="$t('common.status')" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? $t('common.enabled') : $t('common.disabled') }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="$t('common.operation')" width="175" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button v-if="tunnels.find(x => x.id === row.tunnel_id)?.protocol !== 'wireguard'" link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
|
|
<el-dropdown trigger="click" @command="command => userCommand(command, row)">
|
|
<el-button link size="small">{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
|
<template #dropdown><el-dropdown-menu>
|
|
<el-dropdown-item v-if="supportsMFA(row.tunnel_id)" command="otp">{{ $t('vpnUsers.otpManagement') }}</el-dropdown-item>
|
|
<el-dropdown-item command="toggle">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-dropdown-item>
|
|
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
|
</el-dropdown-menu></template>
|
|
</el-dropdown>
|
|
</template>
|
|
</el-table-column>
|
|
<template #empty><el-empty :description="$t('common.noData')" /></template>
|
|
</el-table>
|
|
</el-card>
|
|
|
|
<el-dialog v-model="visible" :title="$t('vpnUsers.addVpnUserTitle')" width="480px">
|
|
<el-form label-width="100px">
|
|
<el-form-item :label="$t('vpnUsers.dialTunnel')" required>
|
|
<el-select v-model="form.tunnel_id" style="width:100%" filterable>
|
|
<el-option v-for="t in tunnels" :key="t.id" :value="t.id"
|
|
:label="`${t.name} (${$t(protocolDisplay(t.protocol))})`" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item :label="$t('common.username')" required>
|
|
<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('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>
|
|
<template #footer>
|
|
<el-button @click="visible = false">{{ $t('common.cancel') }}</el-button>
|
|
<el-button type="primary" :loading="creating" @click="create">{{ $t('vpnUsers.create') }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<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"
|
|
@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">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.source === 'ldap' ? 'primary' : 'info'" size="small">{{ row.source === 'ldap' ? $t('common.ldap') : $t('common.local') }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<template #empty><el-empty :description="$t('vpnUsers.noPlatformUsers')" /></template>
|
|
</el-table>
|
|
<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>
|
|
|
|
<VpnUserMfaDialog ref="mfaDialog" :tunnels="tunnels" @updated="load" />
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { Refresh, ArrowDown } from '@element-plus/icons-vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
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)
|
|
const loadError = ref('')
|
|
const creating = 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: '', password: '', email: '', enabled: true })
|
|
|
|
// 平台用户选择器
|
|
const pickerVisible = ref(false)
|
|
const pickerLoading = ref(false)
|
|
const platformUsers = ref([])
|
|
const platformSelected = ref([])
|
|
const importing = ref(false)
|
|
|
|
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 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 => {
|
|
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() {
|
|
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', 'wireguard'].includes(tun.protocol))
|
|
loadError.value = ''
|
|
} catch (e) {
|
|
loadError.value = e.response?.data?.error || t('common.loadFailed')
|
|
} finally { loading.value = false }
|
|
}
|
|
|
|
function userCommand(command, row) {
|
|
if (command === 'otp') mfaDialog.value?.open(row)
|
|
else if (command === 'toggle') toggle(row)
|
|
else if (command === 'delete') remove(row)
|
|
}
|
|
|
|
function openCreate() {
|
|
form.value = {
|
|
tunnel_id: tunnels.value[0]?.id || null,
|
|
username: '', display_name: '', password: '', email: '', enabled: true
|
|
}
|
|
visible.value = true
|
|
}
|
|
|
|
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 {
|
|
const { data } = await api.get('/users')
|
|
platformUsers.value = data || []
|
|
} catch { platformUsers.value = [] } finally { pickerLoading.value = false }
|
|
}
|
|
|
|
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() {
|
|
if (creating.value) return
|
|
creating.value = true
|
|
try {
|
|
await api.post('/vpn-users', form.value)
|
|
visible.value = false
|
|
ElMessage.success(t('vpnUsers.vpnUserCreated'))
|
|
load()
|
|
} catch (e) {
|
|
ElMessage.error(e.response?.data?.error || t('vpnUsers.createFailed'))
|
|
} finally { creating.value = false }
|
|
}
|
|
|
|
async function toggle(row) {
|
|
try {
|
|
await api.put(`/vpn-users/${row.id}`, { enabled: !row.enabled, display_name: row.display_name, email: row.email })
|
|
load()
|
|
} catch (e) {
|
|
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
|
|
}
|
|
}
|
|
|
|
async function remove(row) {
|
|
try {
|
|
await ElMessageBox.confirm(t('vpnUsers.deleteVpnUserConfirm', { username: row.username }), t('common.confirm'))
|
|
await api.delete(`/vpn-users/${row.id}`)
|
|
load()
|
|
} catch (e) {
|
|
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
|
|
}
|
|
}
|
|
|
|
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`, {
|
|
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json()
|
|
throw new Error(data.error)
|
|
}
|
|
const tun = tunnels.value.find(x => x.id === row.tunnel_id)
|
|
const ext = tun?.protocol === 'openvpn' ? 'ovpn' : 'txt'
|
|
const url = URL.createObjectURL(await res.blob())
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${tun?.protocol || 'vpn'}-${row.username}.${ext}`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch (e) {
|
|
ElMessage.error(e.message || t('vpnUsers.downloadFailed'))
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.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; }
|
|
.vpn-toolbar { padding: 0 0 12px; }
|
|
.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; }
|
|
</style>
|