feat: complete pending oauth account creation UI

This commit is contained in:
IanShaw027
2026-04-21 00:02:51 +08:00
parent 7ef7fd19e7
commit 0fa47f18ed
8 changed files with 469 additions and 116 deletions

View File

@@ -0,0 +1,212 @@
<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="you@example.com"
:disabled="isSubmitting || isSendingCode"
/>
<input
v-model="password"
:data-testid="`${testIdPrefix}-create-account-password`"
type="password"
class="input w-full"
placeholder="Password"
:disabled="isSubmitting"
/>
<div 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()"
@click="handleSendCode"
>
{{
isSendingCode
? t('auth.sendingCode')
: countdown > 0
? t('auth.resendCountdown', { countdown })
: t('auth.sendCode')
}}
</button>
</div>
<p v-if="sendCodeSuccess" class="text-sm text-green-600 dark:text-green-400">
{{ t('auth.codeSentSuccess') }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.verificationCodeHint') }}
</p>
<button
:data-testid="`${testIdPrefix}-create-account-submit`"
type="button"
class="btn btn-primary w-full"
:disabled="isSubmitting || !email.trim() || password.length < 6"
@click="handleSubmit"
>
{{ isSubmitting ? t('common.processing') : 'Create account' }}
</button>
<button
type="button"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="emitSwitchToBind"
>
I already have an account
</button>
<transition name="fade">
<p v-if="sendCodeError" class="text-sm text-red-600 dark:text-red-400">
{{ sendCodeError }}
</p>
</transition>
<transition name="fade">
<p v-if="errorMessage" class="text-sm text-red-600 dark:text-red-400">
{{ errorMessage }}
</p>
</transition>
</form>
</template>
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { sendVerifyCode } from '@/api/auth'
export type PendingOAuthCreateAccountPayload = {
email: string
password: string
verifyCode: 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 email = ref('')
const password = ref('')
const verifyCode = ref('')
const isSendingCode = ref(false)
const sendCodeError = ref('')
const sendCodeSuccess = ref(false)
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
watch(
() => props.initialEmail,
value => {
email.value = value || ''
},
{ immediate: true }
)
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
}
async function handleSendCode() {
const trimmedEmail = email.value.trim()
if (!trimmedEmail) {
return
}
isSendingCode.value = true
sendCodeError.value = ''
sendCodeSuccess.value = false
try {
const response = await sendVerifyCode({
email: trimmedEmail
})
sendCodeSuccess.value = true
startCountdown(response.countdown)
} catch (error: unknown) {
sendCodeError.value = getRequestErrorMessage(error, t('auth.sendCodeFailed'))
} finally {
isSendingCode.value = false
}
}
function handleSubmit() {
const trimmedEmail = email.value.trim()
if (!trimmedEmail || password.value.length < 6) {
return
}
emit('submit', {
email: trimmedEmail,
password: password.value,
verifyCode: verifyCode.value.trim()
})
}
function emitSwitchToBind() {
emit('switchToBind', email.value.trim())
}
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>

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import PendingOAuthCreateAccountForm from '../PendingOAuthCreateAccountForm.vue'
const sendVerifyCode = vi.fn()
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
vi.mock('@/api/auth', async () => {
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
return {
...actual,
sendVerifyCode: (...args: any[]) => sendVerifyCode(...args)
}
})
describe('PendingOAuthCreateAccountForm', () => {
beforeEach(() => {
sendVerifyCode.mockReset()
})
it('emits trimmed email, password, and verify code on submit', async () => {
const wrapper = mount(PendingOAuthCreateAccountForm, {
props: {
providerName: 'LinuxDo',
testIdPrefix: 'linuxdo',
initialEmail: 'prefill@example.com',
isSubmitting: false
}
})
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' user@example.com ')
await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123')
await wrapper.get('[data-testid="linuxdo-create-account-verify-code"]').setValue(' 246810 ')
await wrapper.get('form').trigger('submit.prevent')
expect(wrapper.emitted('submit')).toEqual([
[
{
email: 'user@example.com',
password: 'secret-123',
verifyCode: '246810'
}
]
])
})
it('sends a verify code for the trimmed email value', async () => {
sendVerifyCode.mockResolvedValue({
message: 'sent',
countdown: 60
})
const wrapper = mount(PendingOAuthCreateAccountForm, {
props: {
providerName: 'LinuxDo',
testIdPrefix: 'linuxdo',
initialEmail: '',
isSubmitting: false
}
})
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' user@example.com ')
await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click')
await flushPromises()
expect(sendVerifyCode).toHaveBeenCalledWith({
email: 'user@example.com'
})
})
})