Smail/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js
smail c82be24178
Some checks are pending
Security Checks / security (push) Waiting to run
修复全量测试竞态并更新审查夹具,保存证书覆盖调整前基线
2026-09-20 01:25:34 +08:00

270 lines
7.7 KiB
JavaScript

import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { http } from '@/utils/axios'
import {
isRecentAuthenticationCancelled,
requestWithRecentAuthentication,
} from '@/utils/recentAuthentication'
export const userExternalEmailDialogProps = {
modelValue: {
type: Boolean,
default: false,
},
user: {
type: Object,
default: null,
},
selfManaged: {
type: Boolean,
default: false,
},
}
const verificationCodeLength = 8
function assertSuccess(response, fallback) {
if (!response || response.errorNo !== 0) {
const error = new Error(response?.errorMsg || fallback)
if (typeof response?.data?.code === 'string') {
error.apiErrorCode = response.data.code
}
throw error
}
return response
}
export function externalEmailEndpoints(selfManaged) {
if (selfManaged) {
return {
list: '/api/v1/security/external-email/list',
change: '/api/v1/security/external-email/change',
verify: '/api/v1/security/external-email/change/verify',
}
}
return {
list: '/api/v1/user/security/external-email/list',
change: '/api/v1/user/security/external-email/change',
verify: '/api/v1/user/security/external-email/verify',
}
}
export function useUserExternalEmailDialog(props, emit) {
const form = reactive({ email: '', type: 'primary', code: '' })
const challenge = reactive({ id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
const currentEmail = ref('')
const step = ref('details')
const sending = ref(false)
const verifying = ref(false)
const errorMessage = ref('')
const statusMessage = ref('')
const currentTime = ref(Date.now())
let timer = null
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const busy = computed(() => sending.value || verifying.value)
const endpoints = computed(() => externalEmailEndpoints(props.selfManaged))
const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId))
const remainingSeconds = computed(() => {
if (!challenge.expiresAt) return 0
const expiresAt = new Date(challenge.expiresAt).getTime()
if (!Number.isFinite(expiresAt)) return 0
return Math.max(0, Math.ceil((expiresAt - currentTime.value) / 1000))
})
const expirationText = computed(() => {
if (!hasChallenge.value) return ''
if (remainingSeconds.value <= 0) return '验证码已过期,请重新发送'
const minutes = Math.floor(remainingSeconds.value / 60)
const seconds = remainingSeconds.value % 60
return `${minutes}:${String(seconds).padStart(2, '0')} 后过期`
})
const startTimer = () => {
if (timer) return
currentTime.value = Date.now()
timer = window.setInterval(() => {
currentTime.value = Date.now()
}, 1000)
}
const stopTimer = () => {
if (!timer) return
window.clearInterval(timer)
timer = null
}
const clearMessages = () => {
errorMessage.value = ''
statusMessage.value = ''
}
const reset = () => {
Object.assign(form, { email: '', type: 'primary', code: '' })
Object.assign(challenge, { id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
currentEmail.value = ''
step.value = 'details'
clearMessages()
}
const loadCurrentEmail = async () => {
if (!props.user?.ID) return false
try {
const response = await requestWithRecentAuthentication(async () => {
const config = props.selfManaged ? undefined : { params: { id: props.user.ID } }
const result = await http.get(endpoints.value.list, config)
return assertSuccess(result, '读取外部邮箱失败')
})
const items = props.selfManaged
? (Array.isArray(response.data) ? response.data : [])
: (Array.isArray(response.data?.items) ? response.data.items : [])
const primary = items.find(item => item.type === 'primary') || items[0]
currentEmail.value = primary?.email || ''
if (primary?.type) form.type = primary.type
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '读取外部邮箱失败'
}
return false
}
}
const sendVerificationCode = async () => {
if (busy.value || !props.user?.ID) return false
const email = form.email.trim().toLowerCase()
if (!email) {
errorMessage.value = '请输入外部邮箱地址'
return false
}
sending.value = true
clearMessages()
try {
const response = await requestWithRecentAuthentication(async () => {
const payload = {
email,
type: form.type,
}
if (!props.selfManaged) payload.user_id = props.user.ID
const result = await http.post(endpoints.value.change, payload)
return assertSuccess(result, '验证码发送失败')
})
if (!response.data?.challenge_id || !response.data?.binding_id) {
throw new Error('服务器返回的验证信息不完整,请稍后重试')
}
form.email = email
form.code = ''
Object.assign(challenge, {
id: response.data.challenge_id,
bindingId: response.data.binding_id,
maskedEmail: response.data.email || email,
expiresAt: response.data.expires_at || '',
})
currentTime.value = Date.now()
step.value = 'verification'
statusMessage.value = `验证码已发送到 ${challenge.maskedEmail}`
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '验证码发送失败,请稍后重试'
}
return false
} finally {
sending.value = false
}
}
const verifyAndReplace = async () => {
if (busy.value || !hasChallenge.value || !props.user?.ID) return false
const code = form.code.trim()
if (!code) {
errorMessage.value = '请输入邮箱验证码'
return false
}
if (remainingSeconds.value <= 0) {
errorMessage.value = '验证码已过期,请重新发送'
return false
}
verifying.value = true
clearMessages()
try {
await requestWithRecentAuthentication(async () => {
const payload = {
binding_id: challenge.bindingId,
email: form.email,
code,
}
if (props.selfManaged) {
payload.id = challenge.id
} else {
payload.user_id = props.user.ID
payload.challenge_id = challenge.id
}
const result = await http.post(endpoints.value.verify, payload)
return assertSuccess(result, '外部邮箱验证失败')
})
emit('changed')
visible.value = false
reset()
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '外部邮箱验证失败,请检查验证码后重试'
}
form.code = ''
return false
} finally {
verifying.value = false
}
}
const handleCodeInput = value => {
form.code = String(value || '').replace(/\s+/g, '').slice(0, verificationCodeLength)
errorMessage.value = ''
}
const close = () => {
if (busy.value) return
visible.value = false
reset()
}
watch(
() => props.modelValue,
value => {
if (value) {
reset()
startTimer()
void loadCurrentEmail()
} else {
stopTimer()
}
},
{ immediate: true },
)
onBeforeUnmount(stopTimer)
return {
visible,
form,
step,
currentEmail,
sending,
verifying,
busy,
challenge,
errorMessage,
statusMessage,
remainingSeconds,
expirationText,
verificationCodeLength,
close,
sendVerificationCode,
verifyAndReplace,
handleCodeInput,
}
}