@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.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/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -39,9 +39,17 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
39
39
|
'auth-form',
|
|
40
40
|
'auth-step',
|
|
41
41
|
'auth-step-title',
|
|
42
|
+
// The confirmation as a placeable block, the success twin of 'auth-error':
|
|
43
|
+
// the two forms that have a success state publish their message and this
|
|
44
|
+
// draws it where the design put it. See `auth/form-success-context.tsx`.
|
|
45
|
+
'auth-success',
|
|
42
46
|
'basket-items',
|
|
43
47
|
'basket-summary',
|
|
44
48
|
'button',
|
|
49
|
+
// The security check commerce escalates to, placed inside an auth form.
|
|
50
|
+
'captcha',
|
|
51
|
+
// The screen the password-reset mail lands on.
|
|
52
|
+
'reset-password',
|
|
45
53
|
'change-password-form',
|
|
46
54
|
'checkout-flow-outlet',
|
|
47
55
|
'checkout-steps',
|
|
@@ -60,6 +68,10 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
60
68
|
'favorite-button',
|
|
61
69
|
'favourites-grid',
|
|
62
70
|
'forgot-password',
|
|
71
|
+
// One screen of a composed form — the confirmation, the refusal, the busy
|
|
72
|
+
// moment — as a container the form draws when it is on that screen. See
|
|
73
|
+
// `auth/form-state-context.tsx`.
|
|
74
|
+
'form-state',
|
|
63
75
|
'group',
|
|
64
76
|
'hamburger-menu',
|
|
65
77
|
'hotspot',
|
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
);
|