From d5ad915ad41bd9200eab473b44bbc33c75f6e093 Mon Sep 17 00:00:00 2001 From: Amit S Namboothiry Date: Mon, 18 Sep 2023 17:51:42 +0530 Subject: [PATCH] Ability to update and re-verify email --- api/account-service/post-login.js | 163 ++++++++++++++++--------- api/account-service/pre-signup.js | 76 ++++++------ api/account-service/serverless.yml | 3 +- api/account-service/update.js | 81 +++++++++--- api/config.js | 6 +- package-lock.json | 4 +- package.json | 2 +- src/App.js | 10 ++ src/containers/Login.js | 98 ++++++++------- src/containers/account/Account.css | 6 + src/containers/account/Account.js | 64 +++++++++- src/containers/account/VerifyEmail.css | 5 + src/containers/account/VerifyEmail.js | 103 ++++++++++++++++ 13 files changed, 458 insertions(+), 163 deletions(-) create mode 100644 src/containers/account/VerifyEmail.css create mode 100644 src/containers/account/VerifyEmail.js diff --git a/api/account-service/post-login.js b/api/account-service/post-login.js index 13c10e87..9c9bb935 100644 --- a/api/account-service/post-login.js +++ b/api/account-service/post-login.js @@ -4,75 +4,126 @@ import { syncRewriteFacebookUsername } from "../libs/username-lib"; exports.handler = (event, context, callback) => { try { - const cognito = new AWS.CognitoIdentityServiceProvider({apiVersion: "2016-04-19", region: "ap-south-1"}); + const cognito = new AWS.CognitoIdentityServiceProvider({ + apiVersion: "2016-04-19", + region: "ap-south-1", + }); // Get email let params = { UserPoolId: config.cognito.USER_POOL_ID, - AttributesToGet: ['email'], - Filter: "username = \"" + event.userName + "\"" + AttributesToGet: ["email"], + Filter: 'username = "' + event.userName + '"', }; cognito.listUsers(params, (err, data) => { - if (err) { - event.listUsersError = err; - callback(null, event); - } else if (data != null && data.Users != null && data.Users[0] != null) { - const email = data.Users[0].Attributes[0].Value; - - let allUsersWithEmailParams = { - UserPoolId: config.cognito.USER_POOL_ID, - AttributesToGet: ['email'], - Filter: "email = \"" + email + "\"" - }; + if (err) { + event.listUsersError = err; + callback(null, event); + } else if (data != null && data.Users != null && data.Users[0] != null) { + const email = data.Users[0].Attributes[0].Value; + + let allUsersWithEmailParams = { + UserPoolId: config.cognito.USER_POOL_ID, + AttributesToGet: ["email"], + Filter: 'email = "' + email + '"', + }; - cognito.listUsers(allUsersWithEmailParams, (err, data) => { - if (err) { - event.innerListUsersError = err; - callback(null, event); - } else if (data != null && data.Users != null) { - if (data.Users.length > 1) { - // Has connected atleast 1 federal auth provider - data.Users.forEach((user) => { - if (user.UserStatus === "CONFIRMED") { - /** - * Since the user is already email verified and FB attribute mapping - * overrides to false this code ensures that email_verified is not - * flipped to false. - * */ - var params = { - UserAttributes: [{ + cognito.listUsers(allUsersWithEmailParams, (err, data) => { + if (err) { + event.innerListUsersError = err; + callback(null, event); + } else if (data != null && data.Users != null) { + if (data.Users.length > 1) { + // Has connected atleast 1 federal auth provider + data.Users.forEach((user) => { + if (user.UserStatus === "CONFIRMED") { + /** + * Since the user is already email verified and FB attribute mapping + * overrides to false this code ensures that email_verified is not + * flipped to false. + * */ + var params = { + UserAttributes: [ + { Name: "email_verified", - Value: "true" - }], - UserPoolId: config.cognito.USER_POOL_ID, - Username: user.Username - }; - - cognito.adminUpdateUserAttributes(params, function(err, data) { + Value: "true", + }, + ], + UserPoolId: config.cognito.USER_POOL_ID, + Username: user.Username, + }; + + cognito.adminUpdateUserAttributes( + params, + function (err, data) { + callback(null, event); + } + ); + } + }); + } else if (data.Users.length === 1) { + // Check if email_verified and retain value + allUsersWithEmailParams = { + UserPoolId: config.cognito.USER_POOL_ID, + AttributesToGet: ["custom:email_valid"], + Filter: 'email = "' + email + '"', + }; + + cognito.listUsers(allUsersWithEmailParams, (err, data) => { + if (err) { + syncRewriteFacebookUsername( + email, + () => { + callback(null, event); + }, + (err) => { + event.syncRewriteFacebookUsernameError = err; callback(null, event); - }); - } - }); - } else if (data.Users.length === 1) { - syncRewriteFacebookUsername(email, () => { - callback(null, event); - }, (err) => { - event.syncRewriteFacebookUsernameError = err; - callback(null, event); - }); - } + } + ); + } else { + const email_verified = + data.Users[0].Attributes[0].Value || "false"; + + const update_email_verified_params = { + UserAttributes: [ + { + Name: "email_verified", + Value: email_verified, + }, + ], + UserPoolId: config.cognito.USER_POOL_ID, + Username: data.Users[0].Username, + }; - callback(null, event); - } else { - callback(null, event); + cognito.adminUpdateUserAttributes( + update_email_verified_params, + function (err, data) { + syncRewriteFacebookUsername( + email, + () => { + callback(null, event); + }, + (err) => { + event.syncRewriteFacebookUsernameError = err; + callback(null, event); + } + ); + } + ); + } + }); } - }); - } else { - callback(null, event); - } + } else { + callback(null, event); + } + }); + } else { + callback(null, event); + } }); } catch (e) { event.error = e; callback(null, event); } -} \ No newline at end of file +}; diff --git a/api/account-service/pre-signup.js b/api/account-service/pre-signup.js index ec6b6194..a2a7b6f9 100644 --- a/api/account-service/pre-signup.js +++ b/api/account-service/pre-signup.js @@ -7,50 +7,52 @@ function createNativeAccountAndLink(cognito, context, event) { const generatedUsername = event.request.userAttributes.email.split("@")[0]; const params = { - ClientId: config.cognito.APP_CLIENT_ID, + UserPoolId: config.cognito.USER_POOL_ID, + DesiredDeliveryMediums: [], + MessageAction: "SUPPRESS", Username: generatedUsername, - Password: generatePassword(), - UserAttributes: [{ - Name: 'email', - Value: event.request.userAttributes.email - }, { - Name: 'name', - Value: generatedUsername - }] + UserAttributes: [ + { + Name: "email", + Value: event.request.userAttributes.email, + }, + { + Name: "name", + Value: generatedUsername, + }, + ], }; - cognito.signUp(params, (err, data) => { + cognito.adminCreateUser(params, (err, data) => { if (err) { context.done(null, event); return; } else { let confirmParams = { UserPoolId: config.cognito.USER_POOL_ID, + Password: generatePassword(), Username: generatedUsername, - UserAttributes: [{ - Name: 'email_verified', - Value: 'true' - }] + Permanent: true, }; - cognito.adminUpdateUserAttributes(confirmParams, function() { + cognito.adminSetUserPassword(confirmParams, function () { let emailConfirmParams = { UserPoolId: config.cognito.USER_POOL_ID, - Username: generatedUsername + Username: generatedUsername, }; - cognito.adminConfirmSignUp(emailConfirmParams, function() { + cognito.adminConfirmSignUp(emailConfirmParams, function () { let mergeParams = { DestinationUser: { ProviderAttributeValue: generatedUsername, - ProviderName: 'Cognito' + ProviderName: "Cognito", }, SourceUser: { - ProviderAttributeName: 'Cognito_Subject', + ProviderAttributeName: "Cognito_Subject", ProviderAttributeValue: event.userName.split("_")[1], - ProviderName: 'Facebook' + ProviderName: "Facebook", }, - UserPoolId: config.cognito.USER_POOL_ID + UserPoolId: config.cognito.USER_POOL_ID, }; - cognito.adminLinkProviderForUser(mergeParams, function() { + cognito.adminLinkProviderForUser(mergeParams, function () { event.response.autoConfirmUser = true; context.done(null, event); }); @@ -67,34 +69,38 @@ exports.handler = (event, context) => { try { const cognito = new AWS.CognitoIdentityServiceProvider({ apiVersion: "2016-04-19", - region: config.cognito.REGION + region: config.cognito.REGION, }); - if (event.triggerSource.includes('ExternalProvider')) { + if (event.triggerSource.includes("ExternalProvider")) { // Social login let params = { UserPoolId: config.cognito.USER_POOL_ID, - AttributesToGet: ['sub', 'email'], - Filter: "email = \"" + event.request.userAttributes.email + "\"" + AttributesToGet: ["sub", "email"], + Filter: 'email = "' + event.request.userAttributes.email + '"', }; cognito.listUsers(params, (err, data) => { if (err) { event.listUsersError = err; context.done(null, event); - } else if (data != null && data.Users != null && data.Users[0] != null) { + } else if ( + data != null && + data.Users != null && + data.Users[0] != null + ) { let mergeParams = { - DestinationUser: { + DestinationUser: { ProviderAttributeValue: data.Users[0].Username, - ProviderName: 'Cognito' + ProviderName: "Cognito", }, - SourceUser: { - ProviderAttributeName: 'Cognito_Subject', + SourceUser: { + ProviderAttributeName: "Cognito_Subject", ProviderAttributeValue: event.userName.split("_")[1], - ProviderName: 'Facebook' + ProviderName: "Facebook", }, - UserPoolId: config.cognito.USER_POOL_ID + UserPoolId: config.cognito.USER_POOL_ID, }; - cognito.adminLinkProviderForUser(mergeParams, function() { + cognito.adminLinkProviderForUser(mergeParams, function () { context.done(null, event); }); } else { @@ -109,4 +115,4 @@ exports.handler = (event, context) => { event.error = e; context.done(null, event); } -} \ No newline at end of file +}; diff --git a/api/account-service/serverless.yml b/api/account-service/serverless.yml index ae03e4d1..ce499980 100644 --- a/api/account-service/serverless.yml +++ b/api/account-service/serverless.yml @@ -49,7 +49,8 @@ provider: - cognito-idp:AdminUpdateUserAttributes - cognito-idp:AdminConfirmSignUp - cognito-idp:ListUsersInGroup - - cognito-idp:SignUp + - cognito-idp:AdminCreateUser + - cognito-idp:AdminSetUserPassword Resource: "arn:aws:cognito-idp:ap-south-1:*:*" - Effect: Allow Action: diff --git a/api/account-service/update.js b/api/account-service/update.js index cac1cf57..11bc9566 100644 --- a/api/account-service/update.js +++ b/api/account-service/update.js @@ -8,7 +8,7 @@ export async function main(event) { // Request body is passed in as a JSON encoded string in 'event.body' const data = JSON.parse(event.body); const provider = event.requestContext.identity.cognitoAuthenticationProvider; - const sub = provider.split(':')[2]; + const sub = provider.split(":")[2]; let usernameAttributes = await userNameLib.getAuthorAttributes(sub); const username = usernameAttributes.userName; @@ -17,67 +17,114 @@ export async function main(event) { const userAttributes = []; if (data.picture) { - if (data.picture === 'null') { + if (data.picture === "null") { // User wants to remove picture userAttributes.push({ Name: "picture", - Value: "" + Value: "", }); // Delete picture from s3 - const fileName = `public/${usernameAttributes.preferredUsername ?? usernameAttributes.userName}.png`; + const fileName = `public/${ + usernameAttributes.preferredUsername ?? usernameAttributes.userName + }.png`; const s3Params = { Bucket: "naadanchords-avatars", - Key: fileName + Key: fileName, }; await s3Lib.call("deleteObject", s3Params); } else { userAttributes.push({ Name: "picture", - Value: data.picture + Value: data.picture, }); } } if (data.username) { // Check if username with preferred_username exists - const userParams = { + let userParams = { UserPoolId: config.cognito.USER_POOL_ID, AttributesToGet: ["sub"], - Filter: "preferred_username=\"" + data.username + "\"" + Filter: 'preferred_username="' + data.username + '"', }; - - const userResults = await cognitoLib.call("listUsers", userParams); - if(userResults.Users && userResults.Users.length > 0) { + + let userResults = await cognitoLib.call("listUsers", userParams); + if (userResults.Users && userResults.Users.length > 0) { return failure({ status: false, code: "UsernameExistsException", - message: "Username already exists. Please try a different one." + message: "Username already exists. Please try a different one.", }); } + // Check if username exists + userParams = { + UserPoolId: config.cognito.USER_POOL_ID, + AttributesToGet: ["sub"], + Filter: 'username="' + data.username + '"', + }; + + userResults = await cognitoLib.call("listUsers", userParams); + let deletePreferredUsername = false; + if (userResults.Users && userResults.Users.length > 0) { + if (userResults.Users[0].Attributes[0].Value !== sub) { + return failure({ + status: false, + code: "UsernameExistsException", + message: "Username already exists. Please try a different one.", + }); + } else { + deletePreferredUsername = true; + } + } + userAttributes.push({ Name: "preferred_username", - Value: data.username + Value: deletePreferredUsername ? "" : data.username, }); } if (data.name) { userAttributes.push({ Name: "name", - Value: data.name + Value: data.name, + }); + } + + if (data.email) { + // Check if user with same email exists + const userParams = { + UserPoolId: config.cognito.USER_POOL_ID, + AttributesToGet: ["email"], + Filter: 'email="' + data.email + '"', + }; + + const userResults = await cognitoLib.call("listUsers", userParams); + if (userResults.Users && userResults.Users.length > 0) { + return failure({ + status: false, + code: "EmailExistsException", + message: + "User with this email already exists. Please use a different email.", + }); + } + + userAttributes.push({ + Name: "email", + Value: data.email, }); } const params = { UserPoolId: config.cognito.USER_POOL_ID, Username: username, - UserAttributes: userAttributes + UserAttributes: userAttributes, }; - + const result = await cognitoLib.call("adminUpdateUserAttributes", params); - return success( { result }); + return success({ result }); } catch (e) { return failure({ status: false, ...e }); } diff --git a/api/config.js b/api/config.js index c84eead5..08ce8cb6 100644 --- a/api/config.js +++ b/api/config.js @@ -2,8 +2,8 @@ const config = { cognito: { REGION: "ap-south-1", USER_POOL_ID: "ap-south-1_l5klM91tP", - APP_CLIENT_ID: "senbvolbdevcqlj220thd1dgo" - } -} + APP_CLIENT_ID: "senbvolbdevcqlj220thd1dgo", + }, +}; export default config; diff --git a/package-lock.json b/package-lock.json index 262f27ba..cf72fc75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "naadan-chords", - "version": "0.77.6", + "version": "0.77.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "naadan-chords", - "version": "0.77.6", + "version": "0.77.8", "dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.34", "@fortawesome/free-brands-svg-icons": "^5.15.2", diff --git a/package.json b/package.json index 5eb6e6a5..2c4184dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "naadan-chords", - "version": "0.77.6", + "version": "0.77.8", "homepage": "https://www.naadanchords.com/", "private": true, "dependencies": { diff --git a/src/App.js b/src/App.js index a4d8a702..d333231f 100644 --- a/src/App.js +++ b/src/App.js @@ -35,6 +35,7 @@ class App extends Component { userTheme: "auto", theme: "light", scrollDirection: "", + loginError: "", }; } @@ -142,6 +143,13 @@ class App extends Component { Auth.federatedSignIn({ provider: "Facebook" }); } + if (loginError.indexOf("attributes required: [email]") !== -1) { + this.setState({ + loginError: + "The email field was not returned. This may be because the email was missing, invalid or hasn't been confirmed with Facebook.", + }); + } + try { this.setWebsiteTheme(); let session = await Auth.currentSession(); @@ -202,6 +210,7 @@ class App extends Component { this.setState( { userTheme: "auto", + loginError: "", }, () => { this.setWebsiteTheme(); @@ -280,6 +289,7 @@ class App extends Component { userTheme: this.state.userTheme, theme: theme === "light" ? lightTheme : darkTheme, isLocalhost: window.location.hostname === "localhost", + loginError: this.state.loginError, }; return ( diff --git a/src/containers/Login.js b/src/containers/Login.js index d675c540..e8bbb799 100644 --- a/src/containers/Login.js +++ b/src/containers/Login.js @@ -1,5 +1,12 @@ import React from "react"; -import { Alert, FormGroup, FormControl, FormLabel, FormText, Button } from "react-bootstrap"; +import { + Alert, + FormGroup, + FormControl, + FormLabel, + FormText, + Button, +} from "react-bootstrap"; import { Helmet } from "react-helmet"; import { Auth } from "aws-amplify"; import { LinkContainer } from "react-router-bootstrap"; @@ -20,14 +27,14 @@ export default class Login extends SearchComponent { password: "", isErrorState: false, errorMessage: "", - errorType: "" + errorType: "", }; } componentDidMount() { - if(!this.props.isDialog) { + if (!this.props.isDialog) { window.scrollTo(0, 0); - } else if(this.props.setRedirect) { + } else if (this.props.setRedirect) { insertUrlParam("redirect", this.props.setRedirect); } } @@ -36,13 +43,13 @@ export default class Login extends SearchComponent { return this.state.email.length > 0 && this.state.password.length > 0; } - handleChange = event => { + handleChange = (event) => { this.setState({ - [event.target.id]: event.target.value + [event.target.id]: event.target.value, }); - } + }; - handleSubmit = async event => { + handleSubmit = async (event) => { event.preventDefault(); this.setState({ isLoading: true }); @@ -53,7 +60,7 @@ export default class Login extends SearchComponent { await this.props.getUserPrevileges(session); this.props.userHasAuthenticated(true); - if(this.props.isDialog) { + if (this.props.isDialog) { this.props.closeLoginModal(true); } } catch (e) { @@ -61,33 +68,32 @@ export default class Login extends SearchComponent { isLoading: false, isErrorState: true, errorMessage: e.message, - errorType: e.code + errorType: e.code, }); } - } + }; handleSocialLogin = (provider) => { - Auth.federatedSignIn({provider}); - } + Auth.federatedSignIn({ provider }); + }; renderError = () => { - if(this.state.isErrorState) { - return( + if (this.state.isErrorState || this.props.loginError) { + return ( - {this.state.errorMessage} - {this.state.errorType === "UserNotConfirmedException" ? + {this.state.errorMessage || this.props.loginError} + {this.state.errorType === "UserNotConfirmedException" ? (  Click here to verify. - : null - } + ) : null} ); } - } + }; renderSEOTags() { - if(!this.props.isDialog) { + if (!this.props.isDialog) { return ( Login | Naadan Chords @@ -101,16 +107,20 @@ export default class Login extends SearchComponent { } render() { - let {isDialog} = this.props; + let { isDialog } = this.props; return ( -
- { this.renderSEOTags() } -
+
+ {this.renderSEOTags()} +

Login

{this.renderError()} -
); } -} \ No newline at end of file +} diff --git a/src/containers/account/Account.css b/src/containers/account/Account.css index 584fd45c..ea93cbcf 100644 --- a/src/containers/account/Account.css +++ b/src/containers/account/Account.css @@ -147,6 +147,12 @@ margin-top: 10px; } +.Account .verify-email-modal-contents { + padding: 50px 0; + margin: 0 auto; + max-width: 320px; +} + /* Medium devices (tablets, less than 992px) */ @media (max-width: 991.98px) { .Account .col-sm-2 { diff --git a/src/containers/account/Account.js b/src/containers/account/Account.js index 42fbb382..8bb51161 100644 --- a/src/containers/account/Account.js +++ b/src/containers/account/Account.js @@ -14,6 +14,7 @@ import { Nav, Tab, FormText, + Modal, } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFacebook } from "@fortawesome/free-brands-svg-icons"; @@ -31,6 +32,7 @@ import SearchComponent from "../../components/SearchComponent"; import LoaderButton from "../../components/LoaderButton"; import { base64toBlob } from "../../libs/utils"; import * as urlLib from "../../libs/url-lib"; +import VerifyEmail from "./VerifyEmail"; import "./Account.css"; @@ -59,6 +61,7 @@ export default class Account extends SearchComponent { emailVerified: false, userTheme: "auto", userDeletable: false, + showEmailVerifyModal: false, }; } @@ -136,6 +139,7 @@ export default class Account extends SearchComponent { valid = valid && (this.props.name !== this.state.name || + this.props.email !== this.state.email || (this.props.preferredUsername ? this.props.preferredUsername !== this.state.username : this.props.username !== this.state.username) || @@ -240,6 +244,10 @@ export default class Account extends SearchComponent { request.username = this.state.username; } + if (this.props.email !== this.state.email) { + request.email = this.state.email; + } + await API.post("posts", "/account/update", { body: request, }); @@ -426,6 +434,12 @@ export default class Account extends SearchComponent { } }; + setEmailVerifyModalState = (value) => { + this.setState({ + showEmailVerifyModal: value, + }); + }; + setEditAvatarMode = (e, avatarEditMode) => { e.preventDefault(); @@ -435,8 +449,14 @@ export default class Account extends SearchComponent { }); }; + triggerVerifyEmailFlow = async () => { + let user = await Auth.currentAuthenticatedUser(); + this.setEmailVerifyModalState(true); + await Auth.verifyUserAttribute(user, "email"); + }; + renderProfileForm = () => { - const { theme } = this.props; + const { theme, emailVerified } = this.props; if (this.state.isInitialLoading) { return ( @@ -572,7 +592,21 @@ export default class Account extends SearchComponent { Email - + + {!emailVerified && ( + + You will not be able to login using this Email.  + + Click here to verify + + . + + )} { + this.setEmailVerifyModalState(false); + const session = await Auth.currentSession(); + await this.props.getUserAttributes(session); + this.setState({ + emailVerified: this.props.emailVerified, + }); + }; + render() { let { activeTab, emailVerified } = this.state; return (
{this.renderSEOTags()} + + + Verify Email + + + + +

Account

diff --git a/src/containers/account/VerifyEmail.css b/src/containers/account/VerifyEmail.css new file mode 100644 index 00000000..70486054 --- /dev/null +++ b/src/containers/account/VerifyEmail.css @@ -0,0 +1,5 @@ +.VerifyEmail { + padding: 10px 0 30px 0; + margin: 0 auto; + max-width: 320px; +} diff --git a/src/containers/account/VerifyEmail.js b/src/containers/account/VerifyEmail.js new file mode 100644 index 00000000..278a08ec --- /dev/null +++ b/src/containers/account/VerifyEmail.js @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import { + Alert, + Button, + FormControl, + FormGroup, + FormLabel, + FormText, +} from "react-bootstrap"; +import { Auth } from "aws-amplify"; +import LoaderButton from "../../components/LoaderButton"; + +import "./VerifyEmail.css"; +import { LinkContainer } from "react-router-bootstrap"; + +const VerifyEmail = ({ handleCloseEmailVerifyModal }) => { + const [code, setCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isErrorState, setIsErrorState] = useState(false); + const [isSuccessState, setIsSuccessState] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const handleSubmit = async (event) => { + event.preventDefault(); + setIsLoading(true); + let user = await Auth.currentAuthenticatedUser(); + try { + await Auth.verifyUserAttributeSubmit(user, "email", code); + await Auth.updateUserAttributes(user, { + "custom:email_valid": "true", + }); + setIsErrorState(false); + setIsSuccessState(true); + } catch (e) { + setIsErrorState(true); + setErrorMessage(e.message); + } finally { + setIsLoading(false); + } + }; + + const renderError = () => { + if (isErrorState) { + return ( + setIsErrorState(false)} + dismissible + > + {errorMessage} + + ); + } + }; + + const validateForm = () => !!code; + + return ( +
+ {renderError()} + {isSuccessState ? ( + <> + Email Verified! +

+ If you haven't already,{" "} + + click here + {" "} + to set a new password. +

+ + + ) : ( +
handleSubmit(e)}> + + Verification Code + setCode(e.target.value)} + /> + + Please check your Email for the code. + + + + + )} +
+ ); +}; + +export default VerifyEmail;