fix(auth): require explicit choice for third-party signup
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user