[MM-62986][MM-63011][MM-63013][MM-63014][MM-63018] Various accessibility fixes around login, account creation and MFA setup (#30847)
* [MM-62986] Ensure focus goes back to the inputs after an error for Login/Create Account/MFA * [MM-63011] Show outline on Mattermost logo link when focused * [MM-63018] Remove tabindex from the login/signup cards and use <form> element for submit * [MM-63014] Toggle aria-label when show/hide password is pressed * [MM-63013] Mention the field name when showing an error message about the password field * Fix lint * Update screenshots and fix tests * Update screenshots and fix tests * Fix tests * Update webapp/channels/src/components/mfa/setup/setup.tsx Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com> * update screenshots --------- Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com> Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
@@ -80,8 +80,8 @@ describe('Profile', () => {
|
||||
// # Save
|
||||
cy.uiSave();
|
||||
|
||||
// * Verify for error message: "Must be 5-72 characters long."
|
||||
cy.get('#clientError').should('be.visible').should('have.text', 'Must be 5-72 characters long.');
|
||||
// * Verify for error message: "Your password must be 5-72 characters long."
|
||||
cy.get('#clientError').should('be.visible').should('have.text', 'Your password must be 5-72 characters long.');
|
||||
});
|
||||
|
||||
it('MM-T2084 Password: Cancel out of password changes causes no changes to be made', () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Authentication', () => {
|
||||
|
||||
cy.uiSave();
|
||||
|
||||
cy.findByText('Must be 7-72 characters long.').should('be.visible');
|
||||
cy.findByText('Your password must be 7-72 characters long.').should('be.visible');
|
||||
|
||||
cy.apiLogout();
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('Authentication', () => {
|
||||
cy.findByText('Create Account').click();
|
||||
|
||||
// * Assert the error is what is expected;
|
||||
cy.findByText('Must be 7-72 characters long.').should('be.visible');
|
||||
cy.findByText('Your password must be 7-72 characters long.').should('be.visible');
|
||||
|
||||
cy.get('#input_password-input').clear().type('greaterthan7');
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('Authentication', () => {
|
||||
cy.findByText('Create Account').click();
|
||||
|
||||
// * Assert the error is what is expected;
|
||||
cy.findByText('Must be 5-72 characters long and include both lowercase and uppercase letters, numbers, and special characters.').should('be.visible');
|
||||
cy.findByText('Your password must be 5-72 characters long and include both lowercase and uppercase letters, numbers, and special characters.').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('Authentication', () => {
|
||||
cy.visit('/login');
|
||||
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// # Clear email/username field and type username
|
||||
cy.apiGetClientLicense().then(({isLicensed}) => {
|
||||
|
||||
@@ -28,8 +28,8 @@ describe('Managing bot accounts', () => {
|
||||
cy.apiLogout();
|
||||
cy.visit('/login');
|
||||
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
// # Click on card titile to remove auto focus from the email field
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// # Enter bot name in the email field
|
||||
cy.findByPlaceholderText('Email, Username or AD/LDAP Username', {timeout: TIMEOUTS.ONE_MIN}).clear().type(botName);
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('SignIn Authentication', () => {
|
||||
cy.visit('/login');
|
||||
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
cy.get('.login-body-card-title').click();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('SignIn Authentication', () => {
|
||||
cy.url().should('include', '/login');
|
||||
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// # Enter actual user's username in the email field
|
||||
cy.findByPlaceholderText(loginPlaceholder).clear().type(testUser.username);
|
||||
|
||||
@@ -46,8 +46,8 @@ describe('Login page with open server', () => {
|
||||
// * Verify title of the document is correct
|
||||
cy.title().should('include', config.TeamSettings.SiteName);
|
||||
|
||||
// # Remove autofocus from login id input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// * Verify email/username field is present
|
||||
cy.findByPlaceholderText('Email or Username').should('exist').and('be.visible');
|
||||
@@ -156,6 +156,9 @@ describe('Login page with open server', () => {
|
||||
it('MM-T3306_7 Should show error with invalid password', () => {
|
||||
const invalidPassword = `${Date.now()}-password`;
|
||||
|
||||
// Remove autofocus from login input
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// # Lets verify generated password is not an actual password
|
||||
expect(invalidPassword).to.not.equal(testUser.password);
|
||||
|
||||
@@ -173,6 +176,9 @@ describe('Login page with open server', () => {
|
||||
});
|
||||
|
||||
it('MM-T3306_8 Should login with a valid email and password and logout', () => {
|
||||
// Remove autofocus from login input
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// # Enter actual users email/username in the email field
|
||||
cy.findByPlaceholderText('Email or Username').clear().type(testUser.username);
|
||||
|
||||
@@ -196,8 +202,8 @@ describe('Login page with open server', () => {
|
||||
// # Visit login page
|
||||
cy.visit('/login');
|
||||
|
||||
// # Remove autofocus from login id input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// # Enter actual users email/username in the email field
|
||||
cy.findByPlaceholderText('Email or Username').clear().type(testUser.username);
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('Signup Email page', () => {
|
||||
cy.findByText('You can use lowercase letters, numbers, periods, dashes, and underscores.').should('be.visible');
|
||||
|
||||
cy.get('#input_password-input').should('be.visible').and('have.attr', 'placeholder', 'Choose a Password');
|
||||
cy.findByText('Must be 5-72 characters long.').should('be.visible');
|
||||
cy.findByText('Your password must be 5-72 characters long.').should('be.visible');
|
||||
|
||||
cy.get('#saveSetting').scrollIntoView().should('be.visible');
|
||||
cy.get('#saveSetting').should('contain', 'Create Account');
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('System Console > User Management > Users', () => {
|
||||
|
||||
// * Verify the appropriate error is returned.
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').
|
||||
and('contain', 'Must be 5-72 characters long.');
|
||||
and('contain', 'Your password must be 5-72 characters long.');
|
||||
});
|
||||
|
||||
it('MM-T936 Users - System admin changes own password - Blank fields', () => {
|
||||
@@ -193,7 +193,7 @@ describe('System Console > User Management > Users', () => {
|
||||
|
||||
// * Verify the appropriate error is returned.
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').
|
||||
and('contain', 'Must be 5-72 characters long.');
|
||||
and('contain', 'Your password must be 5-72 characters long.');
|
||||
});
|
||||
|
||||
it('MM-T937 Users - System admin changes own password - Successfully changed', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as TIMEOUTS from '../fixtures/timeouts';
|
||||
|
||||
Cypress.Commands.add('checkLoginPage', (settings = {}) => {
|
||||
// # Remove autofocus from login input
|
||||
cy.get('.login-body-card-content').should('be.visible').focus();
|
||||
cy.get('.login-body-card-title').click();
|
||||
|
||||
// * Check elements in the body
|
||||
cy.get('#input_loginId', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and(($loginTextbox) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class LoginPage {
|
||||
this.loginPlaceholder = page.locator(`[placeholder="Email or Username"]`);
|
||||
this.loginWithAdLdapPlaceholder = page.locator(`[placeholder="Email, Username or AD/LDAP Username"]`);
|
||||
this.passwordInput = page.locator('#input_password-input');
|
||||
this.passwordToggleButton = page.getByRole('button', {name: 'Show or hide password'});
|
||||
this.passwordToggleButton = page.locator('#password_toggle');
|
||||
this.signInButton = page.locator('button:has-text("Log in")');
|
||||
this.createAccountLink = page.locator("text=Don't have an account?");
|
||||
this.forgotPasswordLink = page.locator('text=Forgot your password?');
|
||||
|
||||
@@ -40,7 +40,7 @@ export default class SignupPage {
|
||||
this.emailInput = page.locator('#input_email');
|
||||
this.usernameInput = page.locator('#input_name');
|
||||
this.passwordInput = page.locator('#input_password-input');
|
||||
this.passwordToggleButton = page.getByRole('button', {name: 'Show or hide password'});
|
||||
this.passwordToggleButton = page.locator('#password_toggle');
|
||||
this.createAccountButton = page.locator('button:has-text("Create Account")');
|
||||
this.emailError = page.locator('text=Please enter a valid email address');
|
||||
this.usernameError = page.locator(
|
||||
|
||||
@@ -67,10 +67,6 @@ test('/login accessibility tab support', async ({pw}) => {
|
||||
|
||||
// * Should move focus to login body after shift+tab
|
||||
await pw.loginPage.loginInput.press('Shift+Tab');
|
||||
expect(await pw.loginPage.bodyCard).toBeFocused();
|
||||
|
||||
// * Should move focus to create account link after shift+tab
|
||||
await pw.loginPage.bodyCard.press('Shift+Tab');
|
||||
expect(await pw.loginPage.createAccountLink).toBeFocused();
|
||||
|
||||
// * Should move focus to login body after tab
|
||||
|
||||
@@ -79,14 +79,6 @@ test('/signup_user_complete accessibility tab support', async ({pw}, testInfo) =
|
||||
await pw.signupPage.emailInput.focus();
|
||||
expect(await pw.signupPage.emailInput).toBeFocused();
|
||||
|
||||
// * Should move focus to sign up body after shift+tab
|
||||
await pw.signupPage.emailInput.press('Shift+Tab');
|
||||
expect(await pw.signupPage.bodyCard).toBeFocused();
|
||||
|
||||
// * Should move focus to sign up body after shift+tab
|
||||
await pw.signupPage.emailInput.press('Shift+Tab');
|
||||
expect(await pw.signupPage.bodyCard).toBeFocused();
|
||||
|
||||
if (testInfo.project.name === 'ipad') {
|
||||
// * Should move focus to header back button after shift+tab
|
||||
await pw.signupPage.bodyCard.press('Shift+Tab');
|
||||
|
||||
|
До Ширина: | Высота: | Размер: 186 KiB После Ширина: | Высота: | Размер: 185 KiB |
|
До Ширина: | Высота: | Размер: 488 KiB После Ширина: | Высота: | Размер: 489 KiB |
|
До Ширина: | Высота: | Размер: 124 KiB После Ширина: | Высота: | Размер: 138 KiB |
|
До Ширина: | Высота: | Размер: 211 KiB После Ширина: | Высота: | Размер: 208 KiB |
|
До Ширина: | Высота: | Размер: 219 KiB После Ширина: | Высота: | Размер: 210 KiB |
|
До Ширина: | Высота: | Размер: 507 KiB После Ширина: | Высота: | Размер: 508 KiB |
|
До Ширина: | Высота: | Размер: 176 KiB После Ширина: | Высота: | Размер: 169 KiB |
|
До Ширина: | Высота: | Размер: 507 KiB После Ширина: | Высота: | Размер: 507 KiB |
|
До Ширина: | Высота: | Размер: 168 KiB После Ширина: | Высота: | Размер: 166 KiB |
@@ -81,6 +81,7 @@ const AlertBanner = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
data-testid={id}
|
||||
className={classNames(
|
||||
'AlertBanner',
|
||||
|
||||
@@ -72,6 +72,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
|
||||
<div
|
||||
className="AlertBanner warning AlertBanner--app"
|
||||
data-testid="teamWarningBanner"
|
||||
id="teamWarningBanner"
|
||||
>
|
||||
<div
|
||||
className="AlertBanner__icon"
|
||||
@@ -248,6 +249,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
|
||||
<div
|
||||
className="AlertBanner warning AlertBanner--app"
|
||||
data-testid="teamWarningBanner"
|
||||
id="teamWarningBanner"
|
||||
>
|
||||
<div
|
||||
className="AlertBanner__icon"
|
||||
@@ -498,6 +500,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
|
||||
<div
|
||||
className="AlertBanner warning AlertBanner--app"
|
||||
data-testid="teamWarningBanner"
|
||||
id="teamWarningBanner"
|
||||
>
|
||||
<div
|
||||
className="AlertBanner__icon"
|
||||
@@ -746,6 +749,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
|
||||
<div
|
||||
className="AlertBanner warning AlertBanner--app"
|
||||
data-testid="teamWarningBanner"
|
||||
id="teamWarningBanner"
|
||||
>
|
||||
<div
|
||||
className="AlertBanner__icon"
|
||||
|
||||
@@ -1314,6 +1314,7 @@ Object {
|
||||
<div
|
||||
class="AlertBanner info AlertBanner--app"
|
||||
data-testid="channelNotificationsMutedBanner"
|
||||
id="channelNotificationsMutedBanner"
|
||||
>
|
||||
<div
|
||||
class="AlertBanner__icon"
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
padding: 8px;
|
||||
border: 2px solid var(--sidebar-text-active-border);
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 170px;
|
||||
fill: var(--center-channel-color);
|
||||
|
||||
@@ -16,6 +16,7 @@ import Constants, {WindowSizes} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
jest.unmock('react-intl');
|
||||
jest.unmock('react-router-dom');
|
||||
|
||||
describe('components/login/Login', () => {
|
||||
@@ -277,6 +278,35 @@ describe('components/login/Login', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should focus username field when there is an error', async () => {
|
||||
const state = mergeObjects(baseState, {
|
||||
entities: {
|
||||
general: {
|
||||
config: {
|
||||
EnableSignInWithEmail: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithContext(
|
||||
<Login/>,
|
||||
state,
|
||||
{
|
||||
intlMessages: {
|
||||
'login.noEmail': 'Please enter your email',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Try to submit without entering username
|
||||
screen.getByRole('button', {name: 'Log in'}).click();
|
||||
|
||||
// Verify username field is focused
|
||||
const usernameInput = screen.getByLabelText('Email');
|
||||
expect(usernameInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle openid text and color props', () => {
|
||||
const state = mergeObjects(baseState, {
|
||||
entities: {
|
||||
|
||||
@@ -460,6 +460,12 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
loginIdInput.current?.focus();
|
||||
}
|
||||
}, [hasError]);
|
||||
|
||||
if (initializing) {
|
||||
return (<LoadingScreen/>);
|
||||
}
|
||||
@@ -834,7 +840,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
<div className={classNames('login-body-card', {'custom-branding': enableCustomBrand, 'with-error': hasError})}>
|
||||
<div
|
||||
className='login-body-card-content'
|
||||
tabIndex={0}
|
||||
>
|
||||
<p className='login-body-card-title'>
|
||||
{getCardTitle()}
|
||||
@@ -842,6 +847,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
{enableCustomBrand && getMessageSubtitle()}
|
||||
{alertBanner && (
|
||||
<AlertBanner
|
||||
id='login-body-card-banner'
|
||||
className='login-body-card-banner'
|
||||
mode={alertBanner.mode}
|
||||
title={alertBanner.title}
|
||||
@@ -856,6 +862,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
>
|
||||
<div className='login-body-card-form'>
|
||||
<Input
|
||||
data-testid='login-id-input'
|
||||
ref={loginIdInput}
|
||||
name='loginId'
|
||||
containerClassName='login-body-card-form-input'
|
||||
@@ -867,6 +874,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
placeholder={getInputPlaceholder()}
|
||||
disabled={isWaiting}
|
||||
autoFocus={true}
|
||||
aria-describedby={alertBanner ? 'login-body-card-banner' : undefined}
|
||||
/>
|
||||
<PasswordInput
|
||||
ref={passwordInput}
|
||||
|
||||
@@ -71,4 +71,41 @@ describe('components/mfa/setup', () => {
|
||||
wrapper.find('form').simulate('submit', {preventDefault: () => {}});
|
||||
expect(baseProps.actions.activateMfa).toBeCalledWith('testcodeinput');
|
||||
});
|
||||
|
||||
test('should focus input when code is empty', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Setup {...baseProps}/>,
|
||||
);
|
||||
const input = wrapper.find('input').getDOMNode() as HTMLInputElement;
|
||||
const focusSpy = jest.spyOn(input, 'focus');
|
||||
|
||||
wrapper.find('form').simulate('submit', {preventDefault: () => {}});
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should focus input when authentication fails', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
actions: {
|
||||
...baseProps.actions,
|
||||
activateMfa: jest.fn().mockImplementation(() => Promise.resolve({
|
||||
error: {
|
||||
server_error_id: 'ent.mfa.activate.authenticate.app_error',
|
||||
message: 'Invalid code',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Setup {...props}/>,
|
||||
);
|
||||
const input = wrapper.find('input').getDOMNode() as HTMLInputElement;
|
||||
const focusSpy = jest.spyOn(input, 'focus');
|
||||
|
||||
(wrapper.instance() as Setup).input.current!.value = 'invalidcode';
|
||||
wrapper.find('form').simulate('submit', {preventDefault: () => {}});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for state update
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,7 @@ export default class Setup extends React.PureComponent<Props, State> {
|
||||
/>
|
||||
),
|
||||
});
|
||||
this.input?.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,6 +127,7 @@ export default class Setup extends React.PureComponent<Props, State> {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
this.input?.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +139,15 @@ export default class Setup extends React.PureComponent<Props, State> {
|
||||
let formClass = 'form-group';
|
||||
let errorContent;
|
||||
if (this.state.error) {
|
||||
errorContent = <div className='form-group has-error'><label className='control-label'>{this.state.error}</label></div>;
|
||||
errorContent = (
|
||||
<div
|
||||
id='mfa-setup-error'
|
||||
className='form-group has-error'
|
||||
role='alert'
|
||||
>
|
||||
<label className='control-label'>{this.state.error}</label>
|
||||
</div>
|
||||
);
|
||||
formClass += ' has-error';
|
||||
}
|
||||
|
||||
@@ -212,6 +222,7 @@ export default class Setup extends React.PureComponent<Props, State> {
|
||||
className='form-control'
|
||||
placeholder={defineMessage({id: 'mfa.setup.code', defaultMessage: 'MFA Code'})}
|
||||
autoFocus={true}
|
||||
aria-describedby={this.state.error ? 'mfa-setup-error' : undefined}
|
||||
/>
|
||||
</p>
|
||||
{errorContent}
|
||||
|
||||
@@ -35,8 +35,6 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
>
|
||||
<div
|
||||
className="signup-body-card-content"
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
>
|
||||
<p
|
||||
className="signup-body-card-title"
|
||||
@@ -107,21 +105,20 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
>
|
||||
<div
|
||||
className="signup-body-card-content"
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
>
|
||||
<p
|
||||
className="signup-body-card-title"
|
||||
>
|
||||
Create your account
|
||||
</p>
|
||||
<div
|
||||
<form
|
||||
className="signup-body-card-form"
|
||||
>
|
||||
<ForwardRef
|
||||
autoFocus={true}
|
||||
className="signup-body-card-form-email-input"
|
||||
customMessage={null}
|
||||
data-testid="signup-body-card-form-email-input"
|
||||
disabled={false}
|
||||
inputSize="large"
|
||||
name="email"
|
||||
@@ -140,6 +137,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
"value": "You can use lowercase letters, numbers, periods, dashes, and underscores.",
|
||||
}
|
||||
}
|
||||
data-testid="signup-body-card-form-name-input"
|
||||
disabled={false}
|
||||
inputSize="large"
|
||||
name="name"
|
||||
@@ -152,6 +150,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
<ForwardRef
|
||||
className="signup-body-card-form-password-input"
|
||||
createMode={true}
|
||||
data-testid="signup-body-card-form-password-input"
|
||||
disabled={false}
|
||||
error=""
|
||||
info="Must be 5-72 characters long."
|
||||
@@ -190,7 +189,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
saving={false}
|
||||
savingMessage="Creating account…"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
className="signup-body-card-form-divider"
|
||||
>
|
||||
@@ -275,21 +274,20 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
>
|
||||
<div
|
||||
className="signup-body-card-content"
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
>
|
||||
<p
|
||||
className="signup-body-card-title"
|
||||
>
|
||||
Create your account
|
||||
</p>
|
||||
<div
|
||||
<form
|
||||
className="signup-body-card-form"
|
||||
>
|
||||
<ForwardRef
|
||||
autoFocus={true}
|
||||
className="signup-body-card-form-email-input"
|
||||
customMessage={null}
|
||||
data-testid="signup-body-card-form-email-input"
|
||||
disabled={false}
|
||||
inputSize="large"
|
||||
name="email"
|
||||
@@ -308,6 +306,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
"value": "You can use lowercase letters, numbers, periods, dashes, and underscores.",
|
||||
}
|
||||
}
|
||||
data-testid="signup-body-card-form-name-input"
|
||||
disabled={false}
|
||||
inputSize="large"
|
||||
name="name"
|
||||
@@ -320,6 +319,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
<ForwardRef
|
||||
className="signup-body-card-form-password-input"
|
||||
createMode={true}
|
||||
data-testid="signup-body-card-form-password-input"
|
||||
disabled={false}
|
||||
error=""
|
||||
info="Must be 5-72 characters long."
|
||||
@@ -358,7 +358,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
saving={false}
|
||||
savingMessage="Creating account…"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
className="signup-body-card-form-divider"
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ import Input from 'components/widgets/inputs/input/input';
|
||||
import PasswordInput from 'components/widgets/inputs/password_input/password_input';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {act, renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
import {act, renderWithContext, screen, fireEvent, waitFor} from 'tests/react_testing_utils';
|
||||
import {WindowSizes} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
@@ -274,6 +274,67 @@ describe('components/signup/Signup', () => {
|
||||
expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true);
|
||||
});
|
||||
|
||||
it('should focus email input when email validation fails', async () => {
|
||||
renderWithContext(<Signup/>, mockState);
|
||||
|
||||
const emailInput = screen.getByTestId('signup-body-card-form-email-input');
|
||||
const submitButton = screen.getByText('Create account');
|
||||
|
||||
// Submit with invalid email
|
||||
fireEvent.change(emailInput, {target: {value: 'invalid-email'}});
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('should focus password input when password validation fails', async () => {
|
||||
renderWithContext(<Signup/>, mockState);
|
||||
|
||||
const emailInput = screen.getByTestId('signup-body-card-form-email-input');
|
||||
const usernameInput = screen.getByTestId('signup-body-card-form-name-input');
|
||||
const passwordInput = screen.getByTestId('signup-body-card-form-password-input');
|
||||
const submitButton = screen.getByText('Create account');
|
||||
|
||||
// Submit with valid email and username but invalid password
|
||||
fireEvent.change(emailInput, {target: {value: 'test@example.com'}});
|
||||
fireEvent.change(usernameInput, {target: {value: 'testuser'}});
|
||||
fireEvent.change(passwordInput, {target: {value: '123'}});
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(passwordInput).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('should focus username input when server returns username exists error', async () => {
|
||||
mockDispatch = jest.fn().mockImplementation(() => Promise.resolve({
|
||||
data: {},
|
||||
error: {
|
||||
server_error_id: 'app.user.save.username_exists.app_error',
|
||||
message: 'Username already exists',
|
||||
},
|
||||
}));
|
||||
|
||||
renderWithContext(<Signup/>, mockState);
|
||||
|
||||
const emailInput = screen.getByTestId('signup-body-card-form-email-input');
|
||||
const usernameInput = screen.getByTestId('signup-body-card-form-name-input');
|
||||
const passwordInput = screen.getByTestId('signup-body-card-form-password-input');
|
||||
const submitButton = screen.getByText('Create account');
|
||||
|
||||
// Submit with valid data that will trigger server error
|
||||
fireEvent.change(emailInput, {target: {value: 'test@example.com'}});
|
||||
fireEvent.change(usernameInput, {target: {value: 'existinguser'}});
|
||||
fireEvent.change(passwordInput, {target: {value: 'password123'}});
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usernameInput).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('should add user to team and redirect when team invite valid and logged in', async () => {
|
||||
mockLocation.search = '?id=ppni7a9t87fn3j4d56rwocdctc';
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
const [alertBanner, setAlertBanner] = useState<AlertBannerProps | null>(null);
|
||||
const [isMobileView, setIsMobileView] = useState(false);
|
||||
const [subscribeToSecurityNewsletter, setSubscribeToSecurityNewsletter] = useState(false);
|
||||
const [submitClicked, setSubmitClicked] = useState(false);
|
||||
|
||||
const cwsAvailability = useCWSAvailabilityCheck();
|
||||
|
||||
@@ -368,6 +369,19 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
}
|
||||
}, [onCustomizeHeader, handleHeaderBackButtonOnClick, isMobileView, getAlternateLink, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitClicked) {
|
||||
if (emailError && emailInput.current) {
|
||||
emailInput.current.focus();
|
||||
} else if (nameError && nameInput.current) {
|
||||
nameInput.current.focus();
|
||||
} else if (passwordError && passwordInput.current) {
|
||||
passwordInput.current.focus();
|
||||
}
|
||||
setSubmitClicked(false);
|
||||
}
|
||||
}, [emailError, nameError, passwordError, submitClicked]);
|
||||
|
||||
if (loading) {
|
||||
return (<LoadingScreen/>);
|
||||
}
|
||||
@@ -376,12 +390,6 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
setBrandImageError(true);
|
||||
};
|
||||
|
||||
const onEnterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === Constants.KeyCodes.ENTER[0] && canSubmit) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
const getCardTitle = () => {
|
||||
if (CustomDescriptionText) {
|
||||
return CustomDescriptionText;
|
||||
@@ -579,6 +587,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
e.preventDefault();
|
||||
sendSignUpTelemetryEvents('click_create_account', getRoleFromTrackFlow());
|
||||
setIsWaiting(true);
|
||||
setSubmitClicked(true);
|
||||
|
||||
if (isUserValid()) {
|
||||
setNameError('');
|
||||
@@ -604,6 +613,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
onDismiss: dismissAlert,
|
||||
});
|
||||
setIsWaiting(false);
|
||||
|
||||
// Special case for accessibility to show the error message when the username is already taken
|
||||
if (error.server_error_id === 'app.user.save.username_exists.app_error') {
|
||||
setNameError(error.message);
|
||||
setSubmitClicked(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -785,8 +800,6 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
<div className={classNames('signup-body-card', {'custom-branding': enableCustomBrand, 'with-error': hasError})}>
|
||||
<div
|
||||
className='signup-body-card-content'
|
||||
onKeyDown={onEnterKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<p className='signup-body-card-title'>
|
||||
{getCardTitle()}
|
||||
@@ -801,8 +814,9 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
/>
|
||||
)}
|
||||
{enableSignUpWithEmail && (
|
||||
<div className='signup-body-card-form'>
|
||||
<form className='signup-body-card-form'>
|
||||
<Input
|
||||
data-testid='signup-body-card-form-email-input'
|
||||
ref={emailInput}
|
||||
name='email'
|
||||
className='signup-body-card-form-email-input'
|
||||
@@ -820,6 +834,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
onBlur={(e) => handleOnBlur(e, 'email')}
|
||||
/>
|
||||
<Input
|
||||
data-testid='signup-body-card-form-name-input'
|
||||
ref={nameInput}
|
||||
name='name'
|
||||
className='signup-body-card-form-name-input'
|
||||
@@ -842,6 +857,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
onBlur={(e) => handleOnBlur(e, 'username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
data-testid='signup-body-card-form-password-input'
|
||||
ref={passwordInput}
|
||||
className='signup-body-card-form-password-input'
|
||||
value={password}
|
||||
@@ -862,7 +878,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
defaultMessage={formatMessage({id: 'signup_user_completed.create', defaultMessage: 'Create account'})}
|
||||
savingMessage={formatMessage({id: 'signup_user_completed.saving', defaultMessage: 'Creating account…'})}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{enableSignUpWithEmail && enableExternalSignup && (
|
||||
<div className='signup-body-card-form-divider'>
|
||||
|
||||
@@ -40,6 +40,7 @@ const PasswordInput = React.forwardRef((
|
||||
createMode,
|
||||
disabled,
|
||||
inputSize,
|
||||
...otherProps
|
||||
}: PasswordInputProps,
|
||||
ref?: React.Ref<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -66,7 +67,10 @@ const PasswordInput = React.forwardRef((
|
||||
<button
|
||||
id='password_toggle'
|
||||
type='button'
|
||||
aria-label={formatMessage({id: 'widget.passwordInput.passwordToggle', defaultMessage: 'Show or hide password'})}
|
||||
aria-label={formatMessage({
|
||||
id: showPassword ? 'widget.passwordInput.hidePassword' : 'widget.passwordInput.showPassword',
|
||||
defaultMessage: showPassword ? 'Hide password' : 'Show password',
|
||||
})}
|
||||
className='password-input-toggle'
|
||||
onClick={toggleShowPassword}
|
||||
disabled={disabled}
|
||||
@@ -86,6 +90,7 @@ const PasswordInput = React.forwardRef((
|
||||
customMessage={error || info ? customMessage : undefined}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5974,22 +5974,22 @@
|
||||
"user.settings.security.oneSignin": "You may only have one sign-in method at a time. Switching sign-in method will send an email notifying you if the change was successful.",
|
||||
"user.settings.security.openid": "OpenID",
|
||||
"user.settings.security.password": "Password",
|
||||
"user.settings.security.passwordError": "Must be {min}-{max} characters long.",
|
||||
"user.settings.security.passwordErrorLowercase": "Must be {min}-{max} characters long and include lowercase letters.",
|
||||
"user.settings.security.passwordErrorLowercaseNumber": "Must be {min}-{max} characters long and include lowercase letters and numbers.",
|
||||
"user.settings.security.passwordErrorLowercaseNumberSymbol": "Must be {min}-{max} characters long and include lowercase letters, numbers, and special characters.",
|
||||
"user.settings.security.passwordErrorLowercaseSymbol": "Must be {min}-{max} characters long and include lowercase letters and special characters.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercase": "Must be {min}-{max} characters long and include both lowercase and uppercase letters.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercaseNumber": "Must be {min}-{max} characters long and include both lowercase and uppercase letters, and numbers.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercaseNumberSymbol": "Must be {min}-{max} characters long and include both lowercase and uppercase letters, numbers, and special characters.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercaseSymbol": "Must be {min}-{max} characters long and include both lowercase and uppercase letters, and special characters.",
|
||||
"user.settings.security.passwordErrorNumber": "Must be {min}-{max} characters long and include numbers.",
|
||||
"user.settings.security.passwordErrorNumberSymbol": "Must be {min}-{max} characters long and include numbers and special characters.",
|
||||
"user.settings.security.passwordErrorSymbol": "Must be {min}-{max} characters long and include special characters.",
|
||||
"user.settings.security.passwordErrorUppercase": "Must be {min}-{max} characters long and include uppercase letters.",
|
||||
"user.settings.security.passwordErrorUppercaseNumber": "Must be {min}-{max} characters long and include uppercase letters, and numbers.",
|
||||
"user.settings.security.passwordErrorUppercaseNumberSymbol": "Must be {min}-{max} characters long and include uppercase letters, numbers, and special characters.",
|
||||
"user.settings.security.passwordErrorUppercaseSymbol": "Must be {min}-{max} characters long and include uppercase letters, and special characters.",
|
||||
"user.settings.security.passwordError": "Your password must be {min}-{max} characters long.",
|
||||
"user.settings.security.passwordErrorLowercase": "Your password must be {min}-{max} characters long and include lowercase letters.",
|
||||
"user.settings.security.passwordErrorLowercaseNumber": "Your password must be {min}-{max} characters long and include lowercase letters and numbers.",
|
||||
"user.settings.security.passwordErrorLowercaseNumberSymbol": "Your password must be {min}-{max} characters long and include lowercase letters, numbers, and special characters.",
|
||||
"user.settings.security.passwordErrorLowercaseSymbol": "Your password must be {min}-{max} characters long and include lowercase letters and special characters.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercase": "Your password must be {min}-{max} characters long and include both lowercase and uppercase letters.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercaseNumber": "Your password must be {min}-{max} characters long and include both lowercase and uppercase letters, and numbers.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercaseNumberSymbol": "Your password must be {min}-{max} characters long and include both lowercase and uppercase letters, numbers, and special characters.",
|
||||
"user.settings.security.passwordErrorLowercaseUppercaseSymbol": "Your password must be {min}-{max} characters long and include both lowercase and uppercase letters, and special characters.",
|
||||
"user.settings.security.passwordErrorNumber": "Your password must be {min}-{max} characters long and include numbers.",
|
||||
"user.settings.security.passwordErrorNumberSymbol": "Your password must be {min}-{max} characters long and include numbers and special characters.",
|
||||
"user.settings.security.passwordErrorSymbol": "Your password must be {min}-{max} characters long and include special characters.",
|
||||
"user.settings.security.passwordErrorUppercase": "Your password must be {min}-{max} characters long and include uppercase letters.",
|
||||
"user.settings.security.passwordErrorUppercaseNumber": "Your password must be {min}-{max} characters long and include uppercase letters, and numbers.",
|
||||
"user.settings.security.passwordErrorUppercaseNumberSymbol": "Your password must be {min}-{max} characters long and include uppercase letters, numbers, and special characters.",
|
||||
"user.settings.security.passwordErrorUppercaseSymbol": "Your password must be {min}-{max} characters long and include uppercase letters, and special characters.",
|
||||
"user.settings.security.passwordGitlabCantUpdate": "Login occurs through GitLab. Password cannot be updated.",
|
||||
"user.settings.security.passwordGoogleCantUpdate": "Login occurs through Google Apps. Password cannot be updated.",
|
||||
"user.settings.security.passwordLdapCantUpdate": "Login occurs through AD/LDAP. Password cannot be updated.",
|
||||
@@ -6130,7 +6130,6 @@
|
||||
"widget.input.required": "This field is required",
|
||||
"widget.passwordInput.createPassword": "Choose a Password",
|
||||
"widget.passwordInput.password": "Password",
|
||||
"widget.passwordInput.passwordToggle": "Show or hide password",
|
||||
"widgets.channels_input.empty": "No channels found",
|
||||
"widgets.channels_input.loading": "Loading",
|
||||
"widgets.users_emails_input.loading": "Loading",
|
||||
|
||||
@@ -85,20 +85,20 @@ export function isValidPassword(password: string, passwordConfig: PasswordConfig
|
||||
}
|
||||
|
||||
export const passwordErrors = defineMessages({
|
||||
passwordError: {id: 'user.settings.security.passwordError', defaultMessage: 'Must be {min}-{max} characters long.'},
|
||||
passwordErrorLowercase: {id: 'user.settings.security.passwordErrorLowercase', defaultMessage: 'Must be {min}-{max} characters long and include lowercase letters.'},
|
||||
passwordErrorLowercaseNumber: {id: 'user.settings.security.passwordErrorLowercaseNumber', defaultMessage: 'Must be {min}-{max} characters long and include lowercase letters and numbers.'},
|
||||
passwordErrorLowercaseNumberSymbol: {id: 'user.settings.security.passwordErrorLowercaseNumberSymbol', defaultMessage: 'Must be {min}-{max} characters long and include lowercase letters, numbers, and special characters.'},
|
||||
passwordErrorLowercaseSymbol: {id: 'user.settings.security.passwordErrorLowercaseSymbol', defaultMessage: 'Must be {min}-{max} characters long and include lowercase letters and special characters.'},
|
||||
passwordErrorLowercaseUppercase: {id: 'user.settings.security.passwordErrorLowercaseUppercase', defaultMessage: 'Must be {min}-{max} characters long and include both lowercase and uppercase letters.'},
|
||||
passwordErrorLowercaseUppercaseNumber: {id: 'user.settings.security.passwordErrorLowercaseUppercaseNumber', defaultMessage: 'Must be {min}-{max} characters long and include both lowercase and uppercase letters, and numbers.'},
|
||||
passwordErrorLowercaseUppercaseNumberSymbol: {id: 'user.settings.security.passwordErrorLowercaseUppercaseNumberSymbol', defaultMessage: 'Must be {min}-{max} characters long and include both lowercase and uppercase letters, numbers, and special characters.'},
|
||||
passwordErrorLowercaseUppercaseSymbol: {id: 'user.settings.security.passwordErrorLowercaseUppercaseSymbol', defaultMessage: 'Must be {min}-{max} characters long and include both lowercase and uppercase letters, and special characters.'},
|
||||
passwordErrorNumber: {id: 'user.settings.security.passwordErrorNumber', defaultMessage: 'Must be {min}-{max} characters long and include numbers.'},
|
||||
passwordErrorNumberSymbol: {id: 'user.settings.security.passwordErrorNumberSymbol', defaultMessage: 'Must be {min}-{max} characters long and include numbers and special characters.'},
|
||||
passwordErrorSymbol: {id: 'user.settings.security.passwordErrorSymbol', defaultMessage: 'Must be {min}-{max} characters long and include special characters.'},
|
||||
passwordErrorUppercase: {id: 'user.settings.security.passwordErrorUppercase', defaultMessage: 'Must be {min}-{max} characters long and include uppercase letters.'},
|
||||
passwordErrorUppercaseNumber: {id: 'user.settings.security.passwordErrorUppercaseNumber', defaultMessage: 'Must be {min}-{max} characters long and include uppercase letters, and numbers.'},
|
||||
passwordErrorUppercaseNumberSymbol: {id: 'user.settings.security.passwordErrorUppercaseNumberSymbol', defaultMessage: 'Must be {min}-{max} characters long and include uppercase letters, numbers, and special characters.'},
|
||||
passwordErrorUppercaseSymbol: {id: 'user.settings.security.passwordErrorUppercaseSymbol', defaultMessage: 'Must be {min}-{max} characters long and include uppercase letters, and special characters.'},
|
||||
passwordError: {id: 'user.settings.security.passwordError', defaultMessage: 'Your password must be {min}-{max} characters long.'},
|
||||
passwordErrorLowercase: {id: 'user.settings.security.passwordErrorLowercase', defaultMessage: 'Your password must be {min}-{max} characters long and include lowercase letters.'},
|
||||
passwordErrorLowercaseNumber: {id: 'user.settings.security.passwordErrorLowercaseNumber', defaultMessage: 'Your password must be {min}-{max} characters long and include lowercase letters and numbers.'},
|
||||
passwordErrorLowercaseNumberSymbol: {id: 'user.settings.security.passwordErrorLowercaseNumberSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include lowercase letters, numbers, and special characters.'},
|
||||
passwordErrorLowercaseSymbol: {id: 'user.settings.security.passwordErrorLowercaseSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include lowercase letters and special characters.'},
|
||||
passwordErrorLowercaseUppercase: {id: 'user.settings.security.passwordErrorLowercaseUppercase', defaultMessage: 'Your password must be {min}-{max} characters long and include both lowercase and uppercase letters.'},
|
||||
passwordErrorLowercaseUppercaseNumber: {id: 'user.settings.security.passwordErrorLowercaseUppercaseNumber', defaultMessage: 'Your password must be {min}-{max} characters long and include both lowercase and uppercase letters, and numbers.'},
|
||||
passwordErrorLowercaseUppercaseNumberSymbol: {id: 'user.settings.security.passwordErrorLowercaseUppercaseNumberSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include both lowercase and uppercase letters, numbers, and special characters.'},
|
||||
passwordErrorLowercaseUppercaseSymbol: {id: 'user.settings.security.passwordErrorLowercaseUppercaseSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include both lowercase and uppercase letters, and special characters.'},
|
||||
passwordErrorNumber: {id: 'user.settings.security.passwordErrorNumber', defaultMessage: 'Your password must be {min}-{max} characters long and include numbers.'},
|
||||
passwordErrorNumberSymbol: {id: 'user.settings.security.passwordErrorNumberSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include numbers and special characters.'},
|
||||
passwordErrorSymbol: {id: 'user.settings.security.passwordErrorSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include special characters.'},
|
||||
passwordErrorUppercase: {id: 'user.settings.security.passwordErrorUppercase', defaultMessage: 'Your password must be {min}-{max} characters long and include uppercase letters.'},
|
||||
passwordErrorUppercaseNumber: {id: 'user.settings.security.passwordErrorUppercaseNumber', defaultMessage: 'Your password must be {min}-{max} characters long and include uppercase letters, and numbers.'},
|
||||
passwordErrorUppercaseNumberSymbol: {id: 'user.settings.security.passwordErrorUppercaseNumberSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include uppercase letters, numbers, and special characters.'},
|
||||
passwordErrorUppercaseSymbol: {id: 'user.settings.security.passwordErrorUppercaseSymbol', defaultMessage: 'Your password must be {min}-{max} characters long and include uppercase letters, and special characters.'},
|
||||
});
|
||||
|
||||