@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
package/WORK_ORDERS.md
CHANGED
|
@@ -22,3 +22,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
22
22
|
| ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
|
|
23
23
|
| PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | ee89329 | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
|
|
24
24
|
| NOTIF-6 | NotificationsProvider + bell on canonical dcm feed/* API | Single-WS-owner `NotificationsProvider`/`useNotifications` (initial REST seed via `feedApi.js` + one `/ws/notifications/` socket, reconnect w/ backoff) + generic MUI `NotificationBell`; additive exports only | 2026-07-27 | done | 6c63fb9 | Codex-built direct (no nesting), independent `reviewer`: single-WS-owner invariant traced clean (no way to break it — `authenticated`/`replaceNotifications`/`refresh` all referentially stable across unrelated re-renders, re-render test is genuine not a no-op). R1 (P2, real bug): the `notification.status` WS handler patched item flags but never adjusted `unreadCount`, so the badge went stale after a cross-tab/cross-device mark — the test had locked in the stale count as expected behavior. Fixed: delta computed from before/after unread state on the WS patch path, mirroring `mark()`'s own logic; test corrected + a new cross-tab regression test added. 57 passing, build clean. No new npm dependency. Full history + backend contract: dcm's `WORK_ORDERS.md`/`docs/design/notifications-platform.md`. Publishes ucm 2.11.0 |
|
|
25
|
+
| NOTIF-13 | Layer-1 transport extraction (pluggable realtime) | Extract the socket out of `NotificationsProvider` into a generic subscribe-by-message-type realtime primitive so a second domain (messaging) can ride the same socket; the catch-all that turns every non-`notification.status` message into a feed entry is replaced by explicit dispatch with unknown types ignored. Additive: `NotificationsProvider` stays the sole mount point, its context value unchanged. Full spec: [work-orders/NOTIF-13.md](work-orders/NOTIF-13.md). | 2026-07-29 | planned | | Tier 2 (cockpit consumes `NotificationsProvider` at 2.12.0 in prod). Phase A, the hinge: unblocks NOTIF-15 (jg bell adoption) and is the substrate for Phase B messaging. Canonical register row: django-core-micha `WORK_ORDERS.md`. Operator decision 2026-07-29: additive only, NO standalone `RealtimeProvider` apps must mount. Scope E (envelope contract + helper + doc) also touches dcm; note that the roadmap wording "dcm multi-stream consumer" overstates it — `push_to_users`/`NotificationConsumer` are already stream-agnostic. |
|
|
26
|
+
| NOTIF-12 | Popup channel via the wizard renderer | Make `popup` a real delivery channel: extract the presentational dialog shell out of `OnboardingWizard`, render popup-eligible notifications through it, and record seen/dismissed on `NotificationRecipient` (design D-F7: renderer reuse only, NOT the onboarding progress store); dcm `PopupDispatcher` stops being a logging stub. Full spec: [work-orders/NOTIF-12.md](work-orders/NOTIF-12.md). | 2026-07-29 | planned | | Tier 2 (extracts a renderer cockpit+jg run in production; the roadmap label "uncritical" is about urgency, not blast radius). **HARD GATE: starts only after NOTIF-13 landed + published** — consumes its envelope contract and `subscribe()`, and edits the same ucm files (AGENTS.md overlap rule). **SHIPS WITH ZERO PRODUCERS, deliberately** (operator decision 2026-07-29, made with the evidence): no notification type declares `popup` in `eligible_channels` anywhere in dcm/jg/cockpit/hram/spesix/survey_app, so the channel is wired but inactive until an app opts a type in. WO therefore requires (a) CHANGELOG wording that does not overclaim (NOTIF-4 R1 precedent) and (b) a test-local producer type proving the path end-to-end, so it is not untested dead code. Was NOTIF-11 (renumbered 2026-07-29 for the jg cutover/drop split). |
|
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/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export { authTranslations } from './i18n/authTranslations';
|
|
|
33
33
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
34
34
|
export * from './notifications/api';
|
|
35
35
|
export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
|
|
36
|
+
export { useRealtime } from './notifications/realtime';
|
|
36
37
|
export { NotificationBell } from './notifications/NotificationBell';
|
|
37
38
|
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
38
39
|
// --- 8. Onboarding ---
|
|
@@ -2,8 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
4
|
import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
|
|
5
|
-
|
|
6
|
-
const MAX_BACKOFF_MS = 30000;
|
|
5
|
+
import { DEFAULT_ENVELOPE, RealtimeContext, useRealtimeCore } from './realtime';
|
|
7
6
|
const NotificationsContext = createContext(null);
|
|
8
7
|
function normalizeNotification(notification) {
|
|
9
8
|
var _a, _b, _c;
|
|
@@ -43,7 +42,6 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
43
42
|
const [notifications, setNotifications] = useState([]);
|
|
44
43
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
45
44
|
const notificationsRef = useRef([]);
|
|
46
|
-
const socketRef = useRef(null);
|
|
47
45
|
const replaceNotifications = useCallback((nextNotifications) => {
|
|
48
46
|
notificationsRef.current = nextNotifications;
|
|
49
47
|
setNotifications(nextNotifications);
|
|
@@ -61,94 +59,66 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
61
59
|
setUnreadCount(nextUnreadCount);
|
|
62
60
|
return { notifications: nextNotifications, unreadCount: nextUnreadCount };
|
|
63
61
|
}, [authenticated, replaceNotifications]);
|
|
62
|
+
// Seeded gates the socket connect, not just `authenticated`: the prior single-purpose
|
|
63
|
+
// socket owner awaited the initial REST feed/unread-count fetch before opening the
|
|
64
|
+
// WebSocket, so a live push could never race an empty/incomplete `notificationsRef`.
|
|
65
|
+
// Preserved here rather than connecting immediately, since `refresh()`'s
|
|
66
|
+
// `replaceNotifications(...)` is unconditional and would silently drop a message that
|
|
67
|
+
// arrived and patched state before the initial REST response landed.
|
|
68
|
+
const [seeded, setSeeded] = useState(false);
|
|
69
|
+
const { subscribe } = useRealtimeCore({
|
|
70
|
+
active: authenticated && seeded,
|
|
71
|
+
wsUrl: getWebSocketUrl(wsUrlBase),
|
|
72
|
+
});
|
|
73
|
+
const handleRealtimeMessage = useCallback((data) => {
|
|
74
|
+
if (data.type === 'notification.status') {
|
|
75
|
+
let unreadDelta = 0;
|
|
76
|
+
const nextNotifications = notificationsRef.current.map((notification) => {
|
|
77
|
+
if (notification.notification_id !== data.notification_id)
|
|
78
|
+
return notification;
|
|
79
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
80
|
+
const patched = patchStatus(notification, data.status || {});
|
|
81
|
+
const isUnread = !patched.seen && !patched.dismissed;
|
|
82
|
+
if (wasUnread !== isUnread) {
|
|
83
|
+
unreadDelta += isUnread ? 1 : -1;
|
|
84
|
+
}
|
|
85
|
+
return patched;
|
|
86
|
+
});
|
|
87
|
+
replaceNotifications(nextNotifications);
|
|
88
|
+
if (unreadDelta) {
|
|
89
|
+
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const pushedNotification = normalizeNotificationPush(data);
|
|
94
|
+
replaceNotifications([pushedNotification, ...notificationsRef.current]);
|
|
95
|
+
setUnreadCount((count) => count + 1);
|
|
96
|
+
}, [replaceNotifications]);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!authenticated)
|
|
99
|
+
return undefined;
|
|
100
|
+
return subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage);
|
|
101
|
+
}, [authenticated, subscribe, handleRealtimeMessage]);
|
|
64
102
|
useEffect(() => {
|
|
65
103
|
if (!authenticated) {
|
|
66
104
|
replaceNotifications([]);
|
|
67
105
|
setUnreadCount(0);
|
|
106
|
+
setSeeded(false);
|
|
68
107
|
return undefined;
|
|
69
108
|
}
|
|
70
|
-
let
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
data = JSON.parse(event.data);
|
|
81
|
-
}
|
|
82
|
-
catch (_a) {
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (data.type === 'notification.status') {
|
|
86
|
-
let unreadDelta = 0;
|
|
87
|
-
patchNotifications((current) => current.map((notification) => {
|
|
88
|
-
if (notification.notification_id !== data.notification_id)
|
|
89
|
-
return notification;
|
|
90
|
-
const wasUnread = !notification.seen && !notification.dismissed;
|
|
91
|
-
const patched = patchStatus(notification, data.status || {});
|
|
92
|
-
const isUnread = !patched.seen && !patched.dismissed;
|
|
93
|
-
if (wasUnread !== isUnread) {
|
|
94
|
-
unreadDelta += isUnread ? 1 : -1;
|
|
95
|
-
}
|
|
96
|
-
return patched;
|
|
97
|
-
}));
|
|
98
|
-
if (unreadDelta) {
|
|
99
|
-
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
100
|
-
}
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const pushedNotification = normalizeNotificationPush(data);
|
|
104
|
-
patchNotifications((current) => [pushedNotification, ...current]);
|
|
105
|
-
setUnreadCount((count) => count + 1);
|
|
106
|
-
};
|
|
107
|
-
const connect = () => {
|
|
108
|
-
if (!active || socketRef.current)
|
|
109
|
-
return;
|
|
110
|
-
const socket = new WebSocket(getWebSocketUrl(wsUrlBase));
|
|
111
|
-
socketRef.current = socket;
|
|
112
|
-
socket.onopen = () => {
|
|
113
|
-
backoffMs = INITIAL_BACKOFF_MS;
|
|
114
|
-
};
|
|
115
|
-
socket.onmessage = handleMessage;
|
|
116
|
-
socket.onerror = () => {
|
|
117
|
-
socket.close();
|
|
118
|
-
};
|
|
119
|
-
socket.onclose = () => {
|
|
120
|
-
if (socketRef.current === socket) {
|
|
121
|
-
socketRef.current = null;
|
|
122
|
-
}
|
|
123
|
-
if (!active || reconnectTimer)
|
|
124
|
-
return;
|
|
125
|
-
reconnectTimer = setTimeout(() => {
|
|
126
|
-
reconnectTimer = null;
|
|
127
|
-
connect();
|
|
128
|
-
}, backoffMs);
|
|
129
|
-
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
130
|
-
};
|
|
131
|
-
};
|
|
132
|
-
const seedAndConnect = async () => {
|
|
133
|
-
try {
|
|
134
|
-
await refresh();
|
|
135
|
-
}
|
|
136
|
-
catch (_a) {
|
|
137
|
-
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
138
|
-
}
|
|
139
|
-
if (active)
|
|
140
|
-
connect();
|
|
141
|
-
};
|
|
142
|
-
seedAndConnect();
|
|
109
|
+
let cancelled = false;
|
|
110
|
+
refresh()
|
|
111
|
+
.catch(() => {
|
|
112
|
+
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
113
|
+
})
|
|
114
|
+
.finally(() => {
|
|
115
|
+
if (!cancelled)
|
|
116
|
+
setSeeded(true);
|
|
117
|
+
});
|
|
143
118
|
return () => {
|
|
144
|
-
|
|
145
|
-
clearTimeout(reconnectTimer);
|
|
146
|
-
const socket = socketRef.current;
|
|
147
|
-
socketRef.current = null;
|
|
148
|
-
if (socket)
|
|
149
|
-
socket.close();
|
|
119
|
+
cancelled = true;
|
|
150
120
|
};
|
|
151
|
-
}, [authenticated, refresh, replaceNotifications
|
|
121
|
+
}, [authenticated, refresh, replaceNotifications]);
|
|
152
122
|
const mark = useCallback((action, ids) => {
|
|
153
123
|
const selectedIds = Array.isArray(ids) ? ids : [];
|
|
154
124
|
const selected = new Set(selectedIds);
|
|
@@ -176,14 +146,14 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
176
146
|
const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
|
|
177
147
|
const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
|
|
178
148
|
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
179
|
-
return (_jsx(NotificationsContext.Provider, { value: {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
149
|
+
return (_jsx(RealtimeContext.Provider, { value: { subscribe }, children: _jsx(NotificationsContext.Provider, { value: {
|
|
150
|
+
notifications,
|
|
151
|
+
unreadCount,
|
|
152
|
+
markSeen,
|
|
153
|
+
markDismissed,
|
|
154
|
+
markDone,
|
|
155
|
+
refresh,
|
|
156
|
+
}, children: children }) }));
|
|
187
157
|
}
|
|
188
158
|
export function useNotifications() {
|
|
189
159
|
const context = useContext(NotificationsContext);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
const INITIAL_BACKOFF_MS = 1000;
|
|
3
|
+
const MAX_BACKOFF_MS = 30000;
|
|
4
|
+
// Envelope discriminator for the notifications domain. A message with no
|
|
5
|
+
// `envelope` field (pre-NOTIF-13 dcm) is treated as this default so an
|
|
6
|
+
// un-bumped backend keeps working unchanged (backward-compat).
|
|
7
|
+
export const DEFAULT_ENVELOPE = 'notification';
|
|
8
|
+
const RealtimeContext = createContext(null);
|
|
9
|
+
export function useRealtime() {
|
|
10
|
+
const context = useContext(RealtimeContext);
|
|
11
|
+
if (!context) {
|
|
12
|
+
throw new Error('useRealtime must be used within a NotificationsProvider');
|
|
13
|
+
}
|
|
14
|
+
return context;
|
|
15
|
+
}
|
|
16
|
+
export { RealtimeContext };
|
|
17
|
+
/**
|
|
18
|
+
* Owns the single WebSocket connection and an envelope-keyed subscriber
|
|
19
|
+
* registry. Internal — consumed only by NotificationsProvider, which
|
|
20
|
+
* re-exposes `subscribe` via RealtimeContext so useRealtime() works without
|
|
21
|
+
* a separate provider apps have to mount.
|
|
22
|
+
*
|
|
23
|
+
* Backoff, reconnect, and cleanup semantics are unchanged from the prior
|
|
24
|
+
* single-purpose socket owner. `active`/`wsUrl` are primitives, so the
|
|
25
|
+
* effect does not re-run (and does not reconnect) on unrelated re-renders.
|
|
26
|
+
*/
|
|
27
|
+
export function useRealtimeCore({ active, wsUrl }) {
|
|
28
|
+
const socketRef = useRef(null);
|
|
29
|
+
const subscribersRef = useRef(new Map());
|
|
30
|
+
const subscribe = useCallback((envelope, handler) => {
|
|
31
|
+
const subscribers = subscribersRef.current;
|
|
32
|
+
if (!subscribers.has(envelope)) {
|
|
33
|
+
subscribers.set(envelope, new Set());
|
|
34
|
+
}
|
|
35
|
+
subscribers.get(envelope).add(handler);
|
|
36
|
+
return () => {
|
|
37
|
+
const handlers = subscribers.get(envelope);
|
|
38
|
+
if (!handlers)
|
|
39
|
+
return;
|
|
40
|
+
handlers.delete(handler);
|
|
41
|
+
if (handlers.size === 0) {
|
|
42
|
+
subscribers.delete(envelope);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}, []);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!active)
|
|
48
|
+
return undefined;
|
|
49
|
+
let isActive = true;
|
|
50
|
+
let reconnectTimer = null;
|
|
51
|
+
let backoffMs = INITIAL_BACKOFF_MS;
|
|
52
|
+
const handleMessage = (event) => {
|
|
53
|
+
var _a;
|
|
54
|
+
let data;
|
|
55
|
+
try {
|
|
56
|
+
data = JSON.parse(event.data);
|
|
57
|
+
}
|
|
58
|
+
catch (_b) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const envelope = (_a = data.envelope) !== null && _a !== void 0 ? _a : DEFAULT_ENVELOPE;
|
|
62
|
+
const handlers = subscribersRef.current.get(envelope);
|
|
63
|
+
if (!handlers || handlers.size === 0)
|
|
64
|
+
return;
|
|
65
|
+
handlers.forEach((handler) => {
|
|
66
|
+
try {
|
|
67
|
+
handler(data);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.error('Realtime subscriber error', error);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
const connect = () => {
|
|
75
|
+
if (!isActive || socketRef.current)
|
|
76
|
+
return;
|
|
77
|
+
const socket = new WebSocket(wsUrl);
|
|
78
|
+
socketRef.current = socket;
|
|
79
|
+
socket.onopen = () => {
|
|
80
|
+
backoffMs = INITIAL_BACKOFF_MS;
|
|
81
|
+
};
|
|
82
|
+
socket.onmessage = handleMessage;
|
|
83
|
+
socket.onerror = () => {
|
|
84
|
+
socket.close();
|
|
85
|
+
};
|
|
86
|
+
socket.onclose = () => {
|
|
87
|
+
if (socketRef.current === socket) {
|
|
88
|
+
socketRef.current = null;
|
|
89
|
+
}
|
|
90
|
+
if (!isActive || reconnectTimer)
|
|
91
|
+
return;
|
|
92
|
+
reconnectTimer = setTimeout(() => {
|
|
93
|
+
reconnectTimer = null;
|
|
94
|
+
connect();
|
|
95
|
+
}, backoffMs);
|
|
96
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
connect();
|
|
100
|
+
return () => {
|
|
101
|
+
isActive = false;
|
|
102
|
+
clearTimeout(reconnectTimer);
|
|
103
|
+
const socket = socketRef.current;
|
|
104
|
+
socketRef.current = null;
|
|
105
|
+
if (socket)
|
|
106
|
+
socket.close();
|
|
107
|
+
};
|
|
108
|
+
}, [active, wsUrl]);
|
|
109
|
+
return { subscribe };
|
|
110
|
+
}
|
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/index.js
CHANGED
|
@@ -46,6 +46,7 @@ export { authTranslations } from './i18n/authTranslations';
|
|
|
46
46
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
47
47
|
export * from './notifications/api';
|
|
48
48
|
export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
|
|
49
|
+
export { useRealtime } from './notifications/realtime';
|
|
49
50
|
export { NotificationBell } from './notifications/NotificationBell';
|
|
50
51
|
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
51
52
|
|