diff --git a/migration/1723764281125-add_email_verification_columns.ts b/migration/1723764281125-add_email_verification_columns.ts new file mode 100644 index 000000000..013b318e5 --- /dev/null +++ b/migration/1723764281125-add_email_verification_columns.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEmailVerificationColumns1723764281125 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(` + ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS "verificationCode" VARCHAR, + ADD COLUMN IF NOT EXISTS "isEmailVerified" BOOLEAN DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(` + ALTER TABLE "user" + DROP COLUMN IF EXISTS "verificationCode", + DROP COLUMN IF EXISTS "isEmailVerified"; + `); + } +} diff --git a/package-lock.json b/package-lock.json index f73dc5295..54152c452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "giveth-graphql-api", - "version": "1.24.3", + "version": "1.24.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "giveth-graphql-api", - "version": "1.24.3", + "version": "1.24.4", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/src/adapters/notifications/MockNotificationAdapter.ts b/src/adapters/notifications/MockNotificationAdapter.ts index e47a3825c..69d720e46 100644 --- a/src/adapters/notifications/MockNotificationAdapter.ts +++ b/src/adapters/notifications/MockNotificationAdapter.ts @@ -35,6 +35,14 @@ export class MockNotificationAdapter implements NotificationAdapterInterface { return Promise.resolve(undefined); } + sendEmailConfirmationCodeFlow(params: { email: string }): Promise { + logger.debug( + 'MockNotificationAdapter sendEmailConfirmationCodeFlow', + params, + ); + return Promise.resolve(undefined); + } + userSuperTokensCritical(): Promise { return Promise.resolve(undefined); } diff --git a/src/adapters/notifications/NotificationAdapterInterface.ts b/src/adapters/notifications/NotificationAdapterInterface.ts index 7e19aaacb..3d6b97f8e 100644 --- a/src/adapters/notifications/NotificationAdapterInterface.ts +++ b/src/adapters/notifications/NotificationAdapterInterface.ts @@ -63,6 +63,11 @@ export interface NotificationAdapterInterface { token: string; }): Promise; + sendEmailConfirmationCodeFlow(params: { + email: string; + user: User; + }): Promise; + userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index 1cc873ba6..2ee62fa4d 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -94,6 +94,27 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { } } + async sendEmailConfirmationCodeFlow(params: { + email: string; + user: User; + }): Promise { + const { email, user } = params; + try { + await callSendNotification({ + eventName: NOTIFICATIONS_EVENT_NAMES.SEND_EMAIL_CONFIRMATION_CODE_FLOW, + segment: { + payload: { + email, + verificationCode: user.verificationCode, + userId: user.id, + }, + }, + }); + } catch (e) { + logger.error('sendEmailConfirmationCodeFlow >> error', e); + } + } + async userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts index 44b40ffc4..ae8c48fc5 100644 --- a/src/analytics/analytics.ts +++ b/src/analytics/analytics.ts @@ -49,4 +49,5 @@ export enum NOTIFICATIONS_EVENT_NAMES { SUBSCRIBE_ONBOARDING = 'Subscribe onboarding', CREATE_ORTTO_PROFILE = 'Create Ortto profile', SEND_EMAIL_CONFIRMATION = 'Send email confirmation', + SEND_EMAIL_CONFIRMATION_CODE_FLOW = 'Send email confirmation code flow', } diff --git a/src/entities/user.ts b/src/entities/user.ts index 12ca19950..f40e058ac 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -34,6 +34,7 @@ export const publicSelectionFields = [ 'user.totalReceived', 'user.passportScore', 'user.passportStamps', + 'user.isEmailVerified', ]; export enum UserRole { @@ -156,6 +157,13 @@ export class User extends BaseEntity { @Column('bool', { default: false }) segmentIdentified: boolean; + @Field(_type => Boolean, { nullable: true }) + @Column('bool', { default: false }) + isEmailVerified: boolean; + + @Column('varchar', { nullable: true, default: null }) + verificationCode?: string | null; + // Admin Reviewing Forms @Field(_type => [ProjectVerificationForm], { nullable: true }) @OneToMany( diff --git a/src/resolvers/types/userResolver.ts b/src/resolvers/types/userResolver.ts index 94a9c5761..f4f1a1407 100644 --- a/src/resolvers/types/userResolver.ts +++ b/src/resolvers/types/userResolver.ts @@ -5,4 +5,8 @@ import { User } from '../../entities/user'; export class UserByAddressResponse extends User { @Field(_type => Boolean, { nullable: true }) isSignedIn?: boolean; + @Field(_type => Boolean, { nullable: true }) + isEmailSent?: boolean; + @Field(_type => Boolean, { nullable: true }) + useHasProject?: boolean; } diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index d996747f9..df37d072a 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -18,10 +18,16 @@ import { } from '../../test/testUtils'; import { refreshUserScores, + sendCodeToConfirmEmail, updateUser, userByAddress, + verifyUserEmailCode as verifyUserEmailCodeQuery, + checkEmailAvailability as checkEmailAvailabilityQuery, } from '../../test/graphqlQueries'; -import { errorMessages } from '../utils/errorMessages'; +import { + errorMessages, + translationErrorMessagesKeys, +} from '../utils/errorMessages'; import { insertSinglePowerBoosting } from '../repositories/powerBoostingRepository'; import { DONATION_STATUS } from '../entities/donation'; import { getGitcoinAdapter } from '../adapters/adaptersFactory'; @@ -33,6 +39,13 @@ import { RECURRING_DONATION_STATUS } from '../entities/recurringDonation'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); describe('refreshUserScores() test cases', refreshUserScoresTestCases); +describe( + 'sendUserEmailConfirmationCodeFlow() test cases', + sendUserEmailConfirmationCodeFlow, +); +describe('verifyUserEmailCode() test cases', verifyUserEmailCode); +describe('checkEmailAvailability()', checkEmailAvailability); + // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -925,3 +938,273 @@ function updateUserTestCases() { assert.equal(updatedUser?.url, updateUserData.url); }); } +function sendUserEmailConfirmationCodeFlow(): void { + it('should send the email', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + assert.isNotNull(updatedUser?.verificationCode); + }); + it('should fail send the email not logged in', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + try { + await axios.post(graphqlUrl, { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.AUTHENTICATION_REQUIRED, + ); + } + }); + it('should set the user verification false, user already has been verified', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; + await user.save(); + const accessToken = await generateTestAccessToken(user.id); + + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: 'newemail@test.com', + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + + assert.isNotNull(updatedUser?.verificationCode); + assert.isFalse(updatedUser?.isEmailVerified); + assert.equal(updatedUser?.email, 'newemail@test.com'); + }); + it('should fail send the email when email is already verified', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; + await user.save(); + const accessToken = await generateTestAccessToken(user.id); + + try { + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.EMAIL_ALREADY_USED, + ); + } + }); +} + +function verifyUserEmailCode() { + it('should verify the email code', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + await axios.post( + graphqlUrl, + { + query: verifyUserEmailCodeQuery, + variables: { + code: updatedUser?.verificationCode, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + const verifiedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + + assert.isTrue(verifiedUser?.isEmailVerified); + }); + it('should fail verify the email code not logged in', async () => { + try { + await axios.post(graphqlUrl, { + query: verifyUserEmailCodeQuery, + variables: { + code: 1234, + }, + }); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.AUTHENTICATION_REQUIRED, + ); + } + }); + it('should fail verify the email code when code is wrong', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + + assert.isNotNull(updatedUser?.verificationCode); + + if (!updatedUser?.verificationCode) return; + + try { + await axios.post( + graphqlUrl, + { + query: verifyUserEmailCodeQuery, + variables: { + code: updatedUser?.verificationCode + 1, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.INVALID_EMAIL_CODE, + ); + } + }); +} + +function checkEmailAvailability() { + it('should return true when email is available', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + const email = 'test@gmail.com'; + + const result = await axios.post( + graphqlUrl, + { + query: checkEmailAvailabilityQuery, + variables: { + email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + assert.isTrue(result.data.data.checkEmailAvailability); + }); + + it('should return false when email is not available', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + const email = user.email; + + try { + await axios.post( + graphqlUrl, + { + query: checkEmailAvailabilityQuery, + variables: { + email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.EMAIL_ALREADY_USED, + ); + } + }); +} diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 487f9ac28..3c8f11e85 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -30,6 +30,8 @@ import { isWalletAddressInPurpleList } from '../repositories/projectAddressRepos import { addressHasDonated } from '../repositories/donationRepository'; import { getOrttoPersonAttributes } from '../adapters/notifications/NotificationCenterAdapter'; import { retrieveActiveQfRoundUserMBDScore } from '../repositories/qfRoundRepository'; +import { generateEmailVerificationCode } from '../utils/utils'; +import { getLoggedInUser } from '../services/authorizationServices'; @ObjectType() class UserRelatedAddressResponse { @@ -69,11 +71,13 @@ export class UserResolver { address, includeSensitiveFields, ); + if (!foundUser) { throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); } return { isSignedIn: Boolean(user), + isEmailSent: !!foundUser.verificationCode && !foundUser.isEmailVerified, ...foundUser, }; } @@ -230,4 +234,77 @@ export class UserResolver { return true; } + + @Mutation(_returns => Boolean) + async sendUserEmailConfirmationCodeFlow( + @Arg('email') email: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + const isEmailAlreadyUsed = await User.findOne({ + where: { email: email }, + }); + + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (!!isEmailAlreadyUsed && isEmailAlreadyUsed.isEmailVerified) + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_USED)); + + const code = generateEmailVerificationCode().toString(); + + user.verificationCode = code; + + await getNotificationAdapter().sendEmailConfirmationCodeFlow({ + email: email, + user: user, + }); + + user.email = email; + user.isEmailVerified = false; + + await user.save(); + + return true; + } + + @Mutation(_returns => Boolean) + async verifyUserEmailCode( + @Arg('code') code: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (user.verificationCode !== code) + throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL_CODE)); + + user.isEmailVerified = true; + user.verificationCode = null; + + await user.save(); + + return true; + } + + @Mutation(_returns => Boolean) + async checkEmailAvailability( + @Arg('email') email: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + const isEmailAlreadyUsed = await User.findOne({ + where: { email: email }, + }); + + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (!!isEmailAlreadyUsed && isEmailAlreadyUsed.isEmailVerified) + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_USED)); + + return true; + } } diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index a6584da2a..155b184b8 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -205,6 +205,9 @@ export const errorMessages = { DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED: 'Draft donation cannot be marked as failed', QR_CODE_DATA_URL_REQUIRED: 'QR code data URL is required', + USER_ALREADY_VERIFIED: 'User already verified', + INVALID_EMAIL_CODE: 'Invalid email code', + EMAIL_ALREADY_USED: 'Email already used', }; export const translationErrorMessagesKeys = { @@ -379,4 +382,7 @@ export const translationErrorMessagesKeys = { DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED: 'DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED', QR_CODE_DATA_URL_REQUIRED: 'QR_CODE_DATA_URL_REQUIRED', + USER_ALREADY_VERIFIED: 'USER_ALREADY_VERIFIED', + INVALID_EMAIL_CODE: 'INVALID_EMAIL_CODE', + EMAIL_ALREADY_USED: 'EMAIL_ALREADY_USED', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 70645329d..f9e8675d4 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -117,5 +117,8 @@ "TX_NOT_FOUND": "TX_NOT_FOUND", "PROJECT_DOESNT_ACCEPT_RECURRING_DONATION": "PROJECT_DOESNT_ACCEPT_RECURRING_DONATION", "Project does not accept recurring donation": "Project does not accept recurring donation", - "QR_CODE_DATA_URL_REQUIRED": "QR_CODE_DATA_URL_REQUIRED" + "QR_CODE_DATA_URL_REQUIRED": "QR_CODE_DATA_URL_REQUIRED", + "USER_ALREADY_VERIFIED": "User already verified", + "INVALID_EMAIL_CODE": "Invalid email code", + "EMAIL_ALREADY_USED": "Email already used" } \ No newline at end of file diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index 7f8d184f5..2778020c1 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -106,5 +106,8 @@ "PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED": "El contenido es demasiado largo", "DRAFT_DONATION_DISABLED": "El borrador de donación está deshabilitado", "EVM_SUPPORT_ONLY": "Solo se admite EVM", - "EVM_AND_STELLAR_SUPPORT_ONLY": "Solo se admite EVM y Stellar" + "EVM_AND_STELLAR_SUPPORT_ONLY": "Solo se admite EVM y Stellar", + "USER_ALREADY_VERIFIED": "El usuario ya está verificado", + "INVALID_EMAIL_CODE": "Código de correo electrónico no válido", + "EMAIL_ALREADY_USED": "El correo electrónico ya ha sido utilizado" } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index d50f467e9..f97cfbbd7 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -466,3 +466,7 @@ export const isSocialMediaEqual = ( .sort(), ); }; + +export const generateEmailVerificationCode = (): number => { + return Math.floor(100000 + Math.random() * 900000); +}; diff --git a/src/utils/validators/commonValidators.ts b/src/utils/validators/commonValidators.ts index 59b33dd61..f4bc27edb 100644 --- a/src/utils/validators/commonValidators.ts +++ b/src/utils/validators/commonValidators.ts @@ -1,5 +1,7 @@ export const validateEmail = (email: string): boolean => { - return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - email, + return ( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + email, + ) || email === '' ); }; diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index c05b84047..fccba158e 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2584,3 +2584,21 @@ export const fetchRecurringDonationsByDateQuery = ` } } `; + +export const sendCodeToConfirmEmail = ` + mutation sendCodeToConfirmEmail($email: String!) { + sendUserEmailConfirmationCodeFlow(email: $email) + } +`; + +export const verifyUserEmailCode = ` + mutation verifyUserEmailCode($code: String!) { + verifyUserEmailCode(code: $code) + } +`; + +export const checkEmailAvailability = ` + mutation checkEmailAvailability($email: String!) { + checkEmailAvailability(email: $email) + } +`;