refactor(nestjs): hook up auth endpoints

This commit is contained in:
Ahmed Bouhuolia
2025-05-08 18:10:02 +02:00
parent 401b3dc111
commit f78d6efe27
26 changed files with 304 additions and 111 deletions

View File

@@ -0,0 +1,24 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { mapValues, mapValuesDeep } from '@/utils/deepdash';
@Injectable()
export class ToJsonInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
return mapValuesDeep(data, (value) => {
if (value && typeof value.toJSON === 'function') {
return value.toJSON();
}
return value;
});
}),
);
}
}

View File

@@ -6,6 +6,7 @@ import './utils/moment-mysql';
import { AppModule } from './modules/App/App.module';
import { ServiceErrorFilter } from './common/filters/service-error.filter';
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
global.__static_dirname = path.join(__dirname, '../static');
global.__views_dirname = path.join(global.__static_dirname, '/views');
@@ -18,6 +19,8 @@ async function bootstrap() {
// create and mount the middleware manually here
app.use(new ClsMiddleware({}).use);
app.useGlobalInterceptors(new ToJsonInterceptor());
// use the validation pipe globally
app.useGlobalPipes(new ValidationPipe());

View File

@@ -1,21 +1,22 @@
// @ts-nocheck
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard, PublicRoute } from './guards/jwt.guard';
import { PublicRoute } from './guards/jwt.guard';
import { AuthenticationApplication } from './AuthApplication.sevice';
import { AuthSignupDto } from './dtos/AuthSignup.dto';
import { AuthSigninDto } from './dtos/AuthSignin.dto';
import { LocalAuthGuard } from './guards/local.guard';
import { JwtService } from '@nestjs/jwt';
import { AuthSigninService } from './commands/AuthSignin.service';
import { TenantModel } from '../System/models/TenantModel';
import { SystemUser } from '../System/models/SystemUser';
@Controller('/auth')
@ApiTags('Auth')
@@ -24,15 +25,25 @@ export class AuthController {
constructor(
private readonly authApp: AuthenticationApplication,
private readonly authSignin: AuthSigninService,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
) {}
@Post('/signin')
@UseGuards(LocalAuthGuard)
@ApiOperation({ summary: 'Sign in a user' })
@ApiBody({ type: AuthSigninDto })
signin(@Request() req: Request, @Body() signinDto: AuthSigninDto) {
async signin(@Request() req: Request & { user: SystemUser }, @Body() signinDto: AuthSigninDto) {
const { user } = req;
return { access_token: this.authSignin.signToken(user) };
const tenant = await this.tenantModel.query().findById(user.tenantId);
return {
accessToken: this.authSignin.signToken(user),
organizationId: tenant.organizationId,
tenantId: tenant.id,
userId: user.id,
};
}
@Post('/signup')

View File

@@ -28,11 +28,14 @@ import { MailModule } from '../Mail/Mail.module';
import { ConfigService } from '@nestjs/config';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { GetAuthMetaService } from './queries/GetAuthMeta.service';
import { AuthedController } from './Authed.controller';
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
import { TenancyModule } from '../Tenancy/Tenancy.module';
const models = [InjectSystemModel(PasswordReset)];
@Module({
controllers: [AuthController],
controllers: [AuthController, AuthedController],
imports: [
MailModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
@@ -45,9 +48,9 @@ const models = [InjectSystemModel(PasswordReset)];
}),
}),
TenantDBManagerModule,
TenancyModule,
BullModule.registerQueue({ name: SendResetPasswordMailQueue }),
BullModule.registerQueue({ name: SendSignupVerificationMailQueue }),
],
exports: [...models],
providers: [
@@ -65,6 +68,7 @@ const models = [InjectSystemModel(PasswordReset)];
SendResetPasswordMailProcessor,
SendSignupVerificationMailProcessor,
GetAuthMetaService,
GetAuthenticatedAccount,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,

View File

@@ -0,0 +1,23 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
import { Controller, Get } from '@nestjs/common';
import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards';
import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard';
@Controller('/auth')
@ApiTags('Auth')
@IgnoreTenantSeededRoute()
@IgnoreTenantInitializedRoute()
export class AuthedController {
constructor(
private readonly getAuthedAccountService: GetAuthenticatedAccount,
) {}
@Get('/account')
@ApiOperation({ summary: 'Retrieve the authenticated account' })
async getAuthedAcccount() {
const data = await this.getAuthedAccountService.getAccount();
return { data };
}
}

View File

@@ -43,7 +43,7 @@ export class AuthSigninService {
}
if (!user.verified) {
throw new UnauthorizedException(
`The user is not verified yet, check out your mail inbox.`
`The user is not verified yet, check out your mail inbox.`,
);
}
return user;

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { GetAuthedAccountTransformer } from './GetAuthedAccount.transformer';
@Injectable()
export class GetAuthenticatedAccount {
constructor(
private readonly tenancyContext: TenancyContext,
private readonly transformer: TransformerInjectable,
) {}
async getAccount() {
const account = await this.tenancyContext.getSystemUser();
return this.transformer.transform(
account,
new GetAuthedAccountTransformer(),
);
}
}

View File

@@ -0,0 +1,19 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetAuthedAccountTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'firstName',
'lastName',
'email',
'active',
'language',
'tenantId',
'verified',
];
};
}

View File

@@ -1,6 +1,6 @@
import { DashboardService } from './Dashboard.service';
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Controller, Get } from '@nestjs/common';
import { DashboardService } from './Dashboard.service';
@ApiTags('dashboard')
@Controller('dashboard')

View File

@@ -29,8 +29,6 @@ export class DashboardService {
/**
* Retrieve dashboard meta.
* @param {number} tenantId
* @param {number} authorizedUser
*/
public getBootMeta = async (): Promise<IDashboardBootMeta> => {
// Retrieves all orgnaization abilities.
@@ -60,17 +58,19 @@ export class DashboardService {
/**
* Retrieve the boot abilities.
* @returns
* @returns {Promise<IRoleAbility[]>}
*/
private getBootAbilities = async (): Promise<IRoleAbility[]> => {
const authorizedUser = await this.tenancyContext.getSystemUser();
const tenantUser = await this.tenantUserModel().query()
const tenantUser = await this.tenantUserModel()
.query()
.findOne('systemUserId', authorizedUser.id)
.withGraphFetched('role.permissions');
.withGraphFetched('role.permissions')
.throwIfNotFound();
return tenantUser.role.slug === 'admin'
? [{ subject: 'all', action: 'manage' }]
? [{ subject: 'all', ability: 'manage' }]
: this.transformRoleAbility(tenantUser.role.permissions);
};
}

View File

@@ -8,6 +8,7 @@ import {
Req,
Res,
Next,
HttpCode,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { BuildOrganizationService } from './commands/BuildOrganization.service';
@@ -32,60 +33,52 @@ export class OrganizationController {
) {}
@Post('build')
@HttpCode(200)
@ApiOperation({ summary: 'Build organization database' })
@ApiBody({ type: BuildOrganizationDto })
@ApiResponse({
status: 200,
description: 'The organization database has been initialized',
})
async build(
@Body() buildDTO: BuildOrganizationDto,
@Req() req: Request,
@Res() res: Response,
) {
async build(@Body() buildDTO: BuildOrganizationDto) {
const result = await this.buildOrganizationService.buildRunJob(buildDTO);
return res.status(200).send({
return {
type: 'success',
code: 'ORGANIZATION.DATABASE.INITIALIZED',
message: 'The organization database has been initialized.',
data: result,
});
};
}
@Get('current')
@HttpCode(200)
@ApiOperation({ summary: 'Get current organization' })
@ApiResponse({
status: 200,
description: 'Returns the current organization',
})
async currentOrganization(
@Req() req: Request,
@Res() res: Response,
@Next() next: NextFunction,
) {
async currentOrganization() {
const organization =
await this.getCurrentOrgService.getCurrentOrganization();
return res.status(200).send({ organization });
return { organization };
}
@Put()
@HttpCode(200)
@ApiOperation({ summary: 'Update organization information' })
@ApiBody({ type: UpdateOrganizationDto })
@ApiResponse({
status: 200,
description: 'Organization information has been updated successfully',
})
async updateOrganization(
@Body() updateDTO: UpdateOrganizationDto,
@Res() res: Response,
) {
async updateOrganization(@Body() updateDTO: UpdateOrganizationDto) {
await this.updateOrganizationService.execute(updateDTO);
return res.status(200).send({
return {
code: 200,
message: 'Organization information has been updated successfully.',
});
};
}
}

View File

@@ -10,6 +10,8 @@ import { CommandOrganizationValidators } from './commands/CommandOrganizationVal
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service';
import { SyncSystemUserToTenantService } from './commands/SyncSystemUserToTenant.service';
import { SyncSystemUserToTenantSubscriber } from './subscribers/SyncSystemUserToTenant.subscriber';
@Module({
providers: [
@@ -20,6 +22,8 @@ import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBase
OrganizationBuildProcessor,
CommandOrganizationValidators,
OrganizationBaseCurrencyLocking,
SyncSystemUserToTenantService,
SyncSystemUserToTenantSubscriber
],
imports: [
BullModule.registerQueue({ name: OrganizationBuildQueue }),

View File

@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { pick } from 'lodash';
import { Role } from '@/modules/Roles/models/Role.model';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model';
@Injectable()
export class SyncSystemUserToTenantService {
constructor(
@Inject(TenantUser.name)
private readonly tenantUserModel: TenantModelProxy<typeof TenantUser>,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
* Syncs system user to tenant user.
* @param {number} systemUserId - System user id.
*/
public async syncSystemUserToTenant(systemUserId: number) {
const adminRole = await this.roleModel().query().findOne('slug', 'admin');
const systemUser = await this.systemUserModel
.query()
.findById(systemUserId);
await this.tenantUserModel()
.query()
.insert({
...pick(systemUser, [
'firstName',
'lastName',
'phoneNumber',
'email',
'active',
'inviteAcceptedAt',
]),
systemUserId: systemUser.id,
roleId: adminRole.id,
});
}
}

View File

@@ -2,6 +2,7 @@ import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { throwIfTenantNotExists } from '../Organization/_utils';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { Injectable } from '@nestjs/common';
import { ModelObject } from 'objection';
@Injectable()
export class GetCurrentOrganizationService {
@@ -12,7 +13,7 @@ export class GetCurrentOrganizationService {
* @param {number} tenantId
* @returns {Promise<ITenant[]>}
*/
async getCurrentOrganization(): Promise<TenantModel> {
async getCurrentOrganization(): Promise<ModelObject<TenantModel>> {
const tenant = await this.tenancyContext
.getTenant()
.withGraphFetched('subscriptions')

View File

@@ -0,0 +1,19 @@
import { OnEvent } from '@nestjs/event-emitter';
import { SyncSystemUserToTenantService } from '../commands/SyncSystemUserToTenant.service';
import { events } from '@/common/events/events';
import { IOrganizationBuildEventPayload } from '../Organization.types';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SyncSystemUserToTenantSubscriber {
constructor(
private readonly syncSystemUserToTenantService: SyncSystemUserToTenantService,
) {}
@OnEvent(events.organization.build)
async onOrgBuildSyncSystemUser({ systemUser }: IOrganizationBuildEventPayload) {
await this.syncSystemUserToTenantService.syncSystemUserToTenant(
systemUser.id,
);
}
}

View File

@@ -10,11 +10,15 @@ import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
export const IS_IGNORE_TENANT_INITIALIZED = 'IS_IGNORE_TENANT_INITIALIZED';
export const IgnoreTenantInitializedRoute = () => SetMetadata(IS_IGNORE_TENANT_INITIALIZED, true);
export const IgnoreTenantInitializedRoute = () =>
SetMetadata(IS_IGNORE_TENANT_INITIALIZED, true);
@Injectable()
export class EnsureTenantIsInitializedGuard implements CanActivate {
constructor(private readonly tenancyContext: TenancyContext, private reflector: Reflector) {}
constructor(
private readonly tenancyContext: TenancyContext,
private reflector: Reflector,
) {}
/**
* Validate the tenant of the current request is initialized..
@@ -22,10 +26,11 @@ export class EnsureTenantIsInitializedGuard implements CanActivate {
* @returns {Promise<boolean>}
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const isIgnoreEnsureTenantInitialized = this.reflector.getAllAndOverride<boolean>(
IS_IGNORE_TENANT_INITIALIZED,
[context.getHandler(), context.getClass()],
);
const isIgnoreEnsureTenantInitialized =
this.reflector.getAllAndOverride<boolean>(IS_IGNORE_TENANT_INITIALIZED, [
context.getHandler(),
context.getClass(),
]);
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],

View File

@@ -11,11 +11,15 @@ import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
export const IS_IGNORE_TENANT_SEEDED = 'IS_IGNORE_TENANT_SEEDED';
export const IgnoreTenantSeededRoute = () => SetMetadata(IS_IGNORE_TENANT_SEEDED, true);
export const IgnoreTenantSeededRoute = () =>
SetMetadata(IS_IGNORE_TENANT_SEEDED, true);
@Injectable()
export class EnsureTenantIsSeededGuard implements CanActivate {
constructor(private readonly tenancyContext: TenancyContext, private reflector: Reflector) {}
constructor(
private readonly tenancyContext: TenancyContext,
private reflector: Reflector,
) {}
/**
* Validate the tenant of the current request is seeded.
@@ -27,15 +31,16 @@ export class EnsureTenantIsSeededGuard implements CanActivate {
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],
);
const isIgnoreEnsureTenantSeeded = this.reflector.getAllAndOverride<boolean>(
IS_IGNORE_TENANT_SEEDED,
[context.getHandler(), context.getClass()],
);
const isIgnoreEnsureTenantSeeded =
this.reflector.getAllAndOverride<boolean>(IS_IGNORE_TENANT_SEEDED, [
context.getHandler(),
context.getClass(),
]);
if (isPublic || isIgnoreEnsureTenantSeeded) {
return true;
}
const tenant = await this.tenancyContext.getTenant();
if (!tenant.seededAt) {
throw new UnauthorizedException({
message: 'Tenant database is not seeded with initial data yet.',

View File

@@ -49,6 +49,6 @@ export class TenancyContext {
// Get the user from the request headers.
const userId = this.cls.get('userId');
return this.systemUserModel.query().findOne({ id: userId });
return this.systemUserModel.query().findById(userId);
}
}

View File

@@ -49,7 +49,7 @@ export class TenantUser extends TenantBaseModel {
* Relationship mapping.
*/
static get relationMappings() {
const Role = require('models/Role');
const { Role } = require('../../../Roles/models/Role.model');
return {
/**
@@ -57,7 +57,7 @@ export class TenantUser extends TenantBaseModel {
*/
role: {
relation: Model.BelongsToOneRelation,
modelClass: Role.default,
modelClass: Role,
join: {
from: 'users.roleId',
to: 'roles.id',

View File

@@ -36,7 +36,7 @@ export class TransformerInjectable {
*/
async getTenantDateFormat() {
const tenant = await this.tenancyContext.getTenant(true);
return tenant.metadata.dateFormat;
return tenant.metadata?.dateFormat;
}
/**
@@ -55,7 +55,8 @@ export class TransformerInjectable {
transformer.setContext(context);
const dateFormat = await this.getTenantDateFormat();
transformer.setDateFormat(dateFormat);
transformer.setDateFormat(dateFormat || 'DD-MM-YYYY');
transformer.setOptions(options);
return transformer.work(object);