fix: complete oidc pending auth callback flows
This commit is contained in:
@@ -18,9 +18,10 @@
|
||||
<div
|
||||
v-if="
|
||||
needsInvitation ||
|
||||
needsEmailCollection ||
|
||||
needsExistingAccountBinding ||
|
||||
needsAdoptionConfirmation
|
||||
needsAdoptionConfirmation ||
|
||||
needsCreateAccount ||
|
||||
needsBindLogin ||
|
||||
needsTotpChallenge
|
||||
"
|
||||
class="space-y-4"
|
||||
>
|
||||
@@ -108,39 +109,6 @@
|
||||
</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.
|
||||
@@ -149,6 +117,124 @@
|
||||
{{ isSubmitting ? t('common.processing') : '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.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model="pendingAccountEmail"
|
||||
data-testid="oidc-create-account-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="you@example.com"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleCreateAccount"
|
||||
/>
|
||||
<button
|
||||
data-testid="oidc-create-account-submit"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !pendingAccountEmail.trim()"
|
||||
@click="handleCreateAccount"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Create account' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToBindLoginMode"
|
||||
>
|
||||
I already have an account
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ accountActionError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsBindLogin">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Log in to an existing account to bind this {{ providerName }} sign-in.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model="bindLoginEmail"
|
||||
data-testid="oidc-bind-login-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="you@example.com"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
<input
|
||||
v-model="bindLoginPassword"
|
||||
data-testid="oidc-bind-login-password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder="Password"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
<button
|
||||
data-testid="oidc-bind-login-submit"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||
@click="handleBindLogin"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canReturnToCreateAccount"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ accountActionError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsTotpChallenge">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter the 6-digit verification code for
|
||||
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
|
||||
to finish binding this {{ providerName }} sign-in.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model="totpCode"
|
||||
data-testid="oidc-bind-login-totp"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="input w-full"
|
||||
placeholder="123456"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitTotpChallenge"
|
||||
/>
|
||||
<button
|
||||
data-testid="oidc-bind-login-totp-submit"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||
@click="handleSubmitTotpChallenge"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ totpError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -177,11 +263,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeOIDCOAuthRegistration,
|
||||
@@ -189,6 +276,7 @@ import {
|
||||
getOAuthCompletionKind,
|
||||
getPublicSettings,
|
||||
isOAuthLoginCompletion,
|
||||
login2FA,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
@@ -203,7 +291,6 @@ const appStore = useAppStore()
|
||||
|
||||
const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const needsInvitation = ref(false)
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
@@ -215,11 +302,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 pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
|
||||
const pendingAccountEmail = ref('')
|
||||
const bindLoginEmail = ref('')
|
||||
const bindLoginPassword = ref('')
|
||||
const accountActionError = ref('')
|
||||
const canReturnToCreateAccount = ref(false)
|
||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||
const needsTotpChallenge = ref(false)
|
||||
const totpTempToken = ref('')
|
||||
const totpCode = ref('')
|
||||
const totpError = ref('')
|
||||
const totpUserEmailMasked = ref('')
|
||||
|
||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||
|
||||
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
@@ -229,6 +327,9 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
||||
email?: string
|
||||
provider_fallback?: string
|
||||
intent?: string
|
||||
requires_2fa?: boolean
|
||||
temp_token?: string
|
||||
user_email_masked?: string
|
||||
}
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
@@ -258,34 +359,6 @@ 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,
|
||||
@@ -293,6 +366,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
|
||||
const payload: Record<string, boolean> = {}
|
||||
if (typeof decision.adoptDisplayName === 'boolean') {
|
||||
payload.adopt_display_name = decision.adoptDisplayName
|
||||
}
|
||||
if (typeof decision.adoptAvatar === 'boolean') {
|
||||
payload.adopt_avatar = decision.adoptAvatar
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: {
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
@@ -317,6 +401,100 @@ function hasSuggestedProfile(completion: {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
function normalizedPendingState(value: string | null | undefined): string {
|
||||
return value?.trim().toLowerCase() || ''
|
||||
}
|
||||
|
||||
function extractPendingAccountEmail(completion: PendingOidcCompletion): string {
|
||||
return (
|
||||
completion.pending_email ||
|
||||
completion.existing_account_email ||
|
||||
completion.resolved_email ||
|
||||
completion.email ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolvePendingAccountAction(completion: PendingOidcCompletion): 'none' | 'create_account' | 'bind_login' {
|
||||
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
||||
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||
return 'create_account'
|
||||
}
|
||||
if (
|
||||
raw === 'bind_login_required' ||
|
||||
raw === 'bind_login' ||
|
||||
raw === 'existing_account_binding_required' ||
|
||||
raw === 'existing_account_required' ||
|
||||
raw === 'adopt_existing_user_by_email'
|
||||
) {
|
||||
return 'bind_login'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function applyPendingAccountAction(completion: PendingOidcCompletion) {
|
||||
const action = resolvePendingAccountAction(completion)
|
||||
pendingAccountAction.value = action
|
||||
accountActionError.value = ''
|
||||
needsTotpChallenge.value = false
|
||||
totpTempToken.value = ''
|
||||
totpCode.value = ''
|
||||
totpError.value = ''
|
||||
totpUserEmailMasked.value = ''
|
||||
|
||||
const email = extractPendingAccountEmail(completion)
|
||||
if (action === 'create_account') {
|
||||
pendingAccountEmail.value = email
|
||||
canReturnToCreateAccount.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'bind_login') {
|
||||
bindLoginEmail.value = email
|
||||
bindLoginPassword.value = ''
|
||||
canReturnToCreateAccount.value = false
|
||||
return
|
||||
}
|
||||
|
||||
canReturnToCreateAccount.value = false
|
||||
}
|
||||
|
||||
function applyTotpChallenge(completion: PendingOidcCompletion): boolean {
|
||||
if (completion.requires_2fa !== true || !completion.temp_token) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingAccountAction.value = 'none'
|
||||
needsInvitation.value = false
|
||||
needsAdoptionConfirmation.value = false
|
||||
needsTotpChallenge.value = true
|
||||
totpTempToken.value = completion.temp_token
|
||||
totpCode.value = ''
|
||||
totpError.value = ''
|
||||
totpUserEmailMasked.value = completion.user_email_masked || ''
|
||||
isProcessing.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
function switchToBindLoginMode() {
|
||||
pendingAccountAction.value = 'bind_login'
|
||||
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
||||
bindLoginPassword.value = ''
|
||||
accountActionError.value = ''
|
||||
canReturnToCreateAccount.value = true
|
||||
}
|
||||
|
||||
function switchToCreateAccountMode() {
|
||||
pendingAccountAction.value = 'create_account'
|
||||
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||
}
|
||||
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
@@ -335,6 +513,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function finalizePendingAccountResponse(completion: PendingOidcCompletion) {
|
||||
applyAdoptionSuggestionState(completion)
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
pendingAccountAction.value = 'none'
|
||||
needsInvitation.value = true
|
||||
needsAdoptionConfirmation.value = false
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (applyTotpChallenge(completion)) {
|
||||
return
|
||||
}
|
||||
|
||||
applyPendingAccountAction(completion)
|
||||
if (pendingAccountAction.value !== 'none') {
|
||||
needsInvitation.value = false
|
||||
needsAdoptionConfirmation.value = false
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
|
||||
await finalizeCompletion(completion, redirect)
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
@@ -364,12 +569,7 @@ async function handleContinueLogin() {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeCompletion(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
@@ -377,33 +577,65 @@ async function handleContinueLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueWithEmail() {
|
||||
const email = pendingEmail.value.trim()
|
||||
if (!email) {
|
||||
return
|
||||
}
|
||||
async function handleCreateAccount() {
|
||||
accountActionError.value = ''
|
||||
const email = pendingAccountEmail.value.trim()
|
||||
if (!email) return
|
||||
|
||||
await router.replace({
|
||||
path: '/register',
|
||||
query: {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/create-account', {
|
||||
email,
|
||||
redirect: redirectTo.value,
|
||||
provider: providerName.value
|
||||
}
|
||||
})
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
})
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueToLogin() {
|
||||
const email = pendingEmail.value.trim()
|
||||
async function handleBindLogin() {
|
||||
accountActionError.value = ''
|
||||
const email = bindLoginEmail.value.trim()
|
||||
const password = bindLoginPassword.value
|
||||
if (!email || !password) return
|
||||
|
||||
await router.replace({
|
||||
path: '/login',
|
||||
query: {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/bind-login', {
|
||||
email,
|
||||
redirect: redirectTo.value,
|
||||
provider: providerName.value
|
||||
}
|
||||
})
|
||||
password,
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
})
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitTotpChallenge() {
|
||||
totpError.value = ''
|
||||
const code = totpCode.value.trim()
|
||||
if (!totpTempToken.value || code.length !== 6) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await login2FA({
|
||||
temp_token: totpTempToken.value,
|
||||
totp_code: code
|
||||
})
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
totpError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -427,7 +659,6 @@ onMounted(async () => {
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
pendingEmail.value = resolvePendingEmail(completion)
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
@@ -435,14 +666,12 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresEmailCollection(completion)) {
|
||||
needsEmailCollection.value = true
|
||||
isProcessing.value = false
|
||||
if (applyTotpChallenge(completion)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresExistingAccountBinding(completion)) {
|
||||
needsExistingAccountBinding.value = true
|
||||
applyPendingAccountAction(completion)
|
||||
if (pendingAccountAction.value !== 'none') {
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
@@ -455,12 +684,7 @@ onMounted(async () => {
|
||||
|
||||
await finalizeCompletion(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ const setToken = vi.fn()
|
||||
const exchangePendingOAuthCompletion = vi.fn()
|
||||
const completeOIDCOAuthRegistration = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
const login2FA = vi.fn()
|
||||
const apiClientPost = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
@@ -45,13 +47,20 @@ vi.mock('@/stores', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: (...args: any[]) => apiClientPost(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
...actual,
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args),
|
||||
login2FA: (...args: any[]) => login2FA(...args)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,6 +73,8 @@ describe('OidcCallbackView', () => {
|
||||
exchangePendingOAuthCompletion.mockReset()
|
||||
completeOIDCOAuthRegistration.mockReset()
|
||||
getPublicSettings.mockReset()
|
||||
login2FA.mockReset()
|
||||
apiClientPost.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
oidc_oauth_provider_name: 'ExampleID'
|
||||
})
|
||||
@@ -132,9 +143,7 @@ describe('OidcCallbackView', () => {
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
|
||||
@@ -184,77 +193,6 @@ describe('OidcCallbackView', () => {
|
||||
expect(replace).toHaveBeenCalledWith('/profile')
|
||||
})
|
||||
|
||||
it('renders pending email collection ui and routes to register with the entered email', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/profile',
|
||||
provider_fallback: 'ExampleID'
|
||||
})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('Continue with email')
|
||||
|
||||
await wrapper.get('input[type="email"]').setValue('alice@example.com')
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(replace).toHaveBeenCalledWith({
|
||||
path: '/register',
|
||||
query: {
|
||||
email: 'alice@example.com',
|
||||
redirect: '/profile',
|
||||
provider: 'ExampleID'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('renders existing-account binding ui and routes to login', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'existing_account_binding_required',
|
||||
redirect: '/profile',
|
||||
existing_account_email: 'alice@example.com'
|
||||
})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('alice@example.com')
|
||||
expect(wrapper.text()).toContain('Sign in to bind')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(replace).toHaveBeenCalledWith({
|
||||
path: '/login',
|
||||
query: {
|
||||
email: 'alice@example.com',
|
||||
redirect: '/profile',
|
||||
provider: 'ExampleID'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
@@ -284,13 +222,8 @@ describe('OidcCallbackView', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('OIDC Nick')
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
|
||||
await checkboxes[1].setValue(false)
|
||||
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||
await wrapper.find('button').trigger('click')
|
||||
@@ -300,4 +233,154 @@ describe('OidcCallbackView', () => {
|
||||
adoptAvatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/welcome',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
apiClientPost.mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[1].setValue(false)
|
||||
await wrapper.get('[data-testid="oidc-create-account-email"]').setValue(' new@example.com ')
|
||||
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||
email: 'new@example.com',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('new-access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/welcome')
|
||||
})
|
||||
|
||||
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'adopt_existing_user_by_email',
|
||||
redirect: '/profile/security',
|
||||
email: 'existing@example.com',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
apiClientPost.mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'bind-access-token',
|
||||
refresh_token: 'bind-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[0].setValue(false)
|
||||
await wrapper.get('[data-testid="oidc-bind-login-email"]').setValue('existing@example.com')
|
||||
await wrapper.get('[data-testid="oidc-bind-login-password"]').setValue('secret-password')
|
||||
await wrapper.get('[data-testid="oidc-bind-login-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/bind-login', {
|
||||
email: 'existing@example.com',
|
||||
password: 'secret-password',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('bind-access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/profile/security')
|
||||
})
|
||||
|
||||
it('handles bind-login 2FA challenge before redirecting', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'adopt_existing_user_by_email',
|
||||
redirect: '/profile',
|
||||
email: 'existing@example.com',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
apiClientPost.mockResolvedValue({
|
||||
data: {
|
||||
requires_2fa: true,
|
||||
temp_token: 'temp-123',
|
||||
user_email_masked: 'o***g@example.com'
|
||||
}
|
||||
})
|
||||
login2FA.mockResolvedValue({
|
||||
access_token: '2fa-access-token'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
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-bind-login-password"]').setValue('secret-password')
|
||||
await wrapper.get('[data-testid="oidc-bind-login-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('o***g@example.com')
|
||||
expect(login2FA).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-testid="oidc-bind-login-totp"]').setValue('123456')
|
||||
await wrapper.get('[data-testid="oidc-bind-login-totp-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(login2FA).toHaveBeenCalledWith({
|
||||
temp_token: 'temp-123',
|
||||
totp_code: '123456'
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('2fa-access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/profile')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user