This commit is contained in:
parent
1b25dff25e
commit
0b572d6c74
@ -41,6 +41,16 @@ export function usePage() {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
const settingsMenu = computed(() => [
|
||||
{ key: 'security', label: lang.security, component: SecuritySettings, capability: 'password.change_own' },
|
||||
{ key: 'third-party', label: '第三方授权', component: ThirdPartyAuthorization, capability: 'plugin.write_own' },
|
||||
{ key: 'config', label: '配置管理', component: ConfigSettings, capability: 'config.read' },
|
||||
{ key: 'group', label: lang.group_settings, component: GroupSettings, capability: 'user.read' },
|
||||
{ key: 'rule', label: lang.rule_setting, component: RuleSettings, capability: 'user.read' },
|
||||
{ key: 'users', label: lang.user_management, component: UserManagement, capability: 'user.manage' },
|
||||
{ key: 'plugins', label: lang.plugin_settings, component: PluginSettings, capability: 'plugin.read' },
|
||||
].filter(item => globalStatus.hasCapability(item.capability)))
|
||||
|
||||
return {
|
||||
EpMenu,
|
||||
ref,
|
||||
@ -60,9 +70,7 @@ export function usePage() {
|
||||
userInfos,
|
||||
isMobile,
|
||||
handleResize,
|
||||
canReadConfig: computed(() => globalStatus.hasCapability("config.read")),
|
||||
canManageUsers: computed(() => globalStatus.hasCapability("user.manage")),
|
||||
canReadPlugins: computed(() => globalStatus.hasCapability("plugin.read"))
|
||||
settingsMenu
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,26 +20,8 @@
|
||||
<!-- Settings Drawer -->
|
||||
<el-drawer v-model="globalStatus.settingsDrawerVisible" :size="isMobile ? '100%' : '50vw'" :title="lang.settings" class="settings-drawer" :with-header="true" direction="rtl">
|
||||
<el-tabs :tab-position="isMobile ? 'top' : 'right'" class="settings-tabs">
|
||||
<el-tab-pane :label="lang.security">
|
||||
<SecuritySettings/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="第三方授权">
|
||||
<ThirdPartyAuthorization />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="canReadConfig" label="配置管理">
|
||||
<ConfigSettings />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="lang.group_settings">
|
||||
<GroupSettings/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="lang.rule_setting">
|
||||
<RuleSettings/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="canManageUsers" :label="lang.user_management">
|
||||
<UserManagement/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="canReadPlugins" :label="lang.plugin_settings">
|
||||
<PluginSettings/>
|
||||
<el-tab-pane v-for="item in settingsMenu" :key="item.key" :label="item.label">
|
||||
<component :is="item.component" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
@ -63,9 +45,7 @@ const {
|
||||
globalStatus,
|
||||
isMobile,
|
||||
lang,
|
||||
canReadConfig,
|
||||
canManageUsers,
|
||||
canReadPlugins
|
||||
settingsMenu
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,4 +1,39 @@
|
||||
.settings-card { padding: 0; }
|
||||
.security-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 176px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 20px;
|
||||
}
|
||||
.security-submenu {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-lg);
|
||||
background: var(--pm-bg-secondary);
|
||||
}
|
||||
.security-submenu-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
color: var(--pm-text-secondary);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
border: 0;
|
||||
border-radius: var(--pm-radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: color .2s ease, background-color .2s ease;
|
||||
}
|
||||
.security-submenu-item:hover,
|
||||
.security-submenu-item.active {
|
||||
color: var(--pm-primary-color);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.security-submenu-item.active { font-weight: 600; }
|
||||
.security-main { min-width: 0; }
|
||||
.settings-header { margin-bottom: 24px; }
|
||||
.settings-header h3 { font-size: 18px; font-weight: 600; color: var(--pm-text-primary); margin: 0 0 8px; }
|
||||
.settings-desc { font-size: 14px; color: var(--pm-text-secondary); margin: 0; }
|
||||
@ -20,6 +55,19 @@
|
||||
.credential-warning { width: 100%; color: #b45309 !important; font-size: 13px; }
|
||||
.credential-actions { display: flex; flex: none; gap: 8px; }
|
||||
@media (max-width: 640px) {
|
||||
.security-workspace { grid-template-columns: 1fr; gap: 16px; }
|
||||
.security-submenu {
|
||||
position: static;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
.security-submenu-item {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.security-key-header, .credential-item { flex-direction: column; align-items: stretch; }
|
||||
.credential-actions { width: 100%; }
|
||||
.credential-actions .el-button { flex: 1; }
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { ElMessageBox, ElNotification } from 'element-plus'
|
||||
|
||||
@ -11,6 +11,33 @@ export function usePage() {
|
||||
const globalStatus = useGlobalStatusStore()
|
||||
const isSuperAdmin = computed(() => globalStatus.role === 'super_admin')
|
||||
const supported = webAuthnSupported()
|
||||
const sectionOptions = computed(() => {
|
||||
const options = []
|
||||
if (!isSuperAdmin.value) {
|
||||
options.push({ value: 'password', label: lang.modify_pwd })
|
||||
}
|
||||
options.push({ value: 'question', label: '密保问题' })
|
||||
if (!isSuperAdmin.value) {
|
||||
options.push({ value: 'recovery', label: '离线恢复码' })
|
||||
}
|
||||
options.push({ value: 'external-email', label: '外部安全邮箱' })
|
||||
options.push({ value: 'webauthn', label: '安全密钥' })
|
||||
options.push({ value: 'logout', label: lang.logout })
|
||||
return options
|
||||
})
|
||||
const selectedSection = ref('question')
|
||||
|
||||
const selectSection = value => {
|
||||
if (sectionOptions.value.some(item => item.value === value)) {
|
||||
selectedSection.value = value
|
||||
}
|
||||
}
|
||||
|
||||
watch(sectionOptions, options => {
|
||||
if (!options.some(item => item.value === selectedSection.value)) {
|
||||
selectedSection.value = options[0]?.value || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const registering = ref(false)
|
||||
const credentialsLoading = ref(false)
|
||||
@ -233,7 +260,8 @@ export function usePage() {
|
||||
})
|
||||
|
||||
return {
|
||||
lang, isSuperAdmin, supported, registering, credentialsLoading, credentials, securityLoading,
|
||||
lang, isSuperAdmin, supported, sectionOptions, selectedSection, selectSection,
|
||||
registering, credentialsLoading, credentials, securityLoading,
|
||||
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
|
||||
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
|
||||
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail,
|
||||
|
||||
@ -1,6 +1,22 @@
|
||||
<template>
|
||||
<div class="settings-card" v-loading="securityLoading">
|
||||
<section v-if="!isSuperAdmin" class="security-section">
|
||||
<div class="settings-card security-settings-card" v-loading="securityLoading">
|
||||
<div class="security-workspace">
|
||||
<nav class="security-submenu" aria-label="安全设置">
|
||||
<button
|
||||
v-for="item in sectionOptions"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="security-submenu-item"
|
||||
:class="{ active: selectedSection === item.value }"
|
||||
:aria-current="selectedSection === item.value ? 'page' : undefined"
|
||||
@click="selectSection(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="security-main">
|
||||
<section v-if="selectedSection === 'password' && !isSuperAdmin" class="security-section">
|
||||
<div class="settings-header">
|
||||
<h3>{{ lang.modify_pwd }}</h3>
|
||||
<p class="settings-desc">{{ lang.modify_pwd_desc }}</p>
|
||||
@ -16,9 +32,9 @@
|
||||
<el-divider class="settings-divider" />
|
||||
</section>
|
||||
|
||||
<el-alert v-else title="超级管理员密码只能通过部署端专用本机通道重置,网页不会提供修改入口。" type="warning" :closable="false" show-icon class="security-alert" />
|
||||
<el-alert v-if="selectedSection === 'password' && isSuperAdmin" title="超级管理员密码只能通过部署端专用本机通道重置,网页不会提供修改入口。" type="warning" :closable="false" show-icon class="security-alert" />
|
||||
|
||||
<section class="security-section">
|
||||
<section v-if="selectedSection === 'question'" class="security-section">
|
||||
<div class="settings-header">
|
||||
<h3>密保问题</h3>
|
||||
<p class="settings-desc">密保问题不能单独重置密码,必须与离线恢复码组合使用。</p>
|
||||
@ -30,8 +46,8 @@
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
<el-divider v-if="!isSuperAdmin" class="settings-divider" />
|
||||
<section v-if="!isSuperAdmin" class="security-section">
|
||||
<el-divider v-if="selectedSection === 'recovery'" class="settings-divider" />
|
||||
<section v-if="selectedSection === 'recovery' && !isSuperAdmin" class="security-section">
|
||||
<div class="settings-header security-key-header">
|
||||
<div><h3>离线恢复码</h3><p class="settings-desc">恢复码只在生成时显示一次,请保存到安全位置。</p></div>
|
||||
<div class="section-actions"><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div>
|
||||
@ -44,8 +60,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-divider class="settings-divider" />
|
||||
<section class="security-section">
|
||||
<el-divider v-if="selectedSection === 'external-email'" class="settings-divider" />
|
||||
<section v-if="selectedSection === 'external-email'" class="security-section">
|
||||
<div class="settings-header"><h3>外部安全邮箱</h3><p class="settings-desc">用于登录二次验证和密码恢复,只显示脱敏地址。</p></div>
|
||||
<div class="external-bind-form">
|
||||
<el-input v-model="bindForm.email" placeholder="请输入完整邮箱地址" clearable />
|
||||
@ -61,15 +77,17 @@
|
||||
<el-empty v-else description="暂无外部安全邮箱" />
|
||||
</section>
|
||||
|
||||
<el-divider class="settings-divider" />
|
||||
<section v-if="supported" class="security-section">
|
||||
<el-divider v-if="selectedSection === 'webauthn'" class="settings-divider" />
|
||||
<section v-if="selectedSection === 'webauthn' && supported" class="security-section">
|
||||
<div class="settings-header security-key-header"><div><h3>WebAuthn 安全密钥</h3><p class="settings-desc">使用设备或浏览器内置的安全密钥无密码登录。</p></div><el-button type="primary" :loading="registering" @click="registerCredential">注册安全密钥</el-button></div>
|
||||
<div class="credential-list" v-loading="credentialsLoading"><el-empty v-if="!credentialsLoading && credentials.length === 0" description="暂无已注册的安全密钥" /><div v-for="item in credentials" :key="item.id" class="credential-item"><div class="credential-info"><strong>{{ item.display_name }}</strong><span>创建于{{ formatTime(item.created_at) }}</span><span v-if="item.last_used_at">最后使用于{{ formatTime(item.last_used_at) }}</span><el-tag v-if="item.revoked_at" type="info" size="small">已撤销</el-tag><el-tag v-else-if="item.requires_re_registration" type="warning" size="small">需要重新注册</el-tag><el-tag v-else type="success" size="small">有效</el-tag></div><div class="credential-actions"><el-button size="small" :disabled="Boolean(item.revoked_at)" @click="renameCredential(item)">重命名</el-button><el-button size="small" type="danger" plain :disabled="Boolean(item.revoked_at)" @click="revokeCredential(item)">撤销</el-button></div></div></div>
|
||||
</section>
|
||||
<el-alert v-else title="当前浏览器不支持 WebAuthn,请使用 HTTPS 和受支持的浏览器。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="selectedSection === 'webauthn' && !supported" title="当前浏览器不支持 WebAuthn,请使用 HTTPS 和受支持的浏览器。" type="warning" :closable="false" show-icon />
|
||||
|
||||
<el-divider class="settings-divider" />
|
||||
<section class="security-section"><div class="settings-header"><h3 class="danger-text">{{ lang.logout }}</h3><p class="settings-desc">{{ lang.logout_desc }}</p></div><el-button type="danger" plain @click="logout" class="logout-btn">{{ lang.logout }}</el-button></section>
|
||||
<el-divider v-if="selectedSection === 'logout'" class="settings-divider" />
|
||||
<section v-if="selectedSection === 'logout'" class="security-section"><div class="settings-header"><h3 class="danger-text">{{ lang.logout }}</h3><p class="settings-desc">{{ lang.logout_desc }}</p></div><el-button type="danger" plain @click="logout" class="logout-btn">{{ lang.logout }}</el-button></section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -77,7 +95,8 @@
|
||||
import { usePage } from './SecuritySettings.js'
|
||||
|
||||
const {
|
||||
lang, isSuperAdmin, supported, registering, credentialsLoading, credentials, securityLoading,
|
||||
lang, isSuperAdmin, supported, sectionOptions, selectedSection, selectSection,
|
||||
registering, credentialsLoading, credentials, securityLoading,
|
||||
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
|
||||
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
|
||||
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"logLevel": "debug",
|
||||
"domain": "test.domain",
|
||||
"domain": "xuans.com",
|
||||
"domains": null,
|
||||
"webDomain": "mail.test.domain",
|
||||
"webDomain": "xuans.com",
|
||||
"dkimPrivateKeyPath": "config/dkim/dkim.priv",
|
||||
"sslType": "1",
|
||||
"SSLPrivateKeyPath": "./config/ssl/private.key",
|
||||
@ -14,4 +14,4 @@
|
||||
"httpPort": 80,
|
||||
"httpsPort": 443,
|
||||
"isInit": true
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user