@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.
- package/CHANGELOG.md +33 -15
- package/package.json +4 -4
- package/src/__tests__/auth-commerce-events.test.tsx +1 -5
- package/src/__tests__/auth-form-error-block.test.tsx +0 -2
- package/src/__tests__/auth-form-validation.test.tsx +0 -2
- package/src/__tests__/auth-slot-coverage.test.ts +1 -9
- package/src/__tests__/auth-step-title.test.tsx +0 -2
- package/src/__tests__/detached-submit-state.test.tsx +2 -80
- package/src/__tests__/forgot-password-composed.test.tsx +0 -2
- package/src/__tests__/otp-form-composed.test.tsx +0 -2
- package/src/__tests__/password-reset-step.test.tsx +0 -2
- package/src/__tests__/register-form-composed.test.tsx +4 -253
- package/src/__tests__/registry-parity.test.ts +0 -12
- package/src/auth/auth-field-errors.tsx +0 -31
- package/src/blocks/account-button-block.tsx +0 -25
- package/src/blocks/add-to-cart-block.tsx +0 -15
- package/src/blocks/auth-field-block.tsx +3 -13
- package/src/blocks/auth-flow-block.tsx +3 -45
- package/src/blocks/auth-form-block.tsx +54 -337
- package/src/blocks/block-renderer-registry.tsx +0 -8
- package/src/blocks/button-block.tsx +13 -43
- package/src/blocks/change-password-form-block.tsx +0 -15
- package/src/blocks/forgot-password-block.tsx +9 -132
- package/src/blocks/newsletter-form-block.tsx +0 -16
- package/src/blocks/otp-form-block.tsx +67 -161
- package/src/blocks/profile-form-block.tsx +0 -13
- package/src/blocks/register-form-block.tsx +110 -362
- package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
- package/src/chrome/placeholder-slugs.ts +0 -3
- package/src/chrome/route-slug.ts +0 -11
- package/src/index.ts +0 -10
- package/src/overlays/__tests__/modal-registry.test.ts +1 -5
- package/src/overlays/modal-chrome-css.ts +1 -8
- package/src/overlays/modal-preview-host.tsx +2 -7
- package/src/overlays/modal-registry.ts +1 -38
- package/src/overlays/overlay-surface.tsx +0 -23
- package/src/overlays/overlay-tokens.ts +0 -12
- package/src/overlays/theme-modal-mount.tsx +6 -85
- package/src/utils/visibility-rules.ts +0 -114
- package/src/__tests__/auth-captcha.test.tsx +0 -261
- package/src/__tests__/auth-destination.test.tsx +0 -348
- package/src/__tests__/auth-error-reading.test.ts +0 -232
- package/src/__tests__/auth-flow-scope.test.tsx +0 -162
- package/src/__tests__/auth-form-success-block.test.tsx +0 -429
- package/src/__tests__/block-action-audience.test.ts +0 -154
- package/src/__tests__/form-state-composition.test.tsx +0 -484
- package/src/__tests__/modal-login-close.test.tsx +0 -366
- package/src/__tests__/reset-password-composed.test.tsx +0 -385
- package/src/auth/auth-captcha.tsx +0 -125
- package/src/auth/auth-destination.ts +0 -168
- package/src/auth/form-state-context.tsx +0 -170
- package/src/auth/form-success-context.tsx +0 -56
- package/src/auth/read-auth-error.ts +0 -332
- package/src/blocks/auth-success-block.tsx +0 -85
- package/src/blocks/captcha-block.tsx +0 -61
- package/src/blocks/form-state-block.tsx +0 -84
- package/src/blocks/reset-password-block.tsx +0 -717
- package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
- package/src/overlays/modal-host-context.tsx +0 -66
- 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
|
-
};
|