Files
opsx-vpn/frontend/src/views/Settings.vue
T

405 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-row :gutter="16">
<el-col :xs="24" :lg="10">
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
:title="$t('settings.mustChangePassword')"
style="margin-bottom:16px" />
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('settings.changePassword') }}</b></template>
<el-form label-width="90px" @submit.prevent="changePwd">
<el-form-item :label="$t('settings.oldPassword')">
<el-input v-model="pwd.old_password" type="password" show-password autocomplete="current-password" />
</el-form-item>
<el-form-item :label="$t('settings.newPassword')">
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password" :placeholder="$t('settings.min8Chars')" />
</el-form-item>
<el-form-item :label="$t('settings.confirmNewPassword')">
<el-input v-model="pwd.confirm" type="password" show-password autocomplete="new-password" @keyup.enter="changePwd" />
</el-form-item>
<el-button type="primary" style="width:100%" :loading="pwdLoading" @click="changePwd">{{ $t('settings.change') }}</el-button>
</el-form>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('settings.mfaTitle') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('settings.mfaHint')" style="margin-bottom:10px" />
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">{{ $t('settings.setupMfa') }}</el-button>
<el-button v-else type="danger" @click="disableMFA">{{ $t('settings.disableMfa') }}</el-button>
<div v-if="mfa.uri" style="margin-top:12px">
<canvas ref="mfaCanvas" style="display:block;margin:0 auto"></canvas>
<el-input v-model="mfa.code" :placeholder="$t('settings.input6DigitCode')" style="margin-top:8px" maxlength="6" />
<el-button type="success" style="width:100%;margin-top:8px" :loading="mfaLoading" @click="enableMFA">{{ $t('settings.verifyAndEnable') }}</el-button>
</div>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('settings.deployTitle') }}</b></template>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item :label="$t('settings.deliveryForm')">{{ $t('settings.deliveryDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.dataDir')">{{ $t('settings.dataDirDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.listenAddr')">{{ $t('settings.listenAddrDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.keyStorage')">{{ $t('settings.keyStorageDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.systemDeps')">{{ $t('settings.systemDepsDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.prodSuggestion')">{{ $t('settings.prodSuggestionDesc') }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
<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-card shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.commonSetupTitle') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.commonSetupHint')" style="margin-bottom:12px" />
<template v-if="hostReadiness">
<p class="tls-hint">{{ $t('tls.hostRoot') }}:{{ hostReadiness.root ? '✓' : '—' }} · {{ $t('tls.hostRoute') }}:{{ hostReadiness.default_route_ready ? '✓' : '—' }} · {{ $t('tls.hostForward') }}:{{ hostReadiness.ipv4_forwarding ? '✓' : '—' }}</p>
<el-button :loading="hostFixing" :disabled="!hostReadiness.root || hostReadiness.ipv4_forwarding" @click="enableForwarding">{{ $t('tls.enableForwarding') }}</el-button>
</template>
<el-button :loading="hostLoading" @click="loadHostReadiness">{{ $t('tls.refresh') }}</el-button>
</el-card>
<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?.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>
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
<el-empty :description="$t('settings.auditMigrated')">
<el-button type="primary" @click="$router.push('/logs')">{{ $t('router.logs') }}</el-button>
</el-empty>
</el-card>
</el-col>
<!-- MFA 备用码持久化弹窗 -->
<el-dialog v-model="backupVisible" :title="$t('settings.mfaEnabledTitle')" width="480px" align-center
:close-on-click-modal="false" :close-on-press-escape="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px"
:title="$t('settings.backupCodesHint')" />
<div class="backup-codes">
<code v-for="(c, i) in backupCodes" :key="i">{{ c }}</code>
</div>
<template #footer>
<el-button @click="copyBackup">{{ $t('settings.copyAll') }}</el-button>
<el-button type="primary" @click="backupVisible = false">{{ $t('settings.savedConfirm') }}</el-button>
</template>
</el-dialog>
</el-row>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api, { auth } from '../api'
const { t } = useI18n()
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
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)
async function loadHostReadiness() {
hostLoading.value = true
try {
const { data } = await api.get('/readiness')
hostReadiness.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { hostLoading.value = false }
}
async function enableForwarding() {
hostFixing.value = true
try {
await api.post('/system/fix', { action: 'ip_forward' })
await loadHostReadiness()
ElMessage.success(t('tls.forwardingEnabled'))
} 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'))
pwdLoading.value = true
try {
await api.post('/password', { old_password: pwd.value.old_password, new_password: pwd.value.new_password })
auth.clearMustChange()
ElMessage.success(t('settings.passwordChanged'))
pwd.value = { old_password: '', new_password: '', confirm: '' }
} catch (e) {
ElMessage.error(e.response?.data?.error || t('settings.changeFailed'))
} finally { pwdLoading.value = false }
}
onMounted(async () => {
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness() }
try {
const status = await api.get('/mfa/status')
mfa.value.enabled = status.data.enabled
} catch { /* ignore */ }
})
async function setupMFA() {
try {
const { data } = await api.post('/mfa/setup')
mfa.value.uri = data.otpauth_url
await nextTick()
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 })
} catch (e) { ElMessage.error(e.response?.data?.error || t('settings.setupFailed')) }
}
async function enableMFA() {
mfaLoading.value = true
try {
const { data } = await api.post('/mfa/enable', { code: mfa.value.code })
mfa.value = { enabled: true, uri: '', code: '' }
backupCodes.value = data.backup_codes || []
backupVisible.value = true
} catch (e) {
ElMessage.error(e.response?.data?.error || t('settings.verifyFailed'))
} finally { mfaLoading.value = false }
}
async function disableMFA() {
try {
await api.post('/mfa/disable')
mfa.value = { enabled: false, uri: '', code: '' }
ElMessage.success(t('settings.mfaClosed'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('settings.closeFailed')) }
}
function copyBackup() {
navigator.clipboard.writeText(backupCodes.value.join('\n')).then(() => ElMessage.success(t('common.copiedToClipboard')))
}
</script>
<style scoped>
.backup-codes {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;
background: var(--bg-inset); border-radius: var(--radius-sm); border: 1px solid var(--border-base);
}
.backup-codes code {
font-family: 'JetBrains Mono', Consolas, monospace; font-size: 14px; font-weight: 600;
color: var(--text-primary); text-align: center; padding: 6px;
background: var(--bg-card); border-radius: 4px;
}
.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>