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 aae43f4b4..ff6576bec 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,6 +30,20 @@ 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', + 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 +115,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 +193,58 @@ 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: { email: string; token: string } = + 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); + } + } + + /** + * Resends the confirmation email to the user. + * @param {Request} req + * @param {Response}| res + * @param {Function} next + */ + private async registerVerifyResendMail( + req: Request, + res: Response, + next: Function + ) { + const { user } = req; + + try { + const data = await this.authApplication.signUpConfirmResend(user.id); + + return res.status(200).send({ + type: 'success', + message: 'The given user has verified successfully', + data, + }); + } catch (error) { + next(error); + } + } + /** * Send reset password handler * @param {Request} req diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 76133b5ce..a48905d41 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/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..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, @@ -9,6 +9,9 @@ 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'; +import { AuthSignupConfirmResend } from './AuthSignupResend'; @Service() export default class AuthenticationApplication { @@ -18,6 +21,12 @@ export default class AuthenticationApplication { @Inject() private authSignupService: AuthSignupService; + @Inject() + private authSignupConfirmService: AuthSignupConfirmService; + + @Inject() + private authSignUpConfirmResendService: AuthSignupConfirmResend; + @Inject() private authResetPasswordService: AuthSendResetPassword; @@ -44,6 +53,28 @@ 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); + } + + /** + * Resends the confirmation email of the given system user. + * @param {number} userId - System user id. + * @returns {Promise} + */ + public async signUpConfirmResend(userId: number) { + return this.authSignUpConfirmResendService.signUpConfirmResend(userId); + } + /** * 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..17c7f574c 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 { defaultTo, isEmpty, omit } from 'lodash'; import moment from 'moment'; +import crypto from 'crypto'; import { ServiceError } from '@/exceptions'; import { IAuthSignedUpEventPayload, @@ -42,6 +43,13 @@ export class AuthSignupService { const hashedPassword = await hashPassword(signupDTO.password); + 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, { signupDTO, @@ -50,10 +58,12 @@ export class AuthSignupService { const tenant = await this.tenantsManager.createTenant(); 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 new file mode 100644 index 000000000..b08a4bd2e --- /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 as SystemUser; + } +} diff --git a/packages/server/src/services/Authentication/AuthSignupResend.ts b/packages/server/src/services/Authentication/AuthSignupResend.ts new file mode 100644 index 000000000..8edb08f35 --- /dev/null +++ b/packages/server/src/services/Authentication/AuthSignupResend.ts @@ -0,0 +1,34 @@ +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { SystemUser } from '@/system/models'; +import { ERRORS } from './_constants'; + +@Service() +export class AuthSignupConfirmResend { + @Inject('agenda') + private agenda: any; + + /** + * 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); + } + // Throw error if the verification token is not exist. + 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..533315e72 100644 --- a/packages/server/src/services/Authentication/AuthenticationMailMessages.ts +++ b/packages/server/src/services/Authentication/AuthenticationMailMessages.ts @@ -33,4 +33,33 @@ 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 + ) { + const verifyUrl = `${config.baseURL}/auth/email_confirmation?token=${token}&email=${email}`; + + 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, 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..fada1380f --- /dev/null +++ b/packages/server/src/system/migrations/20240425100821_add_confirmation_columns_to_users.js @@ -0,0 +1,12 @@ +exports.up = function (knex) { + 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) => {}; diff --git a/packages/server/src/system/models/SystemUser.ts b/packages/server/src/system/models/SystemUser.ts index a341ccedf..ce17186df 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. */ @@ -29,23 +35,33 @@ export default class SystemUser extends SystemModel { * Virtual attributes. */ static get virtualAttributes() { - return ['fullName', 'isDeleted', 'isInviteAccepted']; + return ['fullName', 'isDeleted', 'isInviteAccepted', 'isVerified']; } /** - * + * 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/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; 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. + + + + + + + + + diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx index 949a1861f..4f43a8613 100644 --- a/packages/webapp/src/components/App.tsx +++ b/packages/webapp/src/components/App.tsx @@ -9,13 +9,24 @@ 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'; +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'; +import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified'; + +const EmailConfirmation = LazyLoader({ + loader: () => import('@/containers/Authentication/EmailConfirmation'), +}); +const RegisterVerify = LazyLoader({ + loader: () => import('@/containers/Authentication/RegisterVerify'), +}); /** * App inner. @@ -26,9 +37,30 @@ 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 new file mode 100644 index 000000000..97539a32d --- /dev/null +++ b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx @@ -0,0 +1,22 @@ +// @ts-nocheck +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { useIsAuthenticated } from '@/hooks/state'; + +interface EnsureAuthNotAuthenticatedProps { + children: React.ReactNode; + redirectTo?: string; +} + +export function EnsureAuthNotAuthenticated({ + children, + redirectTo = '/', +}: EnsureAuthNotAuthenticatedProps) { + const isAuthenticated = useIsAuthenticated(); + + return !isAuthenticated ? ( + <>{children} + ) : ( + + ); +} diff --git a/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx b/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx new file mode 100644 index 000000000..0a223a9ae --- /dev/null +++ b/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx @@ -0,0 +1,22 @@ +// @ts-nocheck +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { useIsAuthenticated } from '@/hooks/state'; + +interface EnsureAuthenticatedProps { + children: React.ReactNode; + redirectTo?: string; +} + +export function EnsureAuthenticated({ + children, + redirectTo = '/auth/login', +}: EnsureAuthenticatedProps) { + const isAuthenticated = useIsAuthenticated(); + + return isAuthenticated ? ( + <>{children} + ) : ( + + ); +} 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}; +} diff --git a/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx new file mode 100644 index 000000000..f24d93533 --- /dev/null +++ b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { useAuthUserVerified } from '@/hooks/state'; + +interface EnsureUserEmailVerifiedProps { + children: React.ReactNode; + redirectTo?: string; +} + +/** + * 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, + redirectTo = '/auth/register/verify', +}: 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 deleted file mode 100644 index 8e8e167b9..000000000 --- a/packages/webapp/src/components/Guards/PrivateRoute.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import BodyClassName from 'react-body-classname'; -import { Redirect } from 'react-router-dom'; -import { useIsAuthenticated } from '@/hooks/state'; - -export default function PrivateRoute({ component: Component, ...rest }) { - const isAuthenticated = useIsAuthenticated(); - - return ( - - {isAuthenticated ? ( - - ) : ( - - )} - - ); -} 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..408dc78f0 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -1,24 +1,16 @@ // @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..e7fe66d5e --- /dev/null +++ b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx @@ -0,0 +1,46 @@ +// @ts-nocheck +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 history = useHistory(); + const query = useQuery(); + + const token = query.get('token'); + const email = query.get('email'); + + useEffect(() => { + if (!token || !email) { + history.push('/auth/login'); + } + }, [history, token, email]); + + useEffect(() => { + 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.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..a707bfdb0 --- /dev/null +++ b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx @@ -0,0 +1,70 @@ +// @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'; + +export default function RegisterVerify() { + 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.', + }); + }); + }; + 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..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,5 +88,35 @@ export const useAuthMetadata = (props) => { defaultData: {}, ...props, }, - ); + ); +}; + +/** + * + */ +export const useAuthSignUpVerifyResendMail = (props) => { + const apiRequest = useApiRequest(); + + return useMutation( + () => apiRequest.post('auth/register/verify/resend'), + props, + ); +}; + +interface AuthSignUpVerifyValues { + token: string; + email: string; } + +/** + * + */ +export const useAuthSignUpVerify = (props) => { + const apiRequest = useApiRequest(); + + return useMutation( + (values: AuthSignUpVerifyValues) => + apiRequest.post('auth/register/verify', values), + props, + ); +}; 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 4fb20bf6c..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'; @@ -64,3 +67,22 @@ export const useAuthUser = () => { export const useAuthOrganizationId = () => { return useSelector((state) => state.authentication.organizationId); }; + +/** + * Retrieves the user's email verification status. + */ +export const useAuthUserVerified = () => { + 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/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'), }), - } + }, ]; 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