@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.
- package/dist/auth/AuthContext.js +1 -0
- package/dist/auth/authApi.js +3 -1
- package/dist/components/TurnstileWidget.js +84 -0
- package/dist/pages/SignUpPage.js +22 -2
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +1 -0
- package/src/auth/authApi.jsx +2 -0
- package/src/components/TurnstileWidget.jsx +99 -0
- package/src/pages/SignUpPage.jsx +38 -2
- package/tests/SignUpPage.test.jsx +138 -0
- package/tests/authApiRegistration.test.js +31 -0
package/dist/auth/AuthContext.js
CHANGED
package/dist/auth/authApi.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/pages/SignUpPage.js
CHANGED
|
@@ -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
package/src/auth/AuthContext.jsx
CHANGED
package/src/auth/authApi.jsx
CHANGED
|
@@ -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
|
+
});
|
package/src/pages/SignUpPage.jsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import 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
|
+
});
|