设置页面增加权限导航
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-02 21:57:15 +08:00
parent 1b25dff25e
commit 0b572d6c74
6 changed files with 128 additions and 45 deletions

View File

@ -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
}
}

View File

@ -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>

View File

@ -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; }

View File

@ -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,

View File

@ -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,

View File

@ -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
}
}