@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 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. |
@@ -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) Load auth methods (public)
41
- try {
42
- const methods = await fetchAuthMethods();
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
- catch (_a) {
48
- // Keep defaults; login UI remains usable.
53
+ // Preserve the previous unauthenticated/error handling for current-user.
54
+ if (userResult.status === 'rejected') {
55
+ throw userResult.reason;
49
56
  }
50
- // 3) Load user
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
- return Boolean((((_a = ctx.pushState) === null || _a === void 0 ? void 0 : _a.supported) && (!ctx.pushState.subscribed || !ctx.emailOptedIn))
35
- || (!((_b = ctx.pushState) === null || _b === void 0 ? void 0 : _b.supported) && !ctx.emailOptedIn));
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
- export function OnboardingProvider({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false, }) {
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, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled }, extraContext)), [user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, extraContext]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.9.0",
3
+ "version": "2.10.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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) Load auth methods (public)
83
- try {
84
- const methods = await fetchAuthMethods();
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
- // 3) Load user
93
- const data = await fetchCurrentUser();
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) => Boolean(
40
- (ctx.pushState?.supported && (!ctx.pushState.subscribed || !ctx.emailOptedIn))
41
- || (!ctx.pushState?.supported && !ctx.emailOptedIn),
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, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false,
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, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, ...extraContext,
295
+ user,
296
+ pushState,
297
+ emailOptedIn,
298
+ pwaInstall,
299
+ pwaInstallEnabled,
300
+ browserPush: resolvedBrowserPush,
301
+ seenSteps: seenAtMountRef.current,
302
+ ...extraContext,
255
303
  }),
256
- [user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, extraContext],
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 refreshUser', () => {
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 <span data-testid="active-steps">{onboarding?.activeSteps.map((step) => step.id).join(',')}</span>;
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 renderProvider({ extraContext, extraSteps, pwaInstallEnabled, children = <Probe /> } = {}) {
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 when push is supported and either channel remains unconfigured', () => {
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);