@micha.bigler/ui-core-micha 2.9.0 → 2.10.1
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 +22 -0
- package/dist/auth/AuthContext.js +13 -7
- package/dist/onboarding/OnboardingProvider.js +56 -6
- package/dist/onboarding/OnboardingWizard.js +5 -1
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +16 -8
- package/src/onboarding/OnboardingProvider.jsx +66 -9
- package/src/onboarding/OnboardingWizard.jsx +5 -1
- package/tests/AuthContext.test.jsx +82 -3
- package/tests/OnboardingProvider.test.jsx +138 -4
- package/tests/onboardingDescriptors.test.js +18 -1
package/WORK_ORDERS.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# WORK_ORDERS.md — ui-core-micha
|
|
2
|
+
|
|
3
|
+
Work-order register for this repo. Lightweight directory (not the full orders):
|
|
4
|
+
one row per WO with its implementation status. Convention, schema, and maintenance
|
|
5
|
+
rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
|
|
6
|
+
|
|
7
|
+
## Workstream prefixes
|
|
8
|
+
|
|
9
|
+
| Prefix | Workstream |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `ONB-*` | Onboarding wizard (steps, conditions, persistence) |
|
|
12
|
+
| `PERF-*` | Performance improvements |
|
|
13
|
+
|
|
14
|
+
Introduce a new prefix when none fits and add it here. New WOs always get a
|
|
15
|
+
prefixed ID; never reuse a bare flat number across workstreams.
|
|
16
|
+
|
|
17
|
+
## Register
|
|
18
|
+
|
|
19
|
+
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
20
|
+
|---|---|---|---|---|---|---|
|
|
21
|
+
| 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). |
|
|
22
|
+
| 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 | | 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. |
|
package/dist/auth/AuthContext.js
CHANGED
|
@@ -37,18 +37,24 @@ export const AuthProvider = ({ children }) => {
|
|
|
37
37
|
try {
|
|
38
38
|
// 1) Ensure CSRF cookie exists using the specific client
|
|
39
39
|
await ensureCsrfToken();
|
|
40
|
-
// 2)
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// 2) Both calls depend on CSRF, but not on each other. Await both settlements
|
|
41
|
+
// so loading stays true until the complete auth bootstrap has finished.
|
|
42
|
+
const [methodsResult, userResult] = await Promise.allSettled([
|
|
43
|
+
fetchAuthMethods(),
|
|
44
|
+
fetchCurrentUser(),
|
|
45
|
+
]);
|
|
46
|
+
// Keep defaults when the public auth-methods request fails.
|
|
47
|
+
if (methodsResult.status === 'fulfilled') {
|
|
48
|
+
const methods = methodsResult.value;
|
|
43
49
|
if (isMounted && methods && typeof methods === 'object') {
|
|
44
50
|
setAuthMethods((prev) => (Object.assign(Object.assign({}, prev), methods)));
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
// Preserve the previous unauthenticated/error handling for current-user.
|
|
54
|
+
if (userResult.status === 'rejected') {
|
|
55
|
+
throw userResult.reason;
|
|
49
56
|
}
|
|
50
|
-
|
|
51
|
-
const data = await fetchCurrentUser();
|
|
57
|
+
const data = userResult.value;
|
|
52
58
|
if (isMounted) {
|
|
53
59
|
setUser(mapUserFromApi(data));
|
|
54
60
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
2
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
4
|
import { getNotificationPreferences } from '../notifications/api';
|
|
5
5
|
import { getOnboardingStepConfig } from './api';
|
|
@@ -31,8 +31,17 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
31
31
|
id: 'browser_push',
|
|
32
32
|
condition: (ctx) => {
|
|
33
33
|
var _a, _b;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const { nagUntil = 'any-channel', showOnce = false } = ctx.browserPush || {};
|
|
35
|
+
if (showOnce && ((_a = ctx.seenSteps) === null || _a === void 0 ? void 0 : _a.has('browser_push')))
|
|
36
|
+
return false;
|
|
37
|
+
if (nagUntil === 'off')
|
|
38
|
+
return false;
|
|
39
|
+
const supported = (_b = ctx.pushState) === null || _b === void 0 ? void 0 : _b.supported;
|
|
40
|
+
if (!supported)
|
|
41
|
+
return !ctx.emailOptedIn;
|
|
42
|
+
if (nagUntil === 'all-channels')
|
|
43
|
+
return !ctx.pushState.subscribed || !ctx.emailOptedIn;
|
|
44
|
+
return !ctx.pushState.subscribed && !ctx.emailOptedIn; // 'any-channel' (default)
|
|
36
45
|
},
|
|
37
46
|
blocking: false,
|
|
38
47
|
skipable: true,
|
|
@@ -75,6 +84,28 @@ function loadDismissedSteps() {
|
|
|
75
84
|
return new Set();
|
|
76
85
|
}
|
|
77
86
|
}
|
|
87
|
+
function loadSeenSteps() {
|
|
88
|
+
if (typeof window === 'undefined')
|
|
89
|
+
return new Set();
|
|
90
|
+
try {
|
|
91
|
+
return new Set(JSON.parse(window.localStorage.getItem('onboarding_seen')) || []);
|
|
92
|
+
}
|
|
93
|
+
catch (_a) {
|
|
94
|
+
return new Set();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function persistSeenStep(id) {
|
|
98
|
+
if (typeof window === 'undefined')
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
const seenSteps = loadSeenSteps();
|
|
102
|
+
seenSteps.add(id);
|
|
103
|
+
window.localStorage.setItem('onboarding_seen', JSON.stringify([...seenSteps]));
|
|
104
|
+
}
|
|
105
|
+
catch (_a) {
|
|
106
|
+
// Persistence is optional; the current session remains unaffected.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
78
109
|
function getInitialPushState() {
|
|
79
110
|
const supported = typeof navigator !== 'undefined'
|
|
80
111
|
&& typeof window !== 'undefined'
|
|
@@ -100,7 +131,8 @@ function getInitialPwaInstallState() {
|
|
|
100
131
|
// reference every render, invalidating the `ctx` memo below for every
|
|
101
132
|
// consumer even when no extraContext is passed.
|
|
102
133
|
const EMPTY_EXTRA_CONTEXT = {};
|
|
103
|
-
|
|
134
|
+
const EMPTY_BROWSER_PUSH = {};
|
|
135
|
+
export function OnboardingProvider({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false, browserPush = EMPTY_BROWSER_PUSH, }) {
|
|
104
136
|
const { user } = useContext(AuthContext);
|
|
105
137
|
const [configMap, setConfigMap] = useState({});
|
|
106
138
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
@@ -108,6 +140,8 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
108
140
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
109
141
|
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
110
142
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
143
|
+
const seenAtMountRef = useRef(loadSeenSteps());
|
|
144
|
+
const resolvedBrowserPush = useMemo(() => (Object.assign({ nagUntil: 'any-channel', showOnce: false }, browserPush)), [browserPush]);
|
|
111
145
|
const descriptors = useMemo(() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps], [extraSteps]);
|
|
112
146
|
useEffect(() => {
|
|
113
147
|
let cancelled = false;
|
|
@@ -228,13 +262,29 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
228
262
|
return next;
|
|
229
263
|
});
|
|
230
264
|
}, []);
|
|
265
|
+
const markStepSeen = useCallback((id) => {
|
|
266
|
+
persistSeenStep(id);
|
|
267
|
+
}, []);
|
|
231
268
|
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
232
|
-
const ctx = useMemo(() => (Object.assign({ user,
|
|
269
|
+
const ctx = useMemo(() => (Object.assign({ user,
|
|
270
|
+
pushState,
|
|
271
|
+
emailOptedIn,
|
|
272
|
+
pwaInstall,
|
|
273
|
+
pwaInstallEnabled, browserPush: resolvedBrowserPush, seenSteps: seenAtMountRef.current }, extraContext)), [
|
|
274
|
+
user,
|
|
275
|
+
pushState,
|
|
276
|
+
emailOptedIn,
|
|
277
|
+
pwaInstall,
|
|
278
|
+
pwaInstallEnabled,
|
|
279
|
+
resolvedBrowserPush,
|
|
280
|
+
seenAtMountRef.current,
|
|
281
|
+
extraContext,
|
|
282
|
+
]);
|
|
233
283
|
const activeSteps = useMemo(() => {
|
|
234
284
|
if (!configLoaded || !user)
|
|
235
285
|
return [];
|
|
236
286
|
return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
|
|
237
287
|
}, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
|
|
238
|
-
const value = useMemo(() => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }), [activeSteps, dismissStep, ctx, configMap]);
|
|
288
|
+
const value = useMemo(() => ({ activeSteps, dismissStep, markStepSeen, ctx, configMap, setConfigMap }), [activeSteps, dismissStep, markStepSeen, ctx, configMap]);
|
|
239
289
|
return _jsx(OnboardingContext.Provider, { value: value, children: children });
|
|
240
290
|
}
|
|
@@ -18,6 +18,7 @@ export function OnboardingWizard() {
|
|
|
18
18
|
const [completed, setCompleted] = useState(0);
|
|
19
19
|
const activeSteps = (onboarding === null || onboarding === void 0 ? void 0 : onboarding.activeSteps) || [];
|
|
20
20
|
const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
|
|
21
|
+
const currentStep = visibleSteps[0];
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
if (visibleSteps.length > 0 && totalRef.current === null) {
|
|
23
24
|
totalRef.current = visibleSteps.length;
|
|
@@ -28,10 +29,13 @@ export function OnboardingWizard() {
|
|
|
28
29
|
setCompleted(0);
|
|
29
30
|
}
|
|
30
31
|
}, [visibleSteps.length]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (onboarding && currentStep)
|
|
34
|
+
onboarding.markStepSeen(currentStep.id);
|
|
35
|
+
}, [onboarding, currentStep === null || currentStep === void 0 ? void 0 : currentStep.id]);
|
|
31
36
|
if (!onboarding || visibleSteps.length === 0)
|
|
32
37
|
return null;
|
|
33
38
|
const { dismissStep, ctx } = onboarding;
|
|
34
|
-
const currentStep = visibleSteps[0];
|
|
35
39
|
const total = totalRef.current || visibleSteps.length;
|
|
36
40
|
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
37
41
|
const StepComponent = currentStep.Component;
|
package/package.json
CHANGED
package/src/auth/AuthContext.jsx
CHANGED
|
@@ -79,19 +79,27 @@ export const AuthProvider = ({ children }) => {
|
|
|
79
79
|
// 1) Ensure CSRF cookie exists using the specific client
|
|
80
80
|
await ensureCsrfToken();
|
|
81
81
|
|
|
82
|
-
// 2)
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
// 2) Both calls depend on CSRF, but not on each other. Await both settlements
|
|
83
|
+
// so loading stays true until the complete auth bootstrap has finished.
|
|
84
|
+
const [methodsResult, userResult] = await Promise.allSettled([
|
|
85
|
+
fetchAuthMethods(),
|
|
86
|
+
fetchCurrentUser(),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Keep defaults when the public auth-methods request fails.
|
|
90
|
+
if (methodsResult.status === 'fulfilled') {
|
|
91
|
+
const methods = methodsResult.value;
|
|
85
92
|
if (isMounted && methods && typeof methods === 'object') {
|
|
86
93
|
setAuthMethods((prev) => ({ ...prev, ...methods }));
|
|
87
94
|
}
|
|
88
|
-
} catch {
|
|
89
|
-
// Keep defaults; login UI remains usable.
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
// Preserve the previous unauthenticated/error handling for current-user.
|
|
98
|
+
if (userResult.status === 'rejected') {
|
|
99
|
+
throw userResult.reason;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = userResult.value;
|
|
95
103
|
if (isMounted) {
|
|
96
104
|
setUser(mapUserFromApi(data));
|
|
97
105
|
}
|
|
@@ -4,6 +4,7 @@ import React, {
|
|
|
4
4
|
useContext,
|
|
5
5
|
useEffect,
|
|
6
6
|
useMemo,
|
|
7
|
+
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
} from 'react';
|
|
9
10
|
import { AuthContext } from '../auth/AuthContext';
|
|
@@ -36,10 +37,15 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
36
37
|
},
|
|
37
38
|
{
|
|
38
39
|
id: 'browser_push',
|
|
39
|
-
condition: (ctx) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
condition: (ctx) => {
|
|
41
|
+
const { nagUntil = 'any-channel', showOnce = false } = ctx.browserPush || {};
|
|
42
|
+
if (showOnce && ctx.seenSteps?.has('browser_push')) return false;
|
|
43
|
+
if (nagUntil === 'off') return false;
|
|
44
|
+
const supported = ctx.pushState?.supported;
|
|
45
|
+
if (!supported) return !ctx.emailOptedIn;
|
|
46
|
+
if (nagUntil === 'all-channels') return !ctx.pushState.subscribed || !ctx.emailOptedIn;
|
|
47
|
+
return !ctx.pushState.subscribed && !ctx.emailOptedIn; // 'any-channel' (default)
|
|
48
|
+
},
|
|
43
49
|
blocking: false,
|
|
44
50
|
skipable: true,
|
|
45
51
|
persistDismissed: true,
|
|
@@ -82,6 +88,26 @@ function loadDismissedSteps() {
|
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
function loadSeenSteps() {
|
|
92
|
+
if (typeof window === 'undefined') return new Set();
|
|
93
|
+
try {
|
|
94
|
+
return new Set(JSON.parse(window.localStorage.getItem('onboarding_seen')) || []);
|
|
95
|
+
} catch {
|
|
96
|
+
return new Set();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persistSeenStep(id) {
|
|
101
|
+
if (typeof window === 'undefined') return;
|
|
102
|
+
try {
|
|
103
|
+
const seenSteps = loadSeenSteps();
|
|
104
|
+
seenSteps.add(id);
|
|
105
|
+
window.localStorage.setItem('onboarding_seen', JSON.stringify([...seenSteps]));
|
|
106
|
+
} catch {
|
|
107
|
+
// Persistence is optional; the current session remains unaffected.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
function getInitialPushState() {
|
|
86
112
|
const supported = typeof navigator !== 'undefined'
|
|
87
113
|
&& typeof window !== 'undefined'
|
|
@@ -110,9 +136,14 @@ function getInitialPwaInstallState() {
|
|
|
110
136
|
// reference every render, invalidating the `ctx` memo below for every
|
|
111
137
|
// consumer even when no extraContext is passed.
|
|
112
138
|
const EMPTY_EXTRA_CONTEXT = {};
|
|
139
|
+
const EMPTY_BROWSER_PUSH = {};
|
|
113
140
|
|
|
114
141
|
export function OnboardingProvider({
|
|
115
|
-
children,
|
|
142
|
+
children,
|
|
143
|
+
extraSteps = [],
|
|
144
|
+
extraContext = EMPTY_EXTRA_CONTEXT,
|
|
145
|
+
pwaInstallEnabled = false,
|
|
146
|
+
browserPush = EMPTY_BROWSER_PUSH,
|
|
116
147
|
}) {
|
|
117
148
|
const { user } = useContext(AuthContext);
|
|
118
149
|
const [configMap, setConfigMap] = useState({});
|
|
@@ -121,6 +152,12 @@ export function OnboardingProvider({
|
|
|
121
152
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
122
153
|
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
123
154
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
155
|
+
const seenAtMountRef = useRef(loadSeenSteps());
|
|
156
|
+
|
|
157
|
+
const resolvedBrowserPush = useMemo(
|
|
158
|
+
() => ({ nagUntil: 'any-channel', showOnce: false, ...browserPush }),
|
|
159
|
+
[browserPush],
|
|
160
|
+
);
|
|
124
161
|
|
|
125
162
|
const descriptors = useMemo(
|
|
126
163
|
() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps],
|
|
@@ -248,12 +285,32 @@ export function OnboardingProvider({
|
|
|
248
285
|
});
|
|
249
286
|
}, []);
|
|
250
287
|
|
|
288
|
+
const markStepSeen = useCallback((id) => {
|
|
289
|
+
persistSeenStep(id);
|
|
290
|
+
}, []);
|
|
291
|
+
|
|
251
292
|
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
252
293
|
const ctx = useMemo(
|
|
253
294
|
() => ({
|
|
254
|
-
user,
|
|
295
|
+
user,
|
|
296
|
+
pushState,
|
|
297
|
+
emailOptedIn,
|
|
298
|
+
pwaInstall,
|
|
299
|
+
pwaInstallEnabled,
|
|
300
|
+
browserPush: resolvedBrowserPush,
|
|
301
|
+
seenSteps: seenAtMountRef.current,
|
|
302
|
+
...extraContext,
|
|
255
303
|
}),
|
|
256
|
-
[
|
|
304
|
+
[
|
|
305
|
+
user,
|
|
306
|
+
pushState,
|
|
307
|
+
emailOptedIn,
|
|
308
|
+
pwaInstall,
|
|
309
|
+
pwaInstallEnabled,
|
|
310
|
+
resolvedBrowserPush,
|
|
311
|
+
seenAtMountRef.current,
|
|
312
|
+
extraContext,
|
|
313
|
+
],
|
|
257
314
|
);
|
|
258
315
|
const activeSteps = useMemo(() => {
|
|
259
316
|
if (!configLoaded || !user) return [];
|
|
@@ -261,8 +318,8 @@ export function OnboardingProvider({
|
|
|
261
318
|
}, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
|
|
262
319
|
|
|
263
320
|
const value = useMemo(
|
|
264
|
-
() => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }),
|
|
265
|
-
[activeSteps, dismissStep, ctx, configMap],
|
|
321
|
+
() => ({ activeSteps, dismissStep, markStepSeen, ctx, configMap, setConfigMap }),
|
|
322
|
+
[activeSteps, dismissStep, markStepSeen, ctx, configMap],
|
|
266
323
|
);
|
|
267
324
|
|
|
268
325
|
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
|
|
@@ -18,6 +18,7 @@ export function OnboardingWizard() {
|
|
|
18
18
|
const [completed, setCompleted] = useState(0);
|
|
19
19
|
const activeSteps = onboarding?.activeSteps || [];
|
|
20
20
|
const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
|
|
21
|
+
const currentStep = visibleSteps[0];
|
|
21
22
|
|
|
22
23
|
useEffect(() => {
|
|
23
24
|
if (visibleSteps.length > 0 && totalRef.current === null) {
|
|
@@ -30,10 +31,13 @@ export function OnboardingWizard() {
|
|
|
30
31
|
}
|
|
31
32
|
}, [visibleSteps.length]);
|
|
32
33
|
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (onboarding && currentStep) onboarding.markStepSeen(currentStep.id);
|
|
36
|
+
}, [onboarding, currentStep?.id]);
|
|
37
|
+
|
|
33
38
|
if (!onboarding || visibleSteps.length === 0) return null;
|
|
34
39
|
|
|
35
40
|
const { dismissStep, ctx } = onboarding;
|
|
36
|
-
const currentStep = visibleSteps[0];
|
|
37
41
|
const total = totalRef.current || visibleSteps.length;
|
|
38
42
|
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
39
43
|
const StepComponent = currentStep.Component;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React, { useContext } from 'react';
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
6
|
const apiClient = vi.hoisted(() => ({
|
|
7
7
|
ensureCsrfToken: vi.fn().mockResolvedValue(undefined),
|
|
@@ -19,17 +19,19 @@ vi.mock('../src/auth/ReauthModal', () => ({ ReauthModal: () => null }));
|
|
|
19
19
|
import { AuthContext, AuthProvider } from '../src/auth/AuthContext';
|
|
20
20
|
|
|
21
21
|
function Probe() {
|
|
22
|
-
const { user, refreshUser } = useContext(AuthContext);
|
|
22
|
+
const { user, loading, refreshUser } = useContext(AuthContext);
|
|
23
23
|
return (
|
|
24
24
|
<div>
|
|
25
25
|
<span data-testid="first-name">{user?.first_name || ''}</span>
|
|
26
|
+
<span data-testid="loading">{String(loading)}</span>
|
|
26
27
|
<button type="button" onClick={() => refreshUser()}>refresh</button>
|
|
27
28
|
</div>
|
|
28
29
|
);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
describe('AuthContext
|
|
32
|
+
describe('AuthContext', () => {
|
|
32
33
|
beforeEach(() => {
|
|
34
|
+
cleanup();
|
|
33
35
|
vi.clearAllMocks();
|
|
34
36
|
});
|
|
35
37
|
|
|
@@ -47,4 +49,81 @@ describe('AuthContext refreshUser', () => {
|
|
|
47
49
|
await waitFor(() => expect(screen.getByTestId('first-name').textContent).toBe('Grace'));
|
|
48
50
|
expect(authApi.fetchCurrentUser).toHaveBeenCalledTimes(2);
|
|
49
51
|
});
|
|
52
|
+
|
|
53
|
+
it('starts auth methods and current user together after CSRF, then waits for both', async () => {
|
|
54
|
+
let resolveCsrf;
|
|
55
|
+
let resolveMethods;
|
|
56
|
+
let resolveUser;
|
|
57
|
+
const callOrder = [];
|
|
58
|
+
|
|
59
|
+
apiClient.ensureCsrfToken.mockImplementation(() => new Promise((resolve) => {
|
|
60
|
+
resolveCsrf = resolve;
|
|
61
|
+
}));
|
|
62
|
+
authApi.fetchAuthMethods.mockImplementation(() => {
|
|
63
|
+
callOrder.push('auth-methods');
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
resolveMethods = resolve;
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
authApi.fetchCurrentUser.mockImplementation(() => {
|
|
69
|
+
callOrder.push('current-user');
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
resolveUser = resolve;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
render(<AuthProvider><Probe /></AuthProvider>);
|
|
76
|
+
|
|
77
|
+
expect(callOrder).toEqual([]);
|
|
78
|
+
resolveCsrf();
|
|
79
|
+
|
|
80
|
+
await waitFor(() => expect(callOrder).toEqual(['auth-methods', 'current-user']));
|
|
81
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
82
|
+
|
|
83
|
+
resolveUser({ id: 1, first_name: 'Ada' });
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
86
|
+
|
|
87
|
+
resolveMethods({ password_login: false });
|
|
88
|
+
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'));
|
|
89
|
+
expect(screen.getByTestId('first-name').textContent).toBe('Ada');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('still sets the user when auth-methods fails but current-user succeeds', async () => {
|
|
93
|
+
let resolveUser;
|
|
94
|
+
|
|
95
|
+
apiClient.ensureCsrfToken.mockResolvedValue(undefined);
|
|
96
|
+
authApi.fetchAuthMethods.mockRejectedValue(new Error('auth-methods unavailable'));
|
|
97
|
+
authApi.fetchCurrentUser.mockImplementation(() => new Promise((resolve) => {
|
|
98
|
+
resolveUser = resolve;
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
render(<AuthProvider><Probe /></AuthProvider>);
|
|
102
|
+
|
|
103
|
+
await waitFor(() => expect(authApi.fetchAuthMethods).toHaveBeenCalledTimes(1));
|
|
104
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
105
|
+
|
|
106
|
+
resolveUser({ id: 1, first_name: 'Ada' });
|
|
107
|
+
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'));
|
|
108
|
+
expect(screen.getByTestId('first-name').textContent).toBe('Ada');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('preserves the unauthenticated state after a current-user failure', async () => {
|
|
112
|
+
let resolveMethods;
|
|
113
|
+
|
|
114
|
+
apiClient.ensureCsrfToken.mockResolvedValue(undefined);
|
|
115
|
+
authApi.fetchAuthMethods.mockImplementation(() => new Promise((resolve) => {
|
|
116
|
+
resolveMethods = resolve;
|
|
117
|
+
}));
|
|
118
|
+
authApi.fetchCurrentUser.mockRejectedValue(new Error('Unauthenticated'));
|
|
119
|
+
|
|
120
|
+
render(<AuthProvider><Probe /></AuthProvider>);
|
|
121
|
+
|
|
122
|
+
await waitFor(() => expect(authApi.fetchCurrentUser).toHaveBeenCalledTimes(1));
|
|
123
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
124
|
+
|
|
125
|
+
resolveMethods({ password_login: false });
|
|
126
|
+
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'));
|
|
127
|
+
expect(screen.getByTestId('first-name').textContent).toBe('');
|
|
128
|
+
});
|
|
50
129
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
6
|
const onboardingApi = vi.hoisted(() => ({
|
|
7
7
|
getOnboardingStepConfig: vi.fn(),
|
|
@@ -23,10 +23,25 @@ function Probe() {
|
|
|
23
23
|
|
|
24
24
|
function ActiveStepsProbe() {
|
|
25
25
|
const onboarding = useOnboarding();
|
|
26
|
-
return
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<span data-testid="active-steps">{onboarding?.activeSteps.map((step) => step.id).join(',')}</span>
|
|
29
|
+
<span data-testid="push-subscribed">{String(onboarding?.ctx.pushState?.subscribed)}</span>
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
function
|
|
34
|
+
function MarkStepSeenProbe() {
|
|
35
|
+
const onboarding = useOnboarding();
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<ActiveStepsProbe />
|
|
39
|
+
<button type="button" onClick={() => onboarding.markStepSeen('browser_push')}>Mark browser push seen</button>
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderProvider({ browserPush, extraContext, extraSteps, pwaInstallEnabled, children = <Probe /> } = {}) {
|
|
30
45
|
return render(
|
|
31
46
|
<AuthContext.Provider value={{
|
|
32
47
|
user: {
|
|
@@ -40,6 +55,7 @@ function renderProvider({ extraContext, extraSteps, pwaInstallEnabled, children
|
|
|
40
55
|
extraContext={extraContext}
|
|
41
56
|
extraSteps={extraSteps}
|
|
42
57
|
pwaInstallEnabled={pwaInstallEnabled}
|
|
58
|
+
browserPush={browserPush}
|
|
43
59
|
>
|
|
44
60
|
{children}
|
|
45
61
|
</OnboardingProvider>
|
|
@@ -158,3 +174,121 @@ describe('OnboardingProvider pwa_install capture', () => {
|
|
|
158
174
|
expect(screen.getByTestId('active-steps').textContent).not.toContain('pwa_install');
|
|
159
175
|
});
|
|
160
176
|
});
|
|
177
|
+
|
|
178
|
+
describe('OnboardingProvider browser_push configuration', () => {
|
|
179
|
+
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker');
|
|
180
|
+
const pushManagerDescriptor = Object.getOwnPropertyDescriptor(window, 'PushManager');
|
|
181
|
+
const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
|
182
|
+
const localStorageItems = new Map();
|
|
183
|
+
|
|
184
|
+
function installLocalStorage() {
|
|
185
|
+
Object.defineProperty(window, 'localStorage', {
|
|
186
|
+
configurable: true,
|
|
187
|
+
value: {
|
|
188
|
+
getItem: (key) => localStorageItems.get(key) || null,
|
|
189
|
+
setItem: (key, value) => localStorageItems.set(key, String(value)),
|
|
190
|
+
clear: () => localStorageItems.clear(),
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function installPushState(subscribed) {
|
|
196
|
+
Object.defineProperty(navigator, 'serviceWorker', {
|
|
197
|
+
configurable: true,
|
|
198
|
+
value: {
|
|
199
|
+
ready: Promise.resolve({
|
|
200
|
+
pushManager: { getSubscription: () => Promise.resolve(subscribed ? {} : null) },
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
Object.defineProperty(window, 'PushManager', {
|
|
205
|
+
configurable: true,
|
|
206
|
+
value: window.PushManager || function PushManager() {},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function restorePushSupport() {
|
|
211
|
+
if (serviceWorkerDescriptor) {
|
|
212
|
+
Object.defineProperty(navigator, 'serviceWorker', serviceWorkerDescriptor);
|
|
213
|
+
} else {
|
|
214
|
+
delete navigator.serviceWorker;
|
|
215
|
+
}
|
|
216
|
+
if (pushManagerDescriptor) {
|
|
217
|
+
Object.defineProperty(window, 'PushManager', pushManagerDescriptor);
|
|
218
|
+
} else {
|
|
219
|
+
delete window.PushManager;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function restoreLocalStorage() {
|
|
224
|
+
if (localStorageDescriptor) {
|
|
225
|
+
Object.defineProperty(window, 'localStorage', localStorageDescriptor);
|
|
226
|
+
} else {
|
|
227
|
+
delete window.localStorage;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
cleanup();
|
|
233
|
+
vi.clearAllMocks();
|
|
234
|
+
installLocalStorage();
|
|
235
|
+
window.localStorage.clear();
|
|
236
|
+
onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
|
|
237
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: false });
|
|
238
|
+
installPushState(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
restorePushSupport();
|
|
243
|
+
restoreLocalStorage();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('uses any-channel by default and all-channels when explicitly configured', async () => {
|
|
247
|
+
const defaultRender = renderProvider({ children: <ActiveStepsProbe /> });
|
|
248
|
+
|
|
249
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('true'));
|
|
250
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('browser_push');
|
|
251
|
+
|
|
252
|
+
defaultRender.unmount();
|
|
253
|
+
renderProvider({
|
|
254
|
+
browserPush: { nagUntil: 'all-channels' },
|
|
255
|
+
children: <ActiveStepsProbe />,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('true'));
|
|
259
|
+
expect(screen.getByTestId('active-steps').textContent).toContain('browser_push');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('never shows browser_push when nagUntil is off', async () => {
|
|
263
|
+
renderProvider({ browserPush: { nagUntil: 'off' }, children: <ActiveStepsProbe /> });
|
|
264
|
+
|
|
265
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('true'));
|
|
266
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('browser_push');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('persists showOnce for the next provider session without hiding the current session step', async () => {
|
|
270
|
+
installPushState(false);
|
|
271
|
+
const firstRender = renderProvider({
|
|
272
|
+
browserPush: { showOnce: true },
|
|
273
|
+
children: <MarkStepSeenProbe />,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('false'));
|
|
277
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('browser_push'));
|
|
278
|
+
|
|
279
|
+
fireEvent.click(screen.getByRole('button', { name: 'Mark browser push seen' }));
|
|
280
|
+
|
|
281
|
+
expect(screen.getByTestId('active-steps').textContent).toContain('browser_push');
|
|
282
|
+
expect(JSON.parse(window.localStorage.getItem('onboarding_seen'))).toContain('browser_push');
|
|
283
|
+
|
|
284
|
+
firstRender.unmount();
|
|
285
|
+
renderProvider({
|
|
286
|
+
browserPush: { showOnce: true },
|
|
287
|
+
extraSteps: [{ id: 'provider_loaded', condition: () => true, persistDismissed: false }],
|
|
288
|
+
children: <ActiveStepsProbe />,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('provider_loaded'));
|
|
292
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('browser_push');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -28,12 +28,29 @@ describe('cookie_consent descriptor condition', () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
describe('browser_push descriptor condition', () => {
|
|
31
|
-
it('is active
|
|
31
|
+
it('is active by default only when neither supported channel is configured', () => {
|
|
32
32
|
expect(browserPushDescriptor.condition({
|
|
33
|
+
pushState: { supported: true, subscribed: false },
|
|
34
|
+
emailOptedIn: false,
|
|
35
|
+
})).toBe(true);
|
|
36
|
+
expect(browserPushDescriptor.condition({
|
|
37
|
+
pushState: { supported: true, subscribed: false },
|
|
38
|
+
emailOptedIn: true,
|
|
39
|
+
})).toBe(false);
|
|
40
|
+
expect(browserPushDescriptor.condition({
|
|
41
|
+
pushState: { supported: true, subscribed: true },
|
|
42
|
+
emailOptedIn: false,
|
|
43
|
+
})).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps the legacy condition when configured for all-channels', () => {
|
|
47
|
+
expect(browserPushDescriptor.condition({
|
|
48
|
+
browserPush: { nagUntil: 'all-channels' },
|
|
33
49
|
pushState: { supported: true, subscribed: false },
|
|
34
50
|
emailOptedIn: true,
|
|
35
51
|
})).toBe(true);
|
|
36
52
|
expect(browserPushDescriptor.condition({
|
|
53
|
+
browserPush: { nagUntil: 'all-channels' },
|
|
37
54
|
pushState: { supported: true, subscribed: true },
|
|
38
55
|
emailOptedIn: false,
|
|
39
56
|
})).toBe(true);
|