fix(auth): require explicit choice for third-party signup

This commit is contained in:
IanShaw027
2026-04-21 20:36:58 +08:00
parent 2cebb0dc60
commit 4c21320d1b
8 changed files with 638 additions and 296 deletions

View File

@@ -15,6 +15,7 @@
v-if="
needsInvitation ||
needsAdoptionConfirmation ||
needsChooser ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
@@ -109,6 +110,42 @@
</button>
</template>
<template v-else-if="needsChooser">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
<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
</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.'
}}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Create new account
</button>
</div>
</div>
</div>
</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.
@@ -275,7 +312,7 @@ const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true)
const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
@@ -290,12 +327,17 @@ const totpError = ref('')
const totpUserEmailMasked = ref('')
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
step?: string
intent?: string
email?: string
resolved_email?: string
pending_email?: string
existing_account_email?: string
suggested_email?: string
}
function persistPendingAuthSession(redirect?: string) {
@@ -392,12 +434,34 @@ function hasSuggestedProfile(completion: {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
}
function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string {
return (completion.email || completion.resolved_email || '').trim()
function normalizedPendingState(value: string | null | undefined): string {
return value?.trim().toLowerCase() || ''
}
function resolvePendingAccountAction(completion: LinuxDoPendingActionResponse): 'none' | 'create_account' | 'bind_login' {
const raw = (completion.step || completion.error || '').trim().toLowerCase()
function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string {
return (
completion.pending_email ||
completion.existing_account_email ||
completion.email ||
completion.resolved_email ||
completion.suggested_email ||
''
).trim()
}
function resolvePendingAccountAction(
completion: LinuxDoPendingActionResponse
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose'
) {
return 'choose_account_action'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account'
}
@@ -418,6 +482,14 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion)
if (action === 'choose_account_action') {
pendingAccountEmail.value = email
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
if (action === 'create_account') {
pendingAccountEmail.value = email
canReturnToCreateAccount.value = true
@@ -470,28 +542,6 @@ 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')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
@@ -601,10 +651,6 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email)
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false

View File

@@ -19,6 +19,7 @@
v-if="
needsInvitation ||
needsAdoptionConfirmation ||
needsChooser ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
@@ -118,6 +119,42 @@
</button>
</template>
<template v-else-if="needsChooser">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
<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
</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.`
}}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Create new account
</button>
</div>
</div>
</div>
</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.
@@ -284,7 +321,7 @@ const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true)
const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
@@ -299,6 +336,7 @@ const totpError = ref('')
const totpUserEmailMasked = ref('')
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
@@ -307,6 +345,7 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
resolved_email?: string
existing_account_email?: string
email?: string
suggested_email?: string
provider_fallback?: string
intent?: string
requires_2fa?: boolean
@@ -430,12 +469,24 @@ function extractPendingAccountEmail(completion: PendingOidcCompletion): string {
completion.existing_account_email ||
completion.resolved_email ||
completion.email ||
completion.suggested_email ||
''
).trim()
}
function resolvePendingAccountAction(completion: PendingOidcCompletion): 'none' | 'create_account' | 'bind_login' {
function resolvePendingAccountAction(
completion: PendingOidcCompletion
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose'
) {
return 'choose_account_action'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account'
}
@@ -462,6 +513,14 @@ function applyPendingAccountAction(completion: PendingOidcCompletion) {
totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion)
if (action === 'choose_account_action') {
pendingAccountEmail.value = email
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
if (action === 'create_account') {
pendingAccountEmail.value = email
canReturnToCreateAccount.value = true
@@ -514,28 +573,6 @@ 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')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
@@ -645,10 +682,6 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email)
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false

View File

@@ -18,6 +18,7 @@
<div
v-if="
needsInvitation ||
needsChooser ||
needsAdoptionConfirmation ||
needsCreateAccount ||
needsBindLogin ||
@@ -103,7 +104,7 @@
{{
isSubmitting
? t('auth.oidc.completing')
: t('auth.oidc.completeRegistration')
: t('auth.oidc.completeRegistration')
}}
</button>
@@ -147,6 +148,43 @@
</div>
</template>
<template v-else-if="needsChooser">
<div
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<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
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Pick whether to bind an existing account or create a new one.
</p>
</div>
<button
data-testid="wechat-choice-bind-existing"
type="button"
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
</button>
<button
data-testid="wechat-choice-create-account"
type="button"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode()"
>
Create new account
</button>
</div>
</div>
</template>
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
Review the {{ providerName }} profile details before continuing.
@@ -168,13 +206,46 @@
@submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode"
/>
<button
v-if="showBackToChooser"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToChoiceMode"
>
Back to options
</button>
</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.
Bind this {{ providerName }} sign-in to an existing account.
</p>
<div class="space-y-3">
<div
v-if="hasCurrentAuthToken"
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<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
</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.
</p>
</div>
<button
data-testid="existing-account-submit"
type="button"
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="handleBindCurrentAccount"
>
{{ isSubmitting ? t('common.processing') : 'Bind current account' }}
</button>
</div>
</div>
<div v-else class="space-y-3">
<input
v-model="bindLoginEmail"
data-testid="wechat-bind-login-email"
@@ -201,20 +272,15 @@
>
{{ 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>
<button
v-if="showBackToChooser"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToChoiceMode"
>
Back to options
</button>
</template>
<template v-else-if="needsTotpChallenge">
@@ -253,6 +319,12 @@
</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"
@@ -314,6 +386,7 @@ const appStore = useAppStore()
const isProcessing = ref(true)
const errorMessage = ref('')
const needsInvitation = ref(false)
const needsChooser = ref(false)
const invitationCode = ref('')
const isSubmitting = ref(false)
const invitationError = ref('')
@@ -325,13 +398,12 @@ const existingAccountEmail = ref('')
const adoptDisplayName = ref(true)
const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
const pendingAccountAction = ref<'none' | 'choice' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('')
const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const needsTotpChallenge = ref(false)
const totpTempToken = ref('')
const totpCode = ref('')
@@ -340,12 +412,17 @@ const totpUserEmailMasked = ref('')
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
const providerName = 'WeChat'
const showBackToChooser = computed(
() => pendingAccountAction.value === 'create_account' || pendingAccountAction.value === 'bind_login'
)
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
const hasCurrentAuthToken = computed(() => Boolean(getAuthToken()))
type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
step?: string
status?: string
state?: string
pending_email?: string
resolved_email?: string
existing_account_email?: string
@@ -489,11 +566,6 @@ function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_use
intent,
})
const email = existingAccountEmail.value.trim()
if (email) {
params.set('email', email)
}
return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
}
@@ -502,6 +574,7 @@ function buildExistingAccountResumePath(): string | null {
if (!mode) {
return null
}
const params = new URLSearchParams({
wechat_bind_existing: '1',
redirect: resolveRedirectTarget(),
@@ -538,26 +611,31 @@ function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<stri
return payload
}
async function handleExistingAccountBinding() {
async function handleBindCurrentAccount() {
const unavailableMessage = resolveConfiguredWeChatOAuthMode() === null
? resolveWeChatOAuthUnavailableMessage()
: ''
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
}
async function handleExistingAccountBinding() {
if (getAuthToken()) {
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
await handleBindCurrentAccount()
return
}
const resumePath = buildExistingAccountResumePath()
if (!resumePath) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
@@ -606,17 +684,29 @@ function extractPendingAccountEmail(completion: PendingWeChatCompletion): string
function resolvePendingAccountAction(
completion: PendingWeChatCompletion
): 'none' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
): 'none' | 'choice' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(
completion.step || completion.status || completion.state || completion.error || completion.intent
)
if (
raw === 'choice' ||
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'
) {
return 'choice'
}
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'
raw === 'bind_login'
) {
return 'bind_login'
}
@@ -627,6 +717,7 @@ function applyPendingAccountAction(completion: PendingWeChatCompletion) {
const action = resolvePendingAccountAction(completion)
pendingAccountAction.value = action
accountActionError.value = ''
needsChooser.value = false
needsTotpChallenge.value = false
totpTempToken.value = ''
totpCode.value = ''
@@ -634,20 +725,22 @@ function applyPendingAccountAction(completion: PendingWeChatCompletion) {
totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion)
pendingAccountEmail.value = email
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
if (action === 'choice') {
needsChooser.value = true
bindLoginPassword.value = ''
return
}
}
function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
@@ -656,6 +749,7 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
}
pendingAccountAction.value = 'none'
needsChooser.value = false
needsInvitation.value = false
needsAdoptionConfirmation.value = false
needsTotpChallenge.value = true
@@ -669,18 +763,26 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login'
needsChooser.value = false
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
}
function switchToCreateAccountMode() {
pendingAccountAction.value = 'create_account'
needsChooser.value = false
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
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
@@ -705,7 +807,9 @@ function isCreateAccountRecoveryError(error: unknown): boolean {
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email')
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) {
@@ -818,7 +922,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email)
switchToChoiceMode()
pendingAccountEmail.value = payload.email.trim()
bindLoginEmail.value = payload.email.trim()
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
@@ -878,20 +985,15 @@ onMounted(async () => {
}
if (typeof route.query.email === 'string') {
existingAccountEmail.value = route.query.email
const email = route.query.email.trim()
existingAccountEmail.value = email
bindLoginEmail.value = email
pendingAccountEmail.value = email
}
if (route.query.wechat_bind_existing === '1') {
if (getAuthToken()) {
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
await handleBindCurrentAccount()
return
}