Compare commits

...

4 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
171091e0e0 Merge pull request #972 from bigcapitalhq/fix/ahmedbouhuolia/invite-user-service
fix: invite user service issues
2026-02-18 12:33:57 +02:00
Ahmed Bouhuolia
78032d7bfc fix: invite user service issues 2026-02-18 12:32:04 +02:00
Ahmed Bouhuolia
06b8a836c5 Merge pull request #967 from bigcapitalhq/fix/ahmedbouhuolia/baseurl-config-key
fix: correct config key for base URL in email services
2026-02-18 01:31:08 +02:00
Ahmed Bouhuolia
37fa9f9bc6 fix: correct config key for base URL in email services 2026-02-18 01:28:48 +02:00
11 changed files with 98 additions and 52 deletions

View File

@@ -20,7 +20,7 @@ export class AuthenticationMailMesssages {
* @returns {Mail} * @returns {Mail}
*/ */
resetPasswordMessage(user: ModelObject<SystemUser>, token: string) { resetPasswordMessage(user: ModelObject<SystemUser>, token: string) {
const baseURL = this.configService.get('baseURL'); const baseURL = this.configService.get('app.baseUrl');
return new Mail() return new Mail()
.setSubject('Bigcapital - Password Reset') .setSubject('Bigcapital - Password Reset')
@@ -54,7 +54,7 @@ export class AuthenticationMailMesssages {
* @returns {Mail} * @returns {Mail}
*/ */
signupVerificationMail(email: string, fullName: string, token: string) { signupVerificationMail(email: string, fullName: string, token: string) {
const baseURL = this.configService.get('baseURL'); const baseURL = this.configService.get('app.baseUrl');
const verifyUrl = `${baseURL}/auth/email_confirmation?token=${token}&email=${email}`; const verifyUrl = `${baseURL}/auth/email_confirmation?token=${token}&email=${email}`;
return new Mail() return new Mail()

View File

@@ -20,6 +20,7 @@ import { GetUsersService } from './queries/GetUsers.service';
import { AcceptInviteUserService } from './commands/AcceptInviteUser.service'; import { AcceptInviteUserService } from './commands/AcceptInviteUser.service';
import { InviteTenantUserService } from './commands/InviteUser.service'; import { InviteTenantUserService } from './commands/InviteUser.service';
import { UsersInviteController } from './UsersInvite.controller'; import { UsersInviteController } from './UsersInvite.controller';
import { UsersInvitePublicController } from './UsersInvitePublic.controller';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module'; import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { SendInviteUserMailQueue } from './Users.constants'; import { SendInviteUserMailQueue } from './Users.constants';
import InviteSendMainNotificationSubscribe from './subscribers/InviteSendMailNotification.subscriber'; import InviteSendMainNotificationSubscribe from './subscribers/InviteSendMailNotification.subscriber';
@@ -60,6 +61,6 @@ const models = [InjectSystemModel(UserInvite)];
SendInviteUsersMailMessage, SendInviteUsersMailMessage,
UsersApplication UsersApplication
], ],
controllers: [UsersController, UsersInviteController], controllers: [UsersController, UsersInviteController, UsersInvitePublicController],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -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 { ApiOperation, ApiTags } from '@nestjs/swagger';
import { UsersApplication } from './Users.application'; import { UsersApplication } from './Users.application';
import { InviteUserDto, SendInviteUserDto } from './dtos/InviteUser.dto'; import { SendInviteUserDto } from './dtos/InviteUser.dto';
@Controller('invite') @Controller('invite')
@ApiTags('Users') @ApiTags('Users')
export class UsersInviteController { export class UsersInviteController {
constructor(private readonly usersApplication: UsersApplication) {} 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. * Send an invitation to a new user.
*/ */

View File

@@ -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;
}
}

View File

@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as moment from 'moment'; import * as moment from 'moment';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { ClsService } from 'nestjs-cls';
import { import {
IAcceptInviteEventPayload, IAcceptInviteEventPayload,
ICheckInviteEventPayload, ICheckInviteEventPayload,
@@ -15,6 +16,11 @@ import { UserInvite } from '../models/InviteUser.model';
import { ModelObject } from 'objection'; import { ModelObject } from 'objection';
import { InviteUserDto } from '../dtos/InviteUser.dto'; import { InviteUserDto } from '../dtos/InviteUser.dto';
interface InviteAcceptResponseDto {
inviteToken: { email: string, token: string, createdAt: Date };
orgName: string
}
@Injectable() @Injectable()
export class AcceptInviteUserService { export class AcceptInviteUserService {
constructor( constructor(
@@ -27,6 +33,7 @@ export class AcceptInviteUserService {
@Inject(UserInvite.name) @Inject(UserInvite.name)
private readonly userInviteModel: typeof UserInvite, private readonly userInviteModel: typeof UserInvite,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly cls: ClsService,
) {} ) {}
/** /**
@@ -62,6 +69,16 @@ export class AcceptInviteUserService {
// Clear invite token by the given user id. // Clear invite token by the given user id.
await this.clearInviteTokensByUserId(inviteToken.userId); 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. // Triggers `onUserAcceptInvite` event.
await this.eventEmitter.emitAsync(events.inviteUser.acceptInvite, { await this.eventEmitter.emitAsync(events.inviteUser.acceptInvite, {
inviteToken, inviteToken,
@@ -77,7 +94,7 @@ export class AcceptInviteUserService {
*/ */
public async checkInvite( public async checkInvite(
token: string, token: string,
): Promise<{ inviteToken: ModelObject<UserInvite>; orgName: string }> { ): Promise<InviteAcceptResponseDto> {
const inviteToken = await this.getInviteTokenOrThrowError(token); const inviteToken = await this.getInviteTokenOrThrowError(token);
// Find the tenant that associated to the given token. // Find the tenant that associated to the given token.
@@ -92,7 +109,16 @@ export class AcceptInviteUserService {
tenant, tenant,
} as ICheckInviteEventPayload); } 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;
} }
/** /**

View File

@@ -28,7 +28,7 @@ export class SendInviteUsersMailMessage {
) { ) {
const tenant = await this.tenancyContext.getTenant(true); const tenant = await this.tenancyContext.getTenant(true);
const root = path.join(global.__images_dirname, '/bigcapital.png'); const root = path.join(global.__images_dirname, '/bigcapital.png');
const baseURL = this.configService.get('baseURL'); const baseURL = this.configService.get('app.baseUrl');
const mail = new Mail() const mail = new Mail()
.setSubject(`${fromUser.firstName} has invited you to join a Bigcapital`) .setSubject(`${fromUser.firstName} has invited you to join a Bigcapital`)

View File

@@ -6,6 +6,7 @@ export class UserInvite extends BaseModel {
userId!: number; userId!: number;
tenantId!: number; tenantId!: number;
email!: string; email!: string;
createdAt!: Date;
/** /**
* Table name. * Table name.
@@ -32,4 +33,11 @@ export class UserInvite extends BaseModel {
}, },
}; };
} }
/**
* Called before inserting a new record.
*/
$beforeInsert() {
this.createdAt = new Date();
}
} }

View File

@@ -1,4 +1,4 @@
import { omit } from 'lodash'; import { pick } from 'lodash';
import * as moment from 'moment'; import * as moment from 'moment';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
@@ -22,13 +22,12 @@ export class SyncTenantAcceptInviteSubscriber {
async syncTenantAcceptInvite({ async syncTenantAcceptInvite({
inviteToken, inviteToken,
user, user,
inviteUserDTO,
}: IAcceptInviteEventPayload) { }: IAcceptInviteEventPayload) {
await this.tenantUserModel() await this.tenantUserModel()
.query() .query()
.where('systemUserId', inviteToken.userId) .where('systemUserId', inviteToken.userId)
.update({ .update({
...omit(inviteUserDTO, ['password']), ...pick(user, ['firstName', 'lastName', 'email', 'active']),
inviteAcceptedAt: moment().format('YYYY-MM-DD'), inviteAcceptedAt: moment().format('YYYY-MM-DD'),
}); });
} }

View File

@@ -58,7 +58,7 @@ export default function InviteAcceptForm() {
data: { errors }, data: { errors },
}, },
}) => { }) => {
if (errors.find((e) => e.type === 'INVITE.TOKEN.NOT.FOUND')) { if (errors.find((e) => e.type === 'INVITE_TOKEN_INVALID')) {
AppToaster.show({ AppToaster.show({
message: intl.get('an_unexpected_error_occurred'), message: intl.get('an_unexpected_error_occurred'),
intent: Intent.DANGER, intent: Intent.DANGER,
@@ -71,14 +71,6 @@ export default function InviteAcceptForm() {
phone_number: 'This phone number is used in another account.', 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); setSubmitting(false);
}, },
); );

View File

@@ -29,14 +29,22 @@ function InviteAcceptProvider({ token, ...props }) {
if (inviteMetaError) { history.push('/auth/login'); } if (inviteMetaError) { history.push('/auth/login'); }
}, [history, inviteMetaError]); }, [history, inviteMetaError]);
// Transform the backend response to match frontend expectations.
const transformedInviteMeta = inviteMeta
? {
email: inviteMeta.inviteToken?.email,
organizationName: inviteMeta.orgName,
}
: null;
// Provider payload. // Provider payload.
const provider = { const provider = {
token, token,
inviteMeta, inviteMeta: transformedInviteMeta,
inviteMetaError, inviteMetaError,
isInviteMetaError, isInviteMetaError,
isInviteMetaLoading, isInviteMetaLoading,
inviteAcceptMutate inviteAcceptMutate,
}; };
if (inviteMetaError) { if (inviteMetaError) {
@@ -45,7 +53,6 @@ function InviteAcceptProvider({ token, ...props }) {
return ( return (
<InviteAcceptLoading isLoading={isInviteMetaLoading}> <InviteAcceptLoading isLoading={isInviteMetaLoading}>
{ isInviteMetaError }
<InviteAcceptContext.Provider value={provider} {...props} /> <InviteAcceptContext.Provider value={provider} {...props} />
</InviteAcceptLoading> </InviteAcceptLoading>
); );

View File

@@ -2,6 +2,7 @@
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
import { useRequestQuery } from '../useQueryRequest'; import { useRequestQuery } from '../useQueryRequest';
import useApiRequest from '../useRequest'; import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
/** /**
* Authentication invite accept. * Authentication invite accept.
@@ -22,9 +23,9 @@ export const useAuthInviteAccept = (props) => {
export const useInviteMetaByToken = (token, props) => { export const useInviteMetaByToken = (token, props) => {
return useRequestQuery( return useRequestQuery(
['INVITE_META', token], ['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 ...props
} }
); );