Files
sub2api-overdraft/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue
T

350 lines
9.4 KiB
Vue

<template>
<form class="space-y-3" @submit.prevent="handleSubmit">
<input
v-model="email"
:data-testid="`${testIdPrefix}-create-account-email`"
type="email"
class="input w-full"
:placeholder="t('auth.emailPlaceholder')"
:disabled="isSubmitting || isSendingCode"
/>
<input
v-model="password"
:data-testid="`${testIdPrefix}-create-account-password`"
type="password"
class="input w-full"
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
/>
<div v-if="captchaEnabled" class="space-y-2">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
</div>
<div v-if="emailVerifyEnabled" class="flex gap-3">
<input
v-model="verifyCode"
:data-testid="`${testIdPrefix}-create-account-verify-code`"
type="text"
inputmode="numeric"
maxlength="6"
class="input min-w-0 flex-1"
placeholder="123456"
:disabled="isSubmitting"
/>
<button
:data-testid="`${testIdPrefix}-create-account-send-code`"
type="button"
class="btn btn-secondary shrink-0"
:disabled="isSubmitting || isSendingCode || countdown > 0 || !email.trim() || (turnstileEnabled && !turnstileToken)"
@click="handleSendCode"
>
{{
isSendingCode
? t('auth.sendingCode')
: countdown > 0
? t('auth.resendCountdown', { countdown })
: t('auth.sendCode')
}}
</button>
</div>
<p v-if="emailVerifyEnabled && sendCodeSuccess" class="text-sm text-green-600 dark:text-green-400">
{{ t('auth.codeSentSuccess') }}
</p>
<p v-else-if="emailVerifyEnabled" class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.verificationCodeHint') }}
</p>
<input
v-if="invitationCodeEnabled"
v-model="invitationCode"
:data-testid="`${testIdPrefix}-create-account-invitation-code`"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
/>
<button
:data-testid="`${testIdPrefix}-create-account-submit`"
type="button"
class="btn btn-primary w-full"
:disabled="isSubmitting || !email.trim() || password.length < 6 || (invitationCodeEnabled && !invitationCode.trim())"
@click="handleSubmit"
>
{{ isSubmitting ? t('common.processing') : t('auth.createAccount') }}
</button>
<button
type="button"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="emitSwitchToBind"
>
{{ t('auth.alreadyHaveAccount') }}
</button>
</form>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
import { getPublicSettings, sendPendingOAuthVerifyCode } from '@/api/auth'
import { useAppStore } from '@/stores'
export type PendingOAuthCreateAccountPayload = {
email: string
password: string
verifyCode: string
turnstileToken?: string
tencentCaptchaTicket?: string
tencentCaptchaRandstr?: string
invitationCode?: string
}
const props = defineProps<{
initialEmail: string
testIdPrefix: string
isSubmitting: boolean
errorMessage?: string
}>()
const emit = defineEmits<{
submit: [payload: PendingOAuthCreateAccountPayload]
switchToBind: [email: string]
}>()
const { t } = useI18n()
const appStore = useAppStore()
const email = ref('')
const password = ref('')
const verifyCode = ref('')
const invitationCode = ref('')
const isSendingCode = ref(false)
const sendCodeError = ref('')
const sendCodeSuccess = ref(false)
const countdown = ref(0)
const invitationCodeEnabled = ref(false)
const emailVerifyEnabled = ref(true)
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const tencentCaptchaEnabled = ref(false)
const tencentCaptchaAppId = ref('')
const turnstileToken = ref('')
const tencentCaptchaRandstr = ref('')
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const captchaEnabled = computed(
() =>
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
)
let countdownTimer: ReturnType<typeof setInterval> | null = null
watch(
() => props.initialEmail,
value => {
email.value = value || ''
},
{ immediate: true }
)
watch(sendCodeError, value => {
if (value) {
appStore.showError(value)
}
})
watch(
() => props.errorMessage,
value => {
if (value) {
appStore.showError(value)
if (captchaEnabled.value) {
resetTurnstile()
}
}
}
)
function clearCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function startCountdown(seconds: number) {
clearCountdown()
countdown.value = Math.max(0, seconds)
if (countdown.value <= 0) {
return
}
countdownTimer = setInterval(() => {
if (countdown.value <= 1) {
countdown.value = 0
clearCountdown()
return
}
countdown.value -= 1
}, 1000)
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
function resetTurnstile() {
turnstileToken.value = ''
tencentCaptchaRandstr.value = ''
turnstileRef.value?.reset()
}
function onTurnstileVerify(token: string, randstr = '') {
turnstileToken.value = token
tencentCaptchaRandstr.value = randstr
sendCodeError.value = ''
}
function onTurnstileExpire() {
turnstileToken.value = ''
tencentCaptchaRandstr.value = ''
sendCodeError.value = t('auth.turnstileExpired')
}
function onTurnstileError() {
turnstileToken.value = ''
tencentCaptchaRandstr.value = ''
sendCodeError.value = t('auth.turnstileFailed')
}
async function acquireTencentProof(): Promise<boolean> {
if (!tencentCaptchaEnabled.value) return true
const proof = await turnstileRef.value?.verifyTencent()
if (!proof) return false
turnstileToken.value = proof.ticket
tencentCaptchaRandstr.value = proof.randstr
return true
}
async function handleSendCode() {
const trimmedEmail = email.value.trim()
if (!trimmedEmail) {
return
}
if (turnstileEnabled.value && !turnstileToken.value) {
sendCodeError.value = t('auth.completeVerification')
return
}
if (!(await acquireTencentProof())) {
return
}
isSendingCode.value = true
sendCodeError.value = ''
sendCodeSuccess.value = false
try {
const response = await sendPendingOAuthVerifyCode({
email: trimmedEmail,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
})
sendCodeSuccess.value = true
startCountdown(response.countdown)
} catch (error: unknown) {
sendCodeError.value = getRequestErrorMessage(error, t('auth.sendCodeFailed'))
} finally {
if (captchaEnabled.value) {
resetTurnstile()
}
isSendingCode.value = false
}
}
async function handleSubmit() {
const trimmedEmail = email.value.trim()
if (!trimmedEmail || password.value.length < 6) {
return
}
if (!(await acquireTencentProof())) {
return
}
emit('submit', {
email: trimmedEmail,
password: password.value,
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
...(tencentCaptchaEnabled.value && turnstileToken.value
? {
tencentCaptchaTicket: turnstileToken.value,
tencentCaptchaRandstr: tencentCaptchaRandstr.value
}
: {}),
invitationCode: invitationCode.value.trim() || undefined
})
if (tencentCaptchaEnabled.value) {
resetTurnstile()
}
}
function emitSwitchToBind() {
emit('switchToBind', email.value.trim())
}
onMounted(async () => {
try {
const settings = await getPublicSettings()
invitationCodeEnabled.value = settings.invitation_code_enabled === true
emailVerifyEnabled.value = settings.email_verify_enabled !== false
turnstileEnabled.value = settings.turnstile_enabled === true
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
} catch {
invitationCodeEnabled.value = false
emailVerifyEnabled.value = true
turnstileEnabled.value = false
turnstileSiteKey.value = ''
tencentCaptchaEnabled.value = false
tencentCaptchaAppId.value = ''
}
})
onUnmounted(() => {
clearCountdown()
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>