diff --git a/.changeset/bump-versions.md b/.changeset/bump-versions.md new file mode 100644 index 00000000000..6ac03f65b41 --- /dev/null +++ b/.changeset/bump-versions.md @@ -0,0 +1,46 @@ +--- +'@aws-amplify/ui': patch +'@aws-amplify/ui-react': patch +--- + +`@aws-amplify/ui-react` supports validation & re-use & customization of `Authenticator.SignUp.FormFields` via `components` & `services`: + +```js + + + + + I agree with the Terms & Conditions + + + ); + }, + }, + }} + services={{ + async validateCustomSignUp(formData) { + if (!formData.acknowledgement) { + return { + acknowledgement: 'You must agree to the Terms & Conditions', + }; + } + }, + }} +/> +``` diff --git a/.gitattributes b/.gitattributes index d16237dfb79..c6dbc284329 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ **/*.d.ts linguist-generated=true +**/.graphqlconfig.yml linguist-generated=true +**/amplify/** linguist-generated=true yarn.lock linguist-generated=true diff --git a/docs/src/pages/ui/components/authenticator/index.page.mdx b/docs/src/pages/ui/components/authenticator/index.page.mdx index c74797b89a4..934aab452a9 100644 --- a/docs/src/pages/ui/components/authenticator/index.page.mdx +++ b/docs/src/pages/ui/components/authenticator/index.page.mdx @@ -2,7 +2,14 @@ title: Authenticator --- -import { Authenticator, Alert } from '@aws-amplify/ui-react'; +import { + Alert, + Authenticator, + CheckboxField, + Link, + TextField, + useAuthenticator, +} from '@aws-amplify/ui-react'; import { Example } from '@/components/Example'; import { Feature } from '@/components/Feature'; import { Fragment } from '@/components/Fragment'; @@ -146,6 +153,60 @@ For your [configured social providers](https://docs.amplify.aws/lib/auth/social/ ## Customization +### Sign Up Fields + +The following example customizes the Sign Up screen with: + +- Prepending custom `preferred_username` field +- Re-using the default form fields +- Appending a custom "Terms & Conditions" checkbox with custom validation + +```tsx{3-11,28-48,51-57} file=../../../../../../examples/next/pages/ui/components/authenticator/custom-sign-up-fields/index.page.tsx + +``` + + + + + + + I agree with the Terms & Conditions + + + ); + }, + }, + }} + services={{ + async validateCustomSignUp(formData) { + if (!formData.acknowledgement) { + return { + acknowledgement: 'You must agree to the Terms & Conditions', + }; + } + }, + }} + /> + + ### Internationalization (I18n) The `Authenticator` ships with [translations](https://github.com/aws-amplify/amplify-ui/blob/main/packages/ui/src/i18n/translations.ts) for: diff --git a/environments/auth-with-email-and-custom-attributes/.gitignore b/environments/auth-with-email-and-custom-attributes/.gitignore new file mode 100644 index 00000000000..62512a1c3be --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/.gitignore @@ -0,0 +1,21 @@ +#amplify-do-not-edit-begin +amplify/\#current-cloud-backend +amplify/.config/local-* +amplify/logs +amplify/mock-data +amplify/backend/amplify-meta.json +amplify/backend/awscloudformation +amplify/backend/.temp +build/ +dist/ +node_modules/ +aws-exports.js +awsconfiguration.json +amplifyconfiguration.json +amplifyconfiguration.dart +amplify-build-config.json +amplify-gradle-config.json +amplifytools.xcconfig +.secret-* +**.sample +#amplify-do-not-edit-end \ No newline at end of file diff --git a/environments/auth-with-email-and-custom-attributes/README.md b/environments/auth-with-email-and-custom-attributes/README.md new file mode 100644 index 00000000000..17a3e4f24ab --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/README.md @@ -0,0 +1,22 @@ +# Auth with Email & Custom Attributes + +This backend is configured with Amplify Admin UI & cloned with Amplify CLI v6.3.1: + +- Authentication + + - `Email` login mechanism + - Preferred Username (`preferred_username`) custom attribute + +## Using this Backend + +External contributors can re-create this backend by running: + +```shell +amplify pull +``` + +Internal (Amplify UI team) contributors can use this backend directly by running: + +```shell +amplify pull --appId di3yvcoio2o2w --envName staging --yes +``` diff --git a/environments/auth-with-email-and-custom-attributes/amplify/.config/project-config.json b/environments/auth-with-email-and-custom-attributes/amplify/.config/project-config.json new file mode 100644 index 00000000000..dbf6336a023 --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/amplify/.config/project-config.json @@ -0,0 +1,17 @@ +{ + "providers": [ + "awscloudformation" + ], + "projectName": "authwithemailandcust", + "version": "3.1", + "frontend": "javascript", + "javascript": { + "framework": "none", + "config": { + "SourceDir": "src", + "DistributionDir": "dist", + "BuildCommand": "npm run-script build", + "StartCommand": "npm run-script start" + } + } +} \ No newline at end of file diff --git a/environments/auth-with-email-and-custom-attributes/amplify/backend/auth/authwithemailandcustomattributes/authwithemailandcustomattributes-cloudformation-template.yml b/environments/auth-with-email-and-custom-attributes/amplify/backend/auth/authwithemailandcustomattributes/authwithemailandcustomattributes-cloudformation-template.yml new file mode 100644 index 00000000000..79a9f9c355b --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/amplify/backend/auth/authwithemailandcustomattributes/authwithemailandcustomattributes-cloudformation-template.yml @@ -0,0 +1,454 @@ + +AWSTemplateFormatVersion: 2010-09-09 + +Parameters: + env: + Type: String + authRoleArn: + Type: String + unauthRoleArn: + Type: String + + + + + identityPoolName: + Type: String + + + + allowUnauthenticatedIdentities: + Type: String + + resourceNameTruncated: + Type: String + + + userPoolName: + Type: String + + + + autoVerifiedAttributes: + Type: CommaDelimitedList + + mfaConfiguration: + Type: String + + + + mfaTypes: + Type: CommaDelimitedList + + smsAuthenticationMessage: + Type: String + + + smsVerificationMessage: + Type: String + + + emailVerificationSubject: + Type: String + + + emailVerificationMessage: + Type: String + + + + defaultPasswordPolicy: + Type: String + + + passwordPolicyMinLength: + Type: Number + + + passwordPolicyCharacters: + Type: CommaDelimitedList + + + requiredAttributes: + Type: CommaDelimitedList + + + aliasAttributes: + Type: CommaDelimitedList + + + userpoolClientGenerateSecret: + Type: String + + + userpoolClientRefreshTokenValidity: + Type: Number + + + userpoolClientWriteAttributes: + Type: CommaDelimitedList + + + userpoolClientReadAttributes: + Type: CommaDelimitedList + + userpoolClientLambdaRole: + Type: String + + + + userpoolClientSetAttributes: + Type: String + + sharedId: + Type: String + + + resourceName: + Type: String + + + authSelections: + Type: String + + + + + serviceName: + Type: String + + + + usernameAttributes: + Type: CommaDelimitedList + + useDefault: + Type: String + + + + userPoolGroups: + Type: String + + + userPoolGroupList: + Type: CommaDelimitedList + + + adminQueries: + Type: String + + + + thirdPartyAuth: + Type: String + + + authProviders: + Type: CommaDelimitedList + + + usernameCaseSensitive: + Type: String + + + dependsOn: + Type: CommaDelimitedList + +Conditions: + ShouldNotCreateEnvResources: !Equals [ !Ref env, NONE ] + + ShouldOutputAppClientSecrets: !Equals [!Ref userpoolClientGenerateSecret, true ] + + +Resources: + + + # BEGIN SNS ROLE RESOURCE + SNSRole: + # Created to allow the UserPool SMS Config to publish via the Simple Notification Service during MFA Process + Type: AWS::IAM::Role + Properties: + RoleName: !If [ShouldNotCreateEnvResources, 'authwi896641f3_sns-role', !Join ['',[ 'sns', '896641f3', !Select [3, !Split ['-', !Ref 'AWS::StackName']], '-', !Ref env]]] + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: "" + Effect: "Allow" + Principal: + Service: "cognito-idp.amazonaws.com" + Action: + - "sts:AssumeRole" + Condition: + StringEquals: + sts:ExternalId: authwi896641f3_role_external_id + Policies: + - + PolicyName: authwi896641f3-sns-policy + PolicyDocument: + Version: "2012-10-17" + Statement: + - + Effect: "Allow" + Action: + - "sns:Publish" + Resource: "*" + # BEGIN USER POOL RESOURCES + UserPool: + # Created upon user selection + # Depends on SNS Role for Arn if MFA is enabled + Type: AWS::Cognito::UserPool + UpdateReplacePolicy: Retain + Properties: + UserPoolName: !If [ShouldNotCreateEnvResources, !Ref userPoolName, !Join ['',[!Ref userPoolName, '-', !Ref env]]] + + + UsernameConfiguration: + CaseSensitive: false + + Schema: + + - + Name: email + Required: true + Mutable: true + + - + Name: preferred_username + Required: true + Mutable: true + + + + + AutoVerifiedAttributes: + + - email + + + + EmailVerificationMessage: !Ref emailVerificationMessage + EmailVerificationSubject: !Ref emailVerificationSubject + + Policies: + PasswordPolicy: + MinimumLength: !Ref passwordPolicyMinLength + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + RequireUppercase: true + + UsernameAttributes: !Ref usernameAttributes + + + MfaConfiguration: !Ref mfaConfiguration + SmsVerificationMessage: !Ref smsVerificationMessage + SmsAuthenticationMessage: !Ref smsAuthenticationMessage + SmsConfiguration: + SnsCallerArn: !GetAtt SNSRole.Arn + ExternalId: authwi896641f3_role_external_id + + + UserPoolClientWeb: + # Created provide application access to user pool + # Depends on UserPool for ID reference + Type: "AWS::Cognito::UserPoolClient" + Properties: + ClientName: authwi896641f3_app_clientWeb + + RefreshTokenValidity: !Ref userpoolClientRefreshTokenValidity + UserPoolId: !Ref UserPool + DependsOn: UserPool + UserPoolClient: + # Created provide application access to user pool + # Depends on UserPool for ID reference + Type: "AWS::Cognito::UserPoolClient" + Properties: + ClientName: authwi896641f3_app_client + + GenerateSecret: !Ref userpoolClientGenerateSecret + RefreshTokenValidity: !Ref userpoolClientRefreshTokenValidity + UserPoolId: !Ref UserPool + DependsOn: UserPool + # BEGIN USER POOL LAMBDA RESOURCES + UserPoolClientRole: + # Created to execute Lambda which gets userpool app client config values + Type: 'AWS::IAM::Role' + Properties: + RoleName: !If [ShouldNotCreateEnvResources, !Ref userpoolClientLambdaRole, !Join ['',['upClientLambdaRole', '896641f3', !Select [3, !Split ['-', !Ref 'AWS::StackName']], '-', !Ref env]]] + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - 'sts:AssumeRole' + DependsOn: UserPoolClient + UserPoolClientLambda: + # Lambda which gets userpool app client config values + # Depends on UserPool for id + # Depends on UserPoolClientRole for role ARN + Type: 'AWS::Lambda::Function' + Properties: + Code: + ZipFile: !Join + - |+ + - - 'const response = require(''cfn-response'');' + - 'const aws = require(''aws-sdk'');' + - 'const identity = new aws.CognitoIdentityServiceProvider();' + - 'exports.handler = (event, context, callback) => {' + - ' if (event.RequestType == ''Delete'') { ' + - ' response.send(event, context, response.SUCCESS, {})' + - ' }' + - ' if (event.RequestType == ''Update'' || event.RequestType == ''Create'') {' + - ' const params = {' + - ' ClientId: event.ResourceProperties.clientId,' + - ' UserPoolId: event.ResourceProperties.userpoolId' + - ' };' + - ' identity.describeUserPoolClient(params).promise()' + - ' .then((res) => {' + - ' response.send(event, context, response.SUCCESS, {''appSecret'': res.UserPoolClient.ClientSecret});' + - ' })' + - ' .catch((err) => {' + - ' response.send(event, context, response.FAILED, {err});' + - ' });' + - ' }' + - '};' + Handler: index.handler + Runtime: nodejs12.x + Timeout: 300 + Role: !GetAtt + - UserPoolClientRole + - Arn + DependsOn: UserPoolClientRole + UserPoolClientLambdaPolicy: + # Sets userpool policy for the role that executes the Userpool Client Lambda + # Depends on UserPool for Arn + # Marked as depending on UserPoolClientRole for easier to understand CFN sequencing + Type: 'AWS::IAM::Policy' + Properties: + PolicyName: authwi896641f3_userpoolclient_lambda_iam_policy + Roles: + - !Ref UserPoolClientRole + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'cognito-idp:DescribeUserPoolClient' + Resource: !GetAtt UserPool.Arn + DependsOn: UserPoolClientLambda + UserPoolClientLogPolicy: + # Sets log policy for the role that executes the Userpool Client Lambda + # Depends on UserPool for Arn + # Marked as depending on UserPoolClientLambdaPolicy for easier to understand CFN sequencing + Type: 'AWS::IAM::Policy' + Properties: + PolicyName: authwi896641f3_userpoolclient_lambda_log_policy + Roles: + - !Ref UserPoolClientRole + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:PutLogEvents' + Resource: !Sub + - arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:* + - { region: !Ref "AWS::Region", account: !Ref "AWS::AccountId", lambda: !Ref UserPoolClientLambda} + DependsOn: UserPoolClientLambdaPolicy + UserPoolClientInputs: + # Values passed to Userpool client Lambda + # Depends on UserPool for Id + # Depends on UserPoolClient for Id + # Marked as depending on UserPoolClientLambdaPolicy for easier to understand CFN sequencing + Type: 'Custom::LambdaCallout' + Properties: + ServiceToken: !GetAtt UserPoolClientLambda.Arn + clientId: !Ref UserPoolClient + userpoolId: !Ref UserPool + DependsOn: UserPoolClientLogPolicy + + + + + + + + # BEGIN IDENTITY POOL RESOURCES + + + IdentityPool: + # Always created + Type: AWS::Cognito::IdentityPool + Properties: + IdentityPoolName: !If [ShouldNotCreateEnvResources, 'testAuthIdentityPool', !Join ['',['testAuthIdentityPool', '__', !Ref env]]] + + CognitoIdentityProviders: + - ClientId: !Ref UserPoolClient + ProviderName: !Sub + - cognito-idp.${region}.amazonaws.com/${client} + - { region: !Ref "AWS::Region", client: !Ref UserPool} + - ClientId: !Ref UserPoolClientWeb + ProviderName: !Sub + - cognito-idp.${region}.amazonaws.com/${client} + - { region: !Ref "AWS::Region", client: !Ref UserPool} + + AllowUnauthenticatedIdentities: !Ref allowUnauthenticatedIdentities + + + DependsOn: UserPoolClientInputs + + + IdentityPoolRoleMap: + # Created to map Auth and Unauth roles to the identity pool + # Depends on Identity Pool for ID ref + Type: AWS::Cognito::IdentityPoolRoleAttachment + Properties: + IdentityPoolId: !Ref IdentityPool + Roles: + unauthenticated: !Ref unauthRoleArn + authenticated: !Ref authRoleArn + DependsOn: IdentityPool + + +Outputs : + + IdentityPoolId: + Value: !Ref 'IdentityPool' + Description: Id for the identity pool + IdentityPoolName: + Value: !GetAtt IdentityPool.Name + + + + + UserPoolId: + Value: !Ref 'UserPool' + Description: Id for the user pool + UserPoolArn: + Value: !GetAtt UserPool.Arn + Description: Arn for the user pool + UserPoolName: + Value: !Ref userPoolName + AppClientIDWeb: + Value: !Ref 'UserPoolClientWeb' + Description: The user pool app client id for web + AppClientID: + Value: !Ref 'UserPoolClient' + Description: The user pool app client id + AppClientSecret: + Value: !GetAtt UserPoolClientInputs.appSecret + Condition: ShouldOutputAppClientSecrets + + + + + + + + diff --git a/environments/auth-with-email-and-custom-attributes/amplify/backend/auth/authwithemailandcustomattributes/parameters.json b/environments/auth-with-email-and-custom-attributes/amplify/backend/auth/authwithemailandcustomattributes/parameters.json new file mode 100644 index 00000000000..df23528134c --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/amplify/backend/auth/authwithemailandcustomattributes/parameters.json @@ -0,0 +1,63 @@ +{ + "identityPoolName": "testAuthIdentityPool", + "allowUnauthenticatedIdentities": false, + "resourceNameTruncated": "authwi896641f3", + "userPoolName": "authwithemailandcustomattributes", + "autoVerifiedAttributes": [ + "email" + ], + "mfaConfiguration": "OFF", + "mfaTypes": [ + "SMS Text Message" + ], + "smsAuthenticationMessage": "Your authentication code is {####}", + "smsVerificationMessage": "Your verification code is {####}", + "emailVerificationSubject": "Forgot password code: {####}", + "emailVerificationMessage": "Forgot password code: {####}", + "defaultPasswordPolicy": false, + "passwordPolicyMinLength": 8, + "passwordPolicyCharacters": [ + "Requires Lowercase", + "Requires Numbers", + "Requires Symbols", + "Requires Uppercase" + ], + "requiredAttributes": [ + "email", + "preferred_username" + ], + "aliasAttributes": [], + "userpoolClientGenerateSecret": false, + "userpoolClientRefreshTokenValidity": 30, + "userpoolClientWriteAttributes": [], + "userpoolClientReadAttributes": [], + "userpoolClientLambdaRole": "authwi896641f3_userpoolclient_lambda_role", + "userpoolClientSetAttributes": false, + "sharedId": "896641f3", + "resourceName": "authwithemailandcustomattributes", + "authSelections": "identityPoolAndUserPool", + "authRoleArn": { + "Fn::GetAtt": [ + "AuthRole", + "Arn" + ] + }, + "unauthRoleArn": { + "Fn::GetAtt": [ + "UnauthRole", + "Arn" + ] + }, + "serviceName": "Cognito", + "usernameAttributes": [ + "email" + ], + "useDefault": "manual", + "userPoolGroups": false, + "userPoolGroupList": [], + "adminQueries": false, + "thirdPartyAuth": false, + "authProviders": [], + "usernameCaseSensitive": false, + "dependsOn": [] +} \ No newline at end of file diff --git a/environments/auth-with-email-and-custom-attributes/amplify/backend/backend-config.json b/environments/auth-with-email-and-custom-attributes/amplify/backend/backend-config.json new file mode 100644 index 00000000000..22c664a5601 --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/amplify/backend/backend-config.json @@ -0,0 +1,35 @@ +{ + "auth": { + "authwithemailandcustomattributes": { + "service": "Cognito", + "providerPlugin": "awscloudformation", + "dependsOn": [], + "customAuth": false, + "frontendAuthConfig": { + "loginMechanisms": [ + "EMAIL" + ], + "signupAttributes": [ + "EMAIL", + "PREFERRED_USERNAME" + ], + "passwordProtectionSettings": { + "passwordPolicyMinLength": 8, + "passwordPolicyCharacters": [ + "REQUIRES_LOWERCASE", + "REQUIRES_NUMBERS", + "REQUIRES_SYMBOLS", + "REQUIRES_UPPERCASE" + ] + }, + "mfaConfiguration": "OFF", + "mfaTypes": [ + "SMS" + ], + "verificationMechanisms": [ + "EMAIL" + ] + } + } + } +} \ No newline at end of file diff --git a/environments/auth-with-email-and-custom-attributes/amplify/backend/tags.json b/environments/auth-with-email-and-custom-attributes/amplify/backend/tags.json new file mode 100644 index 00000000000..b9321d71b83 --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/amplify/backend/tags.json @@ -0,0 +1,10 @@ +[ + { + "Key": "user:Stack", + "Value": "{project-env}" + }, + { + "Key": "user:Application", + "Value": "{project-name}" + } +] \ No newline at end of file diff --git a/environments/auth-with-email-and-custom-attributes/amplify/cli.json b/environments/auth-with-email-and-custom-attributes/amplify/cli.json new file mode 100644 index 00000000000..a1b2a0b6adf --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/amplify/cli.json @@ -0,0 +1,45 @@ +{ + "features": { + "graphqltransformer": { + "addmissingownerfields": true, + "improvepluralization": false, + "validatetypenamereservedwords": true, + "useexperimentalpipelinedtransformer": false, + "enableiterativegsiupdates": true, + "secondarykeyasgsi": true, + "skipoverridemutationinputtypes": true + }, + "frontend-ios": { + "enablexcodeintegration": true + }, + "auth": { + "enablecaseinsensitivity": true, + "useinclusiveterminology": true, + "breakcirculardependency": true, + "forcealiasattributes": false + }, + "codegen": { + "useappsyncmodelgenplugin": true, + "usedocsgeneratorplugin": true, + "usetypesgeneratorplugin": true, + "cleangeneratedmodelsdirectory": true, + "retaincasestyle": true, + "addtimestampfields": true, + "handlelistnullabilitytransparently": true, + "emitauthprovider": true, + "generateindexrules": true, + "enabledartnullsafety": true + }, + "appsync": { + "generategraphqlpermissions": true + }, + "latestregionsupport": { + "pinpoint": 1, + "translate": 1, + "transcribe": 1, + "rekognition": 1, + "textract": 1, + "comprehend": 1 + } + } +} \ No newline at end of file diff --git a/environments/auth-with-email-and-custom-attributes/package.json b/environments/auth-with-email-and-custom-attributes/package.json new file mode 100644 index 00000000000..32e159a7aba --- /dev/null +++ b/environments/auth-with-email-and-custom-attributes/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "name": "auth-with-email-and-custom-attributes-environment", + "version": "0.0.1", + "scripts": { + "pull": "amplify pull --appId di3yvcoio2o2w --envName staging" + } +} diff --git a/examples/next/pages/ui/components/authenticator/custom-sign-up-fields/index.page.tsx b/examples/next/pages/ui/components/authenticator/custom-sign-up-fields/index.page.tsx new file mode 100644 index 00000000000..37bc8454f1b --- /dev/null +++ b/examples/next/pages/ui/components/authenticator/custom-sign-up-fields/index.page.tsx @@ -0,0 +1,69 @@ +import { Amplify } from 'aws-amplify'; + +import { + // Access the default `Authenticator.SignUp.FormFields` for re-use + Authenticator, + // Amplify UI Primitives to simplify the custom fields + CheckboxField, + TextField, + // React hook to get access to validation errors + useAuthenticator, +} from '@aws-amplify/ui-react'; +import { withAuthenticator } from '@aws-amplify/ui-react'; +import '@aws-amplify/ui-react/styles.css'; + +import awsExports from '@environments/auth-with-email-and-custom-attributes/src/aws-exports'; +Amplify.configure(awsExports); + +function App({ signOut }) { + return ; +} + +export default withAuthenticator(App, { + // Default to Sign Up screen + initialState: 'signUp', + // Backend is configured for `email` instead of `username` + loginMechanisms: ['email'], + components: { + // Customize `Authenticator.SignUp.FormFields` + SignUp: { + FormFields() { + const { validationErrors } = useAuthenticator(); + + return ( + <> + {/* Prepend `preferred_username` custom attribute */} + + + {/* Re-use default `Authenticator.SignUp.FormFields` */} + + + {/* Append & require Terms & Conditions field to sign up */} + + I agree with the Terms & Conditions + + + ); + }, + }, + }, + services: { + async validateCustomSignUp(formData) { + if (!formData.acknowledgement) { + return { + acknowledgement: 'You must agree to the Terms & Conditions', + }; + } + }, + }, +}); diff --git a/packages/e2e/cypress/fixtures/custom-sign-up-fields.json b/packages/e2e/cypress/fixtures/custom-sign-up-fields.json new file mode 100644 index 00000000000..92e63df67f9 --- /dev/null +++ b/packages/e2e/cypress/fixtures/custom-sign-up-fields.json @@ -0,0 +1,9 @@ +{ + "CodeDeliveryDetails": { + "AttributeName": "email", + "DeliveryMedium": "EMAIL", + "Destination": "a***@e***.com" + }, + "UserConfirmed": false, + "UserSub": "••••••-••••-••••-••••-•••••••••••••" +} diff --git a/packages/e2e/cypress/integration/common/shared.ts b/packages/e2e/cypress/integration/common/shared.ts index 53db213c451..c49d30df01d 100644 --- a/packages/e2e/cypress/integration/common/shared.ts +++ b/packages/e2e/cypress/integration/common/shared.ts @@ -31,11 +31,12 @@ Given( } ); -When('I type a new {string}', (loginMechanism: string) => { - cy.findInputField(loginMechanism).typeAliasWithStatus( - loginMechanism, - `${Date.now()}` - ); +When('I type an invalid password', () => { + cy.findInputField('Password').type('invalidpass'); +}); + +When('I type a new {string}', (field: string) => { + cy.findInputField(field).typeAliasWithStatus(field, `${Date.now()}`); }); When('I click the {string} tab', (label: string) => { @@ -50,6 +51,18 @@ When('I click the {string} button', (name: string) => { }).click(); }); +When('I click the {string} checkbox', (label: string) => { + cy.findByLabelText(new RegExp(`^${escapeRegExp(label)}`, 'i')).click({ + // We have to force this click because the checkbox button isn't visible by default + // and instead has ::before decoration. + // + // cy.click() failed because this element: + // is being covered by another element: + //
...
+ force: true, + }); +}); + When('I click the {string} radio button', (label: string) => { cy.findByLabelText(new RegExp(`^${escapeRegExp(label)}`, 'i')).click({ // We have to force this click because the radio input type isn't visible by default @@ -70,6 +83,12 @@ Then('I see {string}', (message: string) => { cy.findByRole('document').contains(new RegExp(escapeRegExp(message), 'i')); }); +Then("I don't see {string}", (message: string) => { + cy.findByRole('document') + .contains(new RegExp(escapeRegExp(message), 'i')) + .should('not.exist'); +}); + Then( '{string} field autocompletes {string}', (fieldName: string, autocomplete: string) => { @@ -78,3 +97,9 @@ Then( .should('eq', autocomplete); } ); + +Then('the {string} button is disabled', (name: string) => { + cy.findByRole('button', { + name: new RegExp(`^${escapeRegExp(name)}$`, 'i'), + }).should('be.disabled'); +}); diff --git a/packages/e2e/cypress/integration/common/sign-in.ts b/packages/e2e/cypress/integration/common/sign-in.ts index b6f17e38d81..e253a91e866 100644 --- a/packages/e2e/cypress/integration/common/sign-in.ts +++ b/packages/e2e/cypress/integration/common/sign-in.ts @@ -35,7 +35,3 @@ When( When('I type my password', () => { cy.findInputField('Password').type(Cypress.env('VALID_PASSWORD')); }); - -When('I type an invalid password', () => { - cy.findInputField('Password').type('invalidpass'); -}); diff --git a/packages/e2e/cypress/support/commands.ts b/packages/e2e/cypress/support/commands.ts index 536463c3079..6baf56a9a87 100644 --- a/packages/e2e/cypress/support/commands.ts +++ b/packages/e2e/cypress/support/commands.ts @@ -40,6 +40,7 @@ Cypress.Commands.add( constant(`${appendStatusToAlias(status)}@${Cypress.env('DOMAIN')}`), ], [eq('phone number'), constant(Cypress.env('PHONE_NUMBER'))], + [eq('preferred username'), constant(appendStatusToAlias(status))], ]); return cy.wrap(inputField).type(buildAlias(loginMechanism)); diff --git a/packages/e2e/features/ui/components/authenticator/custom-sign-up-fields.feature b/packages/e2e/features/ui/components/authenticator/custom-sign-up-fields.feature new file mode 100644 index 00000000000..2285ca824e5 --- /dev/null +++ b/packages/e2e/features/ui/components/authenticator/custom-sign-up-fields.feature @@ -0,0 +1,43 @@ +Feature: Custom Sign Up Fields + + A custom Sign Up form with "Preferred Username" and a T&C checkbox are validated & submitted with the default fields. + + Background: + Given I'm running the example "ui/components/authenticator/custom-sign-up-fields" + And I see "Preferred Username" as an input field + And I see "I agree with the Terms & Conditions" + + @todo-angular @react @todo-vue + Scenario: Form is invalid by default + When I see "You must agree to the Terms & Conditions" + Then the "Create Account" button is disabled + + @todo-angular @react @todo-vue + Scenario: Form performs default validation like Confirm Password + When I type a new "email" + And I type an invalid password + And I confirm my password + Then I see "Your passwords must match" + And I click the "I agree with the Terms & Conditions" checkbox + Then I don't see "You must agree to the Terms & Conditions" + And the "Create Account" button is disabled + + @todo-angular @react @todo-vue + Scenario: Form is valid when I check the Terms & Conditions checkbox, but missing `preferred_username` for Cognito + When I type a new "email" + And I type my password + And I confirm my password + And I click the "I agree with the Terms & Conditions" checkbox + And I click the "Create Account" button + Then I see "Attributes did not conform to the schema: preferred_username: The attribute is required" + + @todo-angular @react @todo-vue + Scenario: Form successfully submits with `preferred_username` and Terms & Conditions checked + Given I intercept '{ "headers": { "X-Amz-Target": "AWSCognitoIdentityProviderService.SignUp" } }' with fixture "custom-sign-up-fields" + When I type a new "preferred username" + And I type a new "email" + And I type my password + And I confirm my password + And I click the "I agree with the Terms & Conditions" checkbox + And I click the "Create Account" button + Then I see "Confirmation Code" diff --git a/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature b/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature index 4fa30d97f7e..68bac5d593d 100644 --- a/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature +++ b/packages/e2e/features/ui/components/authenticator/sign-up-with-email.feature @@ -11,7 +11,6 @@ Feature: Sign Up with Email And I don't see "Username" as an input field And I don't see "Phone Number" as an input field -# Failing because of SES @angular @react @vue Scenario: Sign up with a new email & password Given I intercept '{ "headers": { "X-Amz-Target": "AWSCognitoIdentityProviderService.SignUp" } }' with fixture "sign-up-with-email" diff --git a/packages/react/src/components/Authenticator/Authenticator.tsx b/packages/react/src/components/Authenticator/Authenticator.tsx index 6598e7f4f75..f096b711030 100644 --- a/packages/react/src/components/Authenticator/Authenticator.tsx +++ b/packages/react/src/components/Authenticator/Authenticator.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Provider, ProviderProps } from './Provider'; import { Router, RouterProps } from './Router'; +import { SignUp } from './SignUp'; export type AuthenticatorProps = ProviderProps & RouterProps; @@ -30,3 +31,5 @@ export function Authenticator({ ); } + +Authenticator.SignUp = SignUp; diff --git a/packages/react/src/components/Authenticator/ConfirmSignIn/ConfirmSignIn.tsx b/packages/react/src/components/Authenticator/ConfirmSignIn/ConfirmSignIn.tsx index eeab9b203b0..8b53eb7c95f 100644 --- a/packages/react/src/components/Authenticator/ConfirmSignIn/ConfirmSignIn.tsx +++ b/packages/react/src/components/Authenticator/ConfirmSignIn/ConfirmSignIn.tsx @@ -31,7 +31,9 @@ export const ConfirmSignIn = (): JSX.Element => { } const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/ConfirmSignUp/ConfirmSignUp.tsx b/packages/react/src/components/Authenticator/ConfirmSignUp/ConfirmSignUp.tsx index 6c674ae4010..94adaeacd80 100644 --- a/packages/react/src/components/Authenticator/ConfirmSignUp/ConfirmSignUp.tsx +++ b/packages/react/src/components/Authenticator/ConfirmSignUp/ConfirmSignUp.tsx @@ -12,7 +12,9 @@ export function ConfirmSignUp() { const { isPending, resendCode, submitForm, updateForm } = useAuthenticator(); const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/ForceNewPassword/ForceNewPassword.tsx b/packages/react/src/components/Authenticator/ForceNewPassword/ForceNewPassword.tsx index a69b286ac67..59298cedfc8 100644 --- a/packages/react/src/components/Authenticator/ForceNewPassword/ForceNewPassword.tsx +++ b/packages/react/src/components/Authenticator/ForceNewPassword/ForceNewPassword.tsx @@ -9,7 +9,9 @@ export const ForceNewPassword = (): JSX.Element => { const { validationError } = getActorContext(_state) as SignInContext; const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/Provider/defaultComponents.tsx b/packages/react/src/components/Authenticator/Provider/defaultComponents.tsx index 822964a3b9d..93edbe3b6d5 100644 --- a/packages/react/src/components/Authenticator/Provider/defaultComponents.tsx +++ b/packages/react/src/components/Authenticator/Provider/defaultComponents.tsx @@ -1,5 +1,7 @@ import { SignUp } from '../SignUp'; export const defaultComponents = { - 'SignUp.FormFields': SignUp.FormFields, + SignUp: { + FormFields: SignUp.FormFields, + }, }; diff --git a/packages/react/src/components/Authenticator/Provider/index.tsx b/packages/react/src/components/Authenticator/Provider/index.tsx index cbe1a5c757b..afd0d2c4d77 100644 --- a/packages/react/src/components/Authenticator/Provider/index.tsx +++ b/packages/react/src/components/Authenticator/Provider/index.tsx @@ -1,9 +1,6 @@ import { - ActorContextWithForms, AuthenticatorMachineOptions, createAuthenticatorMachine, - getActorContext, - getActorState, getServiceFacade, } from '@aws-amplify/ui'; import { useMachine } from '@xstate/react'; @@ -13,17 +10,19 @@ import generateContext from 'react-generate-context'; import { defaultComponents } from './defaultComponents'; export type ProviderProps = AuthenticatorMachineOptions & { - components?: typeof defaultComponents; - services?: undefined; + components?: Partial; + services?: AuthenticatorMachineOptions['services']; }; const useAuthenticatorValue = ({ components: customComponents, initialState, loginMechanisms, + services, }: ProviderProps) => { const [state, send] = useMachine( - () => createAuthenticatorMachine({ initialState, loginMechanisms }), + () => + createAuthenticatorMachine({ initialState, loginMechanisms, services }), { devTools: process.env.NODE_ENV === 'development', } @@ -39,20 +38,12 @@ const useAuthenticatorValue = ({ [send, state] ); - const isPending = - state.hasTag('pending') || getActorState(state)?.hasTag('pending'); - - const actorContext: ActorContextWithForms = getActorContext(state); - const error = actorContext?.remoteError; - return { /** @deprecated For internal use only */ _send: send, /** @deprecated For internal use only */ _state: state, components, - error, - isPending, ...facade, }; }; diff --git a/packages/react/src/components/Authenticator/ResetPassword/ConfirmResetPassword.tsx b/packages/react/src/components/Authenticator/ResetPassword/ConfirmResetPassword.tsx index 42e733e4759..63ee4f115bf 100644 --- a/packages/react/src/components/Authenticator/ResetPassword/ConfirmResetPassword.tsx +++ b/packages/react/src/components/Authenticator/ResetPassword/ConfirmResetPassword.tsx @@ -21,7 +21,9 @@ export const ConfirmResetPassword = (): JSX.Element => { const confirmPasswordLabel = translate('Confirm Password'); const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/ResetPassword/ResetPassword.tsx b/packages/react/src/components/Authenticator/ResetPassword/ResetPassword.tsx index 9fd65297e75..d0f21cf4cc4 100644 --- a/packages/react/src/components/Authenticator/ResetPassword/ResetPassword.tsx +++ b/packages/react/src/components/Authenticator/ResetPassword/ResetPassword.tsx @@ -8,7 +8,9 @@ export const ResetPassword = (): JSX.Element => { const { isPending, submitForm, updateForm } = useAuthenticator(); const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/SetupTOTP/SetupTOTP.tsx b/packages/react/src/components/Authenticator/SetupTOTP/SetupTOTP.tsx index a7acce9167e..fff470c2012 100644 --- a/packages/react/src/components/Authenticator/SetupTOTP/SetupTOTP.tsx +++ b/packages/react/src/components/Authenticator/SetupTOTP/SetupTOTP.tsx @@ -45,7 +45,9 @@ export const SetupTOTP = (): JSX.Element => { }, [user]); const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/SignIn/SignIn.tsx b/packages/react/src/components/Authenticator/SignIn/SignIn.tsx index 5d1ead7705c..175b36c34b9 100644 --- a/packages/react/src/components/Authenticator/SignIn/SignIn.tsx +++ b/packages/react/src/components/Authenticator/SignIn/SignIn.tsx @@ -10,7 +10,9 @@ export function SignIn() { useAuthenticator(); const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; diff --git a/packages/react/src/components/Authenticator/SignUp/FormFields.tsx b/packages/react/src/components/Authenticator/SignUp/FormFields.tsx new file mode 100644 index 00000000000..30968266aac --- /dev/null +++ b/packages/react/src/components/Authenticator/SignUp/FormFields.tsx @@ -0,0 +1,76 @@ +import { + getActorContext, + SignUpContext, + translate, + UserNameAlias, + userNameAliasArray, +} from '@aws-amplify/ui'; +import { isEmpty } from 'lodash'; + +import { useAuthenticator } from '..'; +import { PasswordField, Text } from '../../..'; +import { UserNameAlias as UserNameAliasComponent } from '../shared'; + +export function FormFields() { + const { _state } = useAuthenticator(); + const { validationError } = getActorContext(_state) as SignUpContext; + + const [primaryAlias, ...secondaryAliases] = + _state.context.config?.login_mechanisms?.filter( + (alias: any): alias is UserNameAlias => userNameAliasArray.includes(alias) + ) ?? userNameAliasArray; + + /** + * If the login_mechanisms are configured to use ONLY username, we need + * to ask for some sort of secondary contact information in order to + * verify the user for Cognito. Currently matching this to how Vue is + * set up. + */ + if (primaryAlias === 'username' && isEmpty(secondaryAliases)) { + secondaryAliases.push('email', 'phone_number'); + } + + const passwordLabel = translate('Password'); + const confirmPasswordLabel = translate('Confirm Password'); + const passwordFieldClass = 'password-field'; + + return ( + <> + + + + + {!!validationError['confirm_password'] && ( + {validationError['confirm_password']} + )} + + {secondaryAliases.map((alias: UserNameAlias) => ( + + ))} + + ); +} diff --git a/packages/react/src/components/Authenticator/SignUp/SignUp.tsx b/packages/react/src/components/Authenticator/SignUp/SignUp.tsx index a85ef17b873..f3a59986f7c 100644 --- a/packages/react/src/components/Authenticator/SignUp/SignUp.tsx +++ b/packages/react/src/components/Authenticator/SignUp/SignUp.tsx @@ -1,41 +1,22 @@ -import { - getActorContext, - SignUpContext, - UserNameAlias, - userNameAliasArray, - translate, -} from '@aws-amplify/ui'; -import { isEmpty } from 'lodash'; +import { translate } from '@aws-amplify/ui'; import { useAuthenticator } from '..'; -import { Button, Flex, Form, Heading, PasswordField, Text } from '../../..'; +import { Button, Flex, Form, Heading } from '../../..'; import { FederatedSignIn } from '../FederatedSignIn'; -import { - RemoteErrorMessage, - UserNameAlias as UserNameAliasComponent, -} from '../shared'; +import { RemoteErrorMessage } from '../shared'; +import { FormFields } from './FormFields'; export function SignUp() { - const { _state, isPending, submitForm, updateForm } = useAuthenticator(); - const { validationError } = getActorContext(_state) as SignUpContext; - - const [primaryAlias, ...secondaryAliases] = - _state.context.config?.login_mechanisms?.filter( - (alias: any): alias is UserNameAlias => userNameAliasArray.includes(alias) - ) ?? userNameAliasArray; - - /** - * If the login_mechanisms are configured to use ONLY username, we need - * to ask for some sort of secondary contact information in order to - * verify the user for Cognito. Currently matching this to how Vue is - * set up. - */ - if (primaryAlias === 'username' && isEmpty(secondaryAliases)) { - secondaryAliases.push('email', 'phone_number'); - } + const { components, hasValidationErrors, isPending, submitForm, updateForm } = + useAuthenticator(); + const { + SignUp: { FormFields = SignUp.FormFields }, + } = components; const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; + let { checked, name, type, value } = event.target; + if (type === 'checkbox' && !checked) value = undefined; + updateForm({ name, value }); }; @@ -44,10 +25,6 @@ export function SignUp() { submitForm(); }; - const passwordLabel = translate('Password'); - const confirmPasswordLabel = translate('Confirm Password'); - const passwordFieldClass = 'password-field'; - return (
{translate('Create a new account')} - - - - - {!!validationError['confirm_password'] && ( - {validationError['confirm_password']} - )} - - {secondaryAliases.map((alias: UserNameAlias) => ( - - ))} - +