frontend: normalize auth oauth i18n and error toasts

This commit is contained in:
IanShaw027
2026-04-21 22:26:11 +08:00
parent 4c21320d1b
commit a6b919eb53
25 changed files with 909 additions and 472 deletions
+12 -25
View File
@@ -48,10 +48,7 @@
:class="{ 'input-error': errors.code }"
placeholder="000000"
/>
<p v-if="errors.code" class="input-error-text text-center">
{{ errors.code }}
</p>
<p v-else class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
<p class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
</div>
<!-- Code Status -->
@@ -78,28 +75,8 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button type="submit" :disabled="isLoading || !verifyCode" class="btn btn-primary w-full">
<svg
@@ -169,7 +146,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
@@ -258,6 +235,16 @@ const errors = ref({
turnstile: ''
})
const validationToastMessage = computed(
() => errors.value.code || errors.value.turnstile || ''
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
}
})
// ==================== Lifecycle ====================
onMounted(async () => {
+9 -24
View File
@@ -64,9 +64,6 @@
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Turnstile Widget -->
@@ -78,28 +75,8 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
@@ -148,7 +125,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { computed, ref, reactive, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
@@ -185,6 +162,14 @@ const errors = reactive({
turnstile: ''
})
const validationToastMessage = computed(() => errors.email || errors.turnstile || '')
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
}
})
// ==================== Lifecycle ====================
onMounted(async () => {
+79 -63
View File
@@ -29,10 +29,10 @@
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Use LinuxDo profile details
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Choose whether to apply the nickname or avatar from LinuxDo to this account.
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
</p>
</div>
@@ -43,7 +43,7 @@
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
Use display name
{{ t('auth.oauthFlow.useDisplayName') }}
</span>
<span class="block text-gray-500 dark:text-dark-400">
{{ suggestedDisplayName }}
@@ -58,12 +58,12 @@
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
<img
:src="suggestedAvatarUrl"
alt="LinuxDo avatar"
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
/>
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
Use avatar
{{ t('auth.oauthFlow.useAvatar') }}
</span>
<span class="block break-all text-gray-500 dark:text-dark-400">
{{ suggestedAvatarUrl }}
@@ -87,11 +87,6 @@
@keyup.enter="handleSubmitInvitation"
/>
</div>
<transition name="fade">
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
{{ invitationError }}
</p>
</transition>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !invitationCode.trim()"
@@ -103,10 +98,10 @@
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
Review the LinuxDo profile details before continuing.
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
</p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
{{ isSubmitting ? t('common.processing') : 'Continue' }}
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
</button>
</template>
@@ -115,13 +110,13 @@
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Choose how to continue
{{ t('auth.oauthFlow.chooseHowToContinue') }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
pendingAccountEmail
? `Suggested email: ${pendingAccountEmail}`
: 'Choose whether to bind an existing account or create a new one.'
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
: t('auth.oauthFlow.chooseAccountActionHint')
}}
</p>
</div>
@@ -132,14 +127,14 @@
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
{{ t('auth.oauthFlow.bindExistingAccount') }}
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Create new account
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</div>
</div>
@@ -148,7 +143,7 @@
<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.
{{ t('auth.oauthFlow.createAccountHint') }}
</p>
<PendingOAuthCreateAccountForm
test-id-prefix="linuxdo"
@@ -162,7 +157,7 @@
<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 LinuxDo sign-in.
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
</p>
<div class="space-y-3">
<input
@@ -170,7 +165,7 @@
data-testid="linuxdo-bind-login-email"
type="email"
class="input w-full"
placeholder="you@example.com"
:placeholder="t('auth.emailPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
@@ -179,7 +174,7 @@
data-testid="linuxdo-bind-login-password"
type="password"
class="input w-full"
placeholder="Password"
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
@@ -189,7 +184,7 @@
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@click="handleBindLogin"
>
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
</button>
<button
v-if="canReturnToCreateAccount"
@@ -197,21 +192,19 @@
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Use a different email
{{ t('auth.oauthFlow.useDifferentEmail') }}
</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 LinuxDo sign-in.
{{
t('auth.oauthFlow.totpHint', {
providerName,
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
})
}}
</p>
<div class="space-y-3">
<input
@@ -231,51 +224,24 @@
:disabled="isSubmitting || totpCode.trim().length !== 6"
@click="handleSubmitTotpChallenge"
>
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
</button>
</div>
<transition name="fade">
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
{{ totpError }}
</p>
</transition>
</template>
</div>
</transition>
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<div class="space-y-2">
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<router-link to="/login" class="btn btn-primary">
{{ t('auth.linuxdo.backToLogin') }}
</router-link>
</div>
</div>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
import {
@@ -325,11 +291,36 @@ const totpTempToken = ref('')
const totpCode = ref('')
const totpError = ref('')
const totpUserEmailMasked = ref('')
const providerName = 'LinuxDo'
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
watch(invitationError, value => {
if (value) {
appStore.showError(value)
}
})
watch(accountActionError, value => {
if (value) {
appStore.showError(value)
}
})
watch(totpError, value => {
if (value) {
appStore.showError(value)
}
})
watch(errorMessage, value => {
if (value) {
appStore.showError(value)
}
})
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
step?: string
intent?: string
@@ -542,6 +533,30 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
function isCreateAccountRecoveryError(error: unknown): boolean {
const data = (error as {
response?: {
data?: {
reason?: string
error?: string
code?: string
step?: string
intent?: string
}
}
}).response?.data
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
.map(value => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email') ||
states.includes('existing_account_required') ||
states.includes('existing_account_binding_required')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
@@ -629,7 +644,6 @@ async function handleContinueLogin() {
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value)
needsAdoptionConfirmation.value = false
} finally {
isSubmitting.value = false
@@ -651,6 +665,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email.trim())
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
@@ -728,7 +746,6 @@ onMounted(async () => {
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
@@ -770,7 +787,6 @@ onMounted(async () => {
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value)
isProcessing.value = false
}
})
+12 -28
View File
@@ -61,9 +61,6 @@
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Password Input -->
@@ -96,10 +93,7 @@
</button>
</div>
<div class="mt-1 flex items-center justify-between">
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<span v-else></span>
<span></span>
<router-link
v-if="passwordResetEnabled && !backendModeEnabled"
to="/forgot-password"
@@ -119,28 +113,8 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
@@ -199,7 +173,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { computed, ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
@@ -258,6 +232,16 @@ const errors = reactive({
turnstile: ''
})
const validationToastMessage = computed(
() => errors.email || errors.password || errors.turnstile || ''
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
}
})
// ==================== Lifecycle ====================
onMounted(async () => {
+21 -15
View File
@@ -2,10 +2,11 @@
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
<div class="mx-auto max-w-2xl">
<div class="card p-6">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">OAuth Callback</h1>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('auth.oauth.callbackTitle') }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Copy the <code>code</code> (and <code>state</code> if needed) back to the admin
authorization flow.
{{ t('auth.oauth.callbackHint') }}
</p>
<div class="mt-6 space-y-4">
@@ -14,7 +15,7 @@
<div class="flex gap-2">
<input class="input flex-1 font-mono text-sm" :value="code" readonly />
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
Copy
{{ t('common.copy') }}
</button>
</div>
</div>
@@ -29,7 +30,7 @@
:disabled="!state"
@click="copy(state)"
>
Copy
{{ t('common.copy') }}
</button>
</div>
</div>
@@ -44,17 +45,10 @@
:disabled="!fullUrl"
@click="copy(fullUrl)"
>
Copy
{{ t('common.copy') }}
</button>
</div>
</div>
<div
v-if="error"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</div>
</div>
</div>
</div>
@@ -62,14 +56,16 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
const route = useRoute()
const { t } = useI18n()
const { copyToClipboard } = useClipboard()
const appStore = useAppStore()
const code = computed(() => (route.query.code as string) || '')
const state = computed(() => (route.query.state as string) || '')
@@ -82,8 +78,18 @@ const fullUrl = computed(() => {
return window.location.href
})
watch(
error,
(message) => {
if (message) {
appStore.showError(message)
}
},
{ immediate: true }
)
const copy = (value: string) => {
if (!value) return
copyToClipboard(value, 'Copied')
copyToClipboard(value)
}
</script>
+78 -64
View File
@@ -33,11 +33,10 @@
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Use {{ providerName }} profile details
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Choose whether to apply the nickname or avatar from {{ providerName }} to this
account.
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
</p>
</div>
@@ -48,7 +47,7 @@
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
Use display name
{{ t('auth.oauthFlow.useDisplayName') }}
</span>
<span class="block text-gray-500 dark:text-dark-400">
{{ suggestedDisplayName }}
@@ -63,12 +62,12 @@
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
<img
:src="suggestedAvatarUrl"
:alt="`${providerName} avatar`"
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
/>
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
Use avatar
{{ t('auth.oauthFlow.useAvatar') }}
</span>
<span class="block break-all text-gray-500 dark:text-dark-400">
{{ suggestedAvatarUrl }}
@@ -92,11 +91,6 @@
@keyup.enter="handleSubmitInvitation"
/>
</div>
<transition name="fade">
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
{{ invitationError }}
</p>
</transition>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !invitationCode.trim()"
@@ -112,10 +106,10 @@
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
Review the {{ providerName }} profile details before continuing.
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
</p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
{{ isSubmitting ? t('common.processing') : 'Continue' }}
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
</button>
</template>
@@ -124,13 +118,13 @@
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Choose how to continue
{{ t('auth.oauthFlow.chooseHowToContinue') }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
pendingAccountEmail
? `Suggested email: ${pendingAccountEmail}`
: `Choose whether to bind an existing ${providerName} account or create a new one.`
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
: t('auth.oauthFlow.chooseAccountActionHint')
}}
</p>
</div>
@@ -141,14 +135,14 @@
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
{{ t('auth.oauthFlow.bindExistingAccount') }}
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Create new account
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</div>
</div>
@@ -157,7 +151,7 @@
<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.
{{ t('auth.oauthFlow.createAccountHint') }}
</p>
<PendingOAuthCreateAccountForm
test-id-prefix="oidc"
@@ -171,7 +165,7 @@
<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.
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
</p>
<div class="space-y-3">
<input
@@ -179,7 +173,7 @@
data-testid="oidc-bind-login-email"
type="email"
class="input w-full"
placeholder="you@example.com"
:placeholder="t('auth.emailPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
@@ -188,7 +182,7 @@
data-testid="oidc-bind-login-password"
type="password"
class="input w-full"
placeholder="Password"
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
@@ -198,7 +192,7 @@
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@click="handleBindLogin"
>
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
</button>
<button
v-if="canReturnToCreateAccount"
@@ -206,21 +200,19 @@
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Use a different email
{{ t('auth.oauthFlow.useDifferentEmail') }}
</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.
{{
t('auth.oauthFlow.totpHint', {
providerName,
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
})
}}
</p>
<div class="space-y-3">
<input
@@ -240,51 +232,24 @@
:disabled="isSubmitting || totpCode.trim().length !== 6"
@click="handleSubmitTotpChallenge"
>
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
</button>
</div>
<transition name="fade">
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
{{ totpError }}
</p>
</transition>
</template>
</div>
</transition>
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<div class="space-y-2">
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<router-link to="/login" class="btn btn-primary">
{{ t('auth.oidc.backToLogin') }}
</router-link>
</div>
</div>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
import {
@@ -339,6 +304,30 @@ const needsCreateAccount = computed(() => pendingAccountAction.value === 'create
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
watch(invitationError, value => {
if (value) {
appStore.showError(value)
}
})
watch(accountActionError, value => {
if (value) {
appStore.showError(value)
}
})
watch(totpError, value => {
if (value) {
appStore.showError(value)
}
})
watch(errorMessage, value => {
if (value) {
appStore.showError(value)
}
})
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
step?: string
pending_email?: string
@@ -573,6 +562,30 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
function isCreateAccountRecoveryError(error: unknown): boolean {
const data = (error as {
response?: {
data?: {
reason?: string
error?: string
code?: string
step?: string
intent?: string
}
}
}).response?.data
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
.map(value => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email') ||
states.includes('existing_account_required') ||
states.includes('existing_account_binding_required')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
@@ -660,7 +673,6 @@ async function handleContinueLogin() {
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value)
needsAdoptionConfirmation.value = false
} finally {
isSubmitting.value = false
@@ -682,6 +694,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email.trim())
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
@@ -761,7 +777,6 @@ onMounted(async () => {
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
@@ -803,7 +818,6 @@ onMounted(async () => {
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value)
isProcessing.value = false
}
})
+17 -36
View File
@@ -76,9 +76,6 @@
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Password Input -->
@@ -110,9 +107,6 @@
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<p v-else class="input-hint">
{{ t('auth.passwordHint') }}
</p>
@@ -162,12 +156,6 @@
{{ t('auth.invitationCodeValid') }}
</span>
</div>
<p v-else-if="invitationValidation.invalid" class="input-error-text">
{{ invitationValidation.message }}
</p>
<p v-else-if="errors.invitation_code" class="input-error-text">
{{ errors.invitation_code }}
</p>
</transition>
</div>
@@ -216,9 +204,6 @@
{{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }}
</span>
</div>
<p v-else-if="promoValidation.invalid" class="input-error-text">
{{ promoValidation.message }}
</p>
</transition>
</div>
@@ -231,28 +216,8 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
@@ -307,7 +272,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { computed, ref, reactive, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
@@ -391,6 +356,22 @@ const errors = reactive({
invitation_code: ''
})
const validationToastMessage = computed(() =>
errors.email ||
errors.password ||
(invitationValidation.invalid ? invitationValidation.message : '') ||
errors.invitation_code ||
(promoValidation.invalid ? promoValidation.message : '') ||
errors.turnstile ||
''
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
}
})
// ==================== Lifecycle ====================
onMounted(async () => {
+20 -29
View File
@@ -13,16 +13,16 @@
<!-- Invalid Link State -->
<div v-if="isInvalidLink" class="space-y-6">
<div class="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20">
<div class="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800/50 dark:bg-amber-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50">
<Icon name="exclamationCircle" size="lg" class="text-red-600 dark:text-red-400" />
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50">
<Icon name="exclamationCircle" size="lg" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
<h3 class="text-lg font-semibold text-amber-800 dark:text-amber-200">
{{ t('auth.invalidResetLink') }}
</h3>
<p class="mt-2 text-sm text-red-700 dark:text-red-300">
<p class="mt-2 text-sm text-amber-700 dark:text-amber-300">
{{ t('auth.invalidResetLinkHint') }}
</p>
</div>
@@ -119,9 +119,6 @@
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
</div>
<!-- Confirm Password Input -->
@@ -153,28 +150,8 @@
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.confirmPassword" class="input-error-text">
{{ errors.confirmPassword }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
@@ -223,7 +200,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
@@ -260,6 +237,16 @@ const errors = reactive({
confirmPassword: ''
})
const validationToastMessage = computed(
() => errors.password || errors.confirmPassword || ''
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
}
})
// Check if the reset link is valid (has email and token)
const isInvalidLink = computed(() => !email.value || !token.value)
@@ -269,6 +256,10 @@ onMounted(() => {
// Get email and token from URL query parameters
email.value = (route.query.email as string) || ''
token.value = (route.query.token as string) || ''
if (!email.value || !token.value) {
appStore.showError(t('auth.invalidResetLink'))
}
})
// ==================== Validation ====================
+69 -96
View File
@@ -33,10 +33,10 @@
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Use {{ providerName }} profile details
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Choose whether to apply the nickname or avatar from {{ providerName }} to this account.
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
</p>
</div>
@@ -47,7 +47,7 @@
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
Use display name
{{ t('auth.oauthFlow.useDisplayName') }}
</span>
<span class="block text-gray-500 dark:text-dark-400">
{{ suggestedDisplayName }}
@@ -62,12 +62,12 @@
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
<img
:src="suggestedAvatarUrl"
:alt="`${providerName} avatar`"
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
/>
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
Use avatar
{{ t('auth.oauthFlow.useAvatar') }}
</span>
<span class="block break-all text-gray-500 dark:text-dark-400">
{{ suggestedAvatarUrl }}
@@ -91,11 +91,6 @@
@keyup.enter="handleSubmitInvitation"
/>
</div>
<transition name="fade">
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
{{ invitationError }}
</p>
</transition>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !invitationCode.trim()"
@@ -119,8 +114,8 @@
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
hasCurrentAuthToken
? 'Bind this WeChat identity to the account currently signed in on this browser.'
: 'Sign in to an existing account, then bind this WeChat identity to it.'
? t('auth.oauthFlow.bindCurrentAccountDescription', { providerName })
: t('auth.oauthFlow.signInThenBindDescription', { providerName })
}}
</p>
</div>
@@ -142,7 +137,7 @@
:disabled="isSubmitting"
@click="handleExistingAccountBinding"
>
{{ hasCurrentAuthToken ? 'Bind current account' : t('auth.signIn') }}
{{ hasCurrentAuthToken ? t('auth.oauthFlow.bindCurrentAccount') : t('auth.signIn') }}
</button>
</div>
</div>
@@ -155,10 +150,10 @@
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Choose how to continue
{{ t('auth.oauthFlow.chooseHowToContinue') }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Pick whether to bind an existing account or create a new one.
{{ t('auth.oauthFlow.chooseAccountActionHint') }}
</p>
</div>
@@ -169,7 +164,7 @@
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
{{ t('auth.oauthFlow.bindExistingAccount') }}
</button>
<button
@@ -179,7 +174,7 @@
:disabled="isSubmitting"
@click="switchToCreateAccountMode()"
>
Create new account
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</div>
</div>
@@ -187,16 +182,16 @@
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
Review the {{ providerName }} profile details before continuing.
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
</p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
{{ isSubmitting ? t('common.processing') : 'Continue' }}
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
</button>
</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.
{{ t('auth.oauthFlow.createAccountHint') }}
</p>
<PendingOAuthCreateAccountForm
test-id-prefix="wechat"
@@ -210,15 +205,15 @@
v-if="showBackToChooser"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToChoiceMode"
@click="switchToCreateAccountMode()"
>
Back to options
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</template>
<template v-else-if="needsBindLogin">
<p class="text-sm text-gray-700 dark:text-gray-300">
Bind this {{ providerName }} sign-in to an existing account.
{{ t('auth.oauthFlow.bindSignInToExistingAccount', { providerName }) }}
</p>
<div
v-if="hasCurrentAuthToken"
@@ -227,10 +222,10 @@
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Bind the current account
{{ t('auth.oauthFlow.bindCurrentAccountTitle') }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Bind this WeChat identity to the account currently signed in on this browser.
{{ t('auth.oauthFlow.bindCurrentAccountDescription', { providerName }) }}
</p>
</div>
@@ -241,7 +236,7 @@
:disabled="isSubmitting"
@click="handleBindCurrentAccount"
>
{{ isSubmitting ? t('common.processing') : 'Bind current account' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.bindCurrentAccount') }}
</button>
</div>
</div>
@@ -251,7 +246,7 @@
data-testid="wechat-bind-login-email"
type="email"
class="input w-full"
placeholder="you@example.com"
:placeholder="t('auth.emailPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
@@ -260,7 +255,7 @@
data-testid="wechat-bind-login-password"
type="password"
class="input w-full"
placeholder="Password"
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
@@ -270,24 +265,27 @@
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@click="handleBindLogin"
>
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
</button>
</div>
<button
v-if="showBackToChooser"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToChoiceMode"
@click="switchToCreateAccountMode()"
>
Back to options
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</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.
{{
t('auth.oauthFlow.totpHint', {
providerName,
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
})
}}
</p>
<div class="space-y-3">
<input
@@ -307,57 +305,24 @@
:disabled="isSubmitting || totpCode.trim().length !== 6"
@click="handleSubmitTotpChallenge"
>
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
</button>
</div>
<transition name="fade">
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
{{ totpError }}
</p>
</transition>
</template>
</div>
</transition>
<transition name="fade">
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
{{ accountActionError }}
</p>
</transition>
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<div class="space-y-2">
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<router-link to="/login" class="btn btn-primary">
{{ t('auth.oidc.backToLogin') }}
</router-link>
</div>
</div>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
import {
@@ -411,7 +376,7 @@ const totpError = ref('')
const totpUserEmailMasked = ref('')
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
const providerName = 'WeChat'
const providerName = t('auth.wechatProviderName')
const showBackToChooser = computed(
() => pendingAccountAction.value === 'create_account' || pendingAccountAction.value === 'bind_login'
)
@@ -419,6 +384,30 @@ const needsCreateAccount = computed(() => pendingAccountAction.value === 'create
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
const hasCurrentAuthToken = computed(() => Boolean(getAuthToken()))
watch(invitationError, value => {
if (value) {
appStore.showError(value)
}
})
watch(accountActionError, value => {
if (value) {
appStore.showError(value)
}
})
watch(totpError, value => {
if (value) {
appStore.showError(value)
}
})
watch(errorMessage, value => {
if (value) {
appStore.showError(value)
}
})
type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
step?: string
status?: string
@@ -510,13 +499,13 @@ function resolveWeChatOAuthUnavailableMessage(): string {
switch (resolved.unavailableReason) {
case 'capability_unknown':
return 'WeChat sign-in availability could not be confirmed. Refresh and retry.'
return t('auth.oauthFlow.wechatAvailabilityUnknown')
case 'external_browser_required':
return 'This WeChat sign-in flow is only available in your system browser.'
return t('auth.oauthFlow.wechatSystemBrowserOnly')
case 'wechat_browser_required':
return 'This WeChat sign-in flow is only available inside the WeChat browser.'
return t('auth.oauthFlow.wechatBrowserOnly')
case 'not_configured':
return 'WeChat sign-in is not configured yet.'
return t('auth.oauthFlow.wechatNotConfigured')
default:
return t('auth.loginFailed')
}
@@ -619,7 +608,6 @@ async function handleBindCurrentAccount() {
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
@@ -636,7 +624,6 @@ async function handleExistingAccountBinding() {
const resumePath = buildExistingAccountResumePath()
if (!resumePath) {
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
@@ -693,11 +680,7 @@ function resolvePendingAccountAction(
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose' ||
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email'
raw === 'choose'
) {
return 'choice'
}
@@ -705,6 +688,10 @@ function resolvePendingAccountAction(
return 'create_account'
}
if (
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email' ||
raw === 'bind_login_required' ||
raw === 'bind_login'
) {
@@ -776,13 +763,6 @@ function switchToCreateAccountMode() {
accountActionError.value = ''
}
function switchToChoiceMode() {
pendingAccountAction.value = 'choice'
needsChooser.value = true
bindLoginPassword.value = ''
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
@@ -899,7 +879,6 @@ async function handleContinueLogin() {
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value)
needsAdoptionConfirmation.value = false
} finally {
isSubmitting.value = false
@@ -922,10 +901,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToChoiceMode()
pendingAccountEmail.value = payload.email.trim()
bindLoginEmail.value = payload.email.trim()
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
switchToBindLoginMode(payload.email.trim())
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
@@ -1000,7 +976,6 @@ onMounted(async () => {
const resumePath = buildExistingAccountResumePath()
if (!resumePath) {
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
@@ -1044,7 +1019,6 @@ onMounted(async () => {
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
@@ -1086,7 +1060,6 @@ onMounted(async () => {
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value)
isProcessing.value = false
}
})
@@ -20,9 +20,9 @@
<div
v-else
class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-700/50 dark:bg-red-900/20"
class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/80"
>
<p class="text-sm text-red-700 dark:text-red-400">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ errorMessage }}
</p>
<button
@@ -39,40 +39,27 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores'
const { t, locale } = useI18n()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const errorMessage = ref('')
function textWithFallback(key: string, zh: string, en: string): string {
const translated = t(key)
if (translated !== key) return translated
return String(locale.value).toLowerCase().startsWith('zh') ? zh : en
}
watch(errorMessage, (message) => {
if (message) {
appStore.showError(message)
}
})
const callbackProcessingText = computed(() =>
textWithFallback(
'auth.wechatPayment.callbackProcessing',
'正在恢复微信支付...',
'Resuming WeChat payment...',
))
const callbackTitleText = computed(() =>
textWithFallback(
'auth.wechatPayment.callbackTitle',
'正在恢复微信支付',
'Resuming WeChat payment',
))
const backToPaymentText = computed(() =>
textWithFallback(
'auth.wechatPayment.backToPayment',
'返回支付页',
'Back to payment',
))
const callbackProcessingText = computed(() => t('auth.wechatPayment.callbackProcessing'))
const callbackTitleText = computed(() => t('auth.wechatPayment.callbackTitle'))
const backToPaymentText = computed(() => t('auth.wechatPayment.backToPayment'))
function readQueryString(key: string): string {
const value = route.query[key]
@@ -121,11 +108,7 @@ onMounted(async () => {
)
if (!resumeToken) {
errorMessage.value = textWithFallback(
'auth.wechatPayment.callbackMissingResumeToken',
'微信支付回调缺少恢复令牌。',
'The WeChat payment callback is missing the resume token.',
)
errorMessage.value = t('auth.wechatPayment.callbackMissingResumeToken')
return
}
@@ -31,7 +31,12 @@ vi.mock('vue-i18n', async () => {
return {
...actual,
useI18n: () => ({
t: (key: string) => key
t: (key: string, params?: Record<string, string>) => {
if (key === 'auth.oauthFlow.totpHint') {
return `verify ${params?.account ?? ''}`.trim()
}
return key
}
})
}
})
@@ -498,6 +503,34 @@ describe('LinuxDoCallbackView', () => {
)
})
it('shows create-account failures through toast without inline error text', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
redirect: '/welcome'
})
apiClientPost.mockRejectedValue(new Error('create failed'))
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-password"]').setValue('secret-123')
await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledWith('create failed')
expect(wrapper.text()).not.toContain('create failed')
})
it('sends a verify code for pending oauth account creation', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
@@ -0,0 +1,68 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue'
const { routeState, showErrorMock, copyToClipboardMock } = vi.hoisted(() => ({
routeState: {
query: {} as Record<string, unknown>,
},
showErrorMock: vi.fn(),
copyToClipboardMock: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: (...args: any[]) => showErrorMock(...args),
}),
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args),
}),
}))
describe('OAuthCallbackView', () => {
beforeEach(() => {
routeState.query = {}
showErrorMock.mockReset()
copyToClipboardMock.mockReset()
})
it('renders localized callback copy actions', () => {
routeState.query = {
code: 'oauth-code',
state: 'oauth-state',
}
const wrapper = mount(OAuthCallbackView)
expect(wrapper.text()).toContain('auth.oauth.callbackTitle')
expect(wrapper.text()).toContain('auth.oauth.callbackHint')
expect(wrapper.text()).toContain('common.copy')
expect(wrapper.find('input[value="oauth-code"]').exists()).toBe(true)
expect(wrapper.find('input[value="oauth-state"]').exists()).toBe(true)
})
it('sends callback errors to toast instead of rendering inline red text', () => {
routeState.query = {
error: 'oauth failed',
}
const wrapper = mount(OAuthCallbackView)
expect(showErrorMock).toHaveBeenCalledWith('oauth failed')
expect(wrapper.text()).not.toContain('oauth failed')
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
})
})
@@ -32,6 +32,9 @@ vi.mock('vue-i18n', async () => {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (key === 'auth.oauthFlow.totpHint') {
return `verify ${params?.account ?? ''}`.trim()
}
if (!params?.providerName) {
return key
}
@@ -477,6 +480,34 @@ describe('OidcCallbackView', () => {
)
})
it('shows create-account failures through toast without inline error text', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
redirect: '/welcome'
})
apiClientPost.mockRejectedValue(new Error('create failed'))
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-password"]').setValue('secret-123')
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledWith('create failed')
expect(wrapper.text()).not.toContain('create failed')
})
it('sends a verify code for pending oauth account creation', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
@@ -71,6 +71,9 @@ vi.mock('vue-i18n', () => ({
}),
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (key === 'auth.oauthFlow.totpHint') {
return `verify ${params?.account ?? ''}`.trim()
}
if (key === 'auth.oidc.callbackTitle') {
return `Signing you in with ${params?.providerName ?? ''}`.trim()
}
@@ -695,6 +698,34 @@ describe('WechatCallbackView', () => {
)
})
it('shows create-account failures through toast without inline error text', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'email_required',
redirect: '/welcome',
})
apiClientPostMock.mockRejectedValue(new Error('create failed'))
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-password"]').setValue('secret-123')
await wrapper.get('[data-testid="wechat-create-account-submit"]').trigger('click')
await flushPromises()
expect(showErrorMock).toHaveBeenCalledWith('create failed')
expect(wrapper.text()).not.toContain('create failed')
})
it('sends a verify code for pending oauth account creation', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'email_required',
@@ -2,7 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WechatPaymentCallbackView from '@/views/auth/WechatPaymentCallbackView.vue'
const { replaceMock, routeState, locationState } = vi.hoisted(() => ({
const { replaceMock, routeState, locationState, showErrorMock } = vi.hoisted(() => ({
replaceMock: vi.fn(),
routeState: {
query: {} as Record<string, unknown>,
@@ -16,6 +16,7 @@ const { replaceMock, routeState, locationState } = vi.hoisted(() => ({
origin: 'http://localhost',
} as Location & { origin: string },
},
showErrorMock: vi.fn(),
}))
vi.mock('vue-router', () => ({
@@ -27,14 +28,27 @@ vi.mock('vue-router', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
t: (key: string) => {
if (key === 'auth.wechatPayment.callbackTitle') return '正在恢复微信支付'
if (key === 'auth.wechatPayment.callbackProcessing') return '正在恢复微信支付...'
if (key === 'auth.wechatPayment.backToPayment') return '返回支付页'
if (key === 'auth.wechatPayment.callbackMissingResumeToken') return '微信支付回调缺少恢复令牌。'
return key
},
locale: { value: 'zh-CN' },
}),
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: (...args: any[]) => showErrorMock(...args),
}),
}))
describe('WechatPaymentCallbackView', () => {
beforeEach(() => {
replaceMock.mockReset()
showErrorMock.mockReset()
routeState.query = {}
locationState.current = {
href: 'http://localhost/auth/wechat/payment/callback',
@@ -72,6 +86,8 @@ describe('WechatPaymentCallbackView', () => {
await flushPromises()
expect(replaceMock).not.toHaveBeenCalled()
expect(showErrorMock).toHaveBeenCalledWith('微信支付回调缺少恢复令牌。')
expect(wrapper.text()).toContain('微信支付回调缺少恢复令牌。')
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
})
})