496 lines
27 KiB
Vue
496 lines
27 KiB
Vue
<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>
|