feat: complete pending oauth account creation UI
This commit is contained in:
212
frontend/src/components/auth/PendingOAuthCreateAccountForm.vue
Normal file
212
frontend/src/components/auth/PendingOAuthCreateAccountForm.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -113,37 +113,14 @@
|
|||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Enter an email address to create your account and continue.
|
Enter an email address to create your account and continue.
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3">
|
<PendingOAuthCreateAccountForm
|
||||||
<input
|
test-id-prefix="linuxdo"
|
||||||
v-model="pendingAccountEmail"
|
:initial-email="pendingAccountEmail"
|
||||||
data-testid="linuxdo-create-account-email"
|
:is-submitting="isSubmitting"
|
||||||
type="email"
|
:error-message="accountActionError"
|
||||||
class="input w-full"
|
@submit="handleCreateAccount"
|
||||||
placeholder="you@example.com"
|
@switch-to-bind="switchToBindLoginMode"
|
||||||
:disabled="isSubmitting"
|
|
||||||
@keyup.enter="handleCreateAccount"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
data-testid="linuxdo-create-account-submit"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
:disabled="isSubmitting || !pendingAccountEmail.trim()"
|
|
||||||
@click="handleCreateAccount"
|
|
||||||
>
|
|
||||||
{{ isSubmitting ? t('common.processing') : 'Create account' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary w-full"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="switchToBindLoginMode"
|
|
||||||
>
|
|
||||||
I already have an account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<transition name="fade">
|
|
||||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{{ accountActionError }}
|
|
||||||
</p>
|
|
||||||
</transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsBindLogin">
|
<template v-else-if="needsBindLogin">
|
||||||
@@ -258,6 +235,9 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import PendingOAuthCreateAccountForm, {
|
||||||
|
type PendingOAuthCreateAccountPayload
|
||||||
|
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -432,9 +412,9 @@ function applyTotpChallenge(completion: LinuxDoPendingActionResponse): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToBindLoginMode() {
|
function switchToBindLoginMode(nextEmail?: string) {
|
||||||
pendingAccountAction.value = 'bind_login'
|
pendingAccountAction.value = 'bind_login'
|
||||||
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
|
||||||
bindLoginPassword.value = ''
|
bindLoginPassword.value = ''
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
canReturnToCreateAccount.value = true
|
canReturnToCreateAccount.value = true
|
||||||
@@ -533,15 +513,16 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateAccount() {
|
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
const email = pendingAccountEmail.value.trim()
|
if (!payload.email || !payload.password) return
|
||||||
if (!email) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/pending/create-account', {
|
const { data } = await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/pending/create-account', {
|
||||||
email,
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
verify_code: payload.verifyCode || undefined,
|
||||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
})
|
})
|
||||||
await finalizePendingAccountResponse(data)
|
await finalizePendingAccountResponse(data)
|
||||||
|
|||||||
@@ -122,37 +122,14 @@
|
|||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Enter an email address to create your account and continue.
|
Enter an email address to create your account and continue.
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3">
|
<PendingOAuthCreateAccountForm
|
||||||
<input
|
test-id-prefix="oidc"
|
||||||
v-model="pendingAccountEmail"
|
:initial-email="pendingAccountEmail"
|
||||||
data-testid="oidc-create-account-email"
|
:is-submitting="isSubmitting"
|
||||||
type="email"
|
:error-message="accountActionError"
|
||||||
class="input w-full"
|
@submit="handleCreateAccount"
|
||||||
placeholder="you@example.com"
|
@switch-to-bind="switchToBindLoginMode"
|
||||||
:disabled="isSubmitting"
|
|
||||||
@keyup.enter="handleCreateAccount"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
data-testid="oidc-create-account-submit"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
:disabled="isSubmitting || !pendingAccountEmail.trim()"
|
|
||||||
@click="handleCreateAccount"
|
|
||||||
>
|
|
||||||
{{ isSubmitting ? t('common.processing') : 'Create account' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary w-full"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="switchToBindLoginMode"
|
|
||||||
>
|
|
||||||
I already have an account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<transition name="fade">
|
|
||||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{{ accountActionError }}
|
|
||||||
</p>
|
|
||||||
</transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsBindLogin">
|
<template v-else-if="needsBindLogin">
|
||||||
@@ -267,6 +244,9 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import PendingOAuthCreateAccountForm, {
|
||||||
|
type PendingOAuthCreateAccountPayload
|
||||||
|
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -476,9 +456,9 @@ function applyTotpChallenge(completion: PendingOidcCompletion): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToBindLoginMode() {
|
function switchToBindLoginMode(nextEmail?: string) {
|
||||||
pendingAccountAction.value = 'bind_login'
|
pendingAccountAction.value = 'bind_login'
|
||||||
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
|
||||||
bindLoginPassword.value = ''
|
bindLoginPassword.value = ''
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
canReturnToCreateAccount.value = true
|
canReturnToCreateAccount.value = true
|
||||||
@@ -577,15 +557,16 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateAccount() {
|
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
const email = pendingAccountEmail.value.trim()
|
if (!payload.email || !payload.password) return
|
||||||
if (!email) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/create-account', {
|
const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/create-account', {
|
||||||
email,
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
verify_code: payload.verifyCode || undefined,
|
||||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
})
|
})
|
||||||
await finalizePendingAccountResponse(data)
|
await finalizePendingAccountResponse(data)
|
||||||
|
|||||||
@@ -160,37 +160,14 @@
|
|||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Enter an email address to create your account and continue.
|
Enter an email address to create your account and continue.
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3">
|
<PendingOAuthCreateAccountForm
|
||||||
<input
|
test-id-prefix="wechat"
|
||||||
v-model="pendingAccountEmail"
|
:initial-email="pendingAccountEmail"
|
||||||
data-testid="wechat-create-account-email"
|
:is-submitting="isSubmitting"
|
||||||
type="email"
|
:error-message="accountActionError"
|
||||||
class="input w-full"
|
@submit="handleCreateAccount"
|
||||||
placeholder="you@example.com"
|
@switch-to-bind="switchToBindLoginMode"
|
||||||
:disabled="isSubmitting"
|
|
||||||
@keyup.enter="handleCreateAccount"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
data-testid="wechat-create-account-submit"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
:disabled="isSubmitting || !pendingAccountEmail.trim()"
|
|
||||||
@click="handleCreateAccount"
|
|
||||||
>
|
|
||||||
{{ isSubmitting ? t('common.processing') : 'Create account' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary w-full"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="switchToBindLoginMode"
|
|
||||||
>
|
|
||||||
I already have an account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<transition name="fade">
|
|
||||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{{ accountActionError }}
|
|
||||||
</p>
|
|
||||||
</transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsBindLogin">
|
<template v-else-if="needsBindLogin">
|
||||||
@@ -305,6 +282,9 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import PendingOAuthCreateAccountForm, {
|
||||||
|
type PendingOAuthCreateAccountPayload
|
||||||
|
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -575,9 +555,9 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToBindLoginMode() {
|
function switchToBindLoginMode(nextEmail?: string) {
|
||||||
pendingAccountAction.value = 'bind_login'
|
pendingAccountAction.value = 'bind_login'
|
||||||
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
|
||||||
bindLoginPassword.value = ''
|
bindLoginPassword.value = ''
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
canReturnToCreateAccount.value = true
|
canReturnToCreateAccount.value = true
|
||||||
@@ -676,15 +656,16 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateAccount() {
|
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
const email = pendingAccountEmail.value.trim()
|
if (!payload.email || !payload.password) return
|
||||||
if (!email) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<PendingWeChatCompletion>('/auth/oauth/pending/create-account', {
|
const { data } = await apiClient.post<PendingWeChatCompletion>('/auth/oauth/pending/create-account', {
|
||||||
email,
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
verify_code: payload.verifyCode || undefined,
|
||||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
})
|
})
|
||||||
await finalizePendingAccountResponse(data)
|
await finalizePendingAccountResponse(data)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const exchangePendingOAuthCompletion = vi.fn()
|
|||||||
const completeLinuxDoOAuthRegistration = vi.fn()
|
const completeLinuxDoOAuthRegistration = vi.fn()
|
||||||
const login2FA = vi.fn()
|
const login2FA = vi.fn()
|
||||||
const apiClientPost = vi.fn()
|
const apiClientPost = vi.fn()
|
||||||
|
const sendVerifyCode = vi.fn()
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
@@ -53,7 +54,8 @@ vi.mock('@/api/auth', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args),
|
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args),
|
||||||
login2FA: (...args: any[]) => login2FA(...args)
|
login2FA: (...args: any[]) => login2FA(...args),
|
||||||
|
sendVerifyCode: (...args: any[]) => sendVerifyCode(...args)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
completeLinuxDoOAuthRegistration.mockReset()
|
completeLinuxDoOAuthRegistration.mockReset()
|
||||||
login2FA.mockReset()
|
login2FA.mockReset()
|
||||||
apiClientPost.mockReset()
|
apiClientPost.mockReset()
|
||||||
|
sendVerifyCode.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not send adoption decisions during the initial exchange', async () => {
|
it('does not send adoption decisions during the initial exchange', async () => {
|
||||||
@@ -251,7 +254,7 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'email_required',
|
error: 'email_required',
|
||||||
redirect: '/welcome',
|
redirect: '/welcome',
|
||||||
@@ -286,11 +289,15 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
expect(checkboxes).toHaveLength(2)
|
expect(checkboxes).toHaveLength(2)
|
||||||
await checkboxes[1].setValue(false)
|
await checkboxes[1].setValue(false)
|
||||||
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' new@example.com ')
|
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' new@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('[data-testid="linuxdo-create-account-submit"]').trigger('click')
|
await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
|
password: 'secret-123',
|
||||||
|
verify_code: '246810',
|
||||||
adopt_display_name: true,
|
adopt_display_name: true,
|
||||||
adopt_avatar: false
|
adopt_avatar: false
|
||||||
})
|
})
|
||||||
@@ -298,6 +305,38 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/welcome')
|
expect(replace).toHaveBeenCalledWith('/welcome')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends a verify code for pending oauth account creation', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'email_required',
|
||||||
|
redirect: '/welcome'
|
||||||
|
})
|
||||||
|
sendVerifyCode.mockResolvedValue({
|
||||||
|
message: 'sent',
|
||||||
|
countdown: 60
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(LinuxDoCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' new@example.com ')
|
||||||
|
await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(sendVerifyCode).toHaveBeenCalledWith({
|
||||||
|
email: 'new@example.com'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'bind_login_required',
|
error: 'bind_login_required',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const completeOIDCOAuthRegistration = vi.fn()
|
|||||||
const getPublicSettings = vi.fn()
|
const getPublicSettings = vi.fn()
|
||||||
const login2FA = vi.fn()
|
const login2FA = vi.fn()
|
||||||
const apiClientPost = vi.fn()
|
const apiClientPost = vi.fn()
|
||||||
|
const sendVerifyCode = vi.fn()
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
@@ -60,7 +61,8 @@ vi.mock('@/api/auth', async () => {
|
|||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args),
|
getPublicSettings: (...args: any[]) => getPublicSettings(...args),
|
||||||
login2FA: (...args: any[]) => login2FA(...args)
|
login2FA: (...args: any[]) => login2FA(...args),
|
||||||
|
sendVerifyCode: (...args: any[]) => sendVerifyCode(...args)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ describe('OidcCallbackView', () => {
|
|||||||
getPublicSettings.mockReset()
|
getPublicSettings.mockReset()
|
||||||
login2FA.mockReset()
|
login2FA.mockReset()
|
||||||
apiClientPost.mockReset()
|
apiClientPost.mockReset()
|
||||||
|
sendVerifyCode.mockReset()
|
||||||
getPublicSettings.mockResolvedValue({
|
getPublicSettings.mockResolvedValue({
|
||||||
oidc_oauth_provider_name: 'ExampleID'
|
oidc_oauth_provider_name: 'ExampleID'
|
||||||
})
|
})
|
||||||
@@ -234,7 +237,7 @@ describe('OidcCallbackView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'email_required',
|
error: 'email_required',
|
||||||
redirect: '/welcome',
|
redirect: '/welcome',
|
||||||
@@ -269,11 +272,15 @@ describe('OidcCallbackView', () => {
|
|||||||
expect(checkboxes).toHaveLength(2)
|
expect(checkboxes).toHaveLength(2)
|
||||||
await checkboxes[1].setValue(false)
|
await checkboxes[1].setValue(false)
|
||||||
await wrapper.get('[data-testid="oidc-create-account-email"]').setValue(' new@example.com ')
|
await wrapper.get('[data-testid="oidc-create-account-email"]').setValue(' new@example.com ')
|
||||||
|
await wrapper.get('[data-testid="oidc-create-account-password"]').setValue('secret-123')
|
||||||
|
await wrapper.get('[data-testid="oidc-create-account-verify-code"]').setValue('246810')
|
||||||
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
|
password: 'secret-123',
|
||||||
|
verify_code: '246810',
|
||||||
adopt_display_name: true,
|
adopt_display_name: true,
|
||||||
adopt_avatar: false
|
adopt_avatar: false
|
||||||
})
|
})
|
||||||
@@ -281,6 +288,38 @@ describe('OidcCallbackView', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/welcome')
|
expect(replace).toHaveBeenCalledWith('/welcome')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends a verify code for pending oauth account creation', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'email_required',
|
||||||
|
redirect: '/welcome'
|
||||||
|
})
|
||||||
|
sendVerifyCode.mockResolvedValue({
|
||||||
|
message: 'sent',
|
||||||
|
countdown: 60
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(OidcCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="oidc-create-account-email"]').setValue(' new@example.com ')
|
||||||
|
await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(sendVerifyCode).toHaveBeenCalledWith({
|
||||||
|
email: 'new@example.com'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'adopt_existing_user_by_email',
|
error: 'adopt_existing_user_by_email',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
completeWeChatOAuthRegistrationMock,
|
completeWeChatOAuthRegistrationMock,
|
||||||
login2FAMock,
|
login2FAMock,
|
||||||
apiClientPostMock,
|
apiClientPostMock,
|
||||||
|
sendVerifyCodeMock,
|
||||||
prepareOAuthBindAccessTokenCookieMock,
|
prepareOAuthBindAccessTokenCookieMock,
|
||||||
getAuthTokenMock,
|
getAuthTokenMock,
|
||||||
replaceMock,
|
replaceMock,
|
||||||
@@ -20,6 +21,7 @@ const {
|
|||||||
completeWeChatOAuthRegistrationMock: vi.fn(),
|
completeWeChatOAuthRegistrationMock: vi.fn(),
|
||||||
login2FAMock: vi.fn(),
|
login2FAMock: vi.fn(),
|
||||||
apiClientPostMock: vi.fn(),
|
apiClientPostMock: vi.fn(),
|
||||||
|
sendVerifyCodeMock: vi.fn(),
|
||||||
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
|
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
|
||||||
getAuthTokenMock: vi.fn(),
|
getAuthTokenMock: vi.fn(),
|
||||||
replaceMock: vi.fn(),
|
replaceMock: vi.fn(),
|
||||||
@@ -118,6 +120,7 @@ vi.mock('@/api/auth', async () => {
|
|||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||||
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
||||||
login2FA: (...args: any[]) => login2FAMock(...args),
|
login2FA: (...args: any[]) => login2FAMock(...args),
|
||||||
|
sendVerifyCode: (...args: any[]) => sendVerifyCodeMock(...args),
|
||||||
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
|
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
|
||||||
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
|
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
|
||||||
}
|
}
|
||||||
@@ -129,6 +132,7 @@ describe('WechatCallbackView', () => {
|
|||||||
completeWeChatOAuthRegistrationMock.mockReset()
|
completeWeChatOAuthRegistrationMock.mockReset()
|
||||||
login2FAMock.mockReset()
|
login2FAMock.mockReset()
|
||||||
apiClientPostMock.mockReset()
|
apiClientPostMock.mockReset()
|
||||||
|
sendVerifyCodeMock.mockReset()
|
||||||
replaceMock.mockReset()
|
replaceMock.mockReset()
|
||||||
setTokenMock.mockReset()
|
setTokenMock.mockReset()
|
||||||
showSuccessMock.mockReset()
|
showSuccessMock.mockReset()
|
||||||
@@ -374,7 +378,7 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(locationState.current.href).toContain('mode=open')
|
expect(locationState.current.href).toContain('mode=open')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
error: 'email_required',
|
error: 'email_required',
|
||||||
redirect: '/welcome',
|
redirect: '/welcome',
|
||||||
@@ -409,11 +413,15 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(checkboxes).toHaveLength(2)
|
expect(checkboxes).toHaveLength(2)
|
||||||
await checkboxes[1].setValue(false)
|
await checkboxes[1].setValue(false)
|
||||||
await wrapper.get('[data-testid="wechat-create-account-email"]').setValue(' new@example.com ')
|
await wrapper.get('[data-testid="wechat-create-account-email"]').setValue(' new@example.com ')
|
||||||
|
await wrapper.get('[data-testid="wechat-create-account-password"]').setValue('secret-123')
|
||||||
|
await wrapper.get('[data-testid="wechat-create-account-verify-code"]').setValue('246810')
|
||||||
await wrapper.get('[data-testid="wechat-create-account-submit"]').trigger('click')
|
await wrapper.get('[data-testid="wechat-create-account-submit"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
|
password: 'secret-123',
|
||||||
|
verify_code: '246810',
|
||||||
adopt_display_name: true,
|
adopt_display_name: true,
|
||||||
adopt_avatar: false,
|
adopt_avatar: false,
|
||||||
})
|
})
|
||||||
@@ -421,6 +429,38 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(replaceMock).toHaveBeenCalledWith('/welcome')
|
expect(replaceMock).toHaveBeenCalledWith('/welcome')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends a verify code for pending oauth account creation', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'email_required',
|
||||||
|
redirect: '/welcome',
|
||||||
|
})
|
||||||
|
sendVerifyCodeMock.mockResolvedValue({
|
||||||
|
message: 'sent',
|
||||||
|
countdown: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="wechat-create-account-email"]').setValue(' new@example.com ')
|
||||||
|
await wrapper.get('[data-testid="wechat-create-account-send-code"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(sendVerifyCodeMock).toHaveBeenCalledWith({
|
||||||
|
email: 'new@example.com',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
step: 'bind_login_required',
|
step: 'bind_login_required',
|
||||||
|
|||||||
Reference in New Issue
Block a user