Skip to content

Commit cc1d018

Browse files
fix(react): keep the sign-up form usable after a server-side validation error
When the identity server returned the same registration step with a validation error (e.g. a rejected password), the submit button stayed disabled and the user could not resubmit: - BaseSignUp registered its form fields under config.name || component.id, while the input adapters read and write values under config.identifier. The reset after the response validated the (now empty) fields under the id keys and recorded "required" errors that no input could ever clear, so isValid stayed false. - useForm validated against the values of the previous render, so the error state always lagged one change behind and a change followed by a blur in the same event validated the old value. Fix the field naming, validate against the current values (kept in a ref that is updated synchronously), keep the entered values when the same step comes back, and drop the client-side email/password format rules whose messages had no translations; the server enforces those and its message is shown. Regression test added; verified end to end against the reporter's app.
1 parent 5dedc81 commit cc1d018

4 files changed

Lines changed: 261 additions & 24 deletions

File tree

‎.changeset/signin-signup-followups.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ Fixes for the embedded `<SignIn />` and `<SignUp />` components:
1111
- When the automatic sign-in after an embedded registration cannot be performed, the reason is reported in the browser console as well as the server log.
1212
- `<SignUp />` renders `RICH_TEXT` flow components (for example the terms of service text), which were previously dropped, leaving the submit button jammed against the last field.
1313
- The Next.js `<SignUp />` forwards all remaining props to the underlying form (`showTitle`, `showSubtitle`, `showLogo`, `onComplete`, `onFlowChange`, class names, ...), so for instance `showTitle={false}` now works when a registration flow renders its own heading.
14+
- After a server-side validation error (for example a rejected password), `<SignUp />` now keeps the values the user entered and the submit button stays enabled, so the user can correct the field and resubmit. Previously the form's validation errors were recorded under keys no input could clear, which left the button disabled permanently. Form validation also runs against the value being typed instead of the previous one.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
/* eslint-disable sort-keys, @typescript-eslint/typedef, @typescript-eslint/explicit-function-return-type, testing-library/no-container, testing-library/no-node-access */
20+
21+
import {act, cleanup, fireEvent, render, waitFor} from '@testing-library/react';
22+
import {afterEach, describe, expect, it, vi} from 'vitest';
23+
import BaseSignUp from './BaseSignUp';
24+
25+
const mockColors = {
26+
text: {primary: '#000', secondary: '#666'},
27+
background: {surface: '#fff', disabled: '#eee', body: {main: '#fff'}},
28+
border: '#ccc',
29+
action: {
30+
hover: '#f0f0f0',
31+
active: '#e0e0e0',
32+
selected: '#d0d0d0',
33+
disabled: '#bbb',
34+
disabledBackground: '#f5f5f5',
35+
focus: '#0066cc',
36+
hoverOpacity: 0.08,
37+
selectedOpacity: 0.12,
38+
disabledOpacity: 0.38,
39+
focusOpacity: 0.12,
40+
activatedOpacity: 0.12,
41+
},
42+
primary: {main: '#0066cc', contrastText: '#fff'},
43+
secondary: {main: '#666', contrastText: '#fff'},
44+
error: {main: '#d32f2f', contrastText: '#fff'},
45+
success: {main: '#2e7d32', contrastText: '#fff'},
46+
warning: {main: '#ed6c02', contrastText: '#fff'},
47+
info: {main: '#0288d1', contrastText: '#fff'},
48+
};
49+
50+
const mockTypography = {
51+
fontFamily: 'Arial, sans-serif',
52+
fontSizes: {xs: '0.75rem', sm: '0.875rem', md: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem'},
53+
fontWeights: {normal: 400, medium: 500, semibold: 600, bold: 700},
54+
lineHeights: {tight: 1.25, normal: 1.5, relaxed: 1.75},
55+
};
56+
57+
const mockTheme = {
58+
colors: mockColors,
59+
typography: mockTypography,
60+
spacing: {unit: 8},
61+
borderRadius: {small: '2px', medium: '4px', large: '8px'},
62+
shadows: {small: '0 1px 2px rgba(0,0,0,0.1)', medium: '0 2px 4px rgba(0,0,0,0.1)', large: '0 4px 8px rgba(0,0,0,0.1)'},
63+
cssVariables: {},
64+
vars: {
65+
colors: {
66+
...mockColors,
67+
action: {...mockColors.action, hoverOpacity: '0.08', selectedOpacity: '0.12', disabledOpacity: '0.38', focusOpacity: '0.12', activatedOpacity: '0.12'},
68+
},
69+
spacing: {unit: '8px'},
70+
borderRadius: {small: '2px', medium: '4px', large: '8px'},
71+
shadows: {small: '0 1px 2px rgba(0,0,0,0.1)', medium: '0 2px 4px rgba(0,0,0,0.1)', large: '0 4px 8px rgba(0,0,0,0.1)'},
72+
typography: {
73+
...mockTypography,
74+
fontWeights: {normal: '400', medium: '500', semibold: '600', bold: '700'},
75+
lineHeights: {tight: '1.25', normal: '1.5', relaxed: '1.75'},
76+
},
77+
},
78+
};
79+
80+
vi.mock('../../../../../contexts/Theme/useTheme', () => ({
81+
default: () => ({theme: mockTheme, colorScheme: 'light', direction: 'ltr'}),
82+
}));
83+
84+
vi.mock('../../../../../hooks/useTranslation', () => ({
85+
default: () => ({t: (key: string) => key, currentLanguage: 'en', setLanguage: vi.fn(), availableLanguages: ['en']}),
86+
}));
87+
88+
vi.mock('../../../../../contexts/Asgardeo/useAsgardeo', () => ({
89+
default: () => ({}),
90+
}));
91+
92+
/**
93+
* The registration step exactly as the identity server returns it: inputs are keyed by `identifier`,
94+
* there is no `name` in the config.
95+
*/
96+
const registrationStep = (additionalData?: Record<string, unknown>): any => ({
97+
flowId: 'flow-1',
98+
flowStatus: 'INCOMPLETE',
99+
flowType: 'REGISTRATION',
100+
type: 'VIEW',
101+
data: {
102+
...(additionalData ? {additionalData} : {}),
103+
components: [
104+
{
105+
id: 'form_1',
106+
type: 'FORM',
107+
components: [
108+
{
109+
id: 'input_username',
110+
type: 'INPUT',
111+
variant: 'TEXT',
112+
config: {identifier: 'http://wso2.org/claims/username', type: 'text', label: 'Username', required: true},
113+
},
114+
{
115+
id: 'input_password',
116+
type: 'INPUT',
117+
variant: 'PASSWORD',
118+
config: {identifier: 'password', type: 'password', label: 'Password', required: true},
119+
},
120+
{id: 'button_submit', type: 'BUTTON', variant: 'PRIMARY', config: {type: 'submit', text: 'Continue'}},
121+
],
122+
},
123+
],
124+
},
125+
});
126+
127+
const SERVER_ERROR = 'Password does not meet the required format.';
128+
129+
describe('BaseSignUp (v1) after a server-side validation error', () => {
130+
afterEach(() => cleanup());
131+
132+
it('keeps the entered values and lets the user resubmit', async () => {
133+
const onSubmit = vi
134+
.fn()
135+
.mockResolvedValueOnce(registrationStep({error: SERVER_ERROR}))
136+
.mockResolvedValueOnce(registrationStep());
137+
138+
const {container} = render(
139+
<BaseSignUp isInitialized onInitialize={vi.fn().mockResolvedValue(registrationStep())} onSubmit={onSubmit} />,
140+
);
141+
142+
const submit = await waitFor(() => {
143+
const button = container.querySelector('form button[type="submit"]') as HTMLButtonElement | null;
144+
expect(button).not.toBeNull();
145+
return button as HTMLButtonElement;
146+
});
147+
const username = container.querySelector('input[name="http://wso2.org/claims/username"]') as HTMLInputElement;
148+
const password = container.querySelector('input[name="password"]') as HTMLInputElement;
149+
150+
expect(submit.disabled).toBe(false);
151+
152+
fireEvent.change(username, {target: {value: 'sdk-test@example.com'}});
153+
fireEvent.change(password, {target: {value: 'weak'}});
154+
await act(async () => {
155+
fireEvent.click(submit);
156+
});
157+
158+
// The server rejected the password: the message is shown, the values stay, and the form is still usable.
159+
await waitFor(() => expect(container.textContent).toContain(SERVER_ERROR));
160+
expect(onSubmit).toHaveBeenCalledTimes(1);
161+
expect(onSubmit.mock.calls[0][0].inputs).toEqual({
162+
'http://wso2.org/claims/username': 'sdk-test@example.com',
163+
password: 'weak',
164+
});
165+
expect(username.value).toBe('sdk-test@example.com');
166+
expect(password.value).toBe('weak');
167+
expect(submit.disabled).toBe(false);
168+
169+
// Correct the password and resubmit.
170+
fireEvent.change(password, {target: {value: 'Str0ng!Passw0rd'}});
171+
expect(submit.disabled).toBe(false);
172+
await act(async () => {
173+
fireEvent.click(submit);
174+
});
175+
176+
expect(onSubmit).toHaveBeenCalledTimes(2);
177+
expect(onSubmit.mock.calls[1][0].inputs).toEqual({
178+
'http://wso2.org/claims/username': 'sdk-test@example.com',
179+
password: 'Str0ng!Passw0rd',
180+
});
181+
});
182+
183+
it('validates the value being typed, not the previous one', async () => {
184+
const {container} = render(
185+
<BaseSignUp isInitialized onInitialize={vi.fn().mockResolvedValue(registrationStep())} onSubmit={vi.fn()} />,
186+
);
187+
188+
const username = await waitFor(() => {
189+
const input = container.querySelector('input[name="http://wso2.org/claims/username"]') as HTMLInputElement | null;
190+
expect(input).not.toBeNull();
191+
return input as HTMLInputElement;
192+
});
193+
const submit = container.querySelector('form button[type="submit"]') as HTMLButtonElement;
194+
195+
// Emptying a required field must disable submit immediately, and filling it must re-enable it immediately.
196+
fireEvent.change(username, {target: {value: 'a'}});
197+
fireEvent.change(username, {target: {value: ''}});
198+
expect(submit.disabled).toBe(true);
199+
expect(container.textContent).toContain('validations.required.field.error');
200+
201+
fireEvent.change(username, {target: {value: 'sdk-test@example.com'}});
202+
expect(submit.disabled).toBe(false);
203+
expect(container.textContent).not.toContain('validations.required.field.error');
204+
});
205+
});

‎packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -311,20 +311,16 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
311311
const config: any = component.config || {};
312312
fields.push({
313313
initialValue: config.defaultValue || '',
314-
name: config.name || component.id,
314+
// Must match the key the input adapters read/write values under, otherwise validation
315+
// errors are recorded for names no input can ever clear and the submit button locks up.
316+
name: config.identifier || config.name || component.id,
315317
required: config.required || false,
318+
// Format and policy rules (email syntax, password strength) are enforced by the identity
319+
// server, which reports failures in `additionalData.error`; only presence is checked here.
316320
validator: (value: string) => {
317321
if (config.required && (!value || value.trim() === '')) {
318322
return t('validations.required.field.error');
319323
}
320-
// Add email validation if it's an email field
321-
if (config.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
322-
return t('field.email.invalid');
323-
}
324-
// Add password strength validation if it's a password field
325-
if (config.type === 'password' && value && value.length < 8) {
326-
return t('field.password.weak');
327-
}
328324
return null;
329325
},
330326
});
@@ -358,6 +354,7 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
358354
errors: formErrors,
359355
isValid: isFormValid,
360356
setValue: setFormValue,
357+
setValues: setFormValues,
361358
setTouched: setFormTouched,
362359
validateForm,
363360
reset: resetForm,
@@ -376,14 +373,33 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
376373
});
377374

378375
resetForm();
379-
380-
Object.keys(initialValues).forEach((key: any) => {
381-
setFormValue(key, initialValues[key]);
382-
});
376+
// Populate defaults without running the on-change validation: a freshly rendered step must not
377+
// start out with "required" errors that disable the submit button before the user has typed.
378+
setFormValues(initialValues);
383379
},
384-
[extractFormFields, resetForm, setFormValue],
380+
[extractFormFields, resetForm, setFormValues],
385381
);
386382

383+
/**
384+
* Whether two flow responses render the same set of input fields (e.g. the same step re-rendered
385+
* with a validation error), in which case the values the user already entered are kept.
386+
*/
387+
const rendersSameFields: (a: EmbeddedFlowExecuteResponse | null, b: EmbeddedFlowExecuteResponse) => boolean =
388+
useCallback(
389+
(a: EmbeddedFlowExecuteResponse | null, b: EmbeddedFlowExecuteResponse): boolean => {
390+
const names: (response: EmbeddedFlowExecuteResponse | null) => string = (
391+
response: EmbeddedFlowExecuteResponse | null,
392+
): string =>
393+
extractFormFields(response?.data?.components || [])
394+
.map((field: FormField) => field.name)
395+
.sort()
396+
.join('|');
397+
398+
return names(a) === names(b);
399+
},
400+
[extractFormFields],
401+
);
402+
387403
/**
388404
* Handle input value changes.
389405
*/
@@ -626,8 +642,15 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
626642
return;
627643
}
628644

645+
// Keep what the user typed when the same step comes back (typically with a validation error),
646+
// so they can correct a single field instead of filling in the whole form again.
647+
const sameStep: boolean = rendersSameFields(currentFlow, response);
648+
629649
setCurrentFlow(response);
630-
setupFormFields(response);
650+
651+
if (!sameStep) {
652+
setupFormFields(response);
653+
}
631654

632655
// Surface server-side validation failures (e.g. password policy) that arrive with an INCOMPLETE flow.
633656
const flowError: unknown = response.data?.additionalData?.['error'];

0 commit comments

Comments
 (0)