feat: complete pending oauth account creation UI

This commit is contained in:
IanShaw027
2026-04-21 00:02:51 +08:00
parent 7ef7fd19e7
commit 0fa47f18ed
8 changed files with 469 additions and 116 deletions

View File

@@ -113,37 +113,14 @@
<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="linuxdo-create-account-email"
type="email"
class="input w-full"
placeholder="you@example.com"
:disabled="isSubmitting"
@keyup.enter="handleCreateAccount"
/>
<button
data-testid="linuxdo-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>
<PendingOAuthCreateAccountForm
test-id-prefix="linuxdo"
:initial-email="pendingAccountEmail"
:is-submitting="isSubmitting"
:error-message="accountActionError"
@submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode"
/>
</template>
<template v-else-if="needsBindLogin">
@@ -258,6 +235,9 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
@@ -432,9 +412,9 @@ function applyTotpChallenge(completion: LinuxDoPendingActionResponse): boolean {
return true
}
function switchToBindLoginMode() {
function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
@@ -533,15 +513,16 @@ async function handleContinueLogin() {
}
}
async function handleCreateAccount() {
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
accountActionError.value = ''
const email = pendingAccountEmail.value.trim()
if (!email) return
if (!payload.email || !payload.password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/pending/create-account', {
email,
email: payload.email,
password: payload.password,
verify_code: payload.verifyCode || undefined,
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)

View File

@@ -122,37 +122,14 @@
<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>
<PendingOAuthCreateAccountForm
test-id-prefix="oidc"
:initial-email="pendingAccountEmail"
:is-submitting="isSubmitting"
:error-message="accountActionError"
@submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode"
/>
</template>
<template v-else-if="needsBindLogin">
@@ -267,6 +244,9 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
@@ -476,9 +456,9 @@ function applyTotpChallenge(completion: PendingOidcCompletion): boolean {
return true
}
function switchToBindLoginMode() {
function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
@@ -577,15 +557,16 @@ async function handleContinueLogin() {
}
}
async function handleCreateAccount() {
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
accountActionError.value = ''
const email = pendingAccountEmail.value.trim()
if (!email) return
if (!payload.email || !payload.password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/create-account', {
email,
email: payload.email,
password: payload.password,
verify_code: payload.verifyCode || undefined,
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)

View File

@@ -160,37 +160,14 @@
<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="wechat-create-account-email"
type="email"
class="input w-full"
placeholder="you@example.com"
:disabled="isSubmitting"
@keyup.enter="handleCreateAccount"
/>
<button
data-testid="wechat-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>
<PendingOAuthCreateAccountForm
test-id-prefix="wechat"
:initial-email="pendingAccountEmail"
:is-submitting="isSubmitting"
:error-message="accountActionError"
@submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode"
/>
</template>
<template v-else-if="needsBindLogin">
@@ -305,6 +282,9 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
@@ -575,9 +555,9 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
return true
}
function switchToBindLoginMode() {
function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
@@ -676,15 +656,16 @@ async function handleContinueLogin() {
}
}
async function handleCreateAccount() {
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
accountActionError.value = ''
const email = pendingAccountEmail.value.trim()
if (!email) return
if (!payload.email || !payload.password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<PendingWeChatCompletion>('/auth/oauth/pending/create-account', {
email,
email: payload.email,
password: payload.password,
verify_code: payload.verifyCode || undefined,
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)

View File

@@ -11,6 +11,7 @@ const exchangePendingOAuthCompletion = vi.fn()
const completeLinuxDoOAuthRegistration = vi.fn()
const login2FA = vi.fn()
const apiClientPost = vi.fn()
const sendVerifyCode = vi.fn()
vi.mock('vue-router', () => ({
useRoute: () => ({
@@ -53,7 +54,8 @@ vi.mock('@/api/auth', async () => {
...actual,
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args),
login2FA: (...args: any[]) => login2FA(...args)
login2FA: (...args: any[]) => login2FA(...args),
sendVerifyCode: (...args: any[]) => sendVerifyCode(...args)
}
})
@@ -67,6 +69,7 @@ describe('LinuxDoCallbackView', () => {
completeLinuxDoOAuthRegistration.mockReset()
login2FA.mockReset()
apiClientPost.mockReset()
sendVerifyCode.mockReset()
})
it('does not send adoption decisions during the initial exchange', async () => {
@@ -251,7 +254,7 @@ describe('LinuxDoCallbackView', () => {
})
})
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
redirect: '/welcome',
@@ -286,11 +289,15 @@ describe('LinuxDoCallbackView', () => {
expect(checkboxes).toHaveLength(2)
await checkboxes[1].setValue(false)
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' new@example.com ')
await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123')
await wrapper.get('[data-testid="linuxdo-create-account-verify-code"]').setValue('246810')
await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click')
await flushPromises()
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
email: 'new@example.com',
password: 'secret-123',
verify_code: '246810',
adopt_display_name: true,
adopt_avatar: false
})
@@ -298,6 +305,38 @@ describe('LinuxDoCallbackView', () => {
expect(replace).toHaveBeenCalledWith('/welcome')
})
it('sends a verify code for pending oauth account creation', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
redirect: '/welcome'
})
sendVerifyCode.mockResolvedValue({
message: 'sent',
countdown: 60
})
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-create-account-email"]').setValue(' new@example.com ')
await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click')
await flushPromises()
expect(sendVerifyCode).toHaveBeenCalledWith({
email: 'new@example.com'
})
})
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'bind_login_required',

View File

@@ -12,6 +12,7 @@ const completeOIDCOAuthRegistration = vi.fn()
const getPublicSettings = vi.fn()
const login2FA = vi.fn()
const apiClientPost = vi.fn()
const sendVerifyCode = vi.fn()
vi.mock('vue-router', () => ({
useRoute: () => ({
@@ -60,7 +61,8 @@ vi.mock('@/api/auth', async () => {
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
getPublicSettings: (...args: any[]) => getPublicSettings(...args),
login2FA: (...args: any[]) => login2FA(...args)
login2FA: (...args: any[]) => login2FA(...args),
sendVerifyCode: (...args: any[]) => sendVerifyCode(...args)
}
})
@@ -75,6 +77,7 @@ describe('OidcCallbackView', () => {
getPublicSettings.mockReset()
login2FA.mockReset()
apiClientPost.mockReset()
sendVerifyCode.mockReset()
getPublicSettings.mockResolvedValue({
oidc_oauth_provider_name: 'ExampleID'
})
@@ -234,7 +237,7 @@ describe('OidcCallbackView', () => {
})
})
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
redirect: '/welcome',
@@ -269,11 +272,15 @@ describe('OidcCallbackView', () => {
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-password"]').setValue('secret-123')
await wrapper.get('[data-testid="oidc-create-account-verify-code"]').setValue('246810')
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',
password: 'secret-123',
verify_code: '246810',
adopt_display_name: true,
adopt_avatar: false
})
@@ -281,6 +288,38 @@ describe('OidcCallbackView', () => {
expect(replace).toHaveBeenCalledWith('/welcome')
})
it('sends a verify code for pending oauth account creation', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
redirect: '/welcome'
})
sendVerifyCode.mockResolvedValue({
message: 'sent',
countdown: 60
})
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-create-account-email"]').setValue(' new@example.com ')
await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click')
await flushPromises()
expect(sendVerifyCode).toHaveBeenCalledWith({
email: 'new@example.com'
})
})
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'adopt_existing_user_by_email',

View File

@@ -7,6 +7,7 @@ const {
completeWeChatOAuthRegistrationMock,
login2FAMock,
apiClientPostMock,
sendVerifyCodeMock,
prepareOAuthBindAccessTokenCookieMock,
getAuthTokenMock,
replaceMock,
@@ -20,6 +21,7 @@ const {
completeWeChatOAuthRegistrationMock: vi.fn(),
login2FAMock: vi.fn(),
apiClientPostMock: vi.fn(),
sendVerifyCodeMock: vi.fn(),
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
getAuthTokenMock: vi.fn(),
replaceMock: vi.fn(),
@@ -118,6 +120,7 @@ vi.mock('@/api/auth', async () => {
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
login2FA: (...args: any[]) => login2FAMock(...args),
sendVerifyCode: (...args: any[]) => sendVerifyCodeMock(...args),
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
}
@@ -129,6 +132,7 @@ describe('WechatCallbackView', () => {
completeWeChatOAuthRegistrationMock.mockReset()
login2FAMock.mockReset()
apiClientPostMock.mockReset()
sendVerifyCodeMock.mockReset()
replaceMock.mockReset()
setTokenMock.mockReset()
showSuccessMock.mockReset()
@@ -374,7 +378,7 @@ describe('WechatCallbackView', () => {
expect(locationState.current.href).toContain('mode=open')
})
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'email_required',
redirect: '/welcome',
@@ -409,11 +413,15 @@ describe('WechatCallbackView', () => {
expect(checkboxes).toHaveLength(2)
await checkboxes[1].setValue(false)
await wrapper.get('[data-testid="wechat-create-account-email"]').setValue(' new@example.com ')
await wrapper.get('[data-testid="wechat-create-account-password"]').setValue('secret-123')
await wrapper.get('[data-testid="wechat-create-account-verify-code"]').setValue('246810')
await wrapper.get('[data-testid="wechat-create-account-submit"]').trigger('click')
await flushPromises()
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
email: 'new@example.com',
password: 'secret-123',
verify_code: '246810',
adopt_display_name: true,
adopt_avatar: false,
})
@@ -421,6 +429,38 @@ describe('WechatCallbackView', () => {
expect(replaceMock).toHaveBeenCalledWith('/welcome')
})
it('sends a verify code for pending oauth account creation', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'email_required',
redirect: '/welcome',
})
sendVerifyCodeMock.mockResolvedValue({
message: 'sent',
countdown: 60,
})
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
await wrapper.get('[data-testid="wechat-create-account-email"]').setValue(' new@example.com ')
await wrapper.get('[data-testid="wechat-create-account-send-code"]').trigger('click')
await flushPromises()
expect(sendVerifyCodeMock).toHaveBeenCalledWith({
email: 'new@example.com',
})
})
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
step: 'bind_login_required',