@akinon/pz-theme 2.0.95-beta.1 → 2.0.95-rc.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -15
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-commerce-events.test.tsx +1 -5
  4. package/src/__tests__/auth-form-error-block.test.tsx +0 -2
  5. package/src/__tests__/auth-form-validation.test.tsx +0 -2
  6. package/src/__tests__/auth-slot-coverage.test.ts +1 -9
  7. package/src/__tests__/auth-step-title.test.tsx +0 -2
  8. package/src/__tests__/detached-submit-state.test.tsx +2 -80
  9. package/src/__tests__/forgot-password-composed.test.tsx +0 -2
  10. package/src/__tests__/otp-form-composed.test.tsx +0 -2
  11. package/src/__tests__/password-reset-step.test.tsx +0 -2
  12. package/src/__tests__/register-form-composed.test.tsx +4 -253
  13. package/src/__tests__/registry-parity.test.ts +0 -12
  14. package/src/auth/auth-field-errors.tsx +0 -31
  15. package/src/blocks/account-button-block.tsx +0 -25
  16. package/src/blocks/add-to-cart-block.tsx +0 -15
  17. package/src/blocks/auth-field-block.tsx +3 -13
  18. package/src/blocks/auth-flow-block.tsx +3 -45
  19. package/src/blocks/auth-form-block.tsx +54 -337
  20. package/src/blocks/block-renderer-registry.tsx +0 -8
  21. package/src/blocks/button-block.tsx +13 -43
  22. package/src/blocks/change-password-form-block.tsx +0 -15
  23. package/src/blocks/forgot-password-block.tsx +9 -132
  24. package/src/blocks/newsletter-form-block.tsx +0 -16
  25. package/src/blocks/otp-form-block.tsx +67 -161
  26. package/src/blocks/profile-form-block.tsx +0 -13
  27. package/src/blocks/register-form-block.tsx +110 -362
  28. package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
  29. package/src/chrome/placeholder-slugs.ts +0 -3
  30. package/src/chrome/route-slug.ts +0 -11
  31. package/src/index.ts +0 -10
  32. package/src/overlays/__tests__/modal-registry.test.ts +1 -5
  33. package/src/overlays/modal-chrome-css.ts +1 -8
  34. package/src/overlays/modal-preview-host.tsx +2 -7
  35. package/src/overlays/modal-registry.ts +1 -38
  36. package/src/overlays/overlay-surface.tsx +0 -23
  37. package/src/overlays/overlay-tokens.ts +0 -12
  38. package/src/overlays/theme-modal-mount.tsx +6 -85
  39. package/src/utils/visibility-rules.ts +0 -114
  40. package/src/__tests__/auth-captcha.test.tsx +0 -261
  41. package/src/__tests__/auth-destination.test.tsx +0 -348
  42. package/src/__tests__/auth-error-reading.test.ts +0 -232
  43. package/src/__tests__/auth-flow-scope.test.tsx +0 -162
  44. package/src/__tests__/auth-form-success-block.test.tsx +0 -429
  45. package/src/__tests__/block-action-audience.test.ts +0 -154
  46. package/src/__tests__/form-state-composition.test.tsx +0 -484
  47. package/src/__tests__/modal-login-close.test.tsx +0 -366
  48. package/src/__tests__/reset-password-composed.test.tsx +0 -385
  49. package/src/auth/auth-captcha.tsx +0 -125
  50. package/src/auth/auth-destination.ts +0 -168
  51. package/src/auth/form-state-context.tsx +0 -170
  52. package/src/auth/form-success-context.tsx +0 -56
  53. package/src/auth/read-auth-error.ts +0 -332
  54. package/src/blocks/auth-success-block.tsx +0 -85
  55. package/src/blocks/captcha-block.tsx +0 -61
  56. package/src/blocks/form-state-block.tsx +0 -84
  57. package/src/blocks/reset-password-block.tsx +0 -717
  58. package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
  59. package/src/overlays/modal-host-context.tsx +0 -66
  60. package/src/overlays/overlay-loading.tsx +0 -144
@@ -1,385 +0,0 @@
1
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
- import React from 'react';
3
-
4
- import ResetPasswordBlock from '../blocks/reset-password-block';
5
- import { __resetAuthFlowBusForTests, getAuthFlowState } from '../auth/auth-flow-bus';
6
- import { Block } from '../theme-block';
7
-
8
- /**
9
- * The screen the password-reset MAIL lands on.
10
- *
11
- * `forgot-password` composes the request and then handed the shopper to a page
12
- * every brand hand-wrote — the same two inputs and the same four states,
13
- * drifting from the drawer the journey started in, and invisible to the editor.
14
- * These cases pin the four states, the one rule the form checks itself (the two
15
- * passwords matching), and the attribution of a commerce refusal that arrives
16
- * under the names commerce uses rather than the ones on screen.
17
- */
18
-
19
- const passwordReset = jest.fn();
20
- const unwrap = jest.fn();
21
- let resetState = { isLoading: false, isSuccess: false };
22
- let validation: {
23
- data?: { validlink: boolean };
24
- isLoading: boolean;
25
- isError: boolean;
26
- } = { data: { validlink: true }, isLoading: false, isError: false };
27
-
28
- jest.mock('@akinon/next/data/client/account', () => ({
29
- usePasswordResetMutation: () => [
30
- (...args: unknown[]) => {
31
- passwordReset(...args);
32
-
33
- return { unwrap };
34
- },
35
- resetState
36
- ],
37
- useGetValidatePasswordResetTokenQuery: () => validation
38
- }));
39
-
40
- let params: Record<string, string | string[]> = { id: ['tok', 'abc123'] };
41
- let search = new URLSearchParams();
42
-
43
- jest.mock('next/navigation', () => ({
44
- useParams: () => params,
45
- useSearchParams: () => search,
46
- usePathname: () => '/users/reset/tok/abc123'
47
- }));
48
-
49
- const block = (overrides: Partial<Block>): Block =>
50
- ({
51
- id: 'blk',
52
- type: 'text',
53
- label: 'Block',
54
- value: '',
55
- order: 0,
56
- hidden: false,
57
- properties: {},
58
- styles: {},
59
- ...overrides
60
- }) as Block;
61
-
62
- const authField = (
63
- order: number,
64
- properties: Record<string, unknown>
65
- ): Block =>
66
- block({
67
- id: `field-${order}`,
68
- type: 'auth-field',
69
- label: 'Field',
70
- properties,
71
- order
72
- });
73
-
74
- /** The two boxes as a designer composes them: a preset one and a custom one. */
75
- const COMPOSED = [
76
- authField(0, { fieldType: 'password', label: 'New password' }),
77
- authField(1, {
78
- fieldType: 'custom',
79
- name: 'repeatPassword',
80
- inputType: 'password',
81
- label: 'New password again',
82
- required: true
83
- })
84
- ];
85
-
86
- const renderBlock = (properties = {}, children?: Block[]) =>
87
- render(
88
- <ResetPasswordBlock
89
- block={
90
- block({
91
- id: 'reset-1',
92
- type: 'reset-password',
93
- label: 'Reset Password',
94
- properties,
95
- blocks: children
96
- }) as Block
97
- }
98
- placeholderId="placeholder-1"
99
- sectionId="section-1"
100
- currentBreakpoint="desktop"
101
- />
102
- );
103
-
104
- const fill = (container: HTMLElement, first: string, second: string) => {
105
- fireEvent.change(container.querySelector('input[name="password"]')!, {
106
- target: { value: first }
107
- });
108
- fireEvent.change(container.querySelector('input[name="repeatPassword"]')!, {
109
- target: { value: second }
110
- });
111
- };
112
-
113
- beforeEach(() => {
114
- passwordReset.mockClear();
115
- unwrap.mockReset();
116
- unwrap.mockResolvedValue(undefined);
117
- resetState = { isLoading: false, isSuccess: false };
118
- validation = { data: { validlink: true }, isLoading: false, isError: false };
119
- params = { id: ['tok', 'abc123'] };
120
- search = new URLSearchParams();
121
- __resetAuthFlowBusForTests();
122
- });
123
-
124
- describe('the link the mail carries', () => {
125
- it('is read back out of the catch-all route segment, slashes and all', async () => {
126
- const { container } = renderBlock();
127
-
128
- fill(container, 'hunter2A!', 'hunter2A!');
129
- fireEvent.submit(container.querySelector('form')!);
130
-
131
- await waitFor(() => expect(passwordReset).toHaveBeenCalled());
132
- // The token contains slashes, so the segments have to be put back together
133
- // — a single segment would be a different token.
134
- expect(passwordReset).toHaveBeenCalledWith({
135
- password: 'hunter2A!',
136
- repeatPassword: 'hunter2A!',
137
- slug: 'tok/abc123'
138
- });
139
- });
140
-
141
- it('can be read from the query instead, for a brand whose route differs', async () => {
142
- params = {};
143
- search = new URLSearchParams('code=xyz789');
144
-
145
- const { container } = renderBlock({
146
- tokenSource: 'query',
147
- tokenQueryKey: 'code'
148
- });
149
-
150
- fill(container, 'hunter2A!', 'hunter2A!');
151
- fireEvent.submit(container.querySelector('form')!);
152
-
153
- await waitFor(() => expect(passwordReset).toHaveBeenCalled());
154
- expect(passwordReset.mock.calls[0][0].slug).toBe('xyz789');
155
- });
156
-
157
- it('answers a spent link with the expired state, not with a form', () => {
158
- validation = { data: { validlink: false }, isLoading: false, isError: false };
159
-
160
- renderBlock({ expiredLabel: 'Bu bağlantının süresi doldu.' });
161
-
162
- expect(screen.getByText('Bu bağlantının süresi doldu.')).toBeInTheDocument();
163
- expect(document.querySelector('form')).toBeNull();
164
- });
165
-
166
- it('treats no link at all as the same dead end', () => {
167
- params = {};
168
-
169
- renderBlock({ expiredLabel: 'Bu bağlantının süresi doldu.' });
170
-
171
- expect(screen.getByText('Bu bağlantının süresi doldu.')).toBeInTheDocument();
172
- });
173
-
174
- it('sends an expired link back to the step that can issue a new one', () => {
175
- validation = { data: { validlink: false }, isLoading: false, isError: false };
176
-
177
- renderBlock({
178
- expiredStepTarget: 'auth:forgot',
179
- backLabel: 'Yeni bağlantı iste'
180
- });
181
-
182
- fireEvent.click(screen.getByText('Yeni bağlantı iste'));
183
-
184
- // A fresh mail is the only way forward, and the flow is what knows where
185
- // that lives.
186
- expect(getAuthFlowState('auth')?.active).toBe('forgot');
187
- });
188
-
189
- it('says nothing while the link is being checked, unless asked to', () => {
190
- validation = { isLoading: true, isError: false };
191
-
192
- const { container, unmount } = renderBlock();
193
- // A sentence that appears and vanishes within a fraction of a second is
194
- // worse than nothing.
195
- expect(container.innerHTML).toBe('');
196
-
197
- unmount();
198
- renderBlock({ checkingLabel: 'Bağlantınız kontrol ediliyor…' });
199
-
200
- expect(
201
- screen.getByText('Bağlantınız kontrol ediliyor…')
202
- ).toBeInTheDocument();
203
- });
204
- });
205
-
206
- describe('the two boxes', () => {
207
- it('refuses a mismatch before the request leaves', async () => {
208
- const { container } = renderBlock({
209
- mismatchMessage: 'İki şifre aynı değil.'
210
- });
211
-
212
- fill(container, 'hunter2A!', 'hunter3B!');
213
- fireEvent.submit(container.querySelector('form')!);
214
-
215
- expect(screen.getByText('İki şifre aynı değil.')).toBeInTheDocument();
216
- expect(passwordReset).not.toHaveBeenCalled();
217
- // Under the second box, which is the one that has to change.
218
- expect(
219
- container.querySelector('input[name="repeatPassword"]')
220
- ).toHaveAttribute('aria-invalid', 'true');
221
- });
222
-
223
- it('tells a password manager this is a NEW password', () => {
224
- const { container } = renderBlock();
225
-
226
- // Both boxes: a manager offers to generate and save only for
227
- // `new-password`, and this screen is exactly where it should.
228
- expect(
229
- container.querySelector('input[name="password"]')
230
- ).toHaveAttribute('autocomplete', 'new-password');
231
- expect(
232
- container.querySelector('input[name="repeatPassword"]')
233
- ).toHaveAttribute('autocomplete', 'new-password');
234
- });
235
-
236
- it('draws the children instead of its own rows once composed', () => {
237
- const { container } = renderBlock({}, COMPOSED);
238
-
239
- expect(
240
- container.querySelector('[data-reset-password-composed]')
241
- ).not.toBeNull();
242
- expect(container.querySelectorAll('[data-reset-password-field]')).toHaveLength(
243
- 0
244
- );
245
- // One of each, not two: the fixed rows are what a composition replaces.
246
- expect(container.querySelectorAll('input[name="password"]')).toHaveLength(1);
247
- expect(
248
- container.querySelectorAll('input[name="repeatPassword"]')
249
- ).toHaveLength(1);
250
- });
251
-
252
- it('submits what a composed form collected', async () => {
253
- const { container } = renderBlock({}, COMPOSED);
254
-
255
- fill(container, 'hunter2A!', 'hunter2A!');
256
- fireEvent.submit(container.querySelector('form')!);
257
-
258
- await waitFor(() => expect(passwordReset).toHaveBeenCalled());
259
- expect(passwordReset.mock.calls[0][0]).toMatchObject({
260
- password: 'hunter2A!',
261
- repeatPassword: 'hunter2A!'
262
- });
263
- });
264
- });
265
-
266
- describe('what commerce refuses', () => {
267
- it('lands under the box it is about, despite the name it arrives under', async () => {
268
- // The mutation posts `new_password1` / `new_password2`, so that is what a
269
- // refusal names — and nothing on screen submits under those.
270
- unwrap.mockRejectedValue({
271
- data: { new_password1: ['This password is too common.'] }
272
- });
273
-
274
- const { container } = renderBlock();
275
-
276
- fill(container, 'password', 'password');
277
- fireEvent.submit(container.querySelector('form')!);
278
-
279
- await waitFor(() =>
280
- expect(
281
- screen.getByText('This password is too common.')
282
- ).toBeInTheDocument()
283
- );
284
-
285
- expect(container.querySelector('input[name="password"]')).toHaveAttribute(
286
- 'aria-invalid',
287
- 'true'
288
- );
289
- });
290
-
291
- it('keeps a form-level refusal above the form', async () => {
292
- unwrap.mockRejectedValue({
293
- data: { non_field_errors: ['This link has already been used.'] }
294
- });
295
-
296
- const { container } = renderBlock();
297
-
298
- fill(container, 'hunter2A!', 'hunter2A!');
299
- fireEvent.submit(container.querySelector('form')!);
300
-
301
- await waitFor(() =>
302
- expect(
303
- screen.getByText('This link has already been used.')
304
- ).toBeInTheDocument()
305
- );
306
- });
307
-
308
- it('falls back to the authored sentence when nothing legible came back', async () => {
309
- unwrap.mockRejectedValue({ status: 500 });
310
-
311
- const { container } = renderBlock({ errorLabel: 'Bir şeyler ters gitti.' });
312
-
313
- fill(container, 'hunter2A!', 'hunter2A!');
314
- fireEvent.submit(container.querySelector('form')!);
315
-
316
- await waitFor(() =>
317
- expect(screen.getByText('Bir şeyler ters gitti.')).toBeInTheDocument()
318
- );
319
- });
320
- });
321
-
322
- describe('once the password is changed', () => {
323
- it('replaces the form, because the token is spent', () => {
324
- resetState = { isLoading: false, isSuccess: true };
325
-
326
- renderBlock({ successLabel: 'Şifreniz değiştirildi.' });
327
-
328
- expect(screen.getByText('Şifreniz değiştirildi.')).toBeInTheDocument();
329
- expect(document.querySelector('form')).toBeNull();
330
- });
331
-
332
- it('hands the journey to a step when the designer named one', async () => {
333
- const { container } = renderBlock({ successStepTarget: 'auth:done' });
334
-
335
- fill(container, 'hunter2A!', 'hunter2A!');
336
- fireEvent.submit(container.querySelector('form')!);
337
-
338
- await waitFor(() => expect(getAuthFlowState('auth')?.active).toBe('done'));
339
- });
340
- });
341
-
342
- describe('in the canvas', () => {
343
- const renderDesigner = (properties = {}) =>
344
- render(
345
- <ResetPasswordBlock
346
- block={
347
- block({
348
- id: 'reset-1',
349
- type: 'reset-password',
350
- label: 'Reset Password',
351
- properties
352
- }) as Block
353
- }
354
- placeholderId="placeholder-1"
355
- sectionId="section-1"
356
- currentBreakpoint="desktop"
357
- isDesigner
358
- />
359
- );
360
-
361
- it('shows the state the designer asked to style', () => {
362
- renderDesigner({ previewState: 'expired', expiredLabel: 'Süresi doldu.' });
363
-
364
- expect(screen.getByText('Süresi doldu.')).toBeInTheDocument();
365
- });
366
-
367
- it('shows the form by default, whatever a real token would have said', () => {
368
- validation = { data: { validlink: false }, isLoading: false, isError: false };
369
-
370
- const { container } = renderDesigner();
371
-
372
- // The designer has no token, so asking commerce about one would answer
373
- // "expired" for every composition.
374
- expect(container.querySelector('form')).not.toBeNull();
375
- });
376
-
377
- it('never spends a token', () => {
378
- const { container } = renderDesigner();
379
-
380
- fill(container, 'hunter2A!', 'hunter2A!');
381
- fireEvent.submit(container.querySelector('form')!);
382
-
383
- expect(passwordReset).not.toHaveBeenCalled();
384
- });
385
- });
@@ -1,125 +0,0 @@
1
- 'use client';
2
-
3
- import React from 'react';
4
- import { useCaptcha } from '@akinon/next/hooks';
5
-
6
- /**
7
- * The security check commerce escalates to after too many attempts.
8
- *
9
- * Nothing in the composable auth set could answer it before: the provider
10
- * reports the escalation as `{ type: 'captcha' }`, the forms sent no
11
- * `captchaValidated`, and the challenge reached the shopper as the literal
12
- * `[{"type":"captcha"}]` in the error slot. A brand whose commerce is
13
- * abuse-protected therefore could not use composed login or sign-up at all —
14
- * every submit after the threshold was a dead end.
15
- *
16
- * What this adds is the SEAM, not a second captcha implementation: the widget,
17
- * the site key and the validation round trip all stay in `useCaptcha` from
18
- * `@akinon/next`, which is the same hook the brands' own hand-written views
19
- * use. What lives here is where the widget is DRAWN — a composition places a
20
- * Captcha block, a childless form draws it above its submit — and how a form
21
- * reaches the two things it needs at submit time.
22
- *
23
- * Mounted only when the form's `showCaptcha` is on, and that is deliberate:
24
- * `useCaptcha` fetches the site key on mount, and a storefront whose commerce
25
- * never escalates should not pay a request per auth form for a challenge it
26
- * will never be shown.
27
- */
28
-
29
- export interface AuthCaptchaApi {
30
- /** The widget itself; renders nothing until the challenge is revealed. */
31
- View: React.ComponentType<{ className?: string }>;
32
- /** Whether the challenge is on screen. */
33
- isVisible: boolean;
34
- /** Whether the shopper has solved it. */
35
- validated: boolean;
36
- /**
37
- * Reveal the challenge, or validate a solved one. Resolves true only when
38
- * commerce accepted the answer — which is the moment a submit may be retried.
39
- */
40
- validate: () => Promise<boolean>;
41
- }
42
-
43
- const AuthCaptchaContext = React.createContext<AuthCaptchaApi | null>(null);
44
-
45
- /** The api for a Captcha block, or null when no form is offering one. */
46
- export const useAuthCaptcha = (): AuthCaptchaApi | null =>
47
- React.useContext(AuthCaptchaContext);
48
-
49
- interface AuthCaptchaHostProps {
50
- /**
51
- * Called whenever the api changes, so the form can read `validated` and
52
- * `validate` at submit time without re-rendering itself on every captcha
53
- * state change.
54
- */
55
- onApi: (api: AuthCaptchaApi) => void;
56
- children: React.ReactNode;
57
- }
58
-
59
- /**
60
- * Wraps a form's contents while `showCaptcha` is on: hands the api down to any
61
- * Captcha block inside, and up to the form through `onApi`.
62
- */
63
- export const AuthCaptchaHost = ({ onApi, children }: AuthCaptchaHostProps) => {
64
- const { CaptchaView, validated, isVisible, validate } = useCaptcha();
65
-
66
- const api = React.useMemo<AuthCaptchaApi>(
67
- () => ({ View: CaptchaView, validated, isVisible, validate }),
68
- // `validate` is re-created every render by the hook, so it is deliberately
69
- // NOT a dependency: including it would rebuild the api — and re-run the
70
- // effect below — on every keystroke in the form.
71
- // eslint-disable-next-line react-hooks/exhaustive-deps
72
- [CaptchaView, validated, isVisible]
73
- );
74
-
75
- const apiRef = React.useRef(api);
76
- apiRef.current = { ...api, validate };
77
-
78
- React.useEffect(() => {
79
- onApi(apiRef.current);
80
- }, [api, onApi]);
81
-
82
- return (
83
- <AuthCaptchaContext.Provider value={api}>
84
- {children}
85
- </AuthCaptchaContext.Provider>
86
- );
87
- };
88
-
89
- /**
90
- * The widget, drawn by the FORM rather than by a block.
91
- *
92
- * The fallback for a composition that has not placed a Captcha block, and the
93
- * only way a childless form can show one at all — the same relationship the
94
- * built-in error line has with a Form Error block. Renders nothing until
95
- * commerce escalates.
96
- */
97
- export const AuthCaptchaSlot = () => {
98
- const captcha = useAuthCaptcha();
99
-
100
- if (!captcha) return null;
101
-
102
- return (
103
- <div data-auth-captcha="">
104
- <captcha.View />
105
- </div>
106
- );
107
- };
108
-
109
- /**
110
- * Whether the composition placed a Captcha block itself.
111
- *
112
- * Same question, and the same shape of answer, as `containsAuthErrorBlock`: a
113
- * form that finds one draws nothing of its own, so the challenge appears where
114
- * the designer put it rather than twice.
115
- */
116
- export const containsCaptchaBlock = (
117
- blocks: { type?: string; blocks?: unknown[] }[] | undefined
118
- ): boolean =>
119
- (blocks || []).some(
120
- (block) =>
121
- block?.type === 'captcha' ||
122
- containsCaptchaBlock(
123
- block?.blocks as { type?: string; blocks?: unknown[] }[] | undefined
124
- )
125
- );
@@ -1,168 +0,0 @@
1
- /**
2
- * Where a shopper goes after signing in — and where an already signed-in one
3
- * is sent when they land on a login screen.
4
- *
5
- * One resolver for both, because they are the same question asked twice: "what
6
- * was this shopper trying to reach?" The composable forms used to answer it
7
- * with a compiled-in `/`, which is the answer least often right — a shopper
8
- * who was sent to sign in from the basket, or who opened the header's drawer
9
- * two clicks into a product page, is not asking to be taken to the homepage.
10
- *
11
- * Three modes, because the right answer depends on WHERE the form is:
12
- *
13
- * - `stay` is what a drawer wants. There is nothing to navigate to: the
14
- * shopper is already on the page they care about, and the sign-in was a
15
- * detour. The host closes the modal and refreshes; no push at all.
16
- * - `return` is what a login PAGE wants: the storefront put the intended
17
- * destination in the url, and this reads it back.
18
- * - `fixed` is the historical behaviour and stays the default, so no
19
- * published composition changes where it lands.
20
- */
21
-
22
- export const AUTH_AFTER_SIGN_IN_MODES = ['fixed', 'return', 'stay'] as const;
23
-
24
- export type AuthAfterSignIn = (typeof AUTH_AFTER_SIGN_IN_MODES)[number];
25
-
26
- /**
27
- * The query keys a pz storefront carries the destination in.
28
- *
29
- * `callbackUrl` is next-auth's own convention and what the brands' own login
30
- * views read; `next` is what the OAuth middleware writes when it hands a
31
- * shopper back (`middlewares/oauth-login.ts`). Both are read, first one wins,
32
- * so a composed form behaves like the hand-written view it replaces.
33
- */
34
- export const AUTH_RETURN_PARAMS = ['callbackUrl', 'next'] as const;
35
-
36
- /**
37
- * Whether a destination is somewhere on THIS storefront.
38
- *
39
- * The whole point of reading a destination out of the url is that anyone can
40
- * write one — a mail, an ad, a link on another site. `?callbackUrl=https://…`
41
- * would turn every composed login form into an open redirect: the shopper
42
- * signs in on the brand's own domain and is handed to a page that is not it.
43
- * Nothing here is ever navigated to unless it is a path on this origin.
44
- *
45
- * `//evil.example` is the case worth naming: it starts with a slash, and a
46
- * browser reads it as protocol-relative — a different host.
47
- */
48
- export const isSafeInternalPath = (value?: string | null): boolean => {
49
- if (typeof value !== 'string') return false;
50
-
51
- const path = value.trim();
52
-
53
- if (!path.startsWith('/')) return false;
54
- if (path.startsWith('//') || path.startsWith('/\\')) return false;
55
- // A control character in a url is either an encoding accident or an attempt
56
- // to smuggle one past a check like this.
57
- // eslint-disable-next-line no-control-regex
58
- if (/[\u0000-\u001f\u007f]/.test(path)) return false;
59
-
60
- return true;
61
- };
62
-
63
- /** The path itself, or the fallback when it is not ours to navigate to. */
64
- export const safeInternalPath = (
65
- value: string | null | undefined,
66
- fallback: string
67
- ): string => (isSafeInternalPath(value) ? (value as string).trim() : fallback);
68
-
69
- /**
70
- * The destination the url is asking for, if any.
71
- *
72
- * Reads a plain `URLSearchParams` (what `useSearchParams` hands back) so the
73
- * resolver stays testable without a router.
74
- */
75
- export const readReturnPath = (
76
- search: { get(name: string): string | null } | null | undefined
77
- ): string | null => {
78
- if (!search) return null;
79
-
80
- for (const param of AUTH_RETURN_PARAMS) {
81
- const value = search.get(param);
82
- if (isSafeInternalPath(value)) return (value as string).trim();
83
- }
84
-
85
- return null;
86
- };
87
-
88
- export interface AuthDestinationInput {
89
- mode: AuthAfterSignIn;
90
- /** The form's authored destination — the `fixed` answer, and the fallback. */
91
- authored: string;
92
- search?: { get(name: string): string | null } | null;
93
- /**
94
- * Whether the form is inside a modal. A sign-in there is a DETOUR from the
95
- * page the shopper is on, so a destination that says nothing — the shipped
96
- * `/`, or the page they are already on — is read as "nowhere to go" rather
97
- * than as a navigation.
98
- *
99
- * Decided on the VALUE and not on the mode's default, deliberately: the
100
- * editor writes every select's default into the block it inserts
101
- * (`getDefaultBlockProperties`), so a host-aware DEFAULT would be dead for
102
- * every newly authored drawer — it would only ever apply to compositions
103
- * that predate the property. An authored `/account` still navigates.
104
- */
105
- inModal?: boolean;
106
- /** The page the shopper is on, so "go where you already are" can be seen. */
107
- pathname?: string | null;
108
- }
109
-
110
- /**
111
- * Where to go, or `null` for "do not navigate at all" — which is a real answer
112
- * rather than a missing one, and the one a drawer needs.
113
- */
114
- export const resolveAuthDestination = ({
115
- mode,
116
- authored,
117
- search,
118
- inModal = false,
119
- pathname
120
- }: AuthDestinationInput): string | null => {
121
- if (mode === 'stay') return null;
122
-
123
- const fallback = safeInternalPath(authored, '/');
124
- const resolved = mode === 'return' ? readReturnPath(search) ?? fallback : fallback;
125
-
126
- // Inside a modal, a destination that names nowhere in particular is nowhere
127
- // to go — see `inModal`.
128
- if (inModal && (resolved === '/' || resolved === pathname)) return null;
129
-
130
- return resolved;
131
- };
132
-
133
- /**
134
- * The same question for a shopper who is ALREADY signed in and has landed on a
135
- * login screen. The url still knows what they were trying to reach, so it is
136
- * honoured first; `authored` is where a brand sends them otherwise (their
137
- * account, normally).
138
- *
139
- * Never `null`: a signed-in shopper looking at a login form is a dead end, so
140
- * there is always somewhere better to be.
141
- */
142
- export const resolveSignedInDestination = (
143
- authored: string,
144
- search?: { get(name: string): string | null } | null
145
- ): string => readReturnPath(search) ?? safeInternalPath(authored, '/account');
146
-
147
- /**
148
- * The path a trigger records so the form can bring the shopper back.
149
- *
150
- * Appended by the control that NAVIGATES to a login page — the account button,
151
- * a "sign in" link — because that control is the only thing that knows where
152
- * the shopper was standing. Without it the login page has nothing to return
153
- * to, which is why `return` mode used to be unbuildable: pz's own route guard
154
- * redirects to the auth page with no parameter at all.
155
- */
156
- export const withReturnPath = (href: string, currentPath?: string): string => {
157
- if (!isSafeInternalPath(href) || !isSafeInternalPath(currentPath)) return href;
158
-
159
- // Never twice, and never over an answer the author wrote themselves.
160
- const [path, query = ''] = href.split('?');
161
- const params = new URLSearchParams(query);
162
-
163
- if (AUTH_RETURN_PARAMS.some((param) => params.get(param))) return href;
164
-
165
- params.set(AUTH_RETURN_PARAMS[0], (currentPath as string).trim());
166
-
167
- return `${path}?${params.toString()}`;
168
- };