From 4368c1847970defe663242b3df6970644a9ed662 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 26 Apr 2024 12:21:40 +0200 Subject: [PATCH 1/6] feat: User email verification after signing-up. --- .../src/api/controllers/Authentication.ts | 79 ++++ .../server/src/interfaces/Authentication.ts | 21 +- packages/server/src/loaders/eventEmitter.ts | 4 +- packages/server/src/loaders/jobs.ts | 2 + .../Authentication/AuthApplication.ts | 33 ++ .../src/services/Authentication/AuthSignup.ts | 3 + .../Authentication/AuthSignupConfirm.ts | 57 +++ .../Authentication/AuthSignupResend.ts | 35 ++ .../AuthenticationMailMessages.ts | 30 ++ .../src/services/Authentication/_constants.ts | 2 + .../events/SendVerfiyMailOnSignUp.ts | 30 ++ .../Authentication/jobs/SendVerifyMailJob.ts | 35 ++ packages/server/src/subscribers/events.ts | 3 + ...00821_add_confirmation_columns_to_users.js | 8 + .../server/src/system/models/SystemUser.ts | 20 +- .../server/views/mail/SignupVerifyEmail.html | 424 ++++++++++++++++++ 16 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/services/Authentication/AuthSignupConfirm.ts create mode 100644 packages/server/src/services/Authentication/AuthSignupResend.ts create mode 100644 packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts create mode 100644 packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts create mode 100644 packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js create mode 100644 packages/server/views/mail/SignupVerifyEmail.html diff --git a/packages/server/src/api/controllers/Authentication.ts b/packages/server/src/api/controllers/Authentication.ts index aae43f4b4..c162508ba 100644 --- a/packages/server/src/api/controllers/Authentication.ts +++ b/packages/server/src/api/controllers/Authentication.ts @@ -28,6 +28,20 @@ export default class AuthenticationController extends BaseController { asyncMiddleware(this.login.bind(this)), this.handlerErrors ); + router.post( + 'register/verify/resend', + [check('email').exists().isEmail()], + this.validationResult, + asyncMiddleware(this.registerVerifyResendMail.bind(this)), + this.handlerErrors + ); + router.post( + '/register/verify', + this.signupVerifySchema, + this.validationResult, + asyncMiddleware(this.registerVerify.bind(this)), + this.handlerErrors + ); router.post( '/register', this.registerSchema, @@ -99,6 +113,17 @@ export default class AuthenticationController extends BaseController { ]; } + private get signupVerifySchema(): ValidationChain[] { + return [ + check('email') + .exists() + .isString() + .isEmail() + .isLength({ max: DATATYPES_LENGTH.STRING }), + check('token').exists().isString(), + ]; + } + /** * Reset password schema. * @returns {ValidationChain[]} @@ -166,6 +191,60 @@ export default class AuthenticationController extends BaseController { } } + /** + * Verifies the provider user's email after signin-up. + * @param {Request} req + * @param {Response}| res + * @param {Function} next + * @returns {Response|void} + */ + private async registerVerify(req: Request, res: Response, next: Function) { + const signUpVerifyDTO = this.matchedBodyData(req); + + try { + const user = await this.authApplication.signUpConfirm( + signUpVerifyDTO.email, + signUpVerifyDTO.token + ); + return res.status(200).send({ + type: 'success', + message: 'The given user has verified successfully', + user, + }); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response}| res + * @param {Function} next + * @returns + */ + private async registerVerifyResendMail( + req: Request, + res: Response, + next: Function + ) { + const signUpVerifyDTO = this.matchedBodyData(req); + + try { + const user = await this.authApplication.signUpConfirm( + signUpVerifyDTO.email, + signUpVerifyDTO.token + ); + return res.status(200).send({ + type: 'success', + message: 'The given user has verified successfully', + user, + }); + } catch (error) { + next(error); + } + } + /** * Send reset password handler * @param {Request} req diff --git a/packages/server/src/interfaces/Authentication.ts b/packages/server/src/interfaces/Authentication.ts index 253c178f9..af29d0b5f 100644 --- a/packages/server/src/interfaces/Authentication.ts +++ b/packages/server/src/interfaces/Authentication.ts @@ -66,16 +66,27 @@ export interface IAuthResetedPasswordEventPayload { password: string; } - export interface IAuthSendingResetPassword { - user: ISystemUser, + user: ISystemUser; token: string; } export interface IAuthSendedResetPassword { - user: ISystemUser, + user: ISystemUser; token: string; } -export interface IAuthGetMetaPOJO { +export interface IAuthGetMetaPOJO { signupDisabled: boolean; -} \ No newline at end of file +} + +export interface IAuthSignUpVerifingEventPayload { + email: string; + verifyToken: string; + userId: number; +} + +export interface IAuthSignUpVerifiedEventPayload { + email: string; + verifyToken: string; + userId: number; +} diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 91d814f8a..9da52fd86 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -91,6 +91,7 @@ import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/s import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize'; import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete'; import { SubscribeFreeOnSignupCommunity } from '@/services/Subscription/events/SubscribeFreeOnSignupCommunity'; +import { SendVerfiyMailOnSignUp } from '@/services/Authentication/events/SendVerfiyMailOnSignUp'; export default () => { @@ -222,6 +223,7 @@ export const susbcribers = () => { DeleteCashflowTransactionOnUncategorize, PreventDeleteTransactionOnDelete, - SubscribeFreeOnSignupCommunity + SubscribeFreeOnSignupCommunity, + SendVerfiyMailOnSignUp ]; }; diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 58da23291..231149f48 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -12,6 +12,7 @@ import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleRe import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob'; import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob'; import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDeleteExpiredFilesJob'; +import { SendVerifyMailJob } from '@/services/Authentication/jobs/SendVerifyMailJob'; export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); @@ -27,6 +28,7 @@ export default ({ agenda }: { agenda: Agenda }) => { new PaymentReceiveMailNotificationJob(agenda); new PlaidFetchTransactionsJob(agenda); new ImportDeleteExpiredFilesJobs(agenda); + new SendVerifyMailJob(agenda); agenda.start().then(() => { agenda.every('1 hours', 'delete-expired-imported-files', {}); diff --git a/packages/server/src/services/Authentication/AuthApplication.ts b/packages/server/src/services/Authentication/AuthApplication.ts index 9fa74c973..bbc547f1f 100644 --- a/packages/server/src/services/Authentication/AuthApplication.ts +++ b/packages/server/src/services/Authentication/AuthApplication.ts @@ -9,6 +9,13 @@ import { AuthSigninService } from './AuthSignin'; import { AuthSignupService } from './AuthSignup'; import { AuthSendResetPassword } from './AuthSendResetPassword'; import { GetAuthMeta } from './GetAuthMeta'; +import { AuthSignupConfirmService } from './AuthSignupConfirm'; +import { SystemUser } from '@/system/models'; + +interface ISignupConfirmDTO { + token: string; + email: string; +} @Service() export default class AuthenticationApplication { @@ -18,6 +25,9 @@ export default class AuthenticationApplication { @Inject() private authSignupService: AuthSignupService; + @Inject() + private authSignupConfirmService: AuthSignupConfirmService; + @Inject() private authResetPasswordService: AuthSendResetPassword; @@ -44,6 +54,29 @@ export default class AuthenticationApplication { return this.authSignupService.signUp(signupDTO); } + /** + * Verfying the provided user's email after signin-up. + * @param {string} email + * @param {string} token + * @returns {Promise} + */ + public async signUpConfirm( + email: string, + token: string + ): Promise { + return this.authSignupConfirmService.signUpConfirm(email, token); + } + + /** + * + * @param {string} email + * @param {string} token + * @returns + */ + public async signUpConfirmSend(email: string, token: string) { + return this.authSignupConfirmService.signUpConfirm(email, token); + } + /** * Generates and retrieve password reset token for the given user email. * @param {string} email diff --git a/packages/server/src/services/Authentication/AuthSignup.ts b/packages/server/src/services/Authentication/AuthSignup.ts index b064a3d91..2be8c63a2 100644 --- a/packages/server/src/services/Authentication/AuthSignup.ts +++ b/packages/server/src/services/Authentication/AuthSignup.ts @@ -1,5 +1,6 @@ import { isEmpty, omit } from 'lodash'; import moment from 'moment'; +import crypto from 'crypto'; import { ServiceError } from '@/exceptions'; import { IAuthSignedUpEventPayload, @@ -41,6 +42,7 @@ export class AuthSignupService { await this.validateEmailUniqiness(signupDTO.email); const hashedPassword = await hashPassword(signupDTO.password); + const verifyToken = crypto.randomBytes(64).toString('hex'); // Triggers signin up event. await this.eventPublisher.emitAsync(events.auth.signingUp, { @@ -50,6 +52,7 @@ export class AuthSignupService { const tenant = await this.tenantsManager.createTenant(); const registeredUser = await systemUserRepository.create({ ...omit(signupDTO, 'country'), + verifyToken, active: true, password: hashedPassword, tenantId: tenant.id, diff --git a/packages/server/src/services/Authentication/AuthSignupConfirm.ts b/packages/server/src/services/Authentication/AuthSignupConfirm.ts new file mode 100644 index 000000000..940b6ae7f --- /dev/null +++ b/packages/server/src/services/Authentication/AuthSignupConfirm.ts @@ -0,0 +1,57 @@ +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { SystemUser } from '@/system/models'; +import { ERRORS } from './_constants'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { + IAuthSignUpVerifiedEventPayload, + IAuthSignUpVerifingEventPayload, +} from '@/interfaces'; + +@Service() +export class AuthSignupConfirmService { + @Inject() + private eventPublisher: EventPublisher; + + /** + * Verifies the provided user's email after signing-up. + * @throws {ServiceErrors} + * @param {IRegisterDTO} signupDTO + * @returns {Promise} + */ + public async signUpConfirm( + email: string, + verifyToken: string + ): Promise { + const foundUser = await SystemUser.query().findOne({ email, verifyToken }); + + if (!foundUser) { + throw new ServiceError(ERRORS.SIGNUP_CONFIRM_TOKEN_INVALID); + } + const userId = foundUser.id; + + // Triggers `signUpConfirming` event. + await this.eventPublisher.emitAsync(events.auth.signUpConfirming, { + email, + verifyToken, + userId, + } as IAuthSignUpVerifingEventPayload); + + const updatedUser = await SystemUser.query().patchAndFetchById( + foundUser.id, + { + verified: true, + verifyToken: '', + } + ); + // Triggers `signUpConfirmed` event. + await this.eventPublisher.emitAsync(events.auth.signUpConfirmed, { + email, + verifyToken, + userId, + } as IAuthSignUpVerifiedEventPayload); + + return updatedUser; + } +} diff --git a/packages/server/src/services/Authentication/AuthSignupResend.ts b/packages/server/src/services/Authentication/AuthSignupResend.ts new file mode 100644 index 000000000..581a20236 --- /dev/null +++ b/packages/server/src/services/Authentication/AuthSignupResend.ts @@ -0,0 +1,35 @@ +import { ServiceError } from '@/exceptions'; +import { SystemUser } from '@/system/models'; +import { Inject, Service } from 'typedi'; +import { ERRORS } from './_constants'; + +@Service() +export class AuthSignupConfirmResend { + @Inject('agenda') + private agenda: any; + + /** + * + * @param {number} tenantId + * @param {string} email + */ + public async signUpConfirmResend(email: string) { + const user = await SystemUser.query() + .findOne({ email }) + .throwIfNotFound(); + + // + if (user.verified) { + throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED) + } + if (user.verifyToken) { + throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED); + } + const payload = { + email: user.email, + token: user.verifyToken, + fullName: user.firstName, + }; + await this.agenda.now('send-signup-verify-mail', payload); + } +} diff --git a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts index c8c26ea0d..e974d22b9 100644 --- a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts +++ b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts @@ -33,4 +33,34 @@ export default class AuthenticationMailMesssages { }) .send(); } + + /** + * Sends signup verification mail. + * @param {string} email - Email address + * @param {string} fullName - User name. + * @param {string} token - Verification token. + * @returns {Promise} + */ + public async sendSignupVerificationMail( + email: string, + fullName: string, + token: string, + ) { + await new Mail() + .setSubject('Bigcapital - Verify your email') + .setView('mail/SignupVerifyEmail.html') + .setTo(email) + .setAttachments([ + { + filename: 'bigcapital.png', + path: `${global.__views_dir}/images/bigcapital.png`, + cid: 'bigcapital_logo', + }, + ]) + .setData({ + verifyUrl: `${config.baseURL}/auth/reset_password/${token}`, + fullName, + }) + .send(); + } } diff --git a/packages/server/src/services/Authentication/_constants.ts b/packages/server/src/services/Authentication/_constants.ts index 8c62dbe6c..506525779 100644 --- a/packages/server/src/services/Authentication/_constants.ts +++ b/packages/server/src/services/Authentication/_constants.ts @@ -9,4 +9,6 @@ export const ERRORS = { EMAIL_EXISTS: 'EMAIL_EXISTS', SIGNUP_RESTRICTED_NOT_ALLOWED: 'SIGNUP_RESTRICTED_NOT_ALLOWED', SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED', + SIGNUP_CONFIRM_TOKEN_INVALID: 'SIGNUP_CONFIRM_TOKEN_INVALID', + USER_ALREADY_VERIFIED: 'USER_ALREADY_VERIFIED', }; diff --git a/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts b/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts new file mode 100644 index 000000000..14f9aaa07 --- /dev/null +++ b/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts @@ -0,0 +1,30 @@ +import { IAuthSignedUpEventPayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { Inject } from 'typedi'; + +export class SendVerfiyMailOnSignUp { + @Inject('agenda') + private agenda: any; + + /** + * Attaches events with handles. + */ + public attach(bus) { + bus.subscribe(events.auth.signUp, this.handleSendVerifyMailOnSignup); + } + + /** + * + * @param {ITaxRateEditedPayload} payload - + */ + private handleSendVerifyMailOnSignup = async ({ + user, + }: IAuthSignedUpEventPayload) => { + const payload = { + email: user.email, + token: user.verifyToken, + fullName: user.firstName, + }; + await this.agenda.now('send-signup-verify-mail', payload); + }; +} diff --git a/packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts b/packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts new file mode 100644 index 000000000..8e09053da --- /dev/null +++ b/packages/server/src/services/Authentication/jobs/SendVerifyMailJob.ts @@ -0,0 +1,35 @@ +import { Container } from 'typedi'; +import AuthenticationMailMesssages from '@/services/Authentication/AuthenticationMailMessages'; + +export class SendVerifyMailJob { + /** + * Constructor method. + * @param {Agenda} agenda + */ + constructor(agenda) { + agenda.define( + 'send-signup-verify-mail', + { priority: 'high' }, + this.handler.bind(this) + ); + } + + /** + * Handle send welcome mail job. + * @param {Job} job + * @param {Function} done + */ + public async handler(job, done: Function): Promise { + const { data } = job.attrs; + const { email, fullName, token } = data; + const authService = Container.get(AuthenticationMailMesssages); + + try { + await authService.sendSignupVerificationMail(email, fullName, token); + done(); + } catch (error) { + console.log(error); + done(error); + } + } +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 0243cd172..b96cadf95 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -9,6 +9,9 @@ export default { signUp: 'onSignUp', signingUp: 'onSigningUp', + signUpConfirming: 'signUpConfirming', + signUpConfirmed: 'signUpConfirmed', + sendingResetPassword: 'onSendingResetPassword', sendResetPassword: 'onSendResetPassword', diff --git a/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js new file mode 100644 index 000000000..125f508f5 --- /dev/null +++ b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js @@ -0,0 +1,8 @@ +exports.up = function (knex) { + return knex.schema.table('users', (table) => { + table.string('verify_token'); + table.boolean('verified').defaultTo(false); + }); +}; + +exports.down = (knex) => {}; diff --git a/packages/server/src/system/models/SystemUser.ts b/packages/server/src/system/models/SystemUser.ts index a341ccedf..627caaeb6 100644 --- a/packages/server/src/system/models/SystemUser.ts +++ b/packages/server/src/system/models/SystemUser.ts @@ -4,6 +4,12 @@ import SystemModel from '@/system/models/SystemModel'; import SoftDeleteQueryBuilder from '@/collection/SoftDeleteQueryBuilder'; export default class SystemUser extends SystemModel { + firstName!: string; + lastName!: string; + verified!: boolean; + inviteAcceptedAt!: Date | null; + deletedAt!: Date | null; + /** * Table name. */ @@ -33,19 +39,29 @@ export default class SystemUser extends SystemModel { } /** - * + * Detarmines whether the user is deleted. + * @returns {boolean} */ get isDeleted() { return !!this.deletedAt; } /** - * + * Detarmines whether the sent invite is accepted. + * @returns {boolean} */ get isInviteAccepted() { return !!this.inviteAcceptedAt; } + /** + * Detarmines whether the user's email is verified. + * @returns {boolean} + */ + get isVerified() { + return !!this.verified; + } + /** * Full name attribute. */ diff --git a/packages/server/views/mail/SignupVerifyEmail.html b/packages/server/views/mail/SignupVerifyEmail.html new file mode 100644 index 000000000..637e78c12 --- /dev/null +++ b/packages/server/views/mail/SignupVerifyEmail.html @@ -0,0 +1,424 @@ + + + + + + Bigcapital | Reset your password + + + + Verify your email. + + + + + + + + + From b9fc0cdd9e3b52dbc541827dfc3b7bb0b25f6722 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Apr 2024 17:51:11 +0200 Subject: [PATCH 2/6] feat: wip email confirmation --- .../src/api/controllers/Authentication.ts | 21 +++--- .../Authentication/AuthApplication.ts | 4 + .../Authentication/AuthSignupResend.ts | 10 +-- packages/webapp/src/components/App.tsx | 26 ++++++- .../Guards/EnsureAuthNotAuthenticated.tsx | 14 ++++ .../Guards/EnsureUserEmailVerified.tsx | 22 ++++++ .../src/components/Guards/PrivateRoute.tsx | 18 ++--- .../Authentication/AuthContainer.tsx | 34 +++++++++ .../Authentication/Authentication.tsx | 9 +-- .../Authentication/EmailConfirmation.tsx | 27 +++++++ .../Authentication/RegisterVerify.module.scss | 18 +++++ .../Authentication/RegisterVerify.tsx | 74 +++++++++++++++++++ .../webapp/src/hooks/query/authentication.tsx | 27 +++++++ .../webapp/src/hooks/state/authentication.tsx | 7 ++ packages/webapp/src/routes/authentication.tsx | 8 +- 15 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx create mode 100644 packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx create mode 100644 packages/webapp/src/containers/Authentication/AuthContainer.tsx create mode 100644 packages/webapp/src/containers/Authentication/EmailConfirmation.tsx create mode 100644 packages/webapp/src/containers/Authentication/RegisterVerify.module.scss create mode 100644 packages/webapp/src/containers/Authentication/RegisterVerify.tsx diff --git a/packages/server/src/api/controllers/Authentication.ts b/packages/server/src/api/controllers/Authentication.ts index c162508ba..87ac2166c 100644 --- a/packages/server/src/api/controllers/Authentication.ts +++ b/packages/server/src/api/controllers/Authentication.ts @@ -9,6 +9,8 @@ import { DATATYPES_LENGTH } from '@/data/DataTypes'; import LoginThrottlerMiddleware from '@/api/middleware/LoginThrottlerMiddleware'; import AuthenticationApplication from '@/services/Authentication/AuthApplication'; +import JWTAuth from '@/api/middleware/jwtAuth'; +import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; @Service() export default class AuthenticationController extends BaseController { @Inject() @@ -28,10 +30,10 @@ export default class AuthenticationController extends BaseController { asyncMiddleware(this.login.bind(this)), this.handlerErrors ); + router.use('/register/verify/resend', JWTAuth); + router.use('/register/verify/resend', AttachCurrentTenantUser); router.post( - 'register/verify/resend', - [check('email').exists().isEmail()], - this.validationResult, + '/register/verify/resend', asyncMiddleware(this.registerVerifyResendMail.bind(this)), this.handlerErrors ); @@ -199,7 +201,8 @@ export default class AuthenticationController extends BaseController { * @returns {Response|void} */ private async registerVerify(req: Request, res: Response, next: Function) { - const signUpVerifyDTO = this.matchedBodyData(req); + const signUpVerifyDTO: { email: string; token: string } = + this.matchedBodyData(req); try { const user = await this.authApplication.signUpConfirm( @@ -228,17 +231,15 @@ export default class AuthenticationController extends BaseController { res: Response, next: Function ) { - const signUpVerifyDTO = this.matchedBodyData(req); + const { user } = req; try { - const user = await this.authApplication.signUpConfirm( - signUpVerifyDTO.email, - signUpVerifyDTO.token - ); + const data = await this.authApplication.signUpConfirm(user.id); + return res.status(200).send({ type: 'success', message: 'The given user has verified successfully', - user, + data, }); } catch (error) { next(error); diff --git a/packages/server/src/services/Authentication/AuthApplication.ts b/packages/server/src/services/Authentication/AuthApplication.ts index bbc547f1f..47f2168a3 100644 --- a/packages/server/src/services/Authentication/AuthApplication.ts +++ b/packages/server/src/services/Authentication/AuthApplication.ts @@ -11,6 +11,7 @@ import { AuthSendResetPassword } from './AuthSendResetPassword'; import { GetAuthMeta } from './GetAuthMeta'; import { AuthSignupConfirmService } from './AuthSignupConfirm'; import { SystemUser } from '@/system/models'; +import { AuthSignupConfirmResend } from './AuthSignupResend'; interface ISignupConfirmDTO { token: string; @@ -28,6 +29,9 @@ export default class AuthenticationApplication { @Inject() private authSignupConfirmService: AuthSignupConfirmService; + @Inject() + private authSignUpConfirmResendService: AuthSignupConfirmResend; + @Inject() private authResetPasswordService: AuthSendResetPassword; diff --git a/packages/server/src/services/Authentication/AuthSignupResend.ts b/packages/server/src/services/Authentication/AuthSignupResend.ts index 581a20236..5c764e80c 100644 --- a/packages/server/src/services/Authentication/AuthSignupResend.ts +++ b/packages/server/src/services/Authentication/AuthSignupResend.ts @@ -13,14 +13,12 @@ export class AuthSignupConfirmResend { * @param {number} tenantId * @param {string} email */ - public async signUpConfirmResend(email: string) { - const user = await SystemUser.query() - .findOne({ email }) - .throwIfNotFound(); + public async signUpConfirmResend(userId: number) { + const user = await SystemUser.query().findById(userId).throwIfNotFound(); - // + // if (user.verified) { - throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED) + throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED); } if (user.verifyToken) { throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED); diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx index 949a1861f..57effb72a 100644 --- a/packages/webapp/src/components/App.tsx +++ b/packages/webapp/src/components/App.tsx @@ -14,8 +14,15 @@ import GlobalErrors from '@/containers/GlobalErrors/GlobalErrors'; import DashboardPrivatePages from '@/components/Dashboard/PrivatePages'; import { Authentication } from '@/containers/Authentication/Authentication'; +import LazyLoader from '@/components/LazyLoader'; import { SplashScreen, DashboardThemeProvider } from '../components'; import { queryConfig } from '../hooks/query/base'; +import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified'; +import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated'; + +const RegisterVerify = LazyLoader({ + loader: () => import('@/containers/Authentication/RegisterVerify'), +}); /** * App inner. @@ -26,9 +33,24 @@ function AppInsider({ history }) { - + + + + + + + + + + + + - + + + + + diff --git a/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx new file mode 100644 index 000000000..e849a572c --- /dev/null +++ b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { useIsAuthenticated } from '@/hooks/state'; + +interface PrivateRouteProps { + children: React.ReactNode; +} + +export function EnsureAuthNotAuthenticated({ children }: PrivateRouteProps) { + const isAuthenticated = useIsAuthenticated(); + + return !isAuthenticated ? children : ; +} diff --git a/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx new file mode 100644 index 000000000..6fb57c4a5 --- /dev/null +++ b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { useAuthUserVerified } from '@/hooks/state'; + +interface EnsureUserEmailVerifiedProps { + children: React.ReactNode; +} + +/** + * Higher Order Component to ensure that the user's email is verified. + * If not verified, redirects to the email verification page. + */ +export function EnsureUserEmailVerified({ + children, +}: EnsureUserEmailVerifiedProps) { + const isAuthVerified = useAuthUserVerified(); + + if (!isAuthVerified) { + return ; + } + return <>{children}; +} diff --git a/packages/webapp/src/components/Guards/PrivateRoute.tsx b/packages/webapp/src/components/Guards/PrivateRoute.tsx index 8e8e167b9..a4ef8d3b7 100644 --- a/packages/webapp/src/components/Guards/PrivateRoute.tsx +++ b/packages/webapp/src/components/Guards/PrivateRoute.tsx @@ -4,16 +4,16 @@ import BodyClassName from 'react-body-classname'; import { Redirect } from 'react-router-dom'; import { useIsAuthenticated } from '@/hooks/state'; -export default function PrivateRoute({ component: Component, ...rest }) { +interface PrivateRouteProps { + children: React.ReactNode; +} + +export default function PrivateRoute({ children }: PrivateRouteProps) { const isAuthenticated = useIsAuthenticated(); - return ( - - {isAuthenticated ? ( - - ) : ( - - )} - + return isAuthenticated ? ( + children + ) : ( + ); } diff --git a/packages/webapp/src/containers/Authentication/AuthContainer.tsx b/packages/webapp/src/containers/Authentication/AuthContainer.tsx new file mode 100644 index 000000000..6ba68c196 --- /dev/null +++ b/packages/webapp/src/containers/Authentication/AuthContainer.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Icon, FormattedMessage as T } from '@/components'; + +interface AuthContainerProps { + children: React.ReactNode; +} + +export function AuthContainer({ children }: AuthContainerProps) { + return ( + + + + + + + {children} + + + ); +} + +const AuthPage = styled.div``; +const AuthInsider = styled.div` + width: 384px; + margin: 0 auto; + margin-bottom: 40px; + padding-top: 80px; +`; + +const AuthLogo = styled.div` + text-align: center; + margin-bottom: 40px; +`; diff --git a/packages/webapp/src/containers/Authentication/Authentication.tsx b/packages/webapp/src/containers/Authentication/Authentication.tsx index 8b05570c4..34a99f104 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -1,24 +1,17 @@ // @ts-nocheck import React from 'react'; -import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; +import { Route, Switch, useLocation } from 'react-router-dom'; import BodyClassName from 'react-body-classname'; import styled from 'styled-components'; import { TransitionGroup, CSSTransition } from 'react-transition-group'; import authenticationRoutes from '@/routes/authentication'; import { Icon, FormattedMessage as T } from '@/components'; -import { useIsAuthenticated } from '@/hooks/state'; import { AuthMetaBootProvider } from './AuthMetaBoot'; import '@/style/pages/Authentication/Auth.scss'; export function Authentication() { - const to = { pathname: '/' }; - const isAuthenticated = useIsAuthenticated(); - - if (isAuthenticated) { - return ; - } return ( diff --git a/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx new file mode 100644 index 000000000..c9aeb985e --- /dev/null +++ b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx @@ -0,0 +1,27 @@ +// @ts-nocheck +import { useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { useAuthSignUpVerify } from '@/hooks/query'; + +export default function EmailConfirmation() { + const { mutateAsync: authSignupVerify } = useAuthSignUpVerify(); + const params = useParams(); + const history = useHistory(); + + const token = params.token; + const email = params.email; + + useEffect(() => { + if (!token || !email) { + history.push('register/email_confirmation'); + } + }, [history, token, email]); + + useEffect(() => { + authSignupVerify(token, email) + .then(() => {}) + .catch((error) => {}); + }, [token, email, authSignupVerify]); + + return null; +} diff --git a/packages/webapp/src/containers/Authentication/RegisterVerify.module.scss b/packages/webapp/src/containers/Authentication/RegisterVerify.module.scss new file mode 100644 index 000000000..f0754f470 --- /dev/null +++ b/packages/webapp/src/containers/Authentication/RegisterVerify.module.scss @@ -0,0 +1,18 @@ + +.root { + text-align: center; +} + +.title{ + font-size: 18px; + font-weight: 600; + margin-bottom: 0.5rem; + color: #252A31; +} + +.description{ + margin-bottom: 1rem; + font-size: 15px; + line-height: 1.45; + color: #404854; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx new file mode 100644 index 000000000..2147a8f2e --- /dev/null +++ b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import { Button, Intent } from '@blueprintjs/core'; +import AuthInsider from './AuthInsider'; +import { AuthInsiderCard } from './_components'; +import styles from './RegisterVerify.module.scss'; +import { AppToaster, Stack } from '@/components'; +import { useAuthActions } from '@/hooks/state'; +import { useAuthSignUpVerifyResendMail } from '@/hooks/query'; +import { AuthContainer } from './AuthContainer'; +import { useHistory } from 'react-router-dom'; + +export default function RegisterVerify() { + const history = useHistory(); + const { setLogout } = useAuthActions(); + const { mutateAsync: resendSignUpVerifyMail, isLoading } = + useAuthSignUpVerifyResendMail(); + + const handleResendMailBtnClick = () => { + resendSignUpVerifyMail() + .then(() => { + AppToaster.show({ + intent: Intent.SUCCESS, + message: 'The verification mail has sent successfully.', + }); + }) + .catch(() => { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Something went wrong.', + }); + }); + }; + + // Handle logout link click. + const handleSignOutBtnClick = () => { + setLogout(); + }; + + return ( + + + +

Please verify your email

+

+ We sent an email to asdahmed@gmail.com Click the + link inside to get started. +

+ + + + + + +
+
+
+ ); +} diff --git a/packages/webapp/src/hooks/query/authentication.tsx b/packages/webapp/src/hooks/query/authentication.tsx index a604ec322..d43d244c1 100644 --- a/packages/webapp/src/hooks/query/authentication.tsx +++ b/packages/webapp/src/hooks/query/authentication.tsx @@ -90,3 +90,30 @@ export const useAuthMetadata = (props) => { }, ); } + + +/** + * + */ +export const useAuthSignUpVerifyResendMail = (props) => { + const apiRequest = useApiRequest(); + + return useMutation( + () => apiRequest.post('auth/register/verify/resend'), + props, + ); +}; + + + +/** + * + */ +export const useAuthSignUpVerify = (props) => { + const apiRequest = useApiRequest(); + + return useMutation( + (token: string, email: string) => apiRequest.post('auth/register/verify'), + props, + ); +}; \ No newline at end of file diff --git a/packages/webapp/src/hooks/state/authentication.tsx b/packages/webapp/src/hooks/state/authentication.tsx index 4fb20bf6c..b520e1ac1 100644 --- a/packages/webapp/src/hooks/state/authentication.tsx +++ b/packages/webapp/src/hooks/state/authentication.tsx @@ -64,3 +64,10 @@ export const useAuthUser = () => { export const useAuthOrganizationId = () => { return useSelector((state) => state.authentication.organizationId); }; + +/** + * + */ +export const useAuthUserVerified = () => { + return useSelector(() => false); +}; diff --git a/packages/webapp/src/routes/authentication.tsx b/packages/webapp/src/routes/authentication.tsx index 77b1fce6f..7ce79dbbc 100644 --- a/packages/webapp/src/routes/authentication.tsx +++ b/packages/webapp/src/routes/authentication.tsx @@ -28,10 +28,16 @@ export default [ loader: () => import('@/containers/Authentication/InviteAccept'), }), }, + { + path: `${BASE_URL}/register/email_confirmation`, + component: LazyLoader({ + loader: () => import('@/containers/Authentication/EmailConfirmation'), + }), + }, { path: `${BASE_URL}/register`, component: LazyLoader({ loader: () => import('@/containers/Authentication/Register'), }), - } + }, ]; From cb88c234d1aec8da8d10262ac01485ff32a0273d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 3 May 2024 16:00:31 +0200 Subject: [PATCH 3/6] feat: sync the isVerified state of authed user --- .../server/src/system/models/SystemUser.ts | 2 +- packages/webapp/src/components/App.tsx | 25 +++++++----- .../components/Dashboard/DashboardBoot.tsx | 10 ++++- .../Guards/EnsureAuthNotAuthenticated.tsx | 14 +++++-- ...ivateRoute.tsx => EnsureAuthenticated.tsx} | 13 ++++--- .../Guards/EnsureUserEmailVerified.tsx | 4 +- .../Authentication/Authentication.tsx | 1 - .../Authentication/EmailConfirmation.tsx | 39 ++++++++++++++----- .../Authentication/RegisterVerify.tsx | 8 +--- .../webapp/src/hooks/query/authentication.tsx | 21 +++++----- packages/webapp/src/hooks/query/users.tsx | 7 +++- .../webapp/src/hooks/state/authentication.tsx | 21 ++++++++-- .../authentication/authentication.actions.tsx | 6 ++- .../authentication/authentication.reducer.tsx | 13 ++++++- .../authentication/authentication.types.tsx | 1 + 15 files changed, 133 insertions(+), 52 deletions(-) rename packages/webapp/src/components/Guards/{PrivateRoute.tsx => EnsureAuthenticated.tsx} (52%) diff --git a/packages/server/src/system/models/SystemUser.ts b/packages/server/src/system/models/SystemUser.ts index 627caaeb6..ce17186df 100644 --- a/packages/server/src/system/models/SystemUser.ts +++ b/packages/server/src/system/models/SystemUser.ts @@ -35,7 +35,7 @@ export default class SystemUser extends SystemModel { * Virtual attributes. */ static get virtualAttributes() { - return ['fullName', 'isDeleted', 'isInviteAccepted']; + return ['fullName', 'isDeleted', 'isInviteAccepted', 'isVerified']; } /** diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx index 57effb72a..856f83686 100644 --- a/packages/webapp/src/components/App.tsx +++ b/packages/webapp/src/components/App.tsx @@ -9,7 +9,7 @@ import 'moment/locale/ar-ly'; import 'moment/locale/es-us'; import AppIntlLoader from './AppIntlLoader'; -import PrivateRoute from '@/components/Guards/PrivateRoute'; +import { EnsureAuthenticated } from '@/components/Guards/EnsureAuthenticated'; import GlobalErrors from '@/containers/GlobalErrors/GlobalErrors'; import DashboardPrivatePages from '@/components/Dashboard/PrivatePages'; import { Authentication } from '@/containers/Authentication/Authentication'; @@ -20,6 +20,9 @@ import { queryConfig } from '../hooks/query/base'; import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified'; import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated'; +const EmailConfirmation = LazyLoader({ + loader: () => import('@/containers/Authentication/EmailConfirmation'), +}); const RegisterVerify = LazyLoader({ loader: () => import('@/containers/Authentication/RegisterVerify'), }); @@ -33,24 +36,28 @@ function AppInsider({ history }) { + + + + + + + + + + - - - - - - - + - + diff --git a/packages/webapp/src/components/Dashboard/DashboardBoot.tsx b/packages/webapp/src/components/Dashboard/DashboardBoot.tsx index bae18273e..0d105c112 100644 --- a/packages/webapp/src/components/Dashboard/DashboardBoot.tsx +++ b/packages/webapp/src/components/Dashboard/DashboardBoot.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useEffect } from 'react'; import { useAuthenticatedAccount, useCurrentOrganization, @@ -116,6 +116,14 @@ export function useApplicationBoot() { isBooted.current = true; }, ); + // Reset the loading states once the hook unmount. + useEffect( + () => () => { + isAuthUserLoading && !isBooted.current && stopLoading(); + isOrgLoading && !isBooted.current && stopLoading(); + }, + [isAuthUserLoading, isOrgLoading, stopLoading], + ); return { isLoading: isOrgLoading || isAuthUserLoading, diff --git a/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx index e849a572c..97539a32d 100644 --- a/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx +++ b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx @@ -3,12 +3,20 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; import { useIsAuthenticated } from '@/hooks/state'; -interface PrivateRouteProps { +interface EnsureAuthNotAuthenticatedProps { children: React.ReactNode; + redirectTo?: string; } -export function EnsureAuthNotAuthenticated({ children }: PrivateRouteProps) { +export function EnsureAuthNotAuthenticated({ + children, + redirectTo = '/', +}: EnsureAuthNotAuthenticatedProps) { const isAuthenticated = useIsAuthenticated(); - return !isAuthenticated ? children : ; + return !isAuthenticated ? ( + <>{children} + ) : ( + + ); } diff --git a/packages/webapp/src/components/Guards/PrivateRoute.tsx b/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx similarity index 52% rename from packages/webapp/src/components/Guards/PrivateRoute.tsx rename to packages/webapp/src/components/Guards/EnsureAuthenticated.tsx index a4ef8d3b7..0a223a9ae 100644 --- a/packages/webapp/src/components/Guards/PrivateRoute.tsx +++ b/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx @@ -1,19 +1,22 @@ // @ts-nocheck import React from 'react'; -import BodyClassName from 'react-body-classname'; import { Redirect } from 'react-router-dom'; import { useIsAuthenticated } from '@/hooks/state'; -interface PrivateRouteProps { +interface EnsureAuthenticatedProps { children: React.ReactNode; + redirectTo?: string; } -export default function PrivateRoute({ children }: PrivateRouteProps) { +export function EnsureAuthenticated({ + children, + redirectTo = '/auth/login', +}: EnsureAuthenticatedProps) { const isAuthenticated = useIsAuthenticated(); return isAuthenticated ? ( - children + <>{children} ) : ( - + ); } diff --git a/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx index 6fb57c4a5..f24d93533 100644 --- a/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx +++ b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx @@ -4,6 +4,7 @@ import { useAuthUserVerified } from '@/hooks/state'; interface EnsureUserEmailVerifiedProps { children: React.ReactNode; + redirectTo?: string; } /** @@ -12,11 +13,12 @@ interface EnsureUserEmailVerifiedProps { */ export function EnsureUserEmailVerified({ children, + redirectTo = '/auth/register/verify', }: EnsureUserEmailVerifiedProps) { const isAuthVerified = useAuthUserVerified(); if (!isAuthVerified) { - return ; + return ; } return <>{children}; } diff --git a/packages/webapp/src/containers/Authentication/Authentication.tsx b/packages/webapp/src/containers/Authentication/Authentication.tsx index 34a99f104..408dc78f0 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import { Route, Switch, useLocation } from 'react-router-dom'; import BodyClassName from 'react-body-classname'; import styled from 'styled-components'; diff --git a/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx index c9aeb985e..e7fe66d5e 100644 --- a/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx +++ b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx @@ -1,27 +1,46 @@ // @ts-nocheck -import { useEffect } from 'react'; -import { useHistory, useParams } from 'react-router-dom'; +import { useEffect, useMemo } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; import { useAuthSignUpVerify } from '@/hooks/query'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; + +function useQuery() { + const { search } = useLocation(); + return useMemo(() => new URLSearchParams(search), [search]); +} export default function EmailConfirmation() { const { mutateAsync: authSignupVerify } = useAuthSignUpVerify(); - const params = useParams(); const history = useHistory(); + const query = useQuery(); - const token = params.token; - const email = params.email; + const token = query.get('token'); + const email = query.get('email'); useEffect(() => { if (!token || !email) { - history.push('register/email_confirmation'); + history.push('/auth/login'); } }, [history, token, email]); useEffect(() => { - authSignupVerify(token, email) - .then(() => {}) - .catch((error) => {}); - }, [token, email, authSignupVerify]); + authSignupVerify({ token, email }) + .then(() => { + AppToaster.show({ + message: 'Your email has been verified, Congrats!', + intent: Intent.SUCCESS, + }); + history.push('/'); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong', + intent: Intent.DANGER, + }); + history.push('/'); + }); + }, [token, email, authSignupVerify, history]); return null; } diff --git a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx index 2147a8f2e..a707bfdb0 100644 --- a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx +++ b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx @@ -7,10 +7,8 @@ import { AppToaster, Stack } from '@/components'; import { useAuthActions } from '@/hooks/state'; import { useAuthSignUpVerifyResendMail } from '@/hooks/query'; import { AuthContainer } from './AuthContainer'; -import { useHistory } from 'react-router-dom'; export default function RegisterVerify() { - const history = useHistory(); const { setLogout } = useAuthActions(); const { mutateAsync: resendSignUpVerifyMail, isLoading } = useAuthSignUpVerifyResendMail(); @@ -30,8 +28,6 @@ export default function RegisterVerify() { }); }); }; - - // Handle logout link click. const handleSignOutBtnClick = () => { setLogout(); }; @@ -60,11 +56,11 @@ export default function RegisterVerify() { diff --git a/packages/webapp/src/hooks/query/authentication.tsx b/packages/webapp/src/hooks/query/authentication.tsx index d43d244c1..7f48d78b2 100644 --- a/packages/webapp/src/hooks/query/authentication.tsx +++ b/packages/webapp/src/hooks/query/authentication.tsx @@ -78,7 +78,7 @@ export const useAuthResetPassword = (props) => { */ export const useAuthMetadata = (props) => { return useRequestQuery( - [t.AUTH_METADATA_PAGE,], + [t.AUTH_METADATA_PAGE], { method: 'get', url: `auth/meta`, @@ -88,12 +88,11 @@ export const useAuthMetadata = (props) => { defaultData: {}, ...props, }, - ); -} - + ); +}; /** - * + * */ export const useAuthSignUpVerifyResendMail = (props) => { const apiRequest = useApiRequest(); @@ -104,16 +103,20 @@ export const useAuthSignUpVerifyResendMail = (props) => { ); }; - +interface AuthSignUpVerifyValues { + token: string; + email: string; +} /** - * + * */ export const useAuthSignUpVerify = (props) => { const apiRequest = useApiRequest(); return useMutation( - (token: string, email: string) => apiRequest.post('auth/register/verify'), + (values: AuthSignUpVerifyValues) => + apiRequest.post('auth/register/verify', values), props, ); -}; \ No newline at end of file +}; diff --git a/packages/webapp/src/hooks/query/users.tsx b/packages/webapp/src/hooks/query/users.tsx index 73bcf0691..646308353 100644 --- a/packages/webapp/src/hooks/query/users.tsx +++ b/packages/webapp/src/hooks/query/users.tsx @@ -5,6 +5,7 @@ import { useQueryTenant, useRequestQuery } from '../useQueryRequest'; import useApiRequest from '../useRequest'; import { useSetFeatureDashboardMeta } from '../state/feature'; import t from './types'; +import { useSetAuthEmailConfirmed } from '../state'; // Common invalidate queries. const commonInvalidateQueries = (queryClient) => { @@ -130,6 +131,8 @@ export function useUser(id, props) { } export function useAuthenticatedAccount(props) { + const setEmailConfirmed = useSetAuthEmailConfirmed(); + return useRequestQuery( ['AuthenticatedAccount'], { @@ -139,6 +142,9 @@ export function useAuthenticatedAccount(props) { { select: (response) => response.data.data, defaultData: {}, + onSuccess: (data) => { + setEmailConfirmed(data.is_verified); + }, ...props, }, ); @@ -166,4 +172,3 @@ export const useDashboardMeta = (props) => { }, [state.isSuccess, state.data, setFeatureDashboardMeta]); return state; }; - diff --git a/packages/webapp/src/hooks/state/authentication.tsx b/packages/webapp/src/hooks/state/authentication.tsx index b520e1ac1..73054c663 100644 --- a/packages/webapp/src/hooks/state/authentication.tsx +++ b/packages/webapp/src/hooks/state/authentication.tsx @@ -2,7 +2,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { useCallback } from 'react'; import { isAuthenticated } from '@/store/authentication/authentication.reducer'; -import { setLogin } from '@/store/authentication/authentication.actions'; +import { + setEmailConfirmed, + setLogin, +} from '@/store/authentication/authentication.actions'; import { useQueryClient } from 'react-query'; import { removeCookie } from '@/utils'; @@ -66,8 +69,20 @@ export const useAuthOrganizationId = () => { }; /** - * + * Retrieves the user's email verification status. */ export const useAuthUserVerified = () => { - return useSelector(() => false); + return useSelector((state) => state.authentication.verified); +}; + +/** + * Sets the user's email verification status. + */ +export const useSetAuthEmailConfirmed = () => { + const dispatch = useDispatch(); + + return useCallback( + (verified?: boolean = true) => dispatch(setEmailConfirmed(verified)), + [dispatch], + ); }; diff --git a/packages/webapp/src/store/authentication/authentication.actions.tsx b/packages/webapp/src/store/authentication/authentication.actions.tsx index 4d2d5f048..daefd8f48 100644 --- a/packages/webapp/src/store/authentication/authentication.actions.tsx +++ b/packages/webapp/src/store/authentication/authentication.actions.tsx @@ -3,4 +3,8 @@ import t from '@/store/types'; export const setLogin = () => ({ type: t.LOGIN_SUCCESS }); export const setLogout = () => ({ type: t.LOGOUT }); -export const setStoreReset = () => ({ type: t.RESET }); \ No newline at end of file +export const setStoreReset = () => ({ type: t.RESET }); +export const setEmailConfirmed = (verified?: boolean) => ({ + type: t.SET_EMAIL_VERIFIED, + action: { verified }, +}); diff --git a/packages/webapp/src/store/authentication/authentication.reducer.tsx b/packages/webapp/src/store/authentication/authentication.reducer.tsx index 9972bf2a8..ce56b81ec 100644 --- a/packages/webapp/src/store/authentication/authentication.reducer.tsx +++ b/packages/webapp/src/store/authentication/authentication.reducer.tsx @@ -1,8 +1,9 @@ // @ts-nocheck -import { createReducer } from '@reduxjs/toolkit'; +import { PayloadAction, createReducer } from '@reduxjs/toolkit'; import { persistReducer } from 'redux-persist'; import purgeStoredState from 'redux-persist/es/purgeStoredState'; import storage from 'redux-persist/lib/storage'; +import { isUndefined } from 'lodash'; import { getCookie } from '@/utils'; import t from '@/store/types'; @@ -13,6 +14,7 @@ const initialState = { tenantId: getCookie('tenant_id'), userId: getCookie('authenticated_user_id'), locale: getCookie('locale'), + verified: true, // Let's be optimistic and assume the user's email is confirmed. errors: [], }; @@ -32,6 +34,15 @@ const reducerInstance = createReducer(initialState, { state.errors = []; }, + [t.SET_EMAIL_VERIFIED]: ( + state, + payload: PayloadAction<{ verified?: boolean }>, + ) => { + state.verified = !isUndefined(payload.action.verified) + ? payload.action.verified + : true; + }, + [t.RESET]: (state) => { purgeStoredState(CONFIG); }, diff --git a/packages/webapp/src/store/authentication/authentication.types.tsx b/packages/webapp/src/store/authentication/authentication.types.tsx index c5a5b3c3f..f64af4652 100644 --- a/packages/webapp/src/store/authentication/authentication.types.tsx +++ b/packages/webapp/src/store/authentication/authentication.types.tsx @@ -7,4 +7,5 @@ export default { LOGOUT: 'LOGOUT', LOGIN_CLEAR_ERRORS: 'LOGIN_CLEAR_ERRORS', RESET: 'RESET', + SET_EMAIL_VERIFIED: 'SET_EMAIL_VERIFIED' }; \ No newline at end of file From f4440c9a03fdae4b63b0f34e444357f2518aca7d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 3 May 2024 18:30:19 +0200 Subject: [PATCH 4/6] feat: ability to enable/disable email confirmation from env variables --- .env.example | 3 +++ .../src/api/controllers/Authentication.ts | 5 ++--- packages/server/src/config/index.ts | 7 +++++++ .../services/Authentication/AuthApplication.ts | 18 ++++++------------ .../src/services/Authentication/AuthSignup.ts | 13 ++++++++++--- .../Authentication/AuthSignupConfirm.ts | 2 +- .../Authentication/AuthSignupResend.ts | 13 +++++++------ packages/server/src/utils/index.ts | 6 +++--- 8 files changed, 39 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 7621a6a09..dc38bf3e3 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,9 @@ SIGNUP_DISABLED=false SIGNUP_ALLOWED_DOMAINS= SIGNUP_ALLOWED_EMAILS= +# Sign-up Email Confirmation +SIGNUP_EMAIL_CONFIRMATION=false + # API rate limit (points,duration,block duration). API_RATE_LIMIT=120,60,600 diff --git a/packages/server/src/api/controllers/Authentication.ts b/packages/server/src/api/controllers/Authentication.ts index 87ac2166c..ff6576bec 100644 --- a/packages/server/src/api/controllers/Authentication.ts +++ b/packages/server/src/api/controllers/Authentication.ts @@ -220,11 +220,10 @@ export default class AuthenticationController extends BaseController { } /** - * + * Resends the confirmation email to the user. * @param {Request} req * @param {Response}| res * @param {Function} next - * @returns */ private async registerVerifyResendMail( req: Request, @@ -234,7 +233,7 @@ export default class AuthenticationController extends BaseController { const { user } = req; try { - const data = await this.authApplication.signUpConfirm(user.id); + const data = await this.authApplication.signUpConfirmResend(user.id); return res.status(200).send({ type: 'success', diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 6a61da3ca..36dbfd831 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -153,6 +153,13 @@ module.exports = { ), }, + /** + * Sign-up email confirmation + */ + signupConfirmation: { + enabled: parseBoolean(process.env.SIGNUP_EMAIL_CONFIRMATION, false), + }, + /** * Puppeteer remote browserless connection. */ diff --git a/packages/server/src/services/Authentication/AuthApplication.ts b/packages/server/src/services/Authentication/AuthApplication.ts index 47f2168a3..73f18941f 100644 --- a/packages/server/src/services/Authentication/AuthApplication.ts +++ b/packages/server/src/services/Authentication/AuthApplication.ts @@ -1,4 +1,4 @@ -import { Service, Inject, Container } from 'typedi'; +import { Service, Inject } from 'typedi'; import { IRegisterDTO, ISystemUser, @@ -13,11 +13,6 @@ import { AuthSignupConfirmService } from './AuthSignupConfirm'; import { SystemUser } from '@/system/models'; import { AuthSignupConfirmResend } from './AuthSignupResend'; -interface ISignupConfirmDTO { - token: string; - email: string; -} - @Service() export default class AuthenticationApplication { @Inject() @@ -72,13 +67,12 @@ export default class AuthenticationApplication { } /** - * - * @param {string} email - * @param {string} token - * @returns + * Resends the confirmation email of the given system user. + * @param {number} userId - System user id. + * @returns {Promise} */ - public async signUpConfirmSend(email: string, token: string) { - return this.authSignupConfirmService.signUpConfirm(email, token); + public async signUpConfirmResend(userId: number) { + return this.authSignUpConfirmResendService.signUpConfirmResend(userId); } /** diff --git a/packages/server/src/services/Authentication/AuthSignup.ts b/packages/server/src/services/Authentication/AuthSignup.ts index 2be8c63a2..17c7f574c 100644 --- a/packages/server/src/services/Authentication/AuthSignup.ts +++ b/packages/server/src/services/Authentication/AuthSignup.ts @@ -1,4 +1,4 @@ -import { isEmpty, omit } from 'lodash'; +import { defaultTo, isEmpty, omit } from 'lodash'; import moment from 'moment'; import crypto from 'crypto'; import { ServiceError } from '@/exceptions'; @@ -42,7 +42,13 @@ export class AuthSignupService { await this.validateEmailUniqiness(signupDTO.email); const hashedPassword = await hashPassword(signupDTO.password); - const verifyToken = crypto.randomBytes(64).toString('hex'); + + const verifyTokenCrypto = crypto.randomBytes(64).toString('hex'); + const verifiedEnabed = defaultTo(config.signupConfirmation.enabled, false); + const verifyToken = verifiedEnabed ? verifyTokenCrypto : ''; + const verified = !verifiedEnabed; + + const inviteAcceptedAt = moment().format('YYYY-MM-DD'); // Triggers signin up event. await this.eventPublisher.emitAsync(events.auth.signingUp, { @@ -53,10 +59,11 @@ export class AuthSignupService { const registeredUser = await systemUserRepository.create({ ...omit(signupDTO, 'country'), verifyToken, + verified, active: true, password: hashedPassword, tenantId: tenant.id, - inviteAcceptedAt: moment().format('YYYY-MM-DD'), + inviteAcceptedAt, }); // Triggers signed up event. await this.eventPublisher.emitAsync(events.auth.signUp, { diff --git a/packages/server/src/services/Authentication/AuthSignupConfirm.ts b/packages/server/src/services/Authentication/AuthSignupConfirm.ts index 940b6ae7f..b08a4bd2e 100644 --- a/packages/server/src/services/Authentication/AuthSignupConfirm.ts +++ b/packages/server/src/services/Authentication/AuthSignupConfirm.ts @@ -52,6 +52,6 @@ export class AuthSignupConfirmService { userId, } as IAuthSignUpVerifiedEventPayload); - return updatedUser; + return updatedUser as SystemUser; } } diff --git a/packages/server/src/services/Authentication/AuthSignupResend.ts b/packages/server/src/services/Authentication/AuthSignupResend.ts index 5c764e80c..8edb08f35 100644 --- a/packages/server/src/services/Authentication/AuthSignupResend.ts +++ b/packages/server/src/services/Authentication/AuthSignupResend.ts @@ -1,6 +1,6 @@ +import { Inject, Service } from 'typedi'; import { ServiceError } from '@/exceptions'; import { SystemUser } from '@/system/models'; -import { Inject, Service } from 'typedi'; import { ERRORS } from './_constants'; @Service() @@ -9,18 +9,19 @@ export class AuthSignupConfirmResend { private agenda: any; /** - * - * @param {number} tenantId - * @param {string} email + * Resends the email confirmation of the given user. + * @param {number} userId - User ID. + * @returns {Promise} */ public async signUpConfirmResend(userId: number) { const user = await SystemUser.query().findById(userId).throwIfNotFound(); - // + // Throw error if the user is already verified. if (user.verified) { throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED); } - if (user.verifyToken) { + // Throw error if the verification token is not exist. + if (!user.verifyToken) { throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED); } const payload = { diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 919cd7af7..ceef5f572 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcryptjs'; import moment from 'moment'; -import _ from 'lodash'; +import _, { isEmpty } from 'lodash'; import path from 'path'; import * as R from 'ramda'; @@ -329,7 +329,7 @@ const booleanValuesRepresentingTrue: string[] = ['true', '1']; const booleanValuesRepresentingFalse: string[] = ['false', '0']; const normalizeValue = (value: any): string => - value.toString().trim().toLowerCase(); + value?.toString().trim().toLowerCase(); const booleanValues: string[] = [ ...booleanValuesRepresentingTrue, @@ -338,7 +338,7 @@ const booleanValues: string[] = [ export const parseBoolean = (value: any, defaultValue: T): T | boolean => { const normalizedValue = normalizeValue(value); - if (booleanValues.indexOf(normalizedValue) === -1) { + if (isEmpty(value) || booleanValues.indexOf(normalizedValue) === -1) { return defaultValue; } return booleanValuesRepresentingTrue.indexOf(normalizedValue) !== -1; From a5bfb0b02bc25b0a02c0affced2d43ec00c8bc31 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 6 May 2024 17:36:36 +0200 Subject: [PATCH 5/6] fix: the email confirmation link on mail message --- .../Authentication/AuthenticationMailMessages.ts | 11 +++++------ ...240425100821_add_confirmation_columns_to_users.js | 12 ++++++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts index e974d22b9..533315e72 100644 --- a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts +++ b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts @@ -44,8 +44,10 @@ export default class AuthenticationMailMesssages { public async sendSignupVerificationMail( email: string, fullName: string, - token: string, + token: string ) { + const verifyUrl = `${config.baseURL}/auth/email_confirmation?token=${token}&email=${email}`; + await new Mail() .setSubject('Bigcapital - Verify your email') .setView('mail/SignupVerifyEmail.html') @@ -57,10 +59,7 @@ export default class AuthenticationMailMesssages { cid: 'bigcapital_logo', }, ]) - .setData({ - verifyUrl: `${config.baseURL}/auth/reset_password/${token}`, - fullName, - }) - .send(); + .setData({ verifyUrl, fullName }) + .send(); } } diff --git a/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js index 125f508f5..fada1380f 100644 --- a/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js +++ b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js @@ -1,8 +1,12 @@ exports.up = function (knex) { - return knex.schema.table('users', (table) => { - table.string('verify_token'); - table.boolean('verified').defaultTo(false); - }); + return knex.schema + .table('users', (table) => { + table.string('verify_token'); + table.boolean('verified').defaultTo(false); + }) + .then(() => { + return knex('USERS').update({ verified: true }); + }); }; exports.down = (knex) => {}; From dd02ae471e3a3b7f973189c54fe72d106bfe9d9f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 6 May 2024 17:45:32 +0200 Subject: [PATCH 6/6] feat: redirect to the setup page if user is verified --- packages/webapp/src/components/App.tsx | 5 +++- .../Guards/EnsureUserEmailNotVerified.tsx | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 packages/webapp/src/components/Guards/EnsureUserEmailNotVerified.tsx diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx index 856f83686..4f43a8613 100644 --- a/packages/webapp/src/components/App.tsx +++ b/packages/webapp/src/components/App.tsx @@ -19,6 +19,7 @@ import { SplashScreen, DashboardThemeProvider } from '../components'; import { queryConfig } from '../hooks/query/base'; import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified'; import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated'; +import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified'; const EmailConfirmation = LazyLoader({ loader: () => import('@/containers/Authentication/EmailConfirmation'), @@ -38,7 +39,9 @@ function AppInsider({ history }) { - + + + diff --git a/packages/webapp/src/components/Guards/EnsureUserEmailNotVerified.tsx b/packages/webapp/src/components/Guards/EnsureUserEmailNotVerified.tsx new file mode 100644 index 000000000..ac35b649f --- /dev/null +++ b/packages/webapp/src/components/Guards/EnsureUserEmailNotVerified.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { useAuthUserVerified } from '@/hooks/state'; + +interface EnsureUserEmailNotVerifiedProps { + children: React.ReactNode; + redirectTo?: string; +} + +/** + * Higher Order Component to ensure that the user's email is not verified. + * If is verified, redirects to the inner setup page. + */ +export function EnsureUserEmailNotVerified({ + children, + redirectTo = '/', +}: EnsureUserEmailNotVerifiedProps) { + const isAuthVerified = useAuthUserVerified(); + + if (isAuthVerified) { + return ; + } + return <>{children}; +}