From 9702dc82340f7278cf43251ddc68b9937c3a2450 Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 01:37:11 +0330 Subject: [PATCH 01/10] Add new error messages --- src/utils/errorMessages.ts | 2 ++ src/utils/locales/en.json | 4 +++- src/utils/locales/es.json | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 0f4a16eaa..2447e821b 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -318,4 +318,6 @@ export const translationErrorMessagesKeys = { 'PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED', DRAFT_DONATION_DISABLED: 'DRAFT_DONATION_DISABLED', EVM_SUPPORT_ONLY: 'EVM_SUPPORT_ONLY', + + NO_EMAIL_PROVIDED: 'NO_EMAIL_PROVIDED', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 5f58e5254..eab637fc7 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -100,5 +100,7 @@ "DRAFT_DONATION_DISABLED": "Draft donation is disabled", "EVM_SUPPORT_ONLY": "Only EVM support", "INVALID_PROJECT_ID": "INVALID_PROJECT_ID", - "TX_NOT_FOUND": "TX_NOT_FOUND" + "TX_NOT_FOUND": "TX_NOT_FOUND", + + "NO_EMAIL_PROVIDED": "No email address provided." } \ No newline at end of file diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index d7d54ef92..c83c53956 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -97,5 +97,7 @@ "REGISTERED_NON_PROFITS_CATEGORY_DOESNT_EXIST": "No hay ninguna categoría con nombre registrado-sin fines de lucro, probablemente se olvidó de ejecutar las migraciones", "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_SUPPORT_ONLY": "Solo se admite EVM", + + "NO_EMAIL_PROVIDED": "No se ha proporcionado una dirección de correo electrónico." } From 9401c5f941ed2488dfc338298bfdb94d8c2834b4 Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 01:38:09 +0330 Subject: [PATCH 02/10] Add new method to notification adapter for user email verification --- .../notifications/MockNotificationAdapter.ts | 9 ++++++++ .../NotificationAdapterInterface.ts | 6 +++++ .../NotificationCenterAdapter.ts | 23 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/adapters/notifications/MockNotificationAdapter.ts b/src/adapters/notifications/MockNotificationAdapter.ts index 870f37275..97008f20a 100644 --- a/src/adapters/notifications/MockNotificationAdapter.ts +++ b/src/adapters/notifications/MockNotificationAdapter.ts @@ -34,6 +34,15 @@ export class MockNotificationAdapter implements NotificationAdapterInterface { return Promise.resolve(undefined); } + async sendUserEmailConfirmation(params: { + email: string; + user: User; + token: string; + }) { + logger.debug('MockNotificationAdapter sendUserEmailConfirmation', 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 1e02155e9..827b168c4 100644 --- a/src/adapters/notifications/NotificationAdapterInterface.ts +++ b/src/adapters/notifications/NotificationAdapterInterface.ts @@ -62,6 +62,12 @@ export interface NotificationAdapterInterface { token: string; }): Promise; + sendUserEmailConfirmation(params: { + email: string; + user: User; + token: string; + }): Promise; + userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index 039f25525..f030c566b 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -90,6 +90,29 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { } } + // todo: use different eventName specific to Qacc (to show correct icon and description) + // todo: add the new eventName to the notification service and add the schema to Ortto + async sendUserEmailConfirmation(params: { + email: string; + user: User; + token: string; + }): Promise { + const { email, user, token } = params; + try { + await callSendNotification({ + eventName: NOTIFICATIONS_EVENT_NAMES.SEND_EMAIL_CONFIRMATION, + segment: { + payload: { + email, + verificationLink: `${dappUrl}/verification/${user.walletAddress}/${token}`, + }, + }, + }); + } catch (e) { + logger.error('sendUserEmailConfirmation >> error', e); + } + } + async userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; From 12f77a398d636ef69582945e9d315ea626489c10 Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 01:38:56 +0330 Subject: [PATCH 03/10] Add user email verification flow --- src/entities/user.ts | 24 ++++++ src/repositories/userRepository.ts | 65 ++++++++++++++++ src/resolvers/userResolver.ts | 115 +++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/src/entities/user.ts b/src/entities/user.ts index 08e45daa0..04e787622 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -188,6 +188,30 @@ export class User extends BaseEntity { @Field(_type => Float, { nullable: true }) activeQFMBDScore?: number; + @Field(_type => Boolean, { nullable: false }) + @Column({ default: false }) + emailConfirmed: boolean; + + @Field(_type => String, { nullable: true }) + @Column('text', { nullable: true }) + emailConfirmationToken: string | null; + + @Field(_type => Date, { nullable: true }) + @Column('timestamptz', { nullable: true }) + emailConfirmationTokenExpiredAt: Date | null; + + @Field(_type => Boolean, { nullable: true }) + @Column({ default: false }) + emailConfirmationSent: boolean; + + @Field(_type => Date, { nullable: true }) + @Column({ type: 'timestamptz', nullable: true }) + emailConfirmationSentAt: Date | null; + + @Field(_type => Date, { nullable: true }) + @Column({ nullable: true }) + emailConfirmedAt: Date; + @Field(_type => Int, { nullable: true }) async donationsCount() { return await Donation.createQueryBuilder('donation') diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index a9fb86155..985926596 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,3 +1,4 @@ +import { UpdateResult } from 'typeorm'; import { publicSelectionFields, User, UserRole } from '../entities/user'; import { Donation } from '../entities/donation'; import { Reaction } from '../entities/reaction'; @@ -177,3 +178,67 @@ export const findUsersWhoSupportProject = async ( } return users; }; + +export const findUserByEmailConfirmationToken = async ( + emailConfirmationToken: string, +): Promise => { + return User.createQueryBuilder('user') + .where({ + emailConfirmationToken, + }) + .getOne(); +}; + +export const updateUserEmailConfirmationStatus = async (params: { + userId: number; + emailConfirmed: boolean; + emailConfirmationTokenExpiredAt: Date | null; + emailConfirmationToken: string | null; + emailConfirmationSentAt: Date | null; +}): Promise => { + const { + userId, + emailConfirmed, + emailConfirmationTokenExpiredAt, + emailConfirmationToken, + emailConfirmationSentAt, + } = params; + + return User.createQueryBuilder() + .update(User) + .set({ + emailConfirmed, + emailConfirmationTokenExpiredAt, + emailConfirmationToken, + emailConfirmationSentAt, + }) + .where('id = :userId', { userId }) + .execute(); +}; + +export const updateUserEmailConfirmationToken = async (params: { + userId: number; + emailConfirmationToken: string; + emailConfirmationTokenExpiredAt: Date; + emailConfirmationSentAt: Date; +}): Promise => { + const { + userId, + emailConfirmationToken, + emailConfirmationTokenExpiredAt, + emailConfirmationSentAt, + } = params; + + const user = await findUserById(userId); + if (!user) { + throw new Error('User not found'); + } + + user.emailConfirmationToken = emailConfirmationToken; + user.emailConfirmationTokenExpiredAt = emailConfirmationTokenExpiredAt; + user.emailConfirmationSentAt = emailConfirmationSentAt; + user.emailConfirmed = false; + + await user.save(); + return user; +}; diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 4a1861cfb..f1cd89d3e 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -8,13 +8,17 @@ import { Resolver, } from 'type-graphql'; import { Repository } from 'typeorm'; +import * as jwt from 'jsonwebtoken'; +import moment from 'moment'; import { User } from '../entities/user'; +import config from '../config'; import { AccountVerificationInput } from './types/accountVerificationInput'; import { ApolloContext } from '../types/ApolloContext'; import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages'; import { validateEmail } from '../utils/validators/commonValidators'; import { + findUserByEmailConfirmationToken, findUserById, findUserByWalletAddress, } from '../repositories/userRepository'; @@ -231,4 +235,115 @@ export class UserResolver { return true; } + + @Mutation(_returns => User) + async userVerificationSendEmailConfirmation( + @Arg('userId') userId: number, + @Ctx() { req: { user } }: ApolloContext, + ): Promise { + try { + const currentUserId = user?.userId; + if (!currentUserId) { + throw new Error(i18n.__(translationErrorMessagesKeys.UN_AUTHORIZED)); + } + + const userToVerify = await findUserById(userId); + + if (!userToVerify) { + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + } + if (userToVerify.id !== currentUserId) { + throw new Error(i18n.__(translationErrorMessagesKeys.UN_AUTHORIZED)); + } + + const email = userToVerify.email; + if (!email) { + throw new Error( + i18n.__(translationErrorMessagesKeys.NO_EMAIL_PROVIDED), + ); + } + if (userToVerify.emailConfirmed) { + throw new Error( + i18n.__(translationErrorMessagesKeys.YOU_ALREADY_VERIFIED_THIS_EMAIL), + ); + } + + const token = jwt.sign( + { userId }, + config.get('MAILER_JWT_SECRET') as string, + { expiresIn: '5m' }, + ); + + userToVerify.emailConfirmationTokenExpiredAt = moment() + .add(5, 'minutes') + .toDate(); + userToVerify.emailConfirmationToken = token; + userToVerify.emailConfirmationSent = true; + userToVerify.emailConfirmed = false; + userToVerify.emailConfirmationSentAt = new Date(); + await userToVerify.save(); + + await getNotificationAdapter().sendUserEmailConfirmation({ + email, + user: userToVerify, + token, + }); + + return userToVerify; + } catch (e) { + logger.error('userVerificationSendEmailConfirmation() error', e); + throw e; + } + } + + @Mutation(_returns => User) + async userVerificationConfirmEmail( + @Arg('emailConfirmationToken') emailConfirmationToken: string, + ): Promise { + try { + const secret = config.get('MAILER_JWT_SECRET') as string; + + const isValidToken = await findUserByEmailConfirmationToken( + emailConfirmationToken, + ); + + if (!isValidToken) { + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + } + + const decodedJwt: any = jwt.verify(emailConfirmationToken, secret); + const userId = decodedJwt.userId; + const user = await findUserById(userId); + + if (!user) { + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + } + + user.emailConfirmationTokenExpiredAt = null; + user.emailConfirmationToken = null; + user.emailConfirmedAt = new Date(); + user.emailConfirmed = true; + await user.save(); + + return user; + } catch (e) { + const user = await findUserByEmailConfirmationToken( + emailConfirmationToken, + ); + + if (!user) { + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + } + + user.emailConfirmed = false; + user.emailConfirmationTokenExpiredAt = null; + user.emailConfirmationSent = false; + user.emailConfirmationSentAt = null; + user.emailConfirmationToken = null; + + await user.save(); + logger.error('userVerificationConfirmEmail() error', e); + throw e; + } + } } From 157ad7f48cbf49b8ef85ee953e0613b3b7eecc74 Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 01:42:00 +0330 Subject: [PATCH 04/10] Add unit tests for user repository and user resolver new methods --- src/repositories/userRepository.test.ts | 120 ++++++++++++++++ src/resolvers/userResolver.test.ts | 173 ++++++++++++++++++++++++ test/graphqlQueries.ts | 30 ++++ 3 files changed, 323 insertions(+) diff --git a/src/repositories/userRepository.test.ts b/src/repositories/userRepository.test.ts index 84336a6d5..4b482d91b 100644 --- a/src/repositories/userRepository.test.ts +++ b/src/repositories/userRepository.test.ts @@ -11,11 +11,14 @@ import { User, UserRole } from '../entities/user'; import { findAdminUserByEmail, findAllUsers, + findUserByEmailConfirmationToken, findUserById, findUserByWalletAddress, findUsersWhoDonatedToProjectExcludeWhoLiked, findUsersWhoLikedProjectExcludeProjectOwner, findUsersWhoSupportProject, + updateUserEmailConfirmationStatus, + updateUserEmailConfirmationToken, } from './userRepository'; import { Reaction } from '../entities/reaction'; @@ -44,6 +47,19 @@ describe( findUsersWhoDonatedToProjectTestCases, ); +describe( + 'userRepository.findUserByEmailConfirmationToken', + findUserByEmailConfirmationTokenTestCases, +); +describe( + 'userRepository.updateUserEmailConfirmationStatus', + updateUserEmailConfirmationStatusTestCases, +); +describe( + 'userRepository.updateUserEmailConfirmationToken', + updateUserEmailConfirmationTokenTestCases, +); + function findUsersWhoDonatedToProjectTestCases() { it('should find wallet addresses of who donated to a project, exclude who liked', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); @@ -489,3 +505,107 @@ function findUsersWhoSupportProjectTestCases() { ); }); } + +function findUserByEmailConfirmationTokenTestCases() { + it('should return a user if a valid email confirmation token is provided', async () => { + await User.create({ + email: 'test@example.com', + emailConfirmationToken: 'validToken123', + loginType: 'wallet', + }).save(); + + const foundUser = await findUserByEmailConfirmationToken('validToken123'); + assert.isNotNull(foundUser); + assert.equal(foundUser!.email, 'test@example.com'); + assert.equal(foundUser!.emailConfirmationToken, 'validToken123'); + }); + + it('should return null if no user is found with the provided email confirmation token', async () => { + const foundUser = await findUserByEmailConfirmationToken('invalidToken123'); + assert.isNull(foundUser); + }); +} + +function updateUserEmailConfirmationStatusTestCases() { + it('should update the email confirmation status of a user', async () => { + const user = await User.create({ + email: 'test@example.com', + emailConfirmed: false, + emailConfirmationToken: 'validToken123', + loginType: 'wallet', + }).save(); + + await updateUserEmailConfirmationStatus({ + userId: user.id, + emailConfirmed: true, + emailConfirmationTokenExpiredAt: null, + emailConfirmationToken: null, + emailConfirmationSentAt: null, + }); + + // Using findOne with options object + const updatedUser = await User.findOne({ where: { id: user.id } }); + assert.isNotNull(updatedUser); + assert.isTrue(updatedUser!.emailConfirmed); + assert.isNull(updatedUser!.emailConfirmationToken); + }); + + it('should not update any user if the userId does not exist', async () => { + const result = await updateUserEmailConfirmationStatus({ + userId: 999, // non-existent userId + emailConfirmed: true, + emailConfirmationTokenExpiredAt: null, + emailConfirmationToken: null, + emailConfirmationSentAt: null, + }); + + assert.equal(result.affected, 0); // No rows should be affected + }); +} + +function updateUserEmailConfirmationTokenTestCases() { + it('should update the email confirmation token and expiry date for a user', async () => { + const user = await User.create({ + email: 'test@example.com', + loginType: 'wallet', + }).save(); + + const newToken = 'newToken123'; + const newExpiryDate = new Date(Date.now() + 3600 * 1000); // 1 hour from now + const sentAtDate = new Date(); + + await updateUserEmailConfirmationToken({ + userId: user.id, + emailConfirmationToken: newToken, + emailConfirmationTokenExpiredAt: newExpiryDate, + emailConfirmationSentAt: sentAtDate, + }); + + // Using findOne with options object + const updatedUser = await User.findOne({ where: { id: user.id } }); + assert.isNotNull(updatedUser); + assert.equal(updatedUser!.emailConfirmationToken, newToken); + assert.equal( + updatedUser!.emailConfirmationTokenExpiredAt!.getTime(), + newExpiryDate.getTime(), + ); + assert.equal( + updatedUser!.emailConfirmationSentAt!.getTime(), + sentAtDate.getTime(), + ); + }); + + it('should throw an error if the userId does not exist', async () => { + try { + await updateUserEmailConfirmationToken({ + userId: 999, // non-existent userId + emailConfirmationToken: 'newToken123', + emailConfirmationTokenExpiredAt: new Date(), + emailConfirmationSentAt: new Date(), + }); + assert.fail('Expected an error to be thrown'); + } catch (error) { + assert.equal(error.message, 'User not found'); + } + }); +} diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index 10ca74ea6..38772227a 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -6,6 +6,7 @@ import { User } from '../entities/user'; import { createDonationData, createProjectData, + generateConfirmationEmailToken, generateRandomEtheriumAddress, generateTestAccessToken, graphqlUrl, @@ -18,15 +19,27 @@ import { refreshUserScores, updateUser, userByAddress, + userVerificationConfirmEmail, + userVerificationSendEmailConfirmation, } from '../../test/graphqlQueries'; import { errorMessages } from '../utils/errorMessages'; import { DONATION_STATUS } from '../entities/donation'; import { getGitcoinAdapter } from '../adapters/adaptersFactory'; import { updateUserTotalDonated } from '../services/userService'; +import { findUserById } from '../repositories/userRepository'; +import { sleep } from '../utils/utils'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); describe('refreshUserScores() test cases', refreshUserScoresTestCases); +describe( + 'userVerificationSendEmailConfirmation() test cases', + userVerificationSendEmailConfirmationTestCases, +); +describe( + 'userVerificationConfirmEmail() test cases', + userVerificationConfirmEmailTestCases, +); // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -658,3 +671,163 @@ function updateUserTestCases() { assert.equal(updatedUser?.url, updateUserData.url); }); } + +function userVerificationSendEmailConfirmationTestCases() { + it('should send email confirmation for user email verification', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.email = 'test@example.com'; + user.emailConfirmed = false; + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const result = await axios.post( + graphqlUrl, + { + query: userVerificationSendEmailConfirmation, + variables: { + userId: user.id, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + assert.isOk(result.data.data.userVerificationSendEmailConfirmation); + assert.isFalse( + result.data.data.userVerificationSendEmailConfirmation.emailConfirmed, + ); + assert.equal( + result.data.data.userVerificationSendEmailConfirmation.email, + 'test@example.com', + ); + assert.equal( + result.data.data.userVerificationSendEmailConfirmation + .emailConfirmationSent, + true, + ); + assert.isNotNull( + result.data.data.userVerificationSendEmailConfirmation + .emailConfirmationToken, + ); + }); + + it('should throw error when sending email confirmation if email is already confirmed', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.email = 'test@example.com'; + user.emailConfirmed = true; + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const result = await axios.post( + graphqlUrl, + { + query: userVerificationSendEmailConfirmation, + variables: { + userId: user.id, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + assert.equal( + result.data.errors[0].message, + errorMessages.YOU_ALREADY_VERIFIED_THIS_EMAIL, + ); + }); +} + +function userVerificationConfirmEmailTestCases() { + it('should confirm user email verification', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.email = 'test@example.com'; + user.emailConfirmed = false; + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const emailConfirmationSentResult = await axios.post( + graphqlUrl, + { + query: userVerificationSendEmailConfirmation, + variables: { + userId: user.id, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + const token = + emailConfirmationSentResult.data.data + .userVerificationSendEmailConfirmation.emailConfirmationToken; + + const result = await axios.post( + graphqlUrl, + { + query: userVerificationConfirmEmail, + variables: { + emailConfirmationToken: token, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + assert.isOk(result.data.data.userVerificationConfirmEmail); + assert.equal( + result.data.data.userVerificationConfirmEmail.emailConfirmed, + true, + ); + assert.isNotNull( + result.data.data.userVerificationConfirmEmail.emailConfirmedAt, + ); + }); + + it('should throw error when confirm email token is invalid or expired for user email verification', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.email = 'test@example.com'; + user.emailConfirmed = false; + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const token = await generateConfirmationEmailToken(user.id); + user.emailConfirmationToken = token; + await user.save(); + await sleep(500); // Simulating token expiration or invalidity + + const result = await axios.post( + graphqlUrl, + { + query: userVerificationConfirmEmail, + variables: { + emailConfirmationToken: token, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + assert.equal(result.data.errors[0].message, 'jwt expired'); + const userReinitializedEmailParams = await findUserById(user.id); + + assert.isFalse(userReinitializedEmailParams!.emailConfirmed); + assert.isFalse(userReinitializedEmailParams!.emailConfirmationSent); + assert.isNotOk(userReinitializedEmailParams!.emailConfirmationSentAt); + assert.isNull(userReinitializedEmailParams!.emailConfirmationToken); + }); +} diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index a77b3008e..34a5bfcf1 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -1974,3 +1974,33 @@ export const fetchDonationMetricsQuery = ` } } `; + +export const userVerificationSendEmailConfirmation = ` + mutation userVerificationSendEmailConfirmation($userId: Float!){ + userVerificationSendEmailConfirmation(userId: $userId) { + id + email + emailConfirmed + emailConfirmationToken + emailConfirmationTokenExpiredAt + emailConfirmationSent + emailConfirmationSentAt + emailConfirmedAt + } + } +`; + +export const userVerificationConfirmEmail = ` + mutation userVerificationConfirmEmail($emailConfirmationToken: String!){ + userVerificationConfirmEmail(emailConfirmationToken: $emailConfirmationToken) { + id + email + emailConfirmed + emailConfirmationToken + emailConfirmationTokenExpiredAt + emailConfirmationSent + emailConfirmationSentAt + emailConfirmedAt + } + } +`; From 167725de11571c4947f1f81c80014fd6a67e4df9 Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 01:43:03 +0330 Subject: [PATCH 05/10] Add migration for adding new fields to user table --- ...83534955-AddUserEmailVerificationFields.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 migration/1723583534955-AddUserEmailVerificationFields.ts diff --git a/migration/1723583534955-AddUserEmailVerificationFields.ts b/migration/1723583534955-AddUserEmailVerificationFields.ts new file mode 100644 index 000000000..07921e89e --- /dev/null +++ b/migration/1723583534955-AddUserEmailVerificationFields.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserEmailVerificationFields1723583534955 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + ADD "emailConfirmationToken" character varying, + ADD "emailConfirmationTokenExpiredAt" TIMESTAMP, + ADD "emailConfirmed" boolean DEFAULT false, + ADD "emailConfirmationSent" boolean DEFAULT false, + ADD "emailConfirmationSentAt" TIMESTAMP, + ADD "emailConfirmedAt" TIMESTAMP; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + DROP COLUMN "emailConfirmationToken", + DROP COLUMN "emailConfirmationTokenExpiredAt", + DROP COLUMN "emailConfirmed", + DROP COLUMN "emailConfirmationSent", + DROP COLUMN "emailConfirmationSentAt", + DROP COLUMN "emailConfirmedAt"; + `); + } +} From 52075491161faa5059d10faf288d1565de7b581d Mon Sep 17 00:00:00 2001 From: Ali Ebrahimi <65724329+ae2079@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:38:29 +0330 Subject: [PATCH 06/10] change verification link to not having overlap with project email verification link Co-authored-by: Amin Latifi --- src/adapters/notifications/NotificationCenterAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index f030c566b..e0200e8a4 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -104,7 +104,7 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { segment: { payload: { email, - verificationLink: `${dappUrl}/verification/${user.walletAddress}/${token}`, + verificationLink: `${dappUrl}/verification/user/${user.walletAddress}/${token}`, }, }, }); From e99cf10be066539c04f50131105b17c1e54cda68 Mon Sep 17 00:00:00 2001 From: Ali Ebrahimi <65724329+ae2079@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:38:53 +0330 Subject: [PATCH 07/10] merge conditions Co-authored-by: Amin Latifi --- src/resolvers/userResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index f1cd89d3e..c89f2297a 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -243,7 +243,7 @@ export class UserResolver { ): Promise { try { const currentUserId = user?.userId; - if (!currentUserId) { + if (!currentUserId || currentUserId != userId) { throw new Error(i18n.__(translationErrorMessagesKeys.UN_AUTHORIZED)); } From 0e33d29eda0c7dc13dc26112b432ceb5bfd74f0e Mon Sep 17 00:00:00 2001 From: Ali Ebrahimi <65724329+ae2079@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:40:00 +0330 Subject: [PATCH 08/10] based on merging previous condition, remove this redundant one Co-authored-by: Amin Latifi --- src/resolvers/userResolver.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index c89f2297a..393f9c28b 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -252,9 +252,6 @@ export class UserResolver { if (!userToVerify) { throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); } - if (userToVerify.id !== currentUserId) { - throw new Error(i18n.__(translationErrorMessagesKeys.UN_AUTHORIZED)); - } const email = userToVerify.email; if (!email) { From 483543db8ef457156877cb9a3f2630358dd423fc Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 13:41:52 +0330 Subject: [PATCH 09/10] Reset email verification in email update --- src/entities/user.ts | 2 +- src/resolvers/userResolver.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/entities/user.ts b/src/entities/user.ts index 04e787622..0a0d3e17b 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -210,7 +210,7 @@ export class User extends BaseEntity { @Field(_type => Date, { nullable: true }) @Column({ nullable: true }) - emailConfirmedAt: Date; + emailConfirmedAt: Date | null; @Field(_type => Int, { nullable: true }) async donationsCount() { diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 393f9c28b..a6150ab55 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -180,6 +180,14 @@ export class UserResolver { if (!validateEmail(email)) { throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL)); } + if (dbUser.email !== email) { + dbUser.emailConfirmed = false; + dbUser.emailConfirmationSent = false; + dbUser.emailConfirmationToken = null; + dbUser.emailConfirmationTokenExpiredAt = null; + dbUser.emailConfirmationSentAt = null; + dbUser.emailConfirmedAt = null; + } dbUser.email = email; } if (url !== undefined) { From 99b640d54b101123fa78c6acd88d256cc8083044 Mon Sep 17 00:00:00 2001 From: ali ebrahimi Date: Wed, 14 Aug 2024 13:57:17 +0330 Subject: [PATCH 10/10] Add type to emailConfirmedAt field --- src/entities/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/user.ts b/src/entities/user.ts index 0a0d3e17b..6fae47a1d 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -209,7 +209,7 @@ export class User extends BaseEntity { emailConfirmationSentAt: Date | null; @Field(_type => Date, { nullable: true }) - @Column({ nullable: true }) + @Column({ type: 'timestamptz', nullable: true }) emailConfirmedAt: Date | null; @Field(_type => Int, { nullable: true })