270 lines
7.7 KiB
JavaScript
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,
|
|
}
|
|
}
|