feat: add oauth callback email binding ui

This commit is contained in:
IanShaw027
2026-04-20 19:30:19 +08:00
parent 6a75bd77e3
commit 6ea3f42e2f
10 changed files with 916 additions and 36 deletions
+126 -2
View File
@@ -15,7 +15,15 @@
</div>
<transition name="fade">
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
<div
v-if="
needsInvitation ||
needsEmailCollection ||
needsExistingAccountBinding ||
needsAdoptionConfirmation
"
class="space-y-4"
>
<div
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
@@ -100,6 +108,39 @@
</button>
</template>
<template v-else-if="needsEmailCollection">
<p class="text-sm text-gray-700 dark:text-gray-300">
Continue with email to finish setting up your {{ providerName }} sign-in.
</p>
<div>
<input
v-model="pendingEmail"
type="email"
class="input w-full"
placeholder="you@example.com"
:disabled="isSubmitting"
@keyup.enter="handleContinueWithEmail"
/>
</div>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !pendingEmail.trim()"
@click="handleContinueWithEmail"
>
Continue with email
</button>
</template>
<template v-else-if="needsExistingAccountBinding">
<p class="text-sm text-gray-700 dark:text-gray-300">
Sign in to bind {{ providerName }} to the existing account for
<span class="font-medium text-gray-900 dark:text-white">{{ pendingEmail }}</span>.
</p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueToLogin">
Sign in to bind
</button>
</template>
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
Review the {{ providerName }} profile details before continuing.
@@ -174,9 +215,22 @@ const suggestedDisplayName = ref('')
const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true)
const adoptAvatar = ref(true)
const pendingEmail = ref('')
const needsEmailCollection = ref(false)
const needsExistingAccountBinding = ref(false)
const needsAdoptionConfirmation = ref(false)
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
step?: string
pending_email?: string
resolved_email?: string
existing_account_email?: string
email?: string
provider_fallback?: string
intent?: string
}
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
@@ -204,6 +258,34 @@ async function loadProviderName() {
}
}
function normalizedPendingState(value: string | null | undefined): string {
return value?.trim().toLowerCase() || ''
}
function resolvePendingEmail(completion: PendingOidcCompletion): string {
return (
completion.pending_email ||
completion.existing_account_email ||
completion.resolved_email ||
completion.email ||
''
).trim()
}
function requiresEmailCollection(completion: PendingOidcCompletion): boolean {
const state = normalizedPendingState(completion.step || completion.error)
return state === 'email_required'
}
function requiresExistingAccountBinding(completion: PendingOidcCompletion): boolean {
const state = normalizedPendingState(completion.step || completion.error || completion.intent)
return (
state === 'existing_account_binding_required' ||
state === 'existing_account_required' ||
state === 'adopt_existing_user_by_email'
)
}
function currentAdoptionDecision(): OAuthAdoptionDecision {
return {
adoptDisplayName: adoptDisplayName.value,
@@ -295,6 +377,35 @@ async function handleContinueLogin() {
}
}
async function handleContinueWithEmail() {
const email = pendingEmail.value.trim()
if (!email) {
return
}
await router.replace({
path: '/register',
query: {
email,
redirect: redirectTo.value,
provider: providerName.value
}
})
}
async function handleContinueToLogin() {
const email = pendingEmail.value.trim()
await router.replace({
path: '/login',
query: {
email,
redirect: redirectTo.value,
provider: providerName.value
}
})
}
onMounted(async () => {
void loadProviderName()
@@ -310,12 +421,13 @@ onMounted(async () => {
}
try {
const completion = await exchangePendingOAuthCompletion()
const completion = await exchangePendingOAuthCompletion() as PendingOidcCompletion
const redirect = sanitizeRedirectPath(
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
)
applyAdoptionSuggestionState(completion)
redirectTo.value = redirect
pendingEmail.value = resolvePendingEmail(completion)
if (completion.error === 'invitation_required') {
needsInvitation.value = true
@@ -323,6 +435,18 @@ onMounted(async () => {
return
}
if (requiresEmailCollection(completion)) {
needsEmailCollection.value = true
isProcessing.value = false
return
}
if (requiresExistingAccountBinding(completion)) {
needsExistingAccountBinding.value = true
isProcessing.value = false
return
}
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
needsAdoptionConfirmation.value = true
isProcessing.value = false