fix: complete wechat pending auth callback flows
This commit is contained in:
@@ -15,7 +15,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
<div
|
||||||
|
v-if="
|
||||||
|
needsInvitation ||
|
||||||
|
needsAdoptionConfirmation ||
|
||||||
|
needsCreateAccount ||
|
||||||
|
needsBindLogin ||
|
||||||
|
needsTotpChallenge
|
||||||
|
"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||||
@@ -141,6 +150,124 @@
|
|||||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsCreateAccount">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Enter an email address to create your account and continue.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model="pendingAccountEmail"
|
||||||
|
data-testid="wechat-create-account-email"
|
||||||
|
type="email"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
: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 v-else-if="needsBindLogin">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Log in to an existing account to bind this {{ providerName }} sign-in.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model="bindLoginEmail"
|
||||||
|
data-testid="wechat-bind-login-email"
|
||||||
|
type="email"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleBindLogin"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="bindLoginPassword"
|
||||||
|
data-testid="wechat-bind-login-password"
|
||||||
|
type="password"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="Password"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleBindLogin"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid="wechat-bind-login-submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||||
|
@click="handleBindLogin"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canReturnToCreateAccount"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToCreateAccountMode"
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ accountActionError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsTotpChallenge">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Enter the 6-digit verification code for
|
||||||
|
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
|
||||||
|
to finish binding this {{ providerName }} sign-in.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model="totpCode"
|
||||||
|
data-testid="wechat-bind-login-totp"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="123456"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleSubmitTotpChallenge"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid="wechat-bind-login-totp-submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||||
|
@click="handleSubmitTotpChallenge"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ totpError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
@@ -169,11 +296,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
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 Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import {
|
import {
|
||||||
completeWeChatOAuthRegistration,
|
completeWeChatOAuthRegistration,
|
||||||
@@ -181,6 +309,7 @@ import {
|
|||||||
getAuthToken,
|
getAuthToken,
|
||||||
getOAuthCompletionKind,
|
getOAuthCompletionKind,
|
||||||
isOAuthLoginCompletion,
|
isOAuthLoginCompletion,
|
||||||
|
login2FA,
|
||||||
prepareOAuthBindAccessTokenCookie,
|
prepareOAuthBindAccessTokenCookie,
|
||||||
persistOAuthTokenContext,
|
persistOAuthTokenContext,
|
||||||
type OAuthAdoptionDecision,
|
type OAuthAdoptionDecision,
|
||||||
@@ -208,9 +337,34 @@ const existingAccountEmail = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
|
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
|
||||||
|
const pendingAccountEmail = ref('')
|
||||||
|
const bindLoginEmail = ref('')
|
||||||
|
const bindLoginPassword = ref('')
|
||||||
|
const accountActionError = ref('')
|
||||||
|
const canReturnToCreateAccount = ref(false)
|
||||||
|
const needsTotpChallenge = ref(false)
|
||||||
|
const totpTempToken = ref('')
|
||||||
|
const totpCode = ref('')
|
||||||
|
const totpError = ref('')
|
||||||
|
const totpUserEmailMasked = ref('')
|
||||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
const providerName = 'WeChat'
|
const providerName = 'WeChat'
|
||||||
|
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||||
|
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||||
|
|
||||||
|
type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
|
||||||
|
step?: string
|
||||||
|
pending_email?: string
|
||||||
|
resolved_email?: string
|
||||||
|
existing_account_email?: string
|
||||||
|
email?: string
|
||||||
|
intent?: string
|
||||||
|
requires_2fa?: boolean
|
||||||
|
temp_token?: string
|
||||||
|
user_email_masked?: string
|
||||||
|
}
|
||||||
|
|
||||||
function parseFragmentParams(): URLSearchParams {
|
function parseFragmentParams(): URLSearchParams {
|
||||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
@@ -278,6 +432,21 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveResumeEmail(): string {
|
||||||
|
return typeof route.query.email === 'string' ? route.query.email.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
|
||||||
|
const payload: Record<string, boolean> = {}
|
||||||
|
if (typeof decision.adoptDisplayName === 'boolean') {
|
||||||
|
payload.adopt_display_name = decision.adoptDisplayName
|
||||||
|
}
|
||||||
|
if (typeof decision.adoptAvatar === 'boolean') {
|
||||||
|
payload.adopt_avatar = decision.adoptAvatar
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExistingAccountBinding() {
|
async function handleExistingAccountBinding() {
|
||||||
if (getAuthToken()) {
|
if (getAuthToken()) {
|
||||||
prepareOAuthBindAccessTokenCookie()
|
prepareOAuthBindAccessTokenCookie()
|
||||||
@@ -312,6 +481,103 @@ function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean
|
|||||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedPendingState(value: string | null | undefined): string {
|
||||||
|
return value?.trim().toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPendingAccountEmail(completion: PendingWeChatCompletion): string {
|
||||||
|
return (
|
||||||
|
completion.pending_email ||
|
||||||
|
completion.existing_account_email ||
|
||||||
|
completion.resolved_email ||
|
||||||
|
completion.email ||
|
||||||
|
resolveResumeEmail() ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingAccountAction(
|
||||||
|
completion: PendingWeChatCompletion
|
||||||
|
): 'none' | 'create_account' | 'bind_login' {
|
||||||
|
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
||||||
|
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||||
|
return 'create_account'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
raw === 'bind_login_required' ||
|
||||||
|
raw === 'bind_login' ||
|
||||||
|
raw === 'existing_account_binding_required' ||
|
||||||
|
raw === 'existing_account_required' ||
|
||||||
|
raw === 'adopt_existing_user_by_email'
|
||||||
|
) {
|
||||||
|
return 'bind_login'
|
||||||
|
}
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPendingAccountAction(completion: PendingWeChatCompletion) {
|
||||||
|
const action = resolvePendingAccountAction(completion)
|
||||||
|
pendingAccountAction.value = action
|
||||||
|
accountActionError.value = ''
|
||||||
|
needsTotpChallenge.value = false
|
||||||
|
totpTempToken.value = ''
|
||||||
|
totpCode.value = ''
|
||||||
|
totpError.value = ''
|
||||||
|
totpUserEmailMasked.value = ''
|
||||||
|
|
||||||
|
const email = extractPendingAccountEmail(completion)
|
||||||
|
if (action === 'create_account') {
|
||||||
|
pendingAccountEmail.value = email
|
||||||
|
canReturnToCreateAccount.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'bind_login') {
|
||||||
|
bindLoginEmail.value = email
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
canReturnToCreateAccount.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canReturnToCreateAccount.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
|
||||||
|
if (completion.requires_2fa !== true || !completion.temp_token) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAccountAction.value = 'none'
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
needsTotpChallenge.value = true
|
||||||
|
totpTempToken.value = completion.temp_token
|
||||||
|
totpCode.value = ''
|
||||||
|
totpError.value = ''
|
||||||
|
totpUserEmailMasked.value = completion.user_email_masked || ''
|
||||||
|
isProcessing.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToBindLoginMode() {
|
||||||
|
pendingAccountAction.value = 'bind_login'
|
||||||
|
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
accountActionError.value = ''
|
||||||
|
canReturnToCreateAccount.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToCreateAccountMode() {
|
||||||
|
pendingAccountAction.value = 'create_account'
|
||||||
|
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
|
||||||
|
accountActionError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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 finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
@@ -330,6 +596,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
|
|||||||
await router.replace(redirect)
|
await router.replace(redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finalizePendingAccountResponse(completion: PendingWeChatCompletion) {
|
||||||
|
applyAdoptionSuggestionState(completion)
|
||||||
|
|
||||||
|
if (completion.error === 'invitation_required') {
|
||||||
|
pendingAccountAction.value = 'none'
|
||||||
|
needsInvitation.value = true
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyTotpChallenge(completion)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPendingAccountAction(completion)
|
||||||
|
if (pendingAccountAction.value !== 'none') {
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
|
||||||
|
await finalizeCompletion(completion, redirect)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitInvitation() {
|
async function handleSubmitInvitation() {
|
||||||
invitationError.value = ''
|
invitationError.value = ''
|
||||||
if (!invitationCode.value.trim()) return
|
if (!invitationCode.value.trim()) return
|
||||||
@@ -356,15 +649,10 @@ async function handleSubmitInvitation() {
|
|||||||
async function handleContinueLogin() {
|
async function handleContinueLogin() {
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision()) as PendingWeChatCompletion
|
||||||
await finalizeCompletion(completion, redirectTo.value)
|
await finalizePendingAccountResponse(completion)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
errorMessage.value =
|
|
||||||
err.response?.data?.detail ||
|
|
||||||
err.response?.data?.message ||
|
|
||||||
err.message ||
|
|
||||||
t('auth.loginFailed')
|
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
needsAdoptionConfirmation.value = false
|
needsAdoptionConfirmation.value = false
|
||||||
} finally {
|
} finally {
|
||||||
@@ -372,6 +660,68 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateAccount() {
|
||||||
|
accountActionError.value = ''
|
||||||
|
const email = pendingAccountEmail.value.trim()
|
||||||
|
if (!email) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post<PendingWeChatCompletion>('/auth/oauth/pending/create-account', {
|
||||||
|
email,
|
||||||
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
|
})
|
||||||
|
await finalizePendingAccountResponse(data)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBindLogin() {
|
||||||
|
accountActionError.value = ''
|
||||||
|
const email = bindLoginEmail.value.trim()
|
||||||
|
const password = bindLoginPassword.value
|
||||||
|
if (!email || !password) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post<PendingWeChatCompletion>('/auth/oauth/pending/bind-login', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
|
})
|
||||||
|
await finalizePendingAccountResponse(data)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitTotpChallenge() {
|
||||||
|
totpError.value = ''
|
||||||
|
const code = totpCode.value.trim()
|
||||||
|
if (!totpTempToken.value || code.length !== 6) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const completion = await login2FA({
|
||||||
|
temp_token: totpTempToken.value,
|
||||||
|
totp_code: code
|
||||||
|
})
|
||||||
|
persistOAuthTokenContext(completion)
|
||||||
|
await authStore.setToken(completion.access_token)
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(redirectTo.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
totpError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (typeof route.query.email === 'string') {
|
if (typeof route.query.email === 'string') {
|
||||||
existingAccountEmail.value = route.query.email
|
existingAccountEmail.value = route.query.email
|
||||||
@@ -395,7 +745,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const completion = await exchangePendingOAuthCompletion()
|
const completion = await exchangePendingOAuthCompletion() as PendingWeChatCompletion
|
||||||
const redirect = sanitizeRedirectPath(
|
const redirect = sanitizeRedirectPath(
|
||||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||||
)
|
)
|
||||||
@@ -408,6 +758,16 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applyTotpChallenge(completion)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPendingAccountAction(completion)
|
||||||
|
if (pendingAccountAction.value !== 'none') {
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||||
needsAdoptionConfirmation.value = true
|
needsAdoptionConfirmation.value = true
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
@@ -416,12 +776,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await finalizeCompletion(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
errorMessage.value =
|
|
||||||
err.response?.data?.detail ||
|
|
||||||
err.response?.data?.message ||
|
|
||||||
err.message ||
|
|
||||||
t('auth.loginFailed')
|
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
|||||||
const {
|
const {
|
||||||
exchangePendingOAuthCompletionMock,
|
exchangePendingOAuthCompletionMock,
|
||||||
completeWeChatOAuthRegistrationMock,
|
completeWeChatOAuthRegistrationMock,
|
||||||
|
login2FAMock,
|
||||||
|
apiClientPostMock,
|
||||||
prepareOAuthBindAccessTokenCookieMock,
|
prepareOAuthBindAccessTokenCookieMock,
|
||||||
getAuthTokenMock,
|
getAuthTokenMock,
|
||||||
replaceMock,
|
replaceMock,
|
||||||
@@ -16,6 +18,8 @@ const {
|
|||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
exchangePendingOAuthCompletionMock: vi.fn(),
|
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||||
completeWeChatOAuthRegistrationMock: vi.fn(),
|
completeWeChatOAuthRegistrationMock: vi.fn(),
|
||||||
|
login2FAMock: vi.fn(),
|
||||||
|
apiClientPostMock: vi.fn(),
|
||||||
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
|
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
|
||||||
getAuthTokenMock: vi.fn(),
|
getAuthTokenMock: vi.fn(),
|
||||||
replaceMock: vi.fn(),
|
replaceMock: vi.fn(),
|
||||||
@@ -101,12 +105,19 @@ vi.mock('@/stores', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
apiClient: {
|
||||||
|
post: (...args: any[]) => apiClientPostMock(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/auth', async () => {
|
vi.mock('@/api/auth', async () => {
|
||||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
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),
|
||||||
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
|
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
|
||||||
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
|
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
|
||||||
}
|
}
|
||||||
@@ -116,6 +127,8 @@ describe('WechatCallbackView', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
exchangePendingOAuthCompletionMock.mockReset()
|
exchangePendingOAuthCompletionMock.mockReset()
|
||||||
completeWeChatOAuthRegistrationMock.mockReset()
|
completeWeChatOAuthRegistrationMock.mockReset()
|
||||||
|
login2FAMock.mockReset()
|
||||||
|
apiClientPostMock.mockReset()
|
||||||
replaceMock.mockReset()
|
replaceMock.mockReset()
|
||||||
setTokenMock.mockReset()
|
setTokenMock.mockReset()
|
||||||
showSuccessMock.mockReset()
|
showSuccessMock.mockReset()
|
||||||
@@ -331,6 +344,251 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(replaceMock.mock.calls[0]?.[0]).toContain('email=user%40example.com')
|
expect(replaceMock.mock.calls[0]?.[0]).toContain('email=user%40example.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'email_required',
|
||||||
|
redirect: '/welcome',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
apiClientPostMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setTokenMock.mockResolvedValue({})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes).toHaveLength(2)
|
||||||
|
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-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||||
|
email: 'new@example.com',
|
||||||
|
adopt_display_name: true,
|
||||||
|
adopt_avatar: false,
|
||||||
|
})
|
||||||
|
expect(setTokenMock).toHaveBeenCalledWith('new-access-token')
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith('/welcome')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
step: 'bind_login_required',
|
||||||
|
redirect: '/profile/security',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
apiClientPostMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
access_token: 'bind-access-token',
|
||||||
|
refresh_token: 'bind-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setTokenMock.mockResolvedValue({})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes).toHaveLength(2)
|
||||||
|
await checkboxes[0].setValue(false)
|
||||||
|
await wrapper.get('[data-testid="wechat-bind-login-email"]').setValue('existing@example.com')
|
||||||
|
await wrapper.get('[data-testid="wechat-bind-login-password"]').setValue('secret-password')
|
||||||
|
await wrapper.get('[data-testid="wechat-bind-login-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/bind-login', {
|
||||||
|
email: 'existing@example.com',
|
||||||
|
password: 'secret-password',
|
||||||
|
adopt_display_name: false,
|
||||||
|
adopt_avatar: true,
|
||||||
|
})
|
||||||
|
expect(setTokenMock).toHaveBeenCalledWith('bind-access-token')
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith('/profile/security')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows switching from server-driven bind-login to create-account mode', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
step: 'bind_login_required',
|
||||||
|
redirect: '/welcome',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
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('button.btn-secondary').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const createAccountEmail = wrapper.get('[data-testid="wechat-create-account-email"]')
|
||||||
|
expect((createAccountEmail.element as HTMLInputElement).value).toBe('existing@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses query email for bind-login when backend does not echo it back', async () => {
|
||||||
|
routeState.query = {
|
||||||
|
email: 'resume@example.com',
|
||||||
|
}
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
step: 'bind_login_required',
|
||||||
|
redirect: '/profile',
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const bindEmail = wrapper.get('[data-testid="wechat-bind-login-email"]')
|
||||||
|
expect((bindEmail.element as HTMLInputElement).value).toBe('resume@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps rendering pending bind-login UI when adoption confirmation leads to another pending step', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/profile',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
step: 'bind_login_required',
|
||||||
|
redirect: '/profile',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button')[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(showSuccessMock).not.toHaveBeenCalled()
|
||||||
|
expect(replaceMock).not.toHaveBeenCalled()
|
||||||
|
expect((wrapper.get('[data-testid="wechat-bind-login-email"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'existing@example.com'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles bind-login 2FA challenge before redirecting', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'adopt_existing_user_by_email',
|
||||||
|
redirect: '/profile',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
apiClientPostMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
requires_2fa: true,
|
||||||
|
temp_token: 'temp-123',
|
||||||
|
user_email_masked: 'o***g@example.com',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
login2FAMock.mockResolvedValue({
|
||||||
|
access_token: '2fa-access-token',
|
||||||
|
refresh_token: '2fa-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
})
|
||||||
|
setTokenMock.mockResolvedValue({})
|
||||||
|
|
||||||
|
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-bind-login-password"]').setValue('secret-password')
|
||||||
|
await wrapper.get('[data-testid="wechat-bind-login-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('o***g@example.com')
|
||||||
|
expect(login2FAMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="wechat-bind-login-totp"]').setValue('123456')
|
||||||
|
await wrapper.get('[data-testid="wechat-bind-login-totp-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(login2FAMock).toHaveBeenCalledWith({
|
||||||
|
temp_token: 'temp-123',
|
||||||
|
totp_code: '123456',
|
||||||
|
})
|
||||||
|
expect(setTokenMock).toHaveBeenCalledWith('2fa-access-token')
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith('/profile')
|
||||||
|
expect(localStorage.getItem('refresh_token')).toBe('2fa-refresh-token')
|
||||||
|
})
|
||||||
|
|
||||||
it('restarts the current-user bind flow after returning from login', async () => {
|
it('restarts the current-user bind flow after returning from login', async () => {
|
||||||
routeState.query = {
|
routeState.query = {
|
||||||
wechat_bind_existing: '1',
|
wechat_bind_existing: '1',
|
||||||
|
|||||||
Reference in New Issue
Block a user