feat: support linuxdo pending bind 2fa callback

This commit is contained in:
IanShaw027
2026-04-20 19:53:22 +08:00
parent fb6204ea8b
commit 7826e9880c
3 changed files with 158 additions and 2 deletions

View File

@@ -12,7 +12,13 @@
<transition name="fade">
<div
v-if="needsInvitation || needsAdoptionConfirmation || needsCreateAccount || needsBindLogin"
v-if="
needsInvitation ||
needsAdoptionConfirmation ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
"
class="space-y-4"
>
<div
@@ -186,6 +192,40 @@
</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.
</p>
<div class="space-y-3">
<input
v-model="totpCode"
data-testid="linuxdo-bind-login-totp"
type="text"
inputmode="numeric"
maxlength="6"
class="input w-full"
placeholder="123456"
:disabled="isSubmitting"
@keyup.enter="handleSubmitTotpChallenge"
/>
<button
data-testid="linuxdo-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>
@@ -226,6 +266,7 @@ import {
exchangePendingOAuthCompletion,
getOAuthCompletionKind,
isOAuthLoginCompletion,
login2FA,
persistOAuthTokenContext,
type OAuthAdoptionDecision,
type PendingOAuthExchangeResponse
@@ -260,6 +301,11 @@ 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')
@@ -346,6 +392,11 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
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') {
@@ -364,6 +415,23 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
canReturnToCreateAccount.value = false
}
function applyTotpChallenge(completion: LinuxDoPendingActionResponse): 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()
@@ -412,6 +480,10 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
return
}
if (applyTotpChallenge(completion)) {
return
}
applyPendingAccountAction(completion)
if (pendingAccountAction.value !== 'none') {
needsInvitation.value = false
@@ -501,6 +573,27 @@ async function handleBindLogin() {
}
}
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 () => {
const params = parseFragmentParams()
const error = params.get('error')
@@ -527,6 +620,10 @@ onMounted(async () => {
return
}
if (applyTotpChallenge(completion as LinuxDoPendingActionResponse)) {
return
}
applyPendingAccountAction(completion as LinuxDoPendingActionResponse)
if (pendingAccountAction.value !== 'none') {
isProcessing.value = false

View File

@@ -9,6 +9,7 @@ const showError = vi.fn()
const setToken = vi.fn()
const exchangePendingOAuthCompletion = vi.fn()
const completeLinuxDoOAuthRegistration = vi.fn()
const login2FA = vi.fn()
const apiClientPost = vi.fn()
vi.mock('vue-router', () => ({
@@ -51,7 +52,8 @@ vi.mock('@/api/auth', async () => {
return {
...actual,
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args),
login2FA: (...args: any[]) => login2FA(...args)
}
})
@@ -63,6 +65,7 @@ describe('LinuxDoCallbackView', () => {
setToken.mockReset()
exchangePendingOAuthCompletion.mockReset()
completeLinuxDoOAuthRegistration.mockReset()
login2FA.mockReset()
apiClientPost.mockReset()
})
@@ -344,4 +347,57 @@ describe('LinuxDoCallbackView', () => {
expect(setToken).toHaveBeenCalledWith('bind-access-token')
expect(replace).toHaveBeenCalledWith('/profile/security')
})
it('handles bind-login 2FA challenge before redirecting', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'bind_login_required',
redirect: '/profile',
email: 'existing@example.com',
adoption_required: true,
suggested_display_name: 'LinuxDo Nick',
suggested_avatar_url: 'https://cdn.example/linuxdo.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(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-bind-login-password"]').setValue('secret-password')
await wrapper.get('[data-testid="linuxdo-bind-login-submit"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('o***g@example.com')
expect(login2FA).not.toHaveBeenCalled()
await wrapper.get('[data-testid="linuxdo-bind-login-totp"]').setValue('123456')
await wrapper.get('[data-testid="linuxdo-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')
})
})