@micha.bigler/ui-core-micha 2.11.0 → 2.13.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/WORK_ORDERS.md +2 -0
- package/dist/auth/AuthContext.js +1 -0
- package/dist/auth/authApi.js +3 -1
- package/dist/components/TurnstileWidget.js +84 -0
- package/dist/index.js +1 -0
- package/dist/notifications/NotificationsProvider.js +61 -91
- package/dist/notifications/realtime.js +110 -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/index.js +1 -0
- package/src/notifications/NotificationsProvider.jsx +67 -96
- package/src/notifications/realtime.jsx +118 -0
- package/src/pages/SignUpPage.jsx +38 -2
- package/tests/NotificationsProvider.test.jsx +1 -0
- package/tests/SignUpPage.test.jsx +138 -0
- package/tests/authApiRegistration.test.js +31 -0
- package/tests/realtime.test.jsx +192 -0
- package/work-orders/NOTIF-12.md +162 -0
- package/work-orders/NOTIF-13.md +142 -0
|
@@ -2,9 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
2
2
|
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
4
|
import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
|
|
5
|
-
|
|
6
|
-
const INITIAL_BACKOFF_MS = 1_000;
|
|
7
|
-
const MAX_BACKOFF_MS = 30_000;
|
|
5
|
+
import { DEFAULT_ENVELOPE, RealtimeContext, useRealtimeCore } from './realtime';
|
|
8
6
|
|
|
9
7
|
const NotificationsContext = createContext(null);
|
|
10
8
|
|
|
@@ -62,7 +60,6 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
62
60
|
const [notifications, setNotifications] = useState([]);
|
|
63
61
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
64
62
|
const notificationsRef = useRef([]);
|
|
65
|
-
const socketRef = useRef(null);
|
|
66
63
|
|
|
67
64
|
const replaceNotifications = useCallback((nextNotifications) => {
|
|
68
65
|
notificationsRef.current = nextNotifications;
|
|
@@ -84,99 +81,71 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
84
81
|
return { notifications: nextNotifications, unreadCount: nextUnreadCount };
|
|
85
82
|
}, [authenticated, replaceNotifications]);
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
84
|
+
// Seeded gates the socket connect, not just `authenticated`: the prior single-purpose
|
|
85
|
+
// socket owner awaited the initial REST feed/unread-count fetch before opening the
|
|
86
|
+
// WebSocket, so a live push could never race an empty/incomplete `notificationsRef`.
|
|
87
|
+
// Preserved here rather than connecting immediately, since `refresh()`'s
|
|
88
|
+
// `replaceNotifications(...)` is unconditional and would silently drop a message that
|
|
89
|
+
// arrived and patched state before the initial REST response landed.
|
|
90
|
+
const [seeded, setSeeded] = useState(false);
|
|
91
|
+
|
|
92
|
+
const { subscribe } = useRealtimeCore({
|
|
93
|
+
active: authenticated && seeded,
|
|
94
|
+
wsUrl: getWebSocketUrl(wsUrlBase),
|
|
95
|
+
});
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
const handleRealtimeMessage = useCallback((data) => {
|
|
98
|
+
if (data.type === 'notification.status') {
|
|
99
|
+
let unreadDelta = 0;
|
|
100
|
+
const nextNotifications = notificationsRef.current.map((notification) => {
|
|
101
|
+
if (notification.notification_id !== data.notification_id) return notification;
|
|
102
|
+
|
|
103
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
104
|
+
const patched = patchStatus(notification, data.status || {});
|
|
105
|
+
const isUnread = !patched.seen && !patched.dismissed;
|
|
106
|
+
if (wasUnread !== isUnread) {
|
|
107
|
+
unreadDelta += isUnread ? 1 : -1;
|
|
108
|
+
}
|
|
109
|
+
return patched;
|
|
110
|
+
});
|
|
100
111
|
replaceNotifications(nextNotifications);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const handleMessage = (event) => {
|
|
104
|
-
let data;
|
|
105
|
-
try {
|
|
106
|
-
data = JSON.parse(event.data);
|
|
107
|
-
} catch {
|
|
108
|
-
return;
|
|
112
|
+
if (unreadDelta) {
|
|
113
|
+
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
109
114
|
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const wasUnread = !notification.seen && !notification.dismissed;
|
|
117
|
-
const patched = patchStatus(notification, data.status || {});
|
|
118
|
-
const isUnread = !patched.seen && !patched.dismissed;
|
|
119
|
-
if (wasUnread !== isUnread) {
|
|
120
|
-
unreadDelta += isUnread ? 1 : -1;
|
|
121
|
-
}
|
|
122
|
-
return patched;
|
|
123
|
-
}));
|
|
124
|
-
if (unreadDelta) {
|
|
125
|
-
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
126
|
-
}
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
118
|
+
const pushedNotification = normalizeNotificationPush(data);
|
|
119
|
+
replaceNotifications([pushedNotification, ...notificationsRef.current]);
|
|
120
|
+
setUnreadCount((count) => count + 1);
|
|
121
|
+
}, [replaceNotifications]);
|
|
129
122
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!authenticated) return undefined;
|
|
125
|
+
return subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage);
|
|
126
|
+
}, [authenticated, subscribe, handleRealtimeMessage]);
|
|
134
127
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
backoffMs = INITIAL_BACKOFF_MS;
|
|
143
|
-
};
|
|
144
|
-
socket.onmessage = handleMessage;
|
|
145
|
-
socket.onerror = () => {
|
|
146
|
-
socket.close();
|
|
147
|
-
};
|
|
148
|
-
socket.onclose = () => {
|
|
149
|
-
if (socketRef.current === socket) {
|
|
150
|
-
socketRef.current = null;
|
|
151
|
-
}
|
|
152
|
-
if (!active || reconnectTimer) return;
|
|
153
|
-
reconnectTimer = setTimeout(() => {
|
|
154
|
-
reconnectTimer = null;
|
|
155
|
-
connect();
|
|
156
|
-
}, backoffMs);
|
|
157
|
-
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
158
|
-
};
|
|
159
|
-
};
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!authenticated) {
|
|
130
|
+
replaceNotifications([]);
|
|
131
|
+
setUnreadCount(0);
|
|
132
|
+
setSeeded(false);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
160
135
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
} catch {
|
|
136
|
+
let cancelled = false;
|
|
137
|
+
refresh()
|
|
138
|
+
.catch(() => {
|
|
165
139
|
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
seedAndConnect();
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
if (!cancelled) setSeeded(true);
|
|
143
|
+
});
|
|
171
144
|
|
|
172
145
|
return () => {
|
|
173
|
-
|
|
174
|
-
clearTimeout(reconnectTimer);
|
|
175
|
-
const socket = socketRef.current;
|
|
176
|
-
socketRef.current = null;
|
|
177
|
-
if (socket) socket.close();
|
|
146
|
+
cancelled = true;
|
|
178
147
|
};
|
|
179
|
-
}, [authenticated, refresh, replaceNotifications
|
|
148
|
+
}, [authenticated, refresh, replaceNotifications]);
|
|
180
149
|
|
|
181
150
|
const mark = useCallback((action, ids) => {
|
|
182
151
|
const selectedIds = Array.isArray(ids) ? ids : [];
|
|
@@ -210,16 +179,18 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
210
179
|
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
211
180
|
|
|
212
181
|
return (
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
182
|
+
<RealtimeContext.Provider value={{ subscribe }}>
|
|
183
|
+
<NotificationsContext.Provider value={{
|
|
184
|
+
notifications,
|
|
185
|
+
unreadCount,
|
|
186
|
+
markSeen,
|
|
187
|
+
markDismissed,
|
|
188
|
+
markDone,
|
|
189
|
+
refresh,
|
|
190
|
+
}}>
|
|
191
|
+
{children}
|
|
192
|
+
</NotificationsContext.Provider>
|
|
193
|
+
</RealtimeContext.Provider>
|
|
223
194
|
);
|
|
224
195
|
}
|
|
225
196
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const INITIAL_BACKOFF_MS = 1_000;
|
|
4
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
// Envelope discriminator for the notifications domain. A message with no
|
|
7
|
+
// `envelope` field (pre-NOTIF-13 dcm) is treated as this default so an
|
|
8
|
+
// un-bumped backend keeps working unchanged (backward-compat).
|
|
9
|
+
export const DEFAULT_ENVELOPE = 'notification';
|
|
10
|
+
|
|
11
|
+
const RealtimeContext = createContext(null);
|
|
12
|
+
|
|
13
|
+
export function useRealtime() {
|
|
14
|
+
const context = useContext(RealtimeContext);
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error('useRealtime must be used within a NotificationsProvider');
|
|
17
|
+
}
|
|
18
|
+
return context;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { RealtimeContext };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Owns the single WebSocket connection and an envelope-keyed subscriber
|
|
25
|
+
* registry. Internal — consumed only by NotificationsProvider, which
|
|
26
|
+
* re-exposes `subscribe` via RealtimeContext so useRealtime() works without
|
|
27
|
+
* a separate provider apps have to mount.
|
|
28
|
+
*
|
|
29
|
+
* Backoff, reconnect, and cleanup semantics are unchanged from the prior
|
|
30
|
+
* single-purpose socket owner. `active`/`wsUrl` are primitives, so the
|
|
31
|
+
* effect does not re-run (and does not reconnect) on unrelated re-renders.
|
|
32
|
+
*/
|
|
33
|
+
export function useRealtimeCore({ active, wsUrl }) {
|
|
34
|
+
const socketRef = useRef(null);
|
|
35
|
+
const subscribersRef = useRef(new Map());
|
|
36
|
+
|
|
37
|
+
const subscribe = useCallback((envelope, handler) => {
|
|
38
|
+
const subscribers = subscribersRef.current;
|
|
39
|
+
if (!subscribers.has(envelope)) {
|
|
40
|
+
subscribers.set(envelope, new Set());
|
|
41
|
+
}
|
|
42
|
+
subscribers.get(envelope).add(handler);
|
|
43
|
+
return () => {
|
|
44
|
+
const handlers = subscribers.get(envelope);
|
|
45
|
+
if (!handlers) return;
|
|
46
|
+
handlers.delete(handler);
|
|
47
|
+
if (handlers.size === 0) {
|
|
48
|
+
subscribers.delete(envelope);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!active) return undefined;
|
|
55
|
+
|
|
56
|
+
let isActive = true;
|
|
57
|
+
let reconnectTimer = null;
|
|
58
|
+
let backoffMs = INITIAL_BACKOFF_MS;
|
|
59
|
+
|
|
60
|
+
const handleMessage = (event) => {
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = JSON.parse(event.data);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const envelope = data.envelope ?? DEFAULT_ENVELOPE;
|
|
69
|
+
const handlers = subscribersRef.current.get(envelope);
|
|
70
|
+
if (!handlers || handlers.size === 0) return;
|
|
71
|
+
handlers.forEach((handler) => {
|
|
72
|
+
try {
|
|
73
|
+
handler(data);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Realtime subscriber error', error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const connect = () => {
|
|
81
|
+
if (!isActive || socketRef.current) return;
|
|
82
|
+
|
|
83
|
+
const socket = new WebSocket(wsUrl);
|
|
84
|
+
socketRef.current = socket;
|
|
85
|
+
|
|
86
|
+
socket.onopen = () => {
|
|
87
|
+
backoffMs = INITIAL_BACKOFF_MS;
|
|
88
|
+
};
|
|
89
|
+
socket.onmessage = handleMessage;
|
|
90
|
+
socket.onerror = () => {
|
|
91
|
+
socket.close();
|
|
92
|
+
};
|
|
93
|
+
socket.onclose = () => {
|
|
94
|
+
if (socketRef.current === socket) {
|
|
95
|
+
socketRef.current = null;
|
|
96
|
+
}
|
|
97
|
+
if (!isActive || reconnectTimer) return;
|
|
98
|
+
reconnectTimer = setTimeout(() => {
|
|
99
|
+
reconnectTimer = null;
|
|
100
|
+
connect();
|
|
101
|
+
}, backoffMs);
|
|
102
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
connect();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
isActive = false;
|
|
110
|
+
clearTimeout(reconnectTimer);
|
|
111
|
+
const socket = socketRef.current;
|
|
112
|
+
socketRef.current = null;
|
|
113
|
+
if (socket) socket.close();
|
|
114
|
+
};
|
|
115
|
+
}, [active, wsUrl]);
|
|
116
|
+
|
|
117
|
+
return { subscribe };
|
|
118
|
+
}
|
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')
|
|
@@ -136,6 +136,7 @@ describe('NotificationsProvider', () => {
|
|
|
136
136
|
it('decrements unread count on a cross-tab status push and does not double-count a repeat push', async () => {
|
|
137
137
|
renderProvider();
|
|
138
138
|
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
139
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
139
140
|
|
|
140
141
|
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
141
142
|
type: 'notification.status',
|
|
@@ -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
|
+
});
|