@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.
@@ -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
- useEffect(() => {
88
- if (!authenticated) {
89
- replaceNotifications([]);
90
- setUnreadCount(0);
91
- return undefined;
92
- }
93
-
94
- let active = true;
95
- let reconnectTimer = null;
96
- let backoffMs = INITIAL_BACKOFF_MS;
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
- const patchNotifications = (updater) => {
99
- const nextNotifications = updater(notificationsRef.current);
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
- if (data.type === 'notification.status') {
112
- let unreadDelta = 0;
113
- patchNotifications((current) => current.map((notification) => {
114
- if (notification.notification_id !== data.notification_id) return notification;
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
- const pushedNotification = normalizeNotificationPush(data);
131
- patchNotifications((current) => [pushedNotification, ...current]);
132
- setUnreadCount((count) => count + 1);
133
- };
123
+ useEffect(() => {
124
+ if (!authenticated) return undefined;
125
+ return subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage);
126
+ }, [authenticated, subscribe, handleRealtimeMessage]);
134
127
 
135
- const connect = () => {
136
- if (!active || socketRef.current) return;
137
-
138
- const socket = new WebSocket(getWebSocketUrl(wsUrlBase));
139
- socketRef.current = socket;
140
-
141
- socket.onopen = () => {
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
- const seedAndConnect = async () => {
162
- try {
163
- await refresh();
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
- if (active) connect();
168
- };
169
-
170
- seedAndConnect();
140
+ })
141
+ .finally(() => {
142
+ if (!cancelled) setSeeded(true);
143
+ });
171
144
 
172
145
  return () => {
173
- active = false;
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, wsUrlBase]);
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
- <NotificationsContext.Provider value={{
214
- notifications,
215
- unreadCount,
216
- markSeen,
217
- markDismissed,
218
- markDone,
219
- refresh,
220
- }}>
221
- {children}
222
- </NotificationsContext.Provider>
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
+ }
@@ -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')
@@ -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
+ });