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

496 lines
27 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>
<section class="settings-page">
<div class="settings-intro">
<div><span class="settings-eyebrow">OPSX / SETTINGS</span><h2>{{ $t('router.settings') }}</h2><p>{{ $t('settings.pageDescription') }}</p></div>
</div>
<el-tabs v-model="activeTab" class="settings-tabs" @tab-change="changeTab">
<el-tab-pane :label="$t('settings.securityTab')" name="security">
<div class="settings-column">
<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>
</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" />
<el-form 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>
<el-form-item :label="$t('identity.tenant')"><el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" /></el-form-item>
<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="!identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
</el-card>
</div>
</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>
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.hostTab')" name="host">
<div class="settings-column settings-column-wide">
<el-card class="page-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-card class="page-card" shadow="never">
<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>
</div>
</el-tab-pane>
</el-tabs>
<!-- 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>
</section>
</template>
<script setup>
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 api, { auth } from '../api'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const activeTab = ref('security')
const visibleTab = (name) => auth.isAdmin() && ['identity', 'gateway', 'host'].includes(name) ? name : 'security'
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
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 identityLoading = ref(false)
const identitySaving = ref(false)
const identityGatewayLoading = ref(false)
async function loadIdentity() {
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 || '' }
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
finally { identityLoading.value = false }
}
async function saveIdentity() {
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)
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(); loadIdentity() }
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>
.settings-page { max-width: 1160px; margin: 0 auto; padding-bottom: 32px; }
.settings-intro { padding: 8px 4px 22px; }
.settings-eyebrow { color: #287cc6; font-size: 11px; font-weight: 700; letter-spacing: 2px; }
.settings-intro h2 { margin: 9px 0 6px; color: #1b2b43; font-size: 25px; letter-spacing: -.4px; }
.settings-intro p { margin: 0; color: var(--text-regular); line-height: 1.6; font-size: 13px; }
.settings-tabs { background: white; border: 1px solid var(--border-base); border-radius: 14px; box-shadow: var(--shadow-sm); padding: 0 22px 22px; min-height: 380px; }
.settings-tabs :deep(.el-tabs__header) { margin-bottom: 22px; }
.settings-tabs :deep(.el-tabs__item) { height: 55px; font-weight: 600; }
.settings-column { max-width: 620px; }
.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; }
@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;
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>