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'
+ })
+ })
})