diff --git a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue index 0403843eb..ff8721dbd 100644 --- a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue +++ b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue @@ -75,7 +75,7 @@ :data-testid="`${testIdPrefix}-create-account-submit`" type="button" class="btn btn-primary w-full" - :disabled="isSubmitting || !email.trim() || password.length < 6 || (invitationCodeEnabled && !invitationCode.trim())" + :disabled="isSubmitting || !email.trim() || password.length < 6 || (invitationCodeEnabled && !invitationCode.trim()) || (turnstileEnabled && !turnstileToken)" @click="handleSubmit" > {{ isSubmitting ? t('common.processing') : t('auth.createAccount') }} @@ -284,6 +284,14 @@ async function handleSubmit() { return } + // Turnstile 票据一次性:发送验证码已消耗上一枚,reset 后要等新票据回调。 + // 缺票时不能提交——create-account 端点会校验验证码,空 token 直接被判失败。 + // 表单的隐式提交(输入框回车)绕得过按钮的 disabled,所以这里必须再挡一次。 + if (turnstileEnabled.value && !turnstileToken.value) { + sendCodeError.value = t('auth.completeVerification') + return + } + if (!(await acquireTencentProof())) { return } diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index 23111299c..0485db1e9 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -328,4 +328,99 @@ describe('PendingOAuthCreateAccountForm', () => { turnstile_token: 'turnstile-token' }) }) + + it('requires a turnstile token before submitting when turnstile is enabled', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeDefined() + + // 隐式提交(输入框回车)绕过按钮 disabled,仍不能带着空票据发出请求 + await wrapper.get('form').trigger('submit.prevent') + expect(wrapper.emitted('submit')).toBeUndefined() + + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click') + + expect(wrapper.emitted('submit')).toEqual([ + [ + { + email: 'user@example.com', + password: 'secret-123', + verifyCode: '', + turnstileToken: 'turnstile-token', + invitationCode: undefined + } + ] + ]) + }) + + it('blocks submit again after sending a verify code consumes the turnstile proof', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + sendPendingOAuthVerifyCode.mockResolvedValue({ message: 'sent', countdown: 60 }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click') + await flushPromises() + + // 发码消耗掉票据并 reset 组件,新票据回调前提交必须保持关闭 + expect(turnstileReset).toHaveBeenCalled() + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('form').trigger('submit.prevent') + expect(wrapper.emitted('submit')).toBeUndefined() + + // 新票据回调后恢复可提交,且带上新票据 + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click') + + expect(wrapper.emitted('submit')?.[0]?.[0]).toMatchObject({ + turnstileToken: 'turnstile-token' + }) + }) })