diff --git a/packages/server/src/modules/UsersModule/Users.module.ts b/packages/server/src/modules/UsersModule/Users.module.ts index 49ab700ff..3e16edd91 100644 --- a/packages/server/src/modules/UsersModule/Users.module.ts +++ b/packages/server/src/modules/UsersModule/Users.module.ts @@ -20,6 +20,7 @@ import { GetUsersService } from './queries/GetUsers.service'; import { AcceptInviteUserService } from './commands/AcceptInviteUser.service'; import { InviteTenantUserService } from './commands/InviteUser.service'; import { UsersInviteController } from './UsersInvite.controller'; +import { UsersInvitePublicController } from './UsersInvitePublic.controller'; import { InjectSystemModel } from '../System/SystemModels/SystemModels.module'; import { SendInviteUserMailQueue } from './Users.constants'; import InviteSendMainNotificationSubscribe from './subscribers/InviteSendMailNotification.subscriber'; @@ -60,6 +61,6 @@ const models = [InjectSystemModel(UserInvite)]; SendInviteUsersMailMessage, UsersApplication ], - controllers: [UsersController, UsersInviteController], + controllers: [UsersController, UsersInviteController, UsersInvitePublicController], }) export class UsersModule {} diff --git a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts index 3f43baa27..c1c417e7b 100644 --- a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts +++ b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts @@ -1,40 +1,13 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { UsersApplication } from './Users.application'; -import { InviteUserDto, SendInviteUserDto } from './dtos/InviteUser.dto'; +import { SendInviteUserDto } from './dtos/InviteUser.dto'; @Controller('invite') @ApiTags('Users') export class UsersInviteController { constructor(private readonly usersApplication: UsersApplication) {} - /** - * Accept a user invitation. - */ - @Post('accept/:token') - @ApiOperation({ summary: 'Accept a user invitation.' }) - async acceptInvite( - @Param('token') token: string, - @Body() inviteUserDTO: InviteUserDto, - ) { - await this.usersApplication.acceptInvite(token, inviteUserDTO); - - return { - message: 'The invitation has been accepted successfully.', - }; - } - - /** - * Check if an invitation token is valid. - */ - @Get('check/:token') - @ApiOperation({ summary: 'Check if an invitation token is valid.' }) - async checkInvite(@Param('token') token: string) { - const inviteDetails = await this.usersApplication.checkInvite(token); - - return inviteDetails; - } - /** * Send an invitation to a new user. */ diff --git a/packages/server/src/modules/UsersModule/UsersInvitePublic.controller.ts b/packages/server/src/modules/UsersModule/UsersInvitePublic.controller.ts new file mode 100644 index 000000000..56bef5659 --- /dev/null +++ b/packages/server/src/modules/UsersModule/UsersInvitePublic.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PublicRoute } from '@/modules/Auth/guards/jwt.guard'; +import { UsersApplication } from './Users.application'; +import { InviteUserDto } from './dtos/InviteUser.dto'; + +@Controller('invite') +@ApiTags('Users') +@PublicRoute() +export class UsersInvitePublicController { + constructor(private readonly usersApplication: UsersApplication) {} + + /** + * Accept a user invitation. + */ + @Post('accept/:token') + @ApiOperation({ summary: 'Accept a user invitation.' }) + async acceptInvite( + @Param('token') token: string, + @Body() inviteUserDTO: InviteUserDto, + ) { + await this.usersApplication.acceptInvite(token, inviteUserDTO); + + return { + message: 'The invitation has been accepted successfully.', + }; + } + + /** + * Check if an invitation token is valid. + */ + @Get('check/:token') + @ApiOperation({ summary: 'Check if an invitation token is valid.' }) + async checkInvite(@Param('token') token: string) { + const inviteDetails = await this.usersApplication.checkInvite(token); + + return inviteDetails; + } +} diff --git a/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts b/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts index 5e948b1a0..d228a88c7 100644 --- a/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts +++ b/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as moment from 'moment'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ClsService } from 'nestjs-cls'; import { IAcceptInviteEventPayload, ICheckInviteEventPayload, @@ -15,6 +16,11 @@ import { UserInvite } from '../models/InviteUser.model'; import { ModelObject } from 'objection'; import { InviteUserDto } from '../dtos/InviteUser.dto'; +interface InviteAcceptResponseDto { + inviteToken: { email: string, token: string, createdAt: Date }; + orgName: string +} + @Injectable() export class AcceptInviteUserService { constructor( @@ -27,6 +33,7 @@ export class AcceptInviteUserService { @Inject(UserInvite.name) private readonly userInviteModel: typeof UserInvite, private readonly eventEmitter: EventEmitter2, + private readonly cls: ClsService, ) {} /** @@ -62,6 +69,16 @@ export class AcceptInviteUserService { // Clear invite token by the given user id. await this.clearInviteTokensByUserId(inviteToken.userId); + // Retrieve the tenant to get the organizationId for CLS. + const tenant = await this.tenantModel + .query() + .findById(inviteToken.tenantId); + + // Set CLS values for tenant context before triggering sync events. + this.cls.set('tenantId', inviteToken.tenantId); + this.cls.set('userId', systemUser.id); + this.cls.set('organizationId', tenant.organizationId); + // Triggers `onUserAcceptInvite` event. await this.eventEmitter.emitAsync(events.inviteUser.acceptInvite, { inviteToken, @@ -77,7 +94,7 @@ export class AcceptInviteUserService { */ public async checkInvite( token: string, - ): Promise<{ inviteToken: ModelObject; orgName: string }> { + ): Promise { const inviteToken = await this.getInviteTokenOrThrowError(token); // Find the tenant that associated to the given token. @@ -92,7 +109,16 @@ export class AcceptInviteUserService { tenant, } as ICheckInviteEventPayload); - return { inviteToken, orgName: tenant.metadata.name }; + // Explicitly convert to plain object to ensure all fields are serialized + const result = { + inviteToken: { + email: inviteToken.email, + token: inviteToken.token, + createdAt: inviteToken.createdAt, + }, + orgName: tenant.metadata.name, + }; + return result; } /** diff --git a/packages/server/src/modules/UsersModule/models/InviteUser.model.ts b/packages/server/src/modules/UsersModule/models/InviteUser.model.ts index a69e11f69..fff6a1b3c 100644 --- a/packages/server/src/modules/UsersModule/models/InviteUser.model.ts +++ b/packages/server/src/modules/UsersModule/models/InviteUser.model.ts @@ -6,6 +6,7 @@ export class UserInvite extends BaseModel { userId!: number; tenantId!: number; email!: string; + createdAt!: Date; /** * Table name. @@ -32,4 +33,11 @@ export class UserInvite extends BaseModel { }, }; } + + /** + * Called before inserting a new record. + */ + $beforeInsert() { + this.createdAt = new Date(); + } } diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts index 90b5ba7ff..9e4fbc964 100644 --- a/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts +++ b/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts @@ -1,4 +1,4 @@ -import { omit } from 'lodash'; +import { pick } from 'lodash'; import * as moment from 'moment'; import { Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; @@ -22,13 +22,12 @@ export class SyncTenantAcceptInviteSubscriber { async syncTenantAcceptInvite({ inviteToken, user, - inviteUserDTO, }: IAcceptInviteEventPayload) { await this.tenantUserModel() .query() .where('systemUserId', inviteToken.userId) .update({ - ...omit(inviteUserDTO, ['password']), + ...pick(user, ['firstName', 'lastName', 'email', 'active']), inviteAcceptedAt: moment().format('YYYY-MM-DD'), }); } diff --git a/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx b/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx index d4a000c4c..f738edf6c 100644 --- a/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx +++ b/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx @@ -58,7 +58,7 @@ export default function InviteAcceptForm() { data: { errors }, }, }) => { - if (errors.find((e) => e.type === 'INVITE.TOKEN.NOT.FOUND')) { + if (errors.find((e) => e.type === 'INVITE_TOKEN_INVALID')) { AppToaster.show({ message: intl.get('an_unexpected_error_occurred'), intent: Intent.DANGER, @@ -71,14 +71,6 @@ export default function InviteAcceptForm() { phone_number: 'This phone number is used in another account.', }); } - if (errors.find((e) => e.type === 'INVITE.TOKEN.NOT.FOUND')) { - AppToaster.show({ - message: intl.get('an_unexpected_error_occurred'), - intent: Intent.DANGER, - position: Position.BOTTOM, - }); - history.push('/auth/login'); - } setSubmitting(false); }, ); diff --git a/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx b/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx index f78043079..ed9db2d6b 100644 --- a/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx +++ b/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx @@ -29,14 +29,22 @@ function InviteAcceptProvider({ token, ...props }) { if (inviteMetaError) { history.push('/auth/login'); } }, [history, inviteMetaError]); + // Transform the backend response to match frontend expectations. + const transformedInviteMeta = inviteMeta + ? { + email: inviteMeta.inviteToken?.email, + organizationName: inviteMeta.orgName, + } + : null; + // Provider payload. const provider = { token, - inviteMeta, + inviteMeta: transformedInviteMeta, inviteMetaError, isInviteMetaError, isInviteMetaLoading, - inviteAcceptMutate + inviteAcceptMutate, }; if (inviteMetaError) { @@ -45,7 +53,6 @@ function InviteAcceptProvider({ token, ...props }) { return ( - { isInviteMetaError } ); diff --git a/packages/webapp/src/hooks/query/invite.tsx b/packages/webapp/src/hooks/query/invite.tsx index 9d7de9ed4..6466d616a 100644 --- a/packages/webapp/src/hooks/query/invite.tsx +++ b/packages/webapp/src/hooks/query/invite.tsx @@ -2,6 +2,7 @@ import { useMutation } from 'react-query'; import { useRequestQuery } from '../useQueryRequest'; import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; /** * Authentication invite accept. @@ -22,9 +23,9 @@ export const useAuthInviteAccept = (props) => { export const useInviteMetaByToken = (token, props) => { return useRequestQuery( ['INVITE_META', token], - { method: 'get', url: `invite/invited/${token}` }, + { method: 'get', url: `invite/check/${token}` }, { - select: (res) => res.data, + select: (res) => transformToCamelCase(res.data), ...props } );