独立第三方授权设置
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-02 20:34:05 +08:00
parent 896b335c66
commit eb7273d415
8 changed files with 363 additions and 124 deletions

View File

@ -4,6 +4,8 @@ import {computed, ref, onMounted, onUnmounted} from 'vue'
import SecuritySettings from '@/views/components/SecuritySettings/SecuritySettings.vue'
import ThirdPartyAuthorization from '@/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue'
import ConfigSettings from '@/views/components/ConfigSettings/ConfigSettings.vue'
import lang from '../../../i18n/i18n';
@ -45,6 +47,7 @@ export function usePage() {
onMounted,
onUnmounted,
SecuritySettings,
ThirdPartyAuthorization,
ConfigSettings,
lang,
GroupSettings,
@ -67,6 +70,7 @@ export {
EpMenu,
BrandLogo,
SecuritySettings,
ThirdPartyAuthorization,
ConfigSettings,
GroupSettings,
RuleSettings,

View File

@ -23,6 +23,9 @@
<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>
@ -43,7 +46,18 @@
</template>
<script setup>
import { usePage, EpMenu, BrandLogo, SecuritySettings, ConfigSettings, GroupSettings, RuleSettings, UserManagement, PluginSettings } from './HomeHeader.js'
import {
usePage,
EpMenu,
BrandLogo,
SecuritySettings,
ThirdPartyAuthorization,
ConfigSettings,
GroupSettings,
RuleSettings,
UserManagement,
PluginSettings,
} from './HomeHeader.js'
const {
globalStatus,

View File

@ -24,19 +24,3 @@
.credential-actions { width: 100%; }
.credential-actions .el-button { flex: 1; }
}
.protocol-section > .el-alert { margin-bottom: 18px; }
.protocol-list { display: grid; gap: 12px; }
.protocol-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px; border: 1px solid var(--pm-border-color); border-radius: var(--pm-radius-sm); background: var(--pm-bg-secondary); }
.protocol-info { min-width: 0; }
.protocol-title { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.protocol-title strong { color: var(--pm-text-primary); font-size: 16px; }
.protocol-info p { margin: 8px 0 0; color: var(--pm-text-secondary); font-size: 13px; }
.protocol-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; flex: none; }
.protocol-code-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 16px; padding: 16px; border: 1px solid var(--pm-primary-color); border-radius: var(--pm-radius-sm); background: color-mix(in srgb, var(--pm-primary-color) 7%, var(--pm-bg-primary)); }
.protocol-code-panel .one-time-tip { width: 100%; color: var(--pm-text-secondary); font-size: 13px; }
.protocol-code-panel code { padding: 8px 12px; border-radius: var(--pm-radius-sm); background: var(--pm-bg-secondary); color: var(--pm-text-primary); font-size: 16px; font-weight: 700; letter-spacing: 1px; word-break: break-all; }
@media (max-width: 720px) {
.protocol-item { align-items: stretch; flex-direction: column; }
.protocol-actions { justify-content: flex-start; }
}

View File

@ -22,10 +22,6 @@ export function usePage() {
const generatedCodes = ref([])
const externalEmails = ref([])
const externalLoading = ref(false)
const protocolLoading = ref(false)
const protocolCredentials = ref([])
const generatedProtocolCode = ref('')
const generatedProtocol = ref('')
const bindForm = reactive({ email: '', type: 'backup', challengeId: '', bindingId: 0, code: '' })
const ruleForm = reactive({ current_pwd: '', new_pwd: '', new_pwd2: '' })
@ -57,15 +53,13 @@ export function usePage() {
securityLoading.value = true
try {
const requests = [
http.get('/api/v1/security/credentials/protocols'),
http.get('/api/v1/security/credentials/question'),
http.get('/api/v1/security/external-email/list'),
]
if (!isSuperAdmin.value) {
requests.push(http.get('/api/v1/security/credentials/recovery-codes'))
}
const [protocol, question, external, recovery] = await Promise.all(requests)
if (protocol.errorNo === 0) protocolCredentials.value = Array.isArray(protocol.data) ? protocol.data : []
const [question, external, recovery] = await Promise.all(requests)
if (question.errorNo === 0 && question.data) {
securityQuestion.configured = Boolean(question.data.configured)
securityQuestion.question = question.data.question || ''
@ -80,73 +74,6 @@ export function usePage() {
}
}
const protocolLabel = protocol => ({ imap: 'IMAP', pop3: 'POP3', smtp: 'SMTP' }[protocol] || protocol.toUpperCase())
const setProtocolEnabled = async (item, enabled) => {
const previous = item.enabled
item.enabled = enabled
protocolLoading.value = true
try {
const res = await http.put('/api/v1/security/credentials/protocols/enabled', { protocol: item.protocol, enabled })
if (res.errorNo !== 0) throw new Error(res.errorMsg || '协议设置失败')
notify(`${protocolLabel(item.protocol)} 协议已${enabled ? '开启' : '关闭'}`, 'success')
} catch (error) {
item.enabled = previous
notify(error?.message || '协议设置失败')
} finally {
protocolLoading.value = false
}
}
const generateProtocolCode = async item => {
if (!item.enabled) {
notify(`请先开启 ${protocolLabel(item.protocol)} 协议`)
return
}
protocolLoading.value = true
try {
const res = await http.post('/api/v1/security/credentials/protocols/generate', { protocol: item.protocol })
if (res.errorNo !== 0) throw new Error(res.errorMsg || '生成授权码失败')
generatedProtocol.value = item.protocol
generatedProtocolCode.value = res.data?.code || ''
item.configured = true
notify(`${protocolLabel(item.protocol)} 授权码已生成,请立即保存`, 'success')
} catch (error) {
notify(error?.message || '生成授权码失败')
} finally {
protocolLoading.value = false
}
}
const copyProtocolCode = async () => {
if (!generatedProtocolCode.value) return
try {
await navigator.clipboard.writeText(generatedProtocolCode.value)
notify('授权码已复制', 'success')
} catch {
notify('复制失败,请手动复制授权码')
}
}
const revokeProtocolCode = async item => {
try {
await ElMessageBox.confirm(`撤销后 ${protocolLabel(item.protocol)} 授权码将立即失效,是否继续?`, '撤销协议授权码', { type: 'warning' })
protocolLoading.value = true
const res = await http.post('/api/v1/security/credentials/protocols/revoke', { protocol: item.protocol })
if (res.errorNo !== 0) throw new Error(res.errorMsg || '撤销授权码失败')
item.configured = false
if (generatedProtocol.value === item.protocol) {
generatedProtocol.value = ''
generatedProtocolCode.value = ''
}
notify(`${protocolLabel(item.protocol)} 授权码已撤销`, 'success')
} catch (error) {
if (error !== 'cancel' && error !== 'close') notify(error?.message || '撤销授权码失败')
} finally {
protocolLoading.value = false
}
}
const registerCredential = async () => {
if (!supported || registering.value) return
registering.value = true
@ -308,8 +235,6 @@ export function usePage() {
return {
lang, isSuperAdmin, supported, registering, credentialsLoading, credentials, securityLoading,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
protocolLoading, protocolCredentials, generatedProtocolCode, generatedProtocol, protocolLabel,
setProtocolEnabled, generateProtocolCode, copyProtocolCode, revokeProtocolCode,
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail,
disableExternalEmail, formatTime, logout,

View File

@ -45,35 +45,6 @@
</section>
<el-divider class="settings-divider" />
<section class="security-section protocol-section">
<div class="settings-header">
<h3>第三方邮件客户端登录</h3>
<p class="settings-desc">开启协议并生成授权码后,才能使用邮件客户端登录。登录名必须填写完整邮箱地址,密码必须填写授权码,不能填写网页登录密码。</p>
</div>
<el-alert title="授权码只在生成成功后显示一次,请立即保存;撤销授权码或关闭协议后,客户端会立即无法登录。" type="warning" :closable="false" show-icon />
<div class="protocol-list" v-loading="protocolLoading">
<div v-for="item in protocolCredentials" :key="item.protocol" class="protocol-item">
<div class="protocol-info">
<div class="protocol-title">
<strong>{{ protocolLabel(item.protocol) }}</strong>
<el-tag size="small" :type="item.enabled ? 'success' : 'info'">{{ item.enabled ? '已开启' : '已关闭' }}</el-tag>
<el-tag size="small" :type="item.configured ? 'success' : 'warning'">{{ item.configured ? '已配置授权码' : '未生成授权码' }}</el-tag>
</div>
<p>登录名:完整邮箱地址;密码:本协议授权码。</p>
</div>
<div class="protocol-actions">
<el-switch v-model="item.enabled" :loading="protocolLoading" active-text="开启" inactive-text="关闭" @change="value => setProtocolEnabled(item, value)" />
<el-button type="primary" size="small" :disabled="!item.enabled" @click="generateProtocolCode(item)">{{ item.configured ? '重新生成授权码' : '生成授权码' }}</el-button>
<el-button v-if="item.configured" type="danger" plain size="small" @click="revokeProtocolCode(item)">撤销授权码</el-button>
</div>
</div>
</div>
<div v-if="generatedProtocolCode" class="protocol-code-panel">
<div class="one-time-tip">{{ protocolLabel(generatedProtocol) }} 授权码仅本次显示,请复制保存:</div>
<code>{{ generatedProtocolCode }}</code>
<el-button type="primary" plain size="small" @click="copyProtocolCode">复制授权码</el-button>
</div>
</section>
<section class="security-section">
<div class="settings-header"><h3>外部安全邮箱</h3><p class="settings-desc">用于登录二次验证和密码恢复,只显示脱敏地址。</p></div>
<div class="external-bind-form">
@ -108,8 +79,6 @@ import { usePage } from './SecuritySettings.js'
const {
lang, isSuperAdmin, supported, registering, credentialsLoading, credentials, securityLoading,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
protocolLoading, protocolCredentials, generatedProtocolCode, generatedProtocol, protocolLabel,
setProtocolEnabled, generateProtocolCode, copyProtocolCode, revokeProtocolCode,
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail,
disableExternalEmail, formatTime, logout,

View File

@ -0,0 +1,112 @@
.third-party-authorization {
padding: 0;
}
.authorization-header {
margin-bottom: 24px;
}
.authorization-header h3 {
margin: 0 0 8px;
color: var(--pm-text-primary);
font-size: 18px;
font-weight: 600;
}
.authorization-desc {
margin: 0;
color: var(--pm-text-secondary);
font-size: 14px;
line-height: 1.7;
}
.authorization-section > .el-alert {
margin-bottom: 18px;
}
.protocol-list {
display: grid;
gap: 12px;
}
.protocol-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
}
.protocol-info {
min-width: 0;
}
.protocol-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.protocol-title strong {
color: var(--pm-text-primary);
font-size: 16px;
}
.protocol-info p {
margin: 8px 0 0;
color: var(--pm-text-secondary);
font-size: 13px;
}
.protocol-actions {
display: flex;
flex: none;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.protocol-code-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
padding: 16px;
border: 1px solid var(--pm-primary-color);
border-radius: var(--pm-radius-sm);
background: color-mix(in srgb, var(--pm-primary-color) 7%, var(--pm-bg-primary));
}
.protocol-code-panel .one-time-tip {
width: 100%;
color: var(--pm-text-secondary);
font-size: 13px;
}
.protocol-code-panel code {
padding: 8px 12px;
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
color: var(--pm-text-primary);
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
word-break: break-all;
}
@media (max-width: 720px) {
.protocol-item {
align-items: stretch;
flex-direction: column;
}
.protocol-actions {
justify-content: flex-start;
}
}

View File

@ -0,0 +1,140 @@
import { onMounted, ref } from 'vue'
import { ElMessageBox, ElNotification } from 'element-plus'
import { http } from '@/utils/axios'
export function usePage() {
const protocolLoading = ref(false)
const protocolCredentials = ref([])
const generatedProtocolCode = ref('')
const generatedProtocol = ref('')
const notify = (message, type = 'error') => {
ElNotification({
title: type === 'success' ? '操作成功' : '操作失败',
message,
type,
})
}
const protocolLabel = protocol => ({
imap: 'IMAP',
pop3: 'POP3',
smtp: 'SMTP',
}[protocol] || protocol.toUpperCase())
const loadProtocolCredentials = async () => {
protocolLoading.value = true
try {
const response = await http.get('/api/v1/security/credentials/protocols')
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '读取第三方授权设置失败')
}
protocolCredentials.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
notify(error?.message || '读取第三方授权设置失败')
} finally {
protocolLoading.value = false
}
}
const setProtocolEnabled = async (item, enabled) => {
const previous = item.enabled
item.enabled = enabled
protocolLoading.value = true
try {
const response = await http.put('/api/v1/security/credentials/protocols/enabled', {
protocol: item.protocol,
enabled,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '协议设置失败')
}
notify(`${protocolLabel(item.protocol)} 协议已${enabled ? '开启' : '关闭'}`, 'success')
} catch (error) {
item.enabled = previous
notify(error?.message || '协议设置失败')
} finally {
protocolLoading.value = false
}
}
const generateProtocolCode = async item => {
if (!item.enabled) {
notify(`请先开启 ${protocolLabel(item.protocol)} 协议`)
return
}
protocolLoading.value = true
try {
const response = await http.post('/api/v1/security/credentials/protocols/generate', {
protocol: item.protocol,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '生成授权码失败')
}
generatedProtocol.value = item.protocol
generatedProtocolCode.value = response.data?.code || ''
item.configured = true
notify(`${protocolLabel(item.protocol)} 授权码已生成,请立即保存`, 'success')
} catch (error) {
notify(error?.message || '生成授权码失败')
} finally {
protocolLoading.value = false
}
}
const copyProtocolCode = async () => {
if (!generatedProtocolCode.value) return
try {
await navigator.clipboard.writeText(generatedProtocolCode.value)
notify('授权码已复制', 'success')
} catch {
notify('复制失败,请手动复制授权码')
}
}
const revokeProtocolCode = async item => {
try {
await ElMessageBox.confirm(
`撤销后 ${protocolLabel(item.protocol)} 授权码将立即失效,是否继续?`,
'撤销协议授权码',
{ type: 'warning' },
)
protocolLoading.value = true
const response = await http.post('/api/v1/security/credentials/protocols/revoke', {
protocol: item.protocol,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '撤销授权码失败')
}
item.configured = false
if (generatedProtocol.value === item.protocol) {
generatedProtocol.value = ''
generatedProtocolCode.value = ''
}
notify(`${protocolLabel(item.protocol)} 授权码已撤销`, 'success')
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
notify(error?.message || '撤销授权码失败')
}
} finally {
protocolLoading.value = false
}
}
onMounted(loadProtocolCredentials)
return {
protocolLoading,
protocolCredentials,
generatedProtocolCode,
generatedProtocol,
protocolLabel,
setProtocolEnabled,
generateProtocolCode,
copyProtocolCode,
revokeProtocolCode,
}
}

View File

@ -0,0 +1,91 @@
<template>
<div class="third-party-authorization" v-loading="protocolLoading">
<section class="authorization-section">
<div class="authorization-header">
<h3>第三方邮件客户端登录</h3>
<p class="authorization-desc">
开启协议并生成授权码后,才能使用邮件客户端登录。登录名必须填写完整邮箱地址,密码必须填写授权码,不能填写网页登录密码。
</p>
</div>
<el-alert
title="授权码只在生成成功后显示一次,请立即保存;撤销授权码或关闭协议后,客户端会立即无法登录。"
type="warning"
:closable="false"
show-icon
/>
<div class="protocol-list">
<div v-for="item in protocolCredentials" :key="item.protocol" class="protocol-item">
<div class="protocol-info">
<div class="protocol-title">
<strong>{{ protocolLabel(item.protocol) }}</strong>
<el-tag size="small" :type="item.enabled ? 'success' : 'info'">
{{ item.enabled ? '已开启' : '已关闭' }}
</el-tag>
<el-tag size="small" :type="item.configured ? 'success' : 'warning'">
{{ item.configured ? '已配置授权码' : '未生成授权码' }}
</el-tag>
</div>
<p>登录名:完整邮箱地址;密码:本协议授权码。</p>
</div>
<div class="protocol-actions">
<el-switch
v-model="item.enabled"
:loading="protocolLoading"
active-text="开启"
inactive-text="关闭"
@change="value => setProtocolEnabled(item, value)"
/>
<el-button
type="primary"
size="small"
:disabled="!item.enabled"
@click="generateProtocolCode(item)"
>
{{ item.configured ? '重新生成授权码' : '生成授权码' }}
</el-button>
<el-button
v-if="item.configured"
type="danger"
plain
size="small"
@click="revokeProtocolCode(item)"
>
撤销授权码
</el-button>
</div>
</div>
</div>
<div v-if="generatedProtocolCode" class="protocol-code-panel">
<div class="one-time-tip">
{{ protocolLabel(generatedProtocol) }} 授权码仅本次显示,请复制保存:
</div>
<code>{{ generatedProtocolCode }}</code>
<el-button type="primary" plain size="small" @click="copyProtocolCode">
复制授权码
</el-button>
</div>
</section>
</div>
</template>
<script setup>
import { usePage } from './ThirdPartyAuthorization.js'
const {
protocolLoading,
protocolCredentials,
generatedProtocolCode,
generatedProtocol,
protocolLabel,
setProtocolEnabled,
generateProtocolCode,
copyProtocolCode,
revokeProtocolCode,
} = usePage()
</script>
<style scoped src="./ThirdPartyAuthorization.css"></style>