@micha.bigler/ui-core-micha 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ const DEFAULT_AUTH_METHODS = {
10
10
  password_reset: true,
11
11
  signup: true,
12
12
  signup_modes: ['self_signup_access_code'],
13
+ turnstile_site_key: '',
13
14
  password_change: true,
14
15
  social_login: true,
15
16
  social_providers: ['google', 'microsoft'],
@@ -341,7 +341,7 @@ export async function sendAdminInvite(email) {
341
341
  throw normaliseApiError(error, 'Auth.INVITE_FAILED');
342
342
  }
343
343
  }
344
- export async function submitRegistrationRequest({ email, mode, accessCode, registrationContextToken, registrationContext, }) {
344
+ export async function submitRegistrationRequest({ email, mode, accessCode, registrationContextToken, registrationContext, turnstileToken, }) {
345
345
  const payload = { email, mode };
346
346
  if (accessCode)
347
347
  payload.access_code = accessCode;
@@ -351,6 +351,8 @@ export async function submitRegistrationRequest({ email, mode, accessCode, regis
351
351
  if (registrationContext) {
352
352
  payload.registration_context = registrationContext;
353
353
  }
354
+ if (turnstileToken)
355
+ payload.turnstile_token = turnstileToken;
354
356
  try {
355
357
  const res = await apiClient.post(`${USERS_BASE}/register-request/`, payload);
356
358
  return res.data;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, } from 'react';
3
+ const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
4
+ const TURNSTILE_SCRIPT_SELECTOR = 'script[data-turnstile-api="true"]';
5
+ let turnstileScriptPromise;
6
+ function loadTurnstileScript() {
7
+ var _a;
8
+ if ((_a = window.turnstile) === null || _a === void 0 ? void 0 : _a.render) {
9
+ return Promise.resolve(window.turnstile);
10
+ }
11
+ if (!turnstileScriptPromise) {
12
+ turnstileScriptPromise = new Promise((resolve, reject) => {
13
+ let script = document.querySelector(TURNSTILE_SCRIPT_SELECTOR);
14
+ if (!script) {
15
+ script = document.createElement('script');
16
+ script.src = TURNSTILE_SCRIPT_URL;
17
+ script.async = true;
18
+ script.defer = true;
19
+ script.dataset.turnstileApi = 'true';
20
+ document.head.appendChild(script);
21
+ }
22
+ script.addEventListener('load', () => {
23
+ var _a;
24
+ if ((_a = window.turnstile) === null || _a === void 0 ? void 0 : _a.render) {
25
+ resolve(window.turnstile);
26
+ }
27
+ else {
28
+ reject(new Error('Turnstile did not initialize.'));
29
+ }
30
+ }, { once: true });
31
+ script.addEventListener('error', () => {
32
+ reject(new Error('Turnstile could not be loaded.'));
33
+ }, { once: true });
34
+ });
35
+ }
36
+ return turnstileScriptPromise;
37
+ }
38
+ export const TurnstileWidget = forwardRef(function TurnstileWidget({ siteKey, onToken }, ref) {
39
+ const containerRef = useRef(null);
40
+ const widgetIdRef = useRef(null);
41
+ const onTokenRef = useRef(onToken);
42
+ useEffect(() => {
43
+ onTokenRef.current = onToken;
44
+ }, [onToken]);
45
+ const reset = useCallback(() => {
46
+ var _a;
47
+ if (widgetIdRef.current !== null && ((_a = window.turnstile) === null || _a === void 0 ? void 0 : _a.reset)) {
48
+ window.turnstile.reset(widgetIdRef.current);
49
+ }
50
+ onTokenRef.current('');
51
+ }, []);
52
+ useImperativeHandle(ref, () => ({ reset }), [reset]);
53
+ useEffect(() => {
54
+ if (!siteKey)
55
+ return undefined;
56
+ let cancelled = false;
57
+ loadTurnstileScript()
58
+ .then((turnstile) => {
59
+ if (cancelled || !containerRef.current)
60
+ return;
61
+ widgetIdRef.current = turnstile.render(containerRef.current, {
62
+ sitekey: siteKey,
63
+ callback: (token) => onTokenRef.current(token),
64
+ 'expired-callback': reset,
65
+ 'error-callback': () => onTokenRef.current(''),
66
+ });
67
+ })
68
+ .catch(() => {
69
+ if (!cancelled)
70
+ onTokenRef.current('');
71
+ });
72
+ return () => {
73
+ var _a;
74
+ cancelled = true;
75
+ if (widgetIdRef.current !== null && ((_a = window.turnstile) === null || _a === void 0 ? void 0 : _a.remove)) {
76
+ window.turnstile.remove(widgetIdRef.current);
77
+ }
78
+ widgetIdRef.current = null;
79
+ };
80
+ }, [siteKey, reset]);
81
+ if (!siteKey)
82
+ return null;
83
+ return _jsx("div", { ref: containerRef, "data-testid": "turnstile-widget" });
84
+ });
@@ -1,5 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useContext, useEffect, useMemo, useState } from 'react';
2
+ import React, { useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { useNavigate, useLocation } from 'react-router-dom';
4
4
  import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
5
5
  import { Helmet } from 'react-helmet';
@@ -7,6 +7,11 @@ import { useTranslation } from 'react-i18next';
7
7
  import { NarrowPage } from '../layout/PageLayout';
8
8
  import { AuthContext } from '../auth/AuthContext';
9
9
  import { submitRegistrationRequest } from '../auth/authApi';
10
+ import { TurnstileWidget } from '../components/TurnstileWidget';
11
+ const TURNSTILE_SIGNUP_MODES = new Set([
12
+ 'self_signup_open',
13
+ 'self_signup_email_domain',
14
+ ]);
10
15
  const MODE_LABELS = {
11
16
  self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_TAB',
12
17
  self_signup_open: 'Auth.SIGNUP_OPEN_TAB',
@@ -47,6 +52,10 @@ export function SignUpPage() {
47
52
  const [submitting, setSubmitting] = useState(false);
48
53
  const [successKey, setSuccessKey] = useState(null);
49
54
  const [errorKey, setErrorKey] = useState(null);
55
+ const [turnstileToken, setTurnstileToken] = useState('');
56
+ const turnstileWidgetRef = useRef(null);
57
+ const turnstileRequired = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.turnstile_site_key)
58
+ && TURNSTILE_SIGNUP_MODES.has(mode);
50
59
  const pageSubtitle = useMemo(() => {
51
60
  if (mode === 'self_signup_qr' && tokenFromUrl) {
52
61
  return t('Auth.PAGE_SIGNUP_SUBTITLE_QR_READY', 'Enter your email address to complete sign-up with the QR link you opened.');
@@ -56,7 +65,11 @@ export function SignUpPage() {
56
65
  useEffect(() => {
57
66
  setMode(initialMode);
58
67
  }, [initialMode]);
68
+ useEffect(() => {
69
+ setTurnstileToken('');
70
+ }, [mode, authMethods === null || authMethods === void 0 ? void 0 : authMethods.turnstile_site_key]);
59
71
  const handleSubmit = async (event) => {
72
+ var _a;
60
73
  event.preventDefault();
61
74
  setSuccessKey(null);
62
75
  setErrorKey(null);
@@ -72,6 +85,8 @@ export function SignUpPage() {
72
85
  setErrorKey('Auth.SIGNUP_QR_INVALID');
73
86
  return;
74
87
  }
88
+ if (turnstileRequired && !turnstileToken)
89
+ return;
75
90
  setSubmitting(true);
76
91
  try {
77
92
  await submitRegistrationRequest({
@@ -79,11 +94,16 @@ export function SignUpPage() {
79
94
  mode,
80
95
  accessCode,
81
96
  registrationContextToken: mode === 'self_signup_qr' ? tokenFromUrl : null,
97
+ turnstileToken: turnstileRequired ? turnstileToken : undefined,
82
98
  });
83
99
  setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
84
100
  }
85
101
  catch (err) {
86
102
  setErrorKey(err.code || 'Auth.INVITE_FAILED');
103
+ if (turnstileRequired) {
104
+ setTurnstileToken('');
105
+ (_a = turnstileWidgetRef.current) === null || _a === void 0 ? void 0 : _a.reset();
106
+ }
87
107
  }
88
108
  finally {
89
109
  setSubmitting(false);
@@ -92,7 +112,7 @@ export function SignUpPage() {
92
112
  const handleGoToLogin = () => {
93
113
  navigate('/login');
94
114
  };
95
- return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), successKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || signupModes.length === 0, children: submitting
115
+ return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), successKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || signupModes.length === 0 || (turnstileRequired && !turnstileToken), children: submitting
96
116
  ? t('Auth.SIGNUP_SUBMITTING')
97
117
  : t('Auth.SIGNUP_SUBMIT') })] }), _jsx(Box, { sx: { mt: 3 }, children: _jsxs(Typography, { variant: "body2", children: [t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT'), ' ', _jsx(Button, { onClick: handleGoToLogin, variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN') })] }) })] }));
98
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -19,6 +19,7 @@ const DEFAULT_AUTH_METHODS = {
19
19
  password_reset: true,
20
20
  signup: true,
21
21
  signup_modes: ['self_signup_access_code'],
22
+ turnstile_site_key: '',
22
23
  password_change: true,
23
24
  social_login: true,
24
25
  social_providers: ['google', 'microsoft'],
@@ -359,6 +359,7 @@ export async function submitRegistrationRequest({
359
359
  accessCode,
360
360
  registrationContextToken,
361
361
  registrationContext,
362
+ turnstileToken,
362
363
  }) {
363
364
  const payload = { email, mode };
364
365
  if (accessCode) payload.access_code = accessCode;
@@ -368,6 +369,7 @@ export async function submitRegistrationRequest({
368
369
  if (registrationContext) {
369
370
  payload.registration_context = registrationContext;
370
371
  }
372
+ if (turnstileToken) payload.turnstile_token = turnstileToken;
371
373
 
372
374
  try {
373
375
  const res = await apiClient.post(`${USERS_BASE}/register-request/`, payload);
@@ -0,0 +1,99 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useRef,
7
+ } from 'react';
8
+
9
+ const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
10
+ const TURNSTILE_SCRIPT_SELECTOR = 'script[data-turnstile-api="true"]';
11
+
12
+ let turnstileScriptPromise;
13
+
14
+ function loadTurnstileScript() {
15
+ if (window.turnstile?.render) {
16
+ return Promise.resolve(window.turnstile);
17
+ }
18
+
19
+ if (!turnstileScriptPromise) {
20
+ turnstileScriptPromise = new Promise((resolve, reject) => {
21
+ let script = document.querySelector(TURNSTILE_SCRIPT_SELECTOR);
22
+ if (!script) {
23
+ script = document.createElement('script');
24
+ script.src = TURNSTILE_SCRIPT_URL;
25
+ script.async = true;
26
+ script.defer = true;
27
+ script.dataset.turnstileApi = 'true';
28
+ document.head.appendChild(script);
29
+ }
30
+
31
+ script.addEventListener('load', () => {
32
+ if (window.turnstile?.render) {
33
+ resolve(window.turnstile);
34
+ } else {
35
+ reject(new Error('Turnstile did not initialize.'));
36
+ }
37
+ }, { once: true });
38
+ script.addEventListener('error', () => {
39
+ reject(new Error('Turnstile could not be loaded.'));
40
+ }, { once: true });
41
+ });
42
+ }
43
+
44
+ return turnstileScriptPromise;
45
+ }
46
+
47
+ export const TurnstileWidget = forwardRef(function TurnstileWidget(
48
+ { siteKey, onToken },
49
+ ref,
50
+ ) {
51
+ const containerRef = useRef(null);
52
+ const widgetIdRef = useRef(null);
53
+ const onTokenRef = useRef(onToken);
54
+
55
+ useEffect(() => {
56
+ onTokenRef.current = onToken;
57
+ }, [onToken]);
58
+
59
+ const reset = useCallback(() => {
60
+ if (widgetIdRef.current !== null && window.turnstile?.reset) {
61
+ window.turnstile.reset(widgetIdRef.current);
62
+ }
63
+ onTokenRef.current('');
64
+ }, []);
65
+
66
+ useImperativeHandle(ref, () => ({ reset }), [reset]);
67
+
68
+ useEffect(() => {
69
+ if (!siteKey) return undefined;
70
+
71
+ let cancelled = false;
72
+ loadTurnstileScript()
73
+ .then((turnstile) => {
74
+ if (cancelled || !containerRef.current) return;
75
+
76
+ widgetIdRef.current = turnstile.render(containerRef.current, {
77
+ sitekey: siteKey,
78
+ callback: (token) => onTokenRef.current(token),
79
+ 'expired-callback': reset,
80
+ 'error-callback': () => onTokenRef.current(''),
81
+ });
82
+ })
83
+ .catch(() => {
84
+ if (!cancelled) onTokenRef.current('');
85
+ });
86
+
87
+ return () => {
88
+ cancelled = true;
89
+ if (widgetIdRef.current !== null && window.turnstile?.remove) {
90
+ window.turnstile.remove(widgetIdRef.current);
91
+ }
92
+ widgetIdRef.current = null;
93
+ };
94
+ }, [siteKey, reset]);
95
+
96
+ if (!siteKey) return null;
97
+
98
+ return <div ref={containerRef} data-testid="turnstile-widget" />;
99
+ });
@@ -1,4 +1,10 @@
1
- import React, { useContext, useEffect, useMemo, useState } from 'react';
1
+ import React, {
2
+ useContext,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
  import { useNavigate, useLocation } from 'react-router-dom';
3
9
  import {
4
10
  Alert,
@@ -13,6 +19,12 @@ import { useTranslation } from 'react-i18next';
13
19
  import { NarrowPage } from '../layout/PageLayout';
14
20
  import { AuthContext } from '../auth/AuthContext';
15
21
  import { submitRegistrationRequest } from '../auth/authApi';
22
+ import { TurnstileWidget } from '../components/TurnstileWidget';
23
+
24
+ const TURNSTILE_SIGNUP_MODES = new Set([
25
+ 'self_signup_open',
26
+ 'self_signup_email_domain',
27
+ ]);
16
28
 
17
29
  const MODE_LABELS = {
18
30
  self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_TAB',
@@ -60,6 +72,11 @@ export function SignUpPage() {
60
72
  const [submitting, setSubmitting] = useState(false);
61
73
  const [successKey, setSuccessKey] = useState(null);
62
74
  const [errorKey, setErrorKey] = useState(null);
75
+ const [turnstileToken, setTurnstileToken] = useState('');
76
+ const turnstileWidgetRef = useRef(null);
77
+
78
+ const turnstileRequired = Boolean(authMethods?.turnstile_site_key)
79
+ && TURNSTILE_SIGNUP_MODES.has(mode);
63
80
 
64
81
  const pageSubtitle = useMemo(() => {
65
82
  if (mode === 'self_signup_qr' && tokenFromUrl) {
@@ -78,6 +95,10 @@ export function SignUpPage() {
78
95
  setMode(initialMode);
79
96
  }, [initialMode]);
80
97
 
98
+ useEffect(() => {
99
+ setTurnstileToken('');
100
+ }, [mode, authMethods?.turnstile_site_key]);
101
+
81
102
  const handleSubmit = async (event) => {
82
103
  event.preventDefault();
83
104
  setSuccessKey(null);
@@ -98,6 +119,8 @@ export function SignUpPage() {
98
119
  return;
99
120
  }
100
121
 
122
+ if (turnstileRequired && !turnstileToken) return;
123
+
101
124
  setSubmitting(true);
102
125
  try {
103
126
  await submitRegistrationRequest({
@@ -105,10 +128,15 @@ export function SignUpPage() {
105
128
  mode,
106
129
  accessCode,
107
130
  registrationContextToken: mode === 'self_signup_qr' ? tokenFromUrl : null,
131
+ turnstileToken: turnstileRequired ? turnstileToken : undefined,
108
132
  });
109
133
  setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
110
134
  } catch (err) {
111
135
  setErrorKey(err.code || 'Auth.INVITE_FAILED');
136
+ if (turnstileRequired) {
137
+ setTurnstileToken('');
138
+ turnstileWidgetRef.current?.reset();
139
+ }
112
140
  } finally {
113
141
  setSubmitting(false);
114
142
  }
@@ -186,10 +214,18 @@ export function SignUpPage() {
186
214
  />
187
215
  )}
188
216
 
217
+ {turnstileRequired && (
218
+ <TurnstileWidget
219
+ ref={turnstileWidgetRef}
220
+ siteKey={authMethods.turnstile_site_key}
221
+ onToken={setTurnstileToken}
222
+ />
223
+ )}
224
+
189
225
  <Button
190
226
  type="submit"
191
227
  variant="contained"
192
- disabled={submitting || signupModes.length === 0}
228
+ disabled={submitting || signupModes.length === 0 || (turnstileRequired && !turnstileToken)}
193
229
  >
194
230
  {submitting
195
231
  ? t('Auth.SIGNUP_SUBMITTING')
@@ -0,0 +1,138 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ const authApi = vi.hoisted(() => ({
7
+ submitRegistrationRequest: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('../src/auth/authApi', () => authApi);
11
+ vi.mock('react-router-dom', () => ({
12
+ useNavigate: () => vi.fn(),
13
+ useLocation: () => ({ search: '' }),
14
+ }));
15
+ vi.mock('react-helmet', () => ({ Helmet: ({ children }) => <>{children}</> }));
16
+ vi.mock('react-i18next', () => ({
17
+ useTranslation: () => ({ t: (key, fallback) => (typeof fallback === 'string' ? fallback : key) }),
18
+ }));
19
+
20
+ import { AuthContext } from '../src/auth/AuthContext';
21
+ import { SignUpPage } from '../src/pages/SignUpPage';
22
+
23
+ function renderSignUp({ signupModes, siteKey }) {
24
+ return render(
25
+ <AuthContext.Provider value={{ authMethods: {
26
+ signup: true,
27
+ signup_modes: signupModes,
28
+ turnstile_site_key: siteKey,
29
+ } }}>
30
+ <SignUpPage />
31
+ </AuthContext.Provider>,
32
+ );
33
+ }
34
+
35
+ describe('SignUpPage Turnstile integration', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ authApi.submitRegistrationRequest.mockResolvedValue({});
39
+ window.turnstile = {
40
+ render: vi.fn(() => 'widget-id'),
41
+ remove: vi.fn(),
42
+ reset: vi.fn(),
43
+ };
44
+ });
45
+
46
+ afterEach(() => {
47
+ cleanup();
48
+ delete window.turnstile;
49
+ });
50
+
51
+ it('renders only for open and email-domain signup modes when the site key is present', async () => {
52
+ renderSignUp({
53
+ signupModes: ['self_signup_open', 'self_signup_access_code', 'self_signup_email_domain'],
54
+ siteKey: 'site-key',
55
+ });
56
+
57
+ await waitFor(() => expect(screen.getByTestId('turnstile-widget')).toBeTruthy());
58
+ fireEvent.click(screen.getByRole('button', { name: 'self_signup_access_code' }));
59
+ expect(screen.queryByTestId('turnstile-widget')).toBeNull();
60
+
61
+ fireEvent.click(screen.getByRole('button', { name: 'self_signup_email_domain' }));
62
+ await waitFor(() => expect(screen.getByTestId('turnstile-widget')).toBeTruthy());
63
+ });
64
+
65
+ it('gates submission until verified and passes the Turnstile token to the registration API', async () => {
66
+ renderSignUp({ signupModes: ['self_signup_open'], siteKey: 'site-key' });
67
+
68
+ await waitFor(() => expect(window.turnstile.render).toHaveBeenCalledOnce());
69
+ fireEvent.change(screen.getByRole('textbox', { name: /Auth\.EMAIL_LABEL/ }), {
70
+ target: { value: 'ada@example.com' },
71
+ });
72
+
73
+ const submit = screen.getByRole('button', { name: 'Auth.SIGNUP_SUBMIT' });
74
+ expect(submit.disabled).toBe(true);
75
+
76
+ await act(async () => {
77
+ window.turnstile.render.mock.calls[0][1].callback('turnstile-token');
78
+ });
79
+
80
+ await waitFor(() => expect(submit.disabled).toBe(false));
81
+ fireEvent.click(submit);
82
+
83
+ await waitFor(() => expect(authApi.submitRegistrationRequest).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ email: 'ada@example.com',
86
+ mode: 'self_signup_open',
87
+ turnstileToken: 'turnstile-token',
88
+ }),
89
+ ));
90
+ });
91
+
92
+ it('resets the widget after a failed protected signup submission', async () => {
93
+ authApi.submitRegistrationRequest.mockRejectedValue({ code: 'Auth.INVITE_FAILED' });
94
+ renderSignUp({ signupModes: ['self_signup_open'], siteKey: 'site-key' });
95
+
96
+ await waitFor(() => expect(window.turnstile.render).toHaveBeenCalledOnce());
97
+ fireEvent.change(screen.getByRole('textbox', { name: /Auth\.EMAIL_LABEL/ }), {
98
+ target: { value: 'ada@example.com' },
99
+ });
100
+ await act(async () => {
101
+ window.turnstile.render.mock.calls[0][1].callback('turnstile-token');
102
+ });
103
+
104
+ fireEvent.click(screen.getByRole('button', { name: 'Auth.SIGNUP_SUBMIT' }));
105
+
106
+ await waitFor(() => expect(window.turnstile.reset).toHaveBeenCalledWith('widget-id'));
107
+ expect(screen.getByRole('button', { name: 'Auth.SIGNUP_SUBMIT' }).disabled).toBe(true);
108
+ });
109
+
110
+ it('resets the widget and gates submission when its token expires', async () => {
111
+ renderSignUp({ signupModes: ['self_signup_open'], siteKey: 'site-key' });
112
+
113
+ await waitFor(() => expect(window.turnstile.render).toHaveBeenCalledOnce());
114
+ await act(async () => {
115
+ window.turnstile.render.mock.calls[0][1].callback('turnstile-token');
116
+ });
117
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Auth.SIGNUP_SUBMIT' }).disabled).toBe(false));
118
+
119
+ await act(async () => {
120
+ window.turnstile.render.mock.calls[0][1]['expired-callback']();
121
+ });
122
+
123
+ expect(window.turnstile.reset).toHaveBeenCalledWith('widget-id');
124
+ expect(screen.getByRole('button', { name: 'Auth.SIGNUP_SUBMIT' }).disabled).toBe(true);
125
+ });
126
+
127
+ it.each([
128
+ ['self_signup_access_code', 'site-key'],
129
+ ['self_signup_qr', 'site-key'],
130
+ ['self_signup_open', ''],
131
+ ])('does not render for %s without an eligible key/mode combination', async (mode, siteKey) => {
132
+ renderSignUp({ signupModes: [mode], siteKey });
133
+
134
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Auth.SIGNUP_SUBMIT' })).toBeTruthy());
135
+ expect(screen.queryByTestId('turnstile-widget')).toBeNull();
136
+ expect(window.turnstile.render).not.toHaveBeenCalled();
137
+ });
138
+ });
@@ -0,0 +1,31 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const client = vi.hoisted(() => ({
4
+ post: vi.fn(),
5
+ }));
6
+
7
+ vi.mock('../src/auth/apiClient', () => ({ default: client }));
8
+
9
+ import { submitRegistrationRequest } from '../src/auth/authApi';
10
+
11
+ describe('submitRegistrationRequest', () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+
16
+ it('includes a Turnstile token in the registration payload', async () => {
17
+ client.post.mockResolvedValue({ data: { ok: true } });
18
+
19
+ await submitRegistrationRequest({
20
+ email: 'ada@example.com',
21
+ mode: 'self_signup_open',
22
+ turnstileToken: 'turnstile-token',
23
+ });
24
+
25
+ expect(client.post).toHaveBeenCalledWith('/api/users/register-request/', {
26
+ email: 'ada@example.com',
27
+ mode: 'self_signup_open',
28
+ turnstile_token: 'turnstile-token',
29
+ });
30
+ });
31
+ });