mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 14:20:31 +00:00
refactor(nestjs): hook up auth endpoints
This commit is contained in:
@@ -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;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import './utils/moment-mysql';
|
|||||||
import { AppModule } from './modules/App/App.module';
|
import { AppModule } from './modules/App/App.module';
|
||||||
import { ServiceErrorFilter } from './common/filters/service-error.filter';
|
import { ServiceErrorFilter } from './common/filters/service-error.filter';
|
||||||
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
|
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
|
||||||
|
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
|
||||||
|
|
||||||
global.__static_dirname = path.join(__dirname, '../static');
|
global.__static_dirname = path.join(__dirname, '../static');
|
||||||
global.__views_dirname = path.join(global.__static_dirname, '/views');
|
global.__views_dirname = path.join(global.__static_dirname, '/views');
|
||||||
@@ -18,6 +19,8 @@ async function bootstrap() {
|
|||||||
// create and mount the middleware manually here
|
// create and mount the middleware manually here
|
||||||
app.use(new ClsMiddleware({}).use);
|
app.use(new ClsMiddleware({}).use);
|
||||||
|
|
||||||
|
app.useGlobalInterceptors(new ToJsonInterceptor());
|
||||||
|
|
||||||
// use the validation pipe globally
|
// use the validation pipe globally
|
||||||
app.useGlobalPipes(new ValidationPipe());
|
app.useGlobalPipes(new ValidationPipe());
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBody, ApiParam } from '@nestjs/swagger';
|
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 { AuthenticationApplication } from './AuthApplication.sevice';
|
||||||
import { AuthSignupDto } from './dtos/AuthSignup.dto';
|
import { AuthSignupDto } from './dtos/AuthSignup.dto';
|
||||||
import { AuthSigninDto } from './dtos/AuthSignin.dto';
|
import { AuthSigninDto } from './dtos/AuthSignin.dto';
|
||||||
import { LocalAuthGuard } from './guards/local.guard';
|
import { LocalAuthGuard } from './guards/local.guard';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { AuthSigninService } from './commands/AuthSignin.service';
|
import { AuthSigninService } from './commands/AuthSignin.service';
|
||||||
|
import { TenantModel } from '../System/models/TenantModel';
|
||||||
|
import { SystemUser } from '../System/models/SystemUser';
|
||||||
|
|
||||||
@Controller('/auth')
|
@Controller('/auth')
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@@ -24,15 +25,25 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly authApp: AuthenticationApplication,
|
private readonly authApp: AuthenticationApplication,
|
||||||
private readonly authSignin: AuthSigninService,
|
private readonly authSignin: AuthSigninService,
|
||||||
|
|
||||||
|
@Inject(TenantModel.name)
|
||||||
|
private readonly tenantModel: typeof TenantModel,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/signin')
|
@Post('/signin')
|
||||||
@UseGuards(LocalAuthGuard)
|
@UseGuards(LocalAuthGuard)
|
||||||
@ApiOperation({ summary: 'Sign in a user' })
|
@ApiOperation({ summary: 'Sign in a user' })
|
||||||
@ApiBody({ type: AuthSigninDto })
|
@ApiBody({ type: AuthSigninDto })
|
||||||
signin(@Request() req: Request, @Body() signinDto: AuthSigninDto) {
|
async signin(@Request() req: Request & { user: SystemUser }, @Body() signinDto: AuthSigninDto) {
|
||||||
const { user } = req;
|
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')
|
@Post('/signup')
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ import { MailModule } from '../Mail/Mail.module';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
|
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
|
||||||
import { GetAuthMetaService } from './queries/GetAuthMeta.service';
|
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)];
|
const models = [InjectSystemModel(PasswordReset)];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, AuthedController],
|
||||||
imports: [
|
imports: [
|
||||||
MailModule,
|
MailModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
@@ -45,9 +48,9 @@ const models = [InjectSystemModel(PasswordReset)];
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
TenantDBManagerModule,
|
TenantDBManagerModule,
|
||||||
|
TenancyModule,
|
||||||
BullModule.registerQueue({ name: SendResetPasswordMailQueue }),
|
BullModule.registerQueue({ name: SendResetPasswordMailQueue }),
|
||||||
BullModule.registerQueue({ name: SendSignupVerificationMailQueue }),
|
BullModule.registerQueue({ name: SendSignupVerificationMailQueue }),
|
||||||
|
|
||||||
],
|
],
|
||||||
exports: [...models],
|
exports: [...models],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -65,6 +68,7 @@ const models = [InjectSystemModel(PasswordReset)];
|
|||||||
SendResetPasswordMailProcessor,
|
SendResetPasswordMailProcessor,
|
||||||
SendSignupVerificationMailProcessor,
|
SendSignupVerificationMailProcessor,
|
||||||
GetAuthMetaService,
|
GetAuthMetaService,
|
||||||
|
GetAuthenticatedAccount,
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: JwtAuthGuard,
|
useClass: JwtAuthGuard,
|
||||||
|
|||||||
23
packages/server/src/modules/Auth/Authed.controller.ts
Normal file
23
packages/server/src/modules/Auth/Authed.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export class AuthSigninService {
|
|||||||
}
|
}
|
||||||
if (!user.verified) {
|
if (!user.verified) {
|
||||||
throw new UnauthorizedException(
|
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;
|
return user;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DashboardService } from './Dashboard.service';
|
|
||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { DashboardService } from './Dashboard.service';
|
||||||
|
|
||||||
@ApiTags('dashboard')
|
@ApiTags('dashboard')
|
||||||
@Controller('dashboard')
|
@Controller('dashboard')
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export class DashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve dashboard meta.
|
* Retrieve dashboard meta.
|
||||||
* @param {number} tenantId
|
|
||||||
* @param {number} authorizedUser
|
|
||||||
*/
|
*/
|
||||||
public getBootMeta = async (): Promise<IDashboardBootMeta> => {
|
public getBootMeta = async (): Promise<IDashboardBootMeta> => {
|
||||||
// Retrieves all orgnaization abilities.
|
// Retrieves all orgnaization abilities.
|
||||||
@@ -60,17 +58,19 @@ export class DashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the boot abilities.
|
* Retrieve the boot abilities.
|
||||||
* @returns
|
* @returns {Promise<IRoleAbility[]>}
|
||||||
*/
|
*/
|
||||||
private getBootAbilities = async (): Promise<IRoleAbility[]> => {
|
private getBootAbilities = async (): Promise<IRoleAbility[]> => {
|
||||||
const authorizedUser = await this.tenancyContext.getSystemUser();
|
const authorizedUser = await this.tenancyContext.getSystemUser();
|
||||||
|
|
||||||
const tenantUser = await this.tenantUserModel().query()
|
const tenantUser = await this.tenantUserModel()
|
||||||
|
.query()
|
||||||
.findOne('systemUserId', authorizedUser.id)
|
.findOne('systemUserId', authorizedUser.id)
|
||||||
.withGraphFetched('role.permissions');
|
.withGraphFetched('role.permissions')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
return tenantUser.role.slug === 'admin'
|
return tenantUser.role.slug === 'admin'
|
||||||
? [{ subject: 'all', action: 'manage' }]
|
? [{ subject: 'all', ability: 'manage' }]
|
||||||
: this.transformRoleAbility(tenantUser.role.permissions);
|
: this.transformRoleAbility(tenantUser.role.permissions);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
Next,
|
Next,
|
||||||
|
HttpCode,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { BuildOrganizationService } from './commands/BuildOrganization.service';
|
import { BuildOrganizationService } from './commands/BuildOrganization.service';
|
||||||
@@ -32,60 +33,52 @@ export class OrganizationController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('build')
|
@Post('build')
|
||||||
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Build organization database' })
|
@ApiOperation({ summary: 'Build organization database' })
|
||||||
@ApiBody({ type: BuildOrganizationDto })
|
@ApiBody({ type: BuildOrganizationDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'The organization database has been initialized',
|
description: 'The organization database has been initialized',
|
||||||
})
|
})
|
||||||
async build(
|
async build(@Body() buildDTO: BuildOrganizationDto) {
|
||||||
@Body() buildDTO: BuildOrganizationDto,
|
|
||||||
@Req() req: Request,
|
|
||||||
@Res() res: Response,
|
|
||||||
) {
|
|
||||||
const result = await this.buildOrganizationService.buildRunJob(buildDTO);
|
const result = await this.buildOrganizationService.buildRunJob(buildDTO);
|
||||||
|
|
||||||
return res.status(200).send({
|
return {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
code: 'ORGANIZATION.DATABASE.INITIALIZED',
|
code: 'ORGANIZATION.DATABASE.INITIALIZED',
|
||||||
message: 'The organization database has been initialized.',
|
message: 'The organization database has been initialized.',
|
||||||
data: result,
|
data: result,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('current')
|
@Get('current')
|
||||||
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Get current organization' })
|
@ApiOperation({ summary: 'Get current organization' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Returns the current organization',
|
description: 'Returns the current organization',
|
||||||
})
|
})
|
||||||
async currentOrganization(
|
async currentOrganization() {
|
||||||
@Req() req: Request,
|
|
||||||
@Res() res: Response,
|
|
||||||
@Next() next: NextFunction,
|
|
||||||
) {
|
|
||||||
const organization =
|
const organization =
|
||||||
await this.getCurrentOrgService.getCurrentOrganization();
|
await this.getCurrentOrgService.getCurrentOrganization();
|
||||||
|
|
||||||
return res.status(200).send({ organization });
|
return { organization };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Update organization information' })
|
@ApiOperation({ summary: 'Update organization information' })
|
||||||
@ApiBody({ type: UpdateOrganizationDto })
|
@ApiBody({ type: UpdateOrganizationDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Organization information has been updated successfully',
|
description: 'Organization information has been updated successfully',
|
||||||
})
|
})
|
||||||
async updateOrganization(
|
async updateOrganization(@Body() updateDTO: UpdateOrganizationDto) {
|
||||||
@Body() updateDTO: UpdateOrganizationDto,
|
|
||||||
@Res() res: Response,
|
|
||||||
) {
|
|
||||||
await this.updateOrganizationService.execute(updateDTO);
|
await this.updateOrganizationService.execute(updateDTO);
|
||||||
|
|
||||||
return res.status(200).send({
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Organization information has been updated successfully.',
|
message: 'Organization information has been updated successfully.',
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { CommandOrganizationValidators } from './commands/CommandOrganizationVal
|
|||||||
import { TenancyContext } from '../Tenancy/TenancyContext.service';
|
import { TenancyContext } from '../Tenancy/TenancyContext.service';
|
||||||
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
|
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
|
||||||
import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service';
|
import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service';
|
||||||
|
import { SyncSystemUserToTenantService } from './commands/SyncSystemUserToTenant.service';
|
||||||
|
import { SyncSystemUserToTenantSubscriber } from './subscribers/SyncSystemUserToTenant.subscriber';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -20,6 +22,8 @@ import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBase
|
|||||||
OrganizationBuildProcessor,
|
OrganizationBuildProcessor,
|
||||||
CommandOrganizationValidators,
|
CommandOrganizationValidators,
|
||||||
OrganizationBaseCurrencyLocking,
|
OrganizationBaseCurrencyLocking,
|
||||||
|
SyncSystemUserToTenantService,
|
||||||
|
SyncSystemUserToTenantSubscriber
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BullModule.registerQueue({ name: OrganizationBuildQueue }),
|
BullModule.registerQueue({ name: OrganizationBuildQueue }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
|||||||
import { throwIfTenantNotExists } from '../Organization/_utils';
|
import { throwIfTenantNotExists } from '../Organization/_utils';
|
||||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ModelObject } from 'objection';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetCurrentOrganizationService {
|
export class GetCurrentOrganizationService {
|
||||||
@@ -12,7 +13,7 @@ export class GetCurrentOrganizationService {
|
|||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
* @returns {Promise<ITenant[]>}
|
* @returns {Promise<ITenant[]>}
|
||||||
*/
|
*/
|
||||||
async getCurrentOrganization(): Promise<TenantModel> {
|
async getCurrentOrganization(): Promise<ModelObject<TenantModel>> {
|
||||||
const tenant = await this.tenancyContext
|
const tenant = await this.tenancyContext
|
||||||
.getTenant()
|
.getTenant()
|
||||||
.withGraphFetched('subscriptions')
|
.withGraphFetched('subscriptions')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,15 @@ import { Reflector } from '@nestjs/core';
|
|||||||
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
|
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
|
||||||
|
|
||||||
export const IS_IGNORE_TENANT_INITIALIZED = 'IS_IGNORE_TENANT_INITIALIZED';
|
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()
|
@Injectable()
|
||||||
export class EnsureTenantIsInitializedGuard implements CanActivate {
|
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..
|
* Validate the tenant of the current request is initialized..
|
||||||
@@ -22,10 +26,11 @@ export class EnsureTenantIsInitializedGuard implements CanActivate {
|
|||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isIgnoreEnsureTenantInitialized = this.reflector.getAllAndOverride<boolean>(
|
const isIgnoreEnsureTenantInitialized =
|
||||||
IS_IGNORE_TENANT_INITIALIZED,
|
this.reflector.getAllAndOverride<boolean>(IS_IGNORE_TENANT_INITIALIZED, [
|
||||||
[context.getHandler(), context.getClass()],
|
context.getHandler(),
|
||||||
);
|
context.getClass(),
|
||||||
|
]);
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||||
IS_PUBLIC_ROUTE,
|
IS_PUBLIC_ROUTE,
|
||||||
[context.getHandler(), context.getClass()],
|
[context.getHandler(), context.getClass()],
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import { Reflector } from '@nestjs/core';
|
|||||||
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
|
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
|
||||||
|
|
||||||
export const IS_IGNORE_TENANT_SEEDED = 'IS_IGNORE_TENANT_SEEDED';
|
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()
|
@Injectable()
|
||||||
export class EnsureTenantIsSeededGuard implements CanActivate {
|
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.
|
* Validate the tenant of the current request is seeded.
|
||||||
@@ -27,15 +31,16 @@ export class EnsureTenantIsSeededGuard implements CanActivate {
|
|||||||
IS_PUBLIC_ROUTE,
|
IS_PUBLIC_ROUTE,
|
||||||
[context.getHandler(), context.getClass()],
|
[context.getHandler(), context.getClass()],
|
||||||
);
|
);
|
||||||
const isIgnoreEnsureTenantSeeded = this.reflector.getAllAndOverride<boolean>(
|
const isIgnoreEnsureTenantSeeded =
|
||||||
IS_IGNORE_TENANT_SEEDED,
|
this.reflector.getAllAndOverride<boolean>(IS_IGNORE_TENANT_SEEDED, [
|
||||||
[context.getHandler(), context.getClass()],
|
context.getHandler(),
|
||||||
);
|
context.getClass(),
|
||||||
|
]);
|
||||||
if (isPublic || isIgnoreEnsureTenantSeeded) {
|
if (isPublic || isIgnoreEnsureTenantSeeded) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const tenant = await this.tenancyContext.getTenant();
|
const tenant = await this.tenancyContext.getTenant();
|
||||||
|
|
||||||
if (!tenant.seededAt) {
|
if (!tenant.seededAt) {
|
||||||
throw new UnauthorizedException({
|
throw new UnauthorizedException({
|
||||||
message: 'Tenant database is not seeded with initial data yet.',
|
message: 'Tenant database is not seeded with initial data yet.',
|
||||||
|
|||||||
@@ -49,6 +49,6 @@ export class TenancyContext {
|
|||||||
// Get the user from the request headers.
|
// Get the user from the request headers.
|
||||||
const userId = this.cls.get('userId');
|
const userId = this.cls.get('userId');
|
||||||
|
|
||||||
return this.systemUserModel.query().findOne({ id: userId });
|
return this.systemUserModel.query().findById(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class TenantUser extends TenantBaseModel {
|
|||||||
* Relationship mapping.
|
* Relationship mapping.
|
||||||
*/
|
*/
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const Role = require('models/Role');
|
const { Role } = require('../../../Roles/models/Role.model');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ export class TenantUser extends TenantBaseModel {
|
|||||||
*/
|
*/
|
||||||
role: {
|
role: {
|
||||||
relation: Model.BelongsToOneRelation,
|
relation: Model.BelongsToOneRelation,
|
||||||
modelClass: Role.default,
|
modelClass: Role,
|
||||||
join: {
|
join: {
|
||||||
from: 'users.roleId',
|
from: 'users.roleId',
|
||||||
to: 'roles.id',
|
to: 'roles.id',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class TransformerInjectable {
|
|||||||
*/
|
*/
|
||||||
async getTenantDateFormat() {
|
async getTenantDateFormat() {
|
||||||
const tenant = await this.tenancyContext.getTenant(true);
|
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);
|
transformer.setContext(context);
|
||||||
|
|
||||||
const dateFormat = await this.getTenantDateFormat();
|
const dateFormat = await this.getTenantDateFormat();
|
||||||
transformer.setDateFormat(dateFormat);
|
|
||||||
|
transformer.setDateFormat(dateFormat || 'DD-MM-YYYY');
|
||||||
transformer.setOptions(options);
|
transformer.setOptions(options);
|
||||||
|
|
||||||
return transformer.work(object);
|
return transformer.work(object);
|
||||||
|
|||||||
@@ -29,22 +29,19 @@ export default function Login() {
|
|||||||
|
|
||||||
const handleSubmit = (values, { setSubmitting }) => {
|
const handleSubmit = (values, { setSubmitting }) => {
|
||||||
loginMutate({
|
loginMutate({
|
||||||
crediential: values.crediential,
|
email: values.crediential,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
}).catch(
|
}).catch(({ response }) => {
|
||||||
({
|
const {
|
||||||
response: {
|
data: { errors },
|
||||||
data: { errors },
|
} = response;
|
||||||
},
|
const toastBuilders = transformLoginErrorsToToasts(errors);
|
||||||
}) => {
|
|
||||||
const toastBuilders = transformLoginErrorsToToasts(errors);
|
|
||||||
|
|
||||||
toastBuilders.forEach((builder) => {
|
toastBuilders.forEach((builder) => {
|
||||||
Toaster.show(builder);
|
Toaster.show(builder);
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +67,10 @@ function LoginFooterLinks() {
|
|||||||
<AuthFooterLinks>
|
<AuthFooterLinks>
|
||||||
{!signupDisabled && (
|
{!signupDisabled && (
|
||||||
<AuthFooterLink>
|
<AuthFooterLink>
|
||||||
<T id={'dont_have_an_account'} /> <Link to={'/auth/register'}><T id={'sign_up'} /></Link>
|
<T id={'dont_have_an_account'} />{' '}
|
||||||
|
<Link to={'/auth/register'}>
|
||||||
|
<T id={'sign_up'} />
|
||||||
|
</Link>
|
||||||
</AuthFooterLink>
|
</AuthFooterLink>
|
||||||
)}
|
)}
|
||||||
<AuthFooterLink>
|
<AuthFooterLink>
|
||||||
|
|||||||
@@ -53,22 +53,20 @@ export default function RegisterUserForm() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(
|
.catch(({ response }) => {
|
||||||
({
|
const {
|
||||||
response: {
|
data: { errors },
|
||||||
data: { errors },
|
} = response;
|
||||||
},
|
|
||||||
}) => {
|
const formErrors = transformRegisterErrorsToForm(errors);
|
||||||
const formErrors = transformRegisterErrorsToForm(errors);
|
const toastMessages = transformRegisterToastMessages(errors);
|
||||||
const toastMessages = transformRegisterToastMessages(errors);
|
|
||||||
|
|
||||||
toastMessages.forEach((toastMessage) => {
|
toastMessages.forEach((toastMessage) => {
|
||||||
AppToaster.show(toastMessage);
|
AppToaster.show(toastMessage);
|
||||||
});
|
});
|
||||||
setErrors(formErrors);
|
setErrors(formErrors);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,17 +13,27 @@ import {
|
|||||||
useSetTenantId,
|
useSetTenantId,
|
||||||
} from '../state';
|
} from '../state';
|
||||||
|
|
||||||
|
const AuthRoute = {
|
||||||
|
Signin: 'auth/signin',
|
||||||
|
Signup: 'auth/signup',
|
||||||
|
SignupVerify: 'auth/signup/verify',
|
||||||
|
SignupVerifyResend: 'auth/signup/verify/resend',
|
||||||
|
SendResetPassword: 'auth/send_reset_password',
|
||||||
|
ForgetPassword: 'auth/reset_password/:token',
|
||||||
|
AuthMeta: 'auth/meta',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the response data to cookies.
|
* Saves the response data to cookies.
|
||||||
*/
|
*/
|
||||||
export function setAuthLoginCookies(data) {
|
export function setAuthLoginCookies(data) {
|
||||||
setCookie('token', data.token);
|
setCookie('token', data.access_token);
|
||||||
setCookie('authenticated_user_id', data.user.id);
|
setCookie('authenticated_user_id', data.user_id);
|
||||||
setCookie('organization_id', data.tenant.organization_id);
|
setCookie('organization_id', data.organization_id);
|
||||||
setCookie('tenant_id', data.tenant.id);
|
setCookie('tenant_id', data.tenant_id);
|
||||||
|
|
||||||
if (data?.tenant?.metadata?.language)
|
// if (data?.tenant?.metadata?.language)
|
||||||
setCookie('locale', data.tenant.metadata.language);
|
// setCookie('locale', data.tenant.metadata.language);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +48,7 @@ export const useAuthLogin = (props) => {
|
|||||||
const setTenantId = useSetTenantId();
|
const setTenantId = useSetTenantId();
|
||||||
const setLocale = useSetLocale();
|
const setLocale = useSetLocale();
|
||||||
|
|
||||||
return useMutation((values) => apiRequest.post('auth/login', values), {
|
return useMutation((values) => apiRequest.post(AuthRoute.Signin, values), {
|
||||||
select: (res) => res.data,
|
select: (res) => res.data,
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
// Set authentication cookies.
|
// Set authentication cookies.
|
||||||
@@ -46,14 +56,14 @@ export const useAuthLogin = (props) => {
|
|||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
// Sets the auth metadata to global state.
|
// Sets the auth metadata to global state.
|
||||||
setAuthToken(res.data.token);
|
setAuthToken(res.data.access_token);
|
||||||
setOrganizationId(res.data.tenant.organization_id);
|
setOrganizationId(res.data.organization_id);
|
||||||
setUserId(res.data.user.id);
|
setTenantId(res.data.tenant_id);
|
||||||
setTenantId(res.data.tenant.id);
|
setUserId(res.data.user_id);
|
||||||
|
|
||||||
if (res.data?.tenant?.metadata?.language) {
|
// if (res.data?.tenant?.metadata?.language) {
|
||||||
setLocale(res.data?.tenant?.metadata?.language);
|
// setLocale(res.data?.tenant?.metadata?.language);
|
||||||
}
|
// }
|
||||||
});
|
});
|
||||||
props?.onSuccess && props?.onSuccess(...args);
|
props?.onSuccess && props?.onSuccess(...args);
|
||||||
},
|
},
|
||||||
@@ -68,7 +78,7 @@ export const useAuthRegister = (props) => {
|
|||||||
const apiRequest = useApiRequest();
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(values) => apiRequest.post('auth/register', values),
|
(values) => apiRequest.post(AuthRoute.Signup, values),
|
||||||
props,
|
props,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -80,7 +90,7 @@ export const useAuthSendResetPassword = (props) => {
|
|||||||
const apiRequest = useApiRequest();
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(email) => apiRequest.post('auth/send_reset_password', email),
|
(values) => apiRequest.post(AuthRoute.SendResetPassword, values),
|
||||||
props,
|
props,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -105,7 +115,7 @@ export const useAuthMetadata = (props = {}) => {
|
|||||||
[t.AUTH_METADATA_PAGE],
|
[t.AUTH_METADATA_PAGE],
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: `auth/meta`,
|
url: AuthRoute.AuthMeta,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
select: (res) => res.data,
|
select: (res) => res.data,
|
||||||
@@ -116,13 +126,13 @@ export const useAuthMetadata = (props = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Resend the mail of signup verification.
|
||||||
*/
|
*/
|
||||||
export const useAuthSignUpVerifyResendMail = (props) => {
|
export const useAuthSignUpVerifyResendMail = (props) => {
|
||||||
const apiRequest = useApiRequest();
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
() => apiRequest.post('auth/register/verify/resend'),
|
() => apiRequest.post(AuthRoute.SignupVerifyResend),
|
||||||
props,
|
props,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -133,14 +143,14 @@ interface AuthSignUpVerifyValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Signup verification.
|
||||||
*/
|
*/
|
||||||
export const useAuthSignUpVerify = (props) => {
|
export const useAuthSignUpVerify = (props) => {
|
||||||
const apiRequest = useApiRequest();
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(values: AuthSignUpVerifyValues) =>
|
(values: AuthSignUpVerifyValues) =>
|
||||||
apiRequest.post('auth/register/verify', values),
|
apiRequest.post(AuthRoute.SignupVerify, values),
|
||||||
props,
|
props,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import useApiRequest from '../useRequest';
|
|||||||
import { useRequestQuery } from '../useQueryRequest';
|
import { useRequestQuery } from '../useQueryRequest';
|
||||||
import { useSetOrganizations, useSetSubscriptions } from '../state';
|
import { useSetOrganizations, useSetSubscriptions } from '../state';
|
||||||
|
|
||||||
|
const OrganizationRoute = {
|
||||||
|
Current: '/organization/current',
|
||||||
|
Build: '/organization/build',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve organizations of the authenticated user.
|
* Retrieve organizations of the authenticated user.
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +41,7 @@ export function useCurrentOrganization(props) {
|
|||||||
|
|
||||||
return useRequestQuery(
|
return useRequestQuery(
|
||||||
[t.ORGANIZATION_CURRENT],
|
[t.ORGANIZATION_CURRENT],
|
||||||
{ method: 'get', url: `organization` },
|
{ method: 'get', url: OrganizationRoute.Current },
|
||||||
{
|
{
|
||||||
select: (res) => res.data.organization,
|
select: (res) => res.data.organization,
|
||||||
defaultData: {},
|
defaultData: {},
|
||||||
@@ -64,7 +69,7 @@ export function useOrganizationSetup() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(values) => apiRequest.post(`organization/build`, values),
|
(values) => apiRequest.post(OrganizationRoute.Build, values),
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries(t.ORGANIZATION_CURRENT);
|
queryClient.invalidateQueries(t.ORGANIZATION_CURRENT);
|
||||||
|
|||||||
@@ -137,13 +137,14 @@ export function useAuthenticatedAccount(props) {
|
|||||||
['AuthenticatedAccount'],
|
['AuthenticatedAccount'],
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: `account`,
|
url: `auth/account`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
select: (response) => response.data.data,
|
select: (response) => response.data.data,
|
||||||
defaultData: {},
|
defaultData: {},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setEmailConfirmed(data.is_verified, data.email);
|
debugger;
|
||||||
|
setEmailConfirmed(data.verified, data.email);
|
||||||
},
|
},
|
||||||
...props,
|
...props,
|
||||||
},
|
},
|
||||||
@@ -160,7 +161,7 @@ export const useDashboardMeta = (props) => {
|
|||||||
[t.DASHBOARD_META],
|
[t.DASHBOARD_META],
|
||||||
{ method: 'get', url: 'dashboard/boot' },
|
{ method: 'get', url: 'dashboard/boot' },
|
||||||
{
|
{
|
||||||
select: (res) => res.data.meta,
|
select: (res) => res.data,
|
||||||
defaultData: {},
|
defaultData: {},
|
||||||
...props,
|
...props,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function useApiRequest() {
|
|||||||
const locale = currentLocale;
|
const locale = currentLocale;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
request.headers['X-Access-Token'] = token;
|
request.headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
request.headers['organization-id'] = organizationId;
|
request.headers['organization-id'] = organizationId;
|
||||||
|
|||||||
Reference in New Issue
Block a user