diff --git a/backend/apps/cloud/src/action-tokens/action-token.entity.ts b/backend/apps/cloud/src/action-tokens/action-token.entity.ts index 27dd5ed1a..a57b26eee 100644 --- a/backend/apps/cloud/src/action-tokens/action-token.entity.ts +++ b/backend/apps/cloud/src/action-tokens/action-token.entity.ts @@ -15,6 +15,7 @@ export enum ActionTokenType { PROJECT_SHARE, ADDING_PROJECT_SUBSCRIBER, TRANSFER_PROJECT, + ORGANISATION_INVITE, } @Entity() diff --git a/backend/apps/cloud/src/action-tokens/action-tokens.service.ts b/backend/apps/cloud/src/action-tokens/action-tokens.service.ts index 487ac4953..2ff4a7be1 100644 --- a/backend/apps/cloud/src/action-tokens/action-tokens.service.ts +++ b/backend/apps/cloud/src/action-tokens/action-tokens.service.ts @@ -24,22 +24,22 @@ export class ActionTokensService { user: User, action: ActionTokenType, newValue: string = null, - ): Promise { + ) { return this.actionTokensRepository.save({ user, action, newValue }) } - async find(id: string): Promise { + async find(id: string) { return this.actionTokensRepository.findOneOrFail({ where: { id }, relations: ['user'], }) } - async delete(id: string): Promise { + async delete(id: string) { await this.actionTokensRepository.delete(id) } - public async createActionToken( + async createActionToken( userId: string, action: ActionTokenType, newValue?: string, @@ -51,14 +51,14 @@ export class ActionTokensService { }) } - public async findActionToken(token: string) { + async findActionToken(token: string) { return this.actionTokensRepository.findOne({ where: { id: token }, relations: ['user'], }) } - public async deleteActionToken(token: string) { + async deleteActionToken(token: string) { await this.actionTokensRepository.delete(token) } diff --git a/backend/apps/cloud/src/alert/alert.controller.ts b/backend/apps/cloud/src/alert/alert.controller.ts index 4316c7943..dd7ce4b61 100644 --- a/backend/apps/cloud/src/alert/alert.controller.ts +++ b/backend/apps/cloud/src/alert/alert.controller.ts @@ -46,32 +46,56 @@ export class AlertController { ) {} @ApiBearerAuth() - @Get('/') + @Get('/:alertId') @UseGuards(JwtAccessTokenGuard, RolesGuard) @Roles(UserType.ADMIN, UserType.CUSTOMER) @ApiResponse({ status: 200, type: Alert }) - async getAllAlerts( + async getAlert( @CurrentUserId() userId: string, + @Param('alertId') alertId: string, + ) { + this.logger.log({ userId, alertId }, 'GET /alert/:alertId') + + const alert = await this.alertService.findOne({ + where: { id: alertId }, + relations: ['project'], + }) + + if (_isEmpty(alert)) { + throw new NotFoundException('Alert not found') + } + + const project = await this.projectService.getFullProject(alert.project.id) + + this.projectService.allowedToView(project, userId) + + return _omit(alert, ['project']) + } + + @ApiBearerAuth() + @Get('/project/:projectId') + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.ADMIN, UserType.CUSTOMER) + @ApiResponse({ status: 200, type: Alert }) + async getProjectAlerts( + @CurrentUserId() userId: string, + @Param('projectId') projectId: string, @Query('take') take: number | undefined, @Query('skip') skip: number | undefined, ) { - this.logger.log({ userId, take, skip }, 'GET /alert') + this.logger.log({ userId, projectId, take, skip }, 'GET /alert/:projectId') - const projects = await this.projectService.find({ - where: { - admin: { id: userId }, - }, - }) + const project = await this.projectService.getFullProject(projectId) - if (_isEmpty(projects)) { - return [] + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') } - const pids = _map(projects, project => project.id) + this.projectService.allowedToView(project, userId) const result = await this.alertService.paginate( { take, skip }, - { project: In(pids) }, + { project: { id: projectId } }, ['project'], ) diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index 892998436..c8bcdfb6a 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -30,6 +30,7 @@ import { IntegrationsModule } from './integrations/integrations.module' import { HealthModule } from './health/health.module' import { AppController } from './app.controller' import { isPrimaryNode, isPrimaryClusterNode } from './common/utils' +import { OrganisationModule } from './organisation/organisation.module' const modules = [ SentryModule.forRoot(), @@ -91,6 +92,7 @@ const modules = [ CaptchaModule, OgImageModule, HealthModule, + OrganisationModule, ] @Module({ diff --git a/backend/apps/cloud/src/auth/auth.controller.ts b/backend/apps/cloud/src/auth/auth.controller.ts index 348e71002..1d9a191de 100644 --- a/backend/apps/cloud/src/auth/auth.controller.ts +++ b/backend/apps/cloud/src/auth/auth.controller.ts @@ -52,6 +52,7 @@ import { SSOProviders, } from './dtos' import { JwtAccessTokenGuard, JwtRefreshTokenGuard, RolesGuard } from './guards' +import { ProjectService } from '../project/project.service' const OAUTH_RATE_LIMIT = 15 @@ -71,6 +72,7 @@ export class AuthController { constructor( private readonly userService: UserService, private readonly authService: AuthService, + private readonly projectService: ProjectService, ) {} @ApiOperation({ summary: 'Register a new user' }) @@ -85,7 +87,7 @@ export class AuthController { @I18n() i18n: I18nContext, @Headers() headers: unknown, @Ip() requestIp: string, - ): Promise { + ) { const ip = getIPFromHeaders(headers) || requestIp || '' await checkRateLimit(ip, 'register', 5) @@ -121,6 +123,7 @@ export class AuthController { return { ...jwtTokens, user: this.userService.omitSensitiveData(newUser), + totalMonthlyEvents: 0, } } @@ -142,7 +145,7 @@ export class AuthController { await checkRateLimit(ip, 'login', 10, 1800) - let user = await this.authService.validateUser(body.email, body.password) + let user = await this.authService.getBasicUser(body.email, body.password) if (!user) { throw new ConflictException(i18n.t('auth.invalidCredentials')) @@ -153,20 +156,29 @@ export class AuthController { !user.isTwoFactorAuthenticationEnabled, ) + const meta = { + totalMonthlyEvents: undefined, + } + await this.authService.sendTelegramNotification(user.id, headers, ip) - console.log('before:', user) if (user.isTwoFactorAuthenticationEnabled) { // @ts-expect-error user = _pick(user, ['isTwoFactorAuthenticationEnabled', 'email']) } else { - user = await this.authService.getSharedProjectsForUser(user) + const [sharedProjects, organisationMemberships] = await Promise.all([ + this.authService.getSharedProjectsForUser(user.id), + this.authService.getOrganisationsForUser(user.id), + ]) + + user.sharedProjects = sharedProjects + user.organisationMemberships = organisationMemberships + meta.totalMonthlyEvents = await this.projectService.getRedisCount(user.id) } - console.log('after:', user) - return { ...jwtTokens, + ...meta, user: this.userService.omitSensitiveData(user as User), } } @@ -260,7 +272,7 @@ export class AuthController { throw new UnauthorizedException() } - const isPasswordValid = await this.authService.validateUser( + const isPasswordValid = await this.authService.getBasicUser( user.email, body.oldPassword, ) @@ -323,7 +335,7 @@ export class AuthController { throw new UnauthorizedException() } - const isPasswordValid = await this.authService.validateUser( + const isPasswordValid = await this.authService.getBasicUser( user.email, body.password, ) diff --git a/backend/apps/cloud/src/auth/auth.module.ts b/backend/apps/cloud/src/auth/auth.module.ts index ce4477b9c..a9fed335a 100644 --- a/backend/apps/cloud/src/auth/auth.module.ts +++ b/backend/apps/cloud/src/auth/auth.module.ts @@ -15,6 +15,7 @@ import { JwtRefreshTokenStrategy, } from './strategies' import { Message } from '../integrations/telegram/entities/message.entity' +import { OrganisationModule } from '../organisation/organisation.module' @Module({ imports: [ @@ -26,6 +27,7 @@ import { Message } from '../integrations/telegram/entities/message.entity' ActionTokensModule, ProjectModule, TypeOrmModule.forFeature([Message]), + OrganisationModule, ], controllers: [AuthController], providers: [ diff --git a/backend/apps/cloud/src/auth/auth.service.ts b/backend/apps/cloud/src/auth/auth.service.ts index 4dafeccbd..e74d85db9 100644 --- a/backend/apps/cloud/src/auth/auth.service.ts +++ b/backend/apps/cloud/src/auth/auth.service.ts @@ -47,6 +47,7 @@ import { TelegramService } from '../integrations/telegram/telegram.service' import { SSOProviders } from './dtos' import { UserGoogleDTO } from '../user/dto/user-google.dto' import { UserGithubDTO } from '../user/dto/user-github.dto' +import { OrganisationService } from '../organisation/organisation.service' const REDIS_SSO_SESSION_TIMEOUT = 60 * 5 // 5 minutes const getSSORedisKey = (uuid: string) => `${REDIS_SSO_UUID}:${uuid}` @@ -77,6 +78,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly projectService: ProjectService, private readonly telegramService: TelegramService, + private readonly organisationService: OrganisationService, ) { this.oauth2Client = new Auth.OAuth2Client( this.configService.get('GOOGLE_OAUTH2_CLIENT_ID'), @@ -180,7 +182,7 @@ export class AuthService { ) } - public async validateUser( + public async getBasicUser( email: string, password: string, ): Promise { @@ -201,17 +203,32 @@ export class AuthService { return null } - public async getSharedProjectsForUser(user: User): Promise { - const sharedProjects = await this.projectService.findShare({ + async getSharedProjectsForUser(userId: string) { + return this.projectService.findShare({ where: { - user: { id: user.id }, + user: { id: userId }, }, relations: ['project'], }) + } - user.sharedProjects = sharedProjects - - return user + async getOrganisationsForUser(userId: string) { + return this.organisationService.findMemberships({ + where: { + user: { id: userId }, + }, + relations: ['organisation'], + select: { + id: true, + role: true, + confirmed: true, + created: true, + organisation: { + id: true, + name: true, + }, + }, + }) } private async comparePassword( @@ -492,15 +509,28 @@ export class AuthService { !user.isTwoFactorAuthenticationEnabled, ) + const meta = { + totalMonthlyEvents: undefined, + } + if (user.isTwoFactorAuthenticationEnabled) { // @ts-expect-error user = _pick(user, ['isTwoFactorAuthenticationEnabled', 'email']) } else { - user = await this.getSharedProjectsForUser(user) + const [sharedProjects, organisationMemberships] = await Promise.all([ + this.getSharedProjectsForUser(user.id), + this.getOrganisationsForUser(user.id), + ]) + + user.sharedProjects = sharedProjects + user.organisationMemberships = organisationMemberships + + meta.totalMonthlyEvents = await this.projectService.getRedisCount(user.id) } return { ...jwtTokens, + ...meta, user: this.userService.omitSensitiveData(user), } } @@ -539,6 +569,7 @@ export class AuthService { return { ...jwtTokens, user: this.userService.omitSensitiveData(user), + totalMonthlyEvents: 0, } } @@ -929,6 +960,7 @@ export class AuthService { return { ...jwtTokens, user: this.userService.omitSensitiveData(user), + totalMonthlyEvents: 0, } } @@ -944,15 +976,27 @@ export class AuthService { !user.isTwoFactorAuthenticationEnabled, ) + const meta = { + totalMonthlyEvents: undefined, + } + if (user.isTwoFactorAuthenticationEnabled) { // @ts-expect-error user = _pick(user, ['isTwoFactorAuthenticationEnabled', 'email']) } else { - user = await this.getSharedProjectsForUser(user) + const [sharedProjects, organisationMemberships] = await Promise.all([ + this.getSharedProjectsForUser(user.id), + this.getOrganisationsForUser(user.id), + ]) + + user.sharedProjects = sharedProjects + user.organisationMemberships = organisationMemberships + meta.totalMonthlyEvents = await this.projectService.getRedisCount(user.id) } return { ...jwtTokens, + ...meta, user: this.userService.omitSensitiveData(user), } } diff --git a/backend/apps/cloud/src/auth/dtos/register.dto.ts b/backend/apps/cloud/src/auth/dtos/register.dto.ts index d0e977624..bf4e9b35b 100644 --- a/backend/apps/cloud/src/auth/dtos/register.dto.ts +++ b/backend/apps/cloud/src/auth/dtos/register.dto.ts @@ -59,4 +59,9 @@ export class RegisterResponseDto { description: 'User entity', }) user: object + + @ApiProperty({ + description: 'Total used monthly events', + }) + totalMonthlyEvents: number } diff --git a/backend/apps/cloud/src/common/templates/en/organisation-invitation.html b/backend/apps/cloud/src/common/templates/en/organisation-invitation.html new file mode 100644 index 000000000..f928c8f7c --- /dev/null +++ b/backend/apps/cloud/src/common/templates/en/organisation-invitation.html @@ -0,0 +1,11 @@ +{{email}} has invited you to join the {{name}} organisation with the role {{role}} on Swetrix. +
+Click the button below to accept the invitation or ignore this email if you do not want to join the organisation. +
+The invitation will expire in {{expiration}} hours. +
+Accept invitation +
+If you are having trouble with the button above, copy and paste the URL below directly into your web browser: +
+{{url}} \ No newline at end of file diff --git a/backend/apps/cloud/src/mailer/letter.ts b/backend/apps/cloud/src/mailer/letter.ts index 91a333395..0937c8e5b 100644 --- a/backend/apps/cloud/src/mailer/letter.ts +++ b/backend/apps/cloud/src/mailer/letter.ts @@ -21,4 +21,5 @@ export enum LetterTemplate { DashboardLockedExceedingLimits = 'dashboard-locked-exceeding-limits', DashboardLockedPaymentFailure = 'dashboard-locked-payment-failure', UptimeMonitoringFailure = 'uptime-monitoring-failure', + OrganisationInvitation = 'organisation-invitation', } diff --git a/backend/apps/cloud/src/mailer/mailer.service.ts b/backend/apps/cloud/src/mailer/mailer.service.ts index b72617eb9..95f387c3c 100644 --- a/backend/apps/cloud/src/mailer/mailer.service.ts +++ b/backend/apps/cloud/src/mailer/mailer.service.ts @@ -66,6 +66,11 @@ const metaInfoJson = { en: () => 'You have been invited to join the project', }, }, + [LetterTemplate.OrganisationInvitation]: { + subject: { + en: () => 'You have been invited to join the organisation', + }, + }, [LetterTemplate.TwoFAOn]: { subject: { en: () => '2FA has been enabled on your Swetrix account', diff --git a/backend/apps/cloud/src/organisation/dto/organisation.dto.ts b/backend/apps/cloud/src/organisation/dto/organisation.dto.ts new file mode 100644 index 000000000..8b4587ad8 --- /dev/null +++ b/backend/apps/cloud/src/organisation/dto/organisation.dto.ts @@ -0,0 +1,27 @@ +import { IsEmail, IsEnum, IsString, Length } from 'class-validator' +import { OrganisationRole } from '../entity/organisation-member.entity' + +export class CreateOrganisationDTO { + @IsString() + @Length(1, 50) + name: string +} + +export class InviteMemberDTO { + @IsEmail() + email: string + + @IsEnum(OrganisationRole) + role: OrganisationRole +} + +export class UpdateMemberRoleDTO { + @IsEnum(OrganisationRole) + role: OrganisationRole +} + +export class UpdateOrganisationDTO { + @IsString() + @Length(1, 50) + name: string +} diff --git a/backend/apps/cloud/src/organisation/entity/organisation-member.entity.ts b/backend/apps/cloud/src/organisation/entity/organisation-member.entity.ts new file mode 100644 index 000000000..a25b5aa0f --- /dev/null +++ b/backend/apps/cloud/src/organisation/entity/organisation-member.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + Column, + ManyToOne, + BeforeUpdate, + PrimaryGeneratedColumn, +} from 'typeorm' +import { ApiProperty } from '@nestjs/swagger' + +import { User } from '../../user/entities/user.entity' +import { Organisation } from './organisation.entity' + +export enum OrganisationRole { + owner = 'owner', + admin = 'admin', + viewer = 'viewer', +} + +@Entity() +export class OrganisationMember { + @PrimaryGeneratedColumn('uuid') + id: string + + @ApiProperty({ type: () => User }) + @ManyToOne(() => User, user => user.organisationMemberships) + user: User + + @ManyToOne(() => Organisation, org => org.members) + organisation: Organisation + + @Column({ + type: 'enum', + enum: OrganisationRole, + }) + role: OrganisationRole + + @Column({ + default: false, + }) + confirmed: boolean + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + created: Date + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + updated: Date + + @BeforeUpdate() + updateTimestamp() { + this.updated = new Date() + } +} diff --git a/backend/apps/cloud/src/organisation/entity/organisation.entity.ts b/backend/apps/cloud/src/organisation/entity/organisation.entity.ts new file mode 100644 index 000000000..c0b7fa61a --- /dev/null +++ b/backend/apps/cloud/src/organisation/entity/organisation.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToMany, + BeforeUpdate, +} from 'typeorm' + +import { Project } from '../../project/entity/project.entity' +import { OrganisationMember } from './organisation-member.entity' + +@Entity() +export class Organisation { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column('varchar', { length: 50 }) + name: string + + @OneToMany(() => OrganisationMember, member => member.organisation) + members: OrganisationMember[] + + @OneToMany(() => Project, project => project.organisation) + projects: Project[] + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + created: Date + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + updated: Date + + @BeforeUpdate() + updateTimestamp() { + this.updated = new Date() + } +} diff --git a/backend/apps/cloud/src/organisation/organisation.controller.ts b/backend/apps/cloud/src/organisation/organisation.controller.ts new file mode 100644 index 000000000..75c94ffdd --- /dev/null +++ b/backend/apps/cloud/src/organisation/organisation.controller.ts @@ -0,0 +1,408 @@ +import { + Controller, + Post, + Delete, + Body, + Param, + UseGuards, + BadRequestException, + NotFoundException, + HttpCode, + Headers, + Get, + Query, + ForbiddenException, + Patch, +} from '@nestjs/common' +import { ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger' +import { isEmpty as _isEmpty, find as _find, trim as _trim } from 'lodash' + +import { JwtAccessTokenGuard } from '../auth/guards/jwt-access-token.guard' +import { RolesGuard } from '../auth/guards/roles.guard' +import { Roles } from '../auth/decorators/roles.decorator' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { UserType } from '../user/entities/user.entity' +import { Organisation } from './entity/organisation.entity' +import { OrganisationService } from './organisation.service' +import { UserService } from '../user/user.service' +import { MailerService } from '../mailer/mailer.service' +import { ActionTokensService } from '../action-tokens/action-tokens.service' +import { ActionTokenType } from '../action-tokens/action-token.entity' +import { + CreateOrganisationDTO, + InviteMemberDTO, + UpdateMemberRoleDTO, + UpdateOrganisationDTO, +} from './dto/organisation.dto' +import { OrganisationRole } from './entity/organisation-member.entity' +import { AppLoggerService } from '../logger/logger.service' +import { LetterTemplate } from '../mailer/letter' +import { Auth } from '../auth/decorators' +import { Pagination } from '../common/pagination' +import { ProjectService } from '../project/project.service' +import { Project } from '../project/entity' + +const ORGANISATION_INVITE_EXPIRE = 7 * 24 // 7 days in hours +const { PRODUCTION_ORIGIN, isDevelopment } = process.env + +@Controller('organisation') +export class OrganisationController { + constructor( + private readonly organisationService: OrganisationService, + private readonly userService: UserService, + private readonly mailerService: MailerService, + private readonly actionTokensService: ActionTokensService, + private readonly logger: AppLoggerService, + private readonly projectService: ProjectService, + ) {} + + @ApiBearerAuth() + @Get('/') + @ApiQuery({ name: 'take', required: false }) + @ApiQuery({ name: 'skip', required: false }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiResponse({ status: 200, type: [Organisation] }) + @Auth([], true) + async get( + @CurrentUserId() userId: string, + @Query('take') take: number | undefined, + @Query('skip') skip: number | undefined, + @Query('search') search: string | undefined, + ): Promise | Organisation[] | object> { + this.logger.log({ userId, take, skip }, 'GET /organisation') + + return this.organisationService.paginate({ take, skip }, userId, search) + } + + @ApiBearerAuth() + @Get('/:orgId') + @ApiResponse({ status: 200, type: Organisation }) + @Auth([], true) + async getOne( + @Param('orgId') orgId: string, + @CurrentUserId() userId: string, + ): Promise { + this.logger.log({ orgId, userId }, 'GET /organisation/:orgId') + + const canManage = await this.organisationService.canManageOrganisation( + orgId, + userId, + ) + + if (!canManage) { + throw new ForbiddenException( + 'You do not have permission to manage this organisation', + ) + } + + return this.organisationService.findOne({ + where: { id: orgId }, + select: { + id: true, + name: true, + members: { + id: true, + role: true, + created: true, + confirmed: true, + user: { + email: true, + }, + }, + projects: { + id: true, + name: true, + admin: { + email: true, + }, + }, + }, + relations: ['members', 'members.user', 'projects', 'projects.admin'], + }) + } + + @ApiBearerAuth() + @Post('/') + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @ApiResponse({ status: 200, type: Organisation }) + @Auth([], true) + async create( + @Body() createOrgDTO: CreateOrganisationDTO, + @CurrentUserId() uid: string, + ): Promise { + this.logger.log({ uid, createOrgDTO }, 'POST /organisation') + + const user = await this.userService.findOne({ where: { id: uid } }) + + const organisation = await this.organisationService.create({ + name: createOrgDTO.name, + }) + + await this.organisationService.createMembership({ + role: OrganisationRole.owner, + user, + organisation, + confirmed: true, + }) + + return organisation + } + + @ApiBearerAuth() + @Post('/:orgId/invite') + @HttpCode(200) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) + async inviteMember( + @Param('orgId') orgId: string, + @Body() inviteDTO: InviteMemberDTO, + @CurrentUserId() uid: string, + @Headers() headers, + ): Promise { + this.logger.log( + { uid, orgId, inviteDTO }, + 'POST /organisation/:orgId/invite', + ) + + const user = await this.userService.findOne({ where: { id: uid } }) + const organisation = await this.organisationService.findOne({ + where: { id: orgId }, + relations: ['members', 'members.user'], + }) + + if (_isEmpty(organisation)) { + throw new NotFoundException( + `Organisation with ID ${orgId} does not exist`, + ) + } + + await this.organisationService.validateManageAccess(organisation, uid) + + const invitee = await this.userService.findOne({ + where: { email: inviteDTO.email }, + relations: ['organisationMemberships'], + }) + + if (!invitee) { + throw new NotFoundException( + `User with email ${inviteDTO.email} is not registered`, + ) + } + + if (invitee.id === user.id) { + throw new BadRequestException('You cannot invite yourself') + } + + const isAlreadyMember = !_isEmpty( + _find(organisation.members, member => member.user?.id === invitee.id), + ) + + if (isAlreadyMember) { + throw new BadRequestException( + `User ${invitee.email} is already a member of this organisation`, + ) + } + + try { + const membership = await this.organisationService.createMembership({ + role: inviteDTO.role, + user: invitee, + organisation, + }) + + const actionToken = await this.actionTokensService.createForUser( + user, + ActionTokenType.ORGANISATION_INVITE, + membership.id, + ) + + const url = `${ + isDevelopment ? headers.origin : PRODUCTION_ORIGIN + }/organisation/invite/${actionToken.id}` + + await this.mailerService.sendEmail( + invitee.email, + LetterTemplate.OrganisationInvitation, + { + url, + email: user.email, + name: organisation.name, + role: membership.role, + expiration: ORGANISATION_INVITE_EXPIRE, + }, + ) + + return await this.organisationService.findOne({ + where: { id: orgId }, + relations: ['members', 'members.user'], + }) + } catch (reason) { + console.error( + `[ERROR] Could not invite to organisation (orgId: ${organisation.id}, invitee ID: ${invitee.id}): ${reason}`, + ) + throw new BadRequestException(reason) + } + } + + @ApiBearerAuth() + @Patch('/member/:memberId') + @HttpCode(200) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) + async updateMemberRole( + @Param('memberId') memberId: string, + @Body() updateDTO: UpdateMemberRoleDTO, + @CurrentUserId() uid: string, + ) { + this.logger.log( + { uid, memberId, updateDTO }, + 'PATCH /organisation/member/:memberId', + ) + + const membership = await this.organisationService.findOneMembership({ + where: { id: memberId }, + relations: [ + 'organisation', + 'organisation.members', + 'organisation.members.user', + 'user', + ], + }) + + if (_isEmpty(membership)) { + throw new NotFoundException( + `Membership with ID ${memberId} does not exist`, + ) + } + + await this.organisationService.validateManageAccess( + membership.organisation, + uid, + ) + + if (membership.user.id === uid) { + throw new BadRequestException('You cannot modify your own role') + } + + if ( + membership.role === OrganisationRole.owner || + updateDTO.role === OrganisationRole.owner + ) { + throw new BadRequestException('Cannot modify owner role') + } + + return this.organisationService.updateMembership(memberId, { + role: updateDTO.role, + }) + } + + @ApiBearerAuth() + @Delete('/member/:memberId') + @HttpCode(204) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) + async removeMember( + @Param('memberId') memberId: string, + @CurrentUserId() uid: string, + ) { + this.logger.log({ uid, memberId }, 'DELETE /organisation/member/:memberId') + + const membership = await this.organisationService.findOneMembership({ + where: { id: memberId }, + relations: [ + 'organisation', + 'organisation.members', + 'organisation.members.user', + 'user', + ], + }) + + if (_isEmpty(membership)) { + throw new NotFoundException( + `Membership with ID ${memberId} does not exist`, + ) + } + + await this.organisationService.validateManageAccess( + membership.organisation, + uid, + ) + + if (membership.role === OrganisationRole.owner) { + throw new BadRequestException('Cannot remove organisation owner') + } + + await this.organisationService.deleteMembership(memberId) + } + + @ApiBearerAuth() + @Delete('/:orgId') + @ApiResponse({ status: 200, type: Organisation }) + @Auth([], true) + async delete(@Param('orgId') orgId: string, @CurrentUserId() userId: string) { + this.logger.log({ orgId, userId }, 'DELETE /organisation/:orgId') + + const isOwner = await this.organisationService.isOrganisationOwner( + orgId, + userId, + ) + + if (!isOwner) { + throw new ForbiddenException( + 'You must be the organisation owner to delete it', + ) + } + + try { + await this.projectService.update({ organisation: { id: orgId } }, { + organisation: null, + } as Project) + await this.organisationService.deleteMemberships({ + organisation: { id: orgId }, + }) + await this.organisationService.delete(orgId) + } catch (reason) { + console.error('[ERROR] Failed to delete organisation:', reason) + throw new BadRequestException('Failed to delete organisation') + } + } + + @ApiBearerAuth() + @Patch('/:orgId') + @ApiResponse({ status: 200, type: Organisation }) + @Auth([], true) + async update( + @Param('orgId') orgId: string, + @Body() updateOrgDTO: UpdateOrganisationDTO, + @CurrentUserId() userId: string, + ) { + this.logger.log( + { orgId, updateOrgDTO, userId }, + 'PATCH /organisation/:orgId', + ) + + const canManage = await this.organisationService.canManageOrganisation( + orgId, + userId, + ) + + if (!canManage) { + throw new ForbiddenException( + 'You do not have permission to manage this organisation', + ) + } + + try { + await this.organisationService.update(orgId, { + name: _trim(updateOrgDTO.name), + }) + } catch (reason) { + console.error('[ERROR] Failed to update organisation:', reason) + throw new BadRequestException('Failed to update organisation') + } + } +} diff --git a/backend/apps/cloud/src/organisation/organisation.module.ts b/backend/apps/cloud/src/organisation/organisation.module.ts new file mode 100644 index 000000000..a67f76ce7 --- /dev/null +++ b/backend/apps/cloud/src/organisation/organisation.module.ts @@ -0,0 +1,26 @@ +import { Module, forwardRef } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import { Organisation } from './entity/organisation.entity' +import { OrganisationMember } from './entity/organisation-member.entity' +import { OrganisationController } from './organisation.controller' +import { OrganisationService } from './organisation.service' +import { UserModule } from '../user/user.module' +import { MailerModule } from '../mailer/mailer.module' +import { ActionTokensModule } from '../action-tokens/action-tokens.module' +import { AppLoggerModule } from '../logger/logger.module' +import { ProjectModule } from '../project/project.module' + +@Module({ + imports: [ + TypeOrmModule.forFeature([Organisation, OrganisationMember]), + forwardRef(() => UserModule), + forwardRef(() => ProjectModule), + MailerModule, + ActionTokensModule, + AppLoggerModule, + ], + controllers: [OrganisationController], + providers: [OrganisationService], + exports: [OrganisationService], +}) +export class OrganisationModule {} diff --git a/backend/apps/cloud/src/organisation/organisation.service.ts b/backend/apps/cloud/src/organisation/organisation.service.ts new file mode 100644 index 000000000..badb4c63f --- /dev/null +++ b/backend/apps/cloud/src/organisation/organisation.service.ts @@ -0,0 +1,179 @@ +import { Injectable, ForbiddenException } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { + FindManyOptions, + FindOneOptions, + FindOptionsWhere, + Repository, +} from 'typeorm' +import { find as _find } from 'lodash' + +import { Organisation } from './entity/organisation.entity' +import { + OrganisationMember, + OrganisationRole, +} from './entity/organisation-member.entity' +import { Pagination, PaginationOptionsInterface } from '../common/pagination' + +@Injectable() +export class OrganisationService { + constructor( + @InjectRepository(Organisation) + private organisationRepository: Repository, + @InjectRepository(OrganisationMember) + private membershipRepository: Repository, + ) {} + + async create(data: Partial) { + const org = this.organisationRepository.create(data) + return this.organisationRepository.save(org) + } + + async update(id: string, data: Partial) { + return this.organisationRepository.update(id, data) + } + + async findOne(options: FindOneOptions) { + return this.organisationRepository.findOne(options) + } + + async find(options: FindManyOptions) { + return this.organisationRepository.find(options) + } + + async delete(id: string) { + return this.organisationRepository.delete(id) + } + + async deleteMemberships(options: FindOptionsWhere) { + return this.membershipRepository.delete(options) + } + + async findMemberships(options: FindManyOptions) { + return this.membershipRepository.find(options) + } + + async createMembership(data: Partial) { + const membership = this.membershipRepository.create(data) + return this.membershipRepository.save(membership) + } + + async findOneMembership(options: FindOneOptions) { + return this.membershipRepository.findOne(options) + } + + async updateMembership(id: string, data: Partial) { + await this.membershipRepository.update(id, data) + return this.findOneMembership({ where: { id } }) + } + + async deleteMembership(id: string) { + await this.membershipRepository.delete(id) + } + + validateManageAccess(organisation: Organisation, userId: string) { + const membership = _find( + organisation.members, + member => member.user?.id === userId, + ) + + if (!membership || membership.role === OrganisationRole.viewer) { + throw new ForbiddenException( + 'You do not have permission to manage this organisation', + ) + } + } + + async canManageOrganisation(organisationId: string, userId: string) { + const organisation = await this.findOne({ + where: { id: organisationId }, + relations: ['members', 'members.user'], + }) + + if (!organisation) { + return false + } + + try { + this.validateManageAccess(organisation, userId) + return true + } catch { + return false + } + } + + async isOrganisationOwner(organisationId: string, userId: string) { + const ownerMembership = await this.findOneMembership({ + where: { + organisation: { id: organisationId }, + role: OrganisationRole.owner, + user: { id: userId }, + }, + }) + + return !!ownerMembership + } + + async getOrganisationOwner(organisationId: string) { + const ownerMembership = await this.findOneMembership({ + where: { + organisation: { id: organisationId }, + role: OrganisationRole.owner, + }, + relations: ['user'], + }) + + if (!ownerMembership) { + throw new ForbiddenException('Organisation owner not found') + } + + return ownerMembership.user + } + + async paginate( + options: PaginationOptionsInterface, + userId: string, + search?: string, + ) { + const queryBuilder = this.organisationRepository + .createQueryBuilder('organisation') + // We do left join twice to avoid the issue with TypeORM where it does not return + // all the relations when filtering by one of them + // see: https://github.com/typeorm/typeorm/issues/3731 + .leftJoin('organisation.members', 'membersFilter') + .leftJoin('membersFilter.user', 'userFilter') + .leftJoinAndSelect('organisation.members', 'members') + .leftJoinAndSelect('members.user', 'user') + .select([ + 'organisation.id', + 'organisation.name', + 'members.id', + 'members.role', + 'members.confirmed', + 'members.created', + 'user.email', + ]) + .orderBy('organisation.name', 'ASC') + .take(options.take || 100) + .skip(options.skip || 0) + + if (userId) { + queryBuilder.andWhere('userFilter.id = :userId', { + userId, + }) + } + + if (search) { + queryBuilder.andWhere('LOWER(organisation.name) LIKE LOWER(:search)', { + search: `%${search.trim()}%`, + }) + } + + const [results, total] = await queryBuilder.getManyAndCount() + + return new Pagination({ + results, + total, + }) + } +} diff --git a/backend/apps/cloud/src/project/dto/create-project.dto.ts b/backend/apps/cloud/src/project/dto/create-project.dto.ts index 24ba5b2ef..5daad7d9c 100644 --- a/backend/apps/cloud/src/project/dto/create-project.dto.ts +++ b/backend/apps/cloud/src/project/dto/create-project.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger' import { IsNotEmpty, Length } from 'class-validator' +import { ProjectOrganisationDto } from './project-organisation.dto' -export class CreateProjectDTO { +export class CreateProjectDTO extends ProjectOrganisationDto { @ApiProperty({ example: 'Your awesome project', required: true, diff --git a/backend/apps/cloud/src/project/dto/project-organisation.dto.ts b/backend/apps/cloud/src/project/dto/project-organisation.dto.ts new file mode 100644 index 000000000..4b4a2704e --- /dev/null +++ b/backend/apps/cloud/src/project/dto/project-organisation.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsOptional, IsString } from 'class-validator' + +export class ProjectOrganisationDto { + @ApiProperty({ + required: false, + }) + @IsString() + @IsOptional() + organisationId?: string +} diff --git a/backend/apps/cloud/src/project/entity/project.entity.ts b/backend/apps/cloud/src/project/entity/project.entity.ts index 725f9fd73..819795784 100644 --- a/backend/apps/cloud/src/project/entity/project.entity.ts +++ b/backend/apps/cloud/src/project/entity/project.entity.ts @@ -17,6 +17,7 @@ import { Funnel } from './funnel.entity' import { CAPTCHA_SECRET_KEY_LENGTH } from '../../common/constants' import { ProjectViewEntity } from './project-view.entity' import { MonitorEntity } from './monitor.entity' +import { Organisation } from '../../organisation/entity/organisation.entity' export enum BotsProtectionLevel { OFF = 'off', @@ -134,4 +135,7 @@ export class Project { @OneToMany(() => MonitorEntity, monitor => monitor.id) monitors: MonitorEntity[] + + @ManyToOne(() => Organisation, org => org.projects, { nullable: true }) + organisation: Organisation | null } diff --git a/backend/apps/cloud/src/project/project.controller.ts b/backend/apps/cloud/src/project/project.controller.ts index 9436341c9..afb736d52 100644 --- a/backend/apps/cloud/src/project/project.controller.ts +++ b/backend/apps/cloud/src/project/project.controller.ts @@ -21,8 +21,6 @@ import { ConflictException, Res, UnauthorizedException, - ParseBoolPipe, - DefaultValuePipe, } from '@nestjs/common' import { Response } from 'express' import { @@ -34,7 +32,7 @@ import { ApiOkResponse, ApiNoContentResponse, } from '@nestjs/swagger' -import { Equal, FindOptionsWhere, ILike, In } from 'typeorm' +import { Equal } from 'typeorm' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import _trim from 'lodash/trim' @@ -113,6 +111,9 @@ import { MonitorEntity } from './entity/monitor.entity' import { UpdateMonitorHttpRequestDTO } from './dto/update-monitor.dto' import { BulkAddUsersDto } from './dto/bulk-add-users.dto' import { BulkAddUsersResponse } from './interfaces/bulk-add-users' +import { OrganisationService } from '../organisation/organisation.service' +import { Organisation } from '../organisation/entity/organisation.entity' +import { ProjectOrganisationDto } from './dto/project-organisation.dto' const PROJECTS_MAXIMUM = 50 @@ -134,78 +135,29 @@ export class ProjectController { private readonly actionTokensService: ActionTokensService, private readonly mailerService: MailerService, private readonly projectsViewsRepository: ProjectsViewsRepository, + private readonly organisationService: OrganisationService, ) {} @ApiBearerAuth() @Get('/') @ApiQuery({ name: 'take', required: false }) @ApiQuery({ name: 'skip', required: false }) - @ApiQuery({ name: 'isCaptcha', required: false, type: Boolean }) - @ApiQuery({ name: 'relatedonly', required: false, type: Boolean }) @ApiQuery({ name: 'search', required: false, type: String }) - @ApiQuery({ name: 'showArchived', required: false, type: Boolean }) @ApiResponse({ status: 200, type: [Project] }) @Auth([], true) async get( @CurrentUserId() userId: string, @Query('take') take: number | undefined, @Query('skip') skip: number | undefined, - @Query('isCaptcha') isCaptchaStr: string | undefined, @Query('search') search: string | undefined, - @Query('showArchived', new DefaultValuePipe(false), ParseBoolPipe) - showArchived?: boolean, ): Promise | Project[] | object> { this.logger.log({ userId, take, skip }, 'GET /project') - const isCaptcha = isCaptchaStr === 'true' - let where: FindOptionsWhere | FindOptionsWhere[] - - if (search) { - where = [ - { - admin: { - id: userId, - }, - isCaptchaProject: isCaptcha, - isAnalyticsProject: !isCaptcha, - name: ILike(`%${search}%`), - isArchived: showArchived, - // name: ILike(`%${mysql.escape(search).slice(1, 0).slice(0, -1)}%`), - }, - { - admin: { - id: userId, - }, - isCaptchaProject: isCaptcha, - isAnalyticsProject: !isCaptcha, - id: ILike(`%${search}%`), - isArchived: showArchived, - // id: ILike(`%${mysql.escape(search).slice(1, 0).slice(0, -1)}%`), - }, - ] as FindOptionsWhere[] - } else { - where = { - admin: { - id: userId, - }, - } as FindOptionsWhere - - if (isCaptcha) { - where.isCaptchaProject = true - } else { - where.isAnalyticsProject = true - } - - if (showArchived) { - where.isArchived = true - } - } - - const [paginated, totalMonthlyEvents, user] = await Promise.all([ - this.projectService.paginate({ take, skip }, where), - this.projectService.getRedisCount(userId), - this.userService.findOne({ where: { id: userId } }), - ]) + const paginated = await this.projectService.paginate( + { take, skip }, + userId, + search, + ) const pidsWithData = await this.projectService.getPIDsWhereAnalyticsDataExists( @@ -217,135 +169,66 @@ export class ProjectController { _map(paginated.results, ({ id }) => id), ) - paginated.results = _map(paginated.results, p => ({ - ...p, - isOwner: true, - isLocked: !!user?.dashboardBlockReason, - isDataExists: _includes(pidsWithData, p?.id), - isErrorDataExists: _includes(pidsWithErrorData, p?.id), - })) - - return { - ...paginated, - totalMonthlyEvents, - } - } - - @Get('/shared') - @ApiQuery({ name: 'take', required: false }) - @ApiQuery({ name: 'skip', required: false }) - @ApiQuery({ name: 'relatedonly', required: false, type: Boolean }) - @ApiQuery({ name: 'search', required: false, type: String }) - @ApiResponse({ status: 200, type: [Project] }) - @Auth([UserType.CUSTOMER, UserType.ADMIN], true) - async getShared( - @CurrentUserId() userId: string, - @Query('take') take: number | undefined, - @Query('skip') skip: number | undefined, - @Query('search') search: string | undefined, - ): Promise | ProjectShare[] | object> { - this.logger.log({ userId, take, skip, search }, 'GET /project/shared') - - let where: FindOptionsWhere | FindOptionsWhere[] - - if (search) { - where = [ - { - user: { id: userId }, - project: { - name: ILike(`%${search}%`), - }, - }, - { - user: { id: userId }, - project: { - id: ILike(`%${search}%`), - }, - }, - ] as FindOptionsWhere[] - } else { - where = { - user: { - id: userId, - }, - } as FindOptionsWhere - } - - const paginated = await this.projectService.paginateShared( - { take, skip }, - where, - ) - - const pidsWithData = - await this.projectService.getPIDsWhereAnalyticsDataExists( - _map(paginated.results, ({ project }) => project.id), + paginated.results = _map(paginated.results, project => { + const userShare = project.share.find(share => share.user.id === userId) + const organisationMembership = project.organisation?.members.find( + member => member.user.id === userId, ) - const pidsWithErrorData = - await this.projectService.getPIDsWhereErrorsDataExists( - _map(paginated.results, ({ project }) => project.id), - ) - - // @ts-expect-error - paginated.results = _map(paginated.results, share => { - const project = processProjectUser(share.project) + let role + let isAccessConfirmed = true + + if (project.admin.id === userId) { + role = 'owner' + } else if (userShare) { + role = userShare.role + isAccessConfirmed = userShare.confirmed + } else if (organisationMembership) { + role = organisationMembership.role + isAccessConfirmed = organisationMembership.confirmed + } return { - ...share, - project: { - ...project, - admin: undefined, - passwordHash: undefined, - isLocked: !!share?.project?.admin?.dashboardBlockReason, - isDataExists: _includes(pidsWithData, share?.project?.id), - isErrorDataExists: _includes(pidsWithErrorData, share?.project?.id), - }, + ...project, + isAccessConfirmed, + isLocked: !!project.admin?.dashboardBlockReason, + isDataExists: _includes(pidsWithData, project?.id), + isErrorDataExists: _includes(pidsWithErrorData, project?.id), + organisationId: project?.organisation?.id, + role, + passwordHash: undefined, + admin: undefined, } }) return paginated } - @Get('/all') - @ApiQuery({ name: 'take', required: false }) - @ApiQuery({ name: 'skip', required: false }) - @UseGuards(JwtAccessTokenGuard, RolesGuard) - @Auth([UserType.ADMIN]) - @ApiResponse({ status: 200, type: Project }) - async getAllProjects( - @Query('take') take: number | undefined, - @Query('skip') skip: number | undefined, - ): Promise { - this.logger.log({ take, skip }, 'GET /all') - - return this.projectService.paginate({ take, skip }) - } - @ApiBearerAuth() - @Get('/user/:id') + @Get('/available-for-organisation') @ApiQuery({ name: 'take', required: false }) @ApiQuery({ name: 'skip', required: false }) - @ApiQuery({ name: 'relatedonly', required: false, type: Boolean }) + @ApiQuery({ name: 'search', required: false, type: String }) @ApiResponse({ status: 200, type: [Project] }) - @UseGuards(JwtAccessTokenGuard, RolesGuard) - @Roles(UserType.ADMIN) - async getUserProject( - @Param('id') userId: string, + @Auth([], true) + async getAvailableProjectsForOrganization( + @CurrentUserId() userId: string, @Query('take') take: number | undefined, @Query('skip') skip: number | undefined, + @Query('search') search: string | undefined, ): Promise | Project[] | object> { - this.logger.log({ userId, take, skip }, 'GET /user/:id') - - const where = Object() - where.admin = userId + this.logger.log( + { userId, take, skip }, + 'GET /project/available-for-organisation', + ) - const paginated = await this.projectService.paginate({ take, skip }, where) - const totalMonthlyEvents = await this.projectService.getRedisCount(userId) + const paginated = await this.projectService.paginateForOrganisation( + { take, skip }, + userId, + search, + ) - return { - ...paginated, - totalMonthlyEvents, - } + return paginated } @ApiBearerAuth() @@ -411,23 +294,42 @@ export class ProjectController { async create( @Body() projectDTO: CreateProjectDTO, @CurrentUserId() userId: string, - ): Promise { + ): Promise> { this.logger.log({ projectDTO, userId }, 'POST /project') if (!userId) { throw new UnauthorizedException('Please auth first') } - const user = await this.userService.findOne({ + const initiatingUser = await this.userService.findOne({ where: { id: userId }, relations: ['projects'], }) - const { maxProjects = PROJECTS_MAXIMUM } = user + const { maxProjects = PROJECTS_MAXIMUM } = initiatingUser - if (!user.isActive) { + if (!initiatingUser.isActive) { throw new ForbiddenException('Please, verify your email address first') } + let user = initiatingUser + + if (projectDTO.organisationId) { + const canManage = await this.organisationService.canManageOrganisation( + projectDTO.organisationId, + userId, + ) + + if (!canManage) { + throw new ForbiddenException( + 'You are not allowed to add projects to the selected organisation', + ) + } + + user = await this.organisationService.getOrganisationOwner( + projectDTO.organisationId, + ) + } + if (user.planCode === PlanCode.none) { throw new HttpException( 'You cannot create new projects due to no active subscription. Please upgrade your account plan to continue.', @@ -478,10 +380,16 @@ export class ProjectController { } try { - const project = new Project() - project.id = pid - project.name = _trim(projectDTO.name) - project.origins = [] + const project = { + id: pid, + name: _trim(projectDTO.name), + origins: [], + active: true, + + admin: { + id: user.id, + }, + } as Project if (projectDTO.isCaptcha) { project.isCaptchaProject = true @@ -492,35 +400,14 @@ export class ProjectController { ) } - if (projectDTO.isPasswordProtected && projectDTO.password) { - project.isPasswordProtected = true - project.passwordHash = await hash(projectDTO.password, 10) - } - - if (projectDTO.public) { - project.public = Boolean(projectDTO.public) - } - - if (projectDTO.active) { - project.active = Boolean(projectDTO.active) - } - - if (projectDTO.origins) { - this.projectService.validateOrigins(projectDTO) - project.origins = projectDTO.origins - } - - if (projectDTO.ipBlacklist) { - this.projectService.validateIPBlacklist(projectDTO) - project.ipBlacklist = projectDTO.ipBlacklist + if (projectDTO.organisationId) { + project.organisation = { + id: projectDTO.organisationId, + } as Organisation } const newProject = await this.projectService.create(project) - user.projects.push(project) - await this.userService.create(user) - - // @ts-expect-error return _omit(newProject, ['passwordHash']) } catch (reason) { console.error('[ERROR] Failed to create a new project:') @@ -565,15 +452,10 @@ export class ProjectController { ) } - const project = await this.projectService.findOne({ - where: { - id: funnelDTO.pid, - admin: { - id: userId, - }, - }, - relations: ['admin', 'share'], - }) + const project = await this.projectService.getFullProject( + funnelDTO.pid, + userId, + ) if (!project) { throw new NotFoundException('Project not found.') @@ -620,15 +502,11 @@ export class ProjectController { ) } - const project = await this.projectService.findOne({ - where: { - id: funnelDTO.pid, - admin: { - id: userId, - }, - }, - relations: ['admin', 'share', 'funnels'], - }) + const project = await this.projectService.getFullProject( + funnelDTO.pid, + userId, + ['funnels'], + ) if (!project) { throw new NotFoundException('Project not found.') @@ -672,15 +550,7 @@ export class ProjectController { throw new UnauthorizedException('Please auth first') } - const project = await this.projectService.findOne({ - where: { - id: pid, - admin: { - id: userId, - }, - }, - relations: ['admin', 'share'], - }) + const project = await this.projectService.getFullProject(pid, userId) if (!project) { throw new NotFoundException('Project not found') @@ -711,7 +581,7 @@ export class ProjectController { throw new UnauthorizedException('Please auth first') } - const project = await this.projectService.getProject(pid, userId) + const project = await this.projectService.getFullProject(pid, userId) if (!project) { throw new NotFoundException('Project not found.') @@ -739,19 +609,12 @@ export class ProjectController { ) } - const user = await this.userService.findOne({ where: { id: uid } }) - const project = await this.projectService.findOne({ - where: { id }, - relations: ['admin'], - select: ['id'], - }) + const project = await this.projectService.getOwnProject(id, uid) if (_isEmpty(project)) { throw new NotFoundException(`Project with ID ${id} does not exist`) } - this.projectService.allowedToManage(project, uid, user.roles) - const queries = [ 'DELETE FROM analytics WHERE pid={pid:FixedString(12)}', 'DELETE FROM customEV WHERE pid={pid:FixedString(12)}', @@ -796,11 +659,7 @@ export class ProjectController { } const user = await this.userService.findOne({ where: { id: uid } }) - const project = await this.projectService.findOne({ - where: { id }, - relations: ['admin'], - select: ['id'], - }) + const project = await this.projectService.getFullProject(id) if (_isEmpty(project)) { throw new NotFoundException(`Project with ID ${id} does not exist`) @@ -840,10 +699,7 @@ export class ProjectController { ) } - const project = await this.projectService.findOne({ - where: { id: pid }, - relations: ['admin'], - }) + const project = await this.projectService.getFullProject(pid) const user = await this.userService.findOne({ where: { id: uid } }) if (_isEmpty(project)) { @@ -855,7 +711,7 @@ export class ProjectController { const secret = generateRandomString(CAPTCHA_SECRET_KEY_LENGTH) // @ts-ignore - await this.projectService.update(pid, { captchaSecretKey: secret }) + await this.projectService.update({ id: pid }, { captchaSecretKey: secret }) await deleteProjectRedis(pid) @@ -881,11 +737,7 @@ export class ProjectController { } const user = await this.userService.findOne({ where: { id: uid } }) - const project = await this.projectService.findOne({ - where: { id }, - relations: ['admin'], - select: ['id'], - }) + const project = await this.projectService.getFullProject(id) if (_isEmpty(project)) { throw new NotFoundException(`Project with ID ${id} does not exist`) @@ -905,7 +757,7 @@ export class ProjectController { project.isCaptchaEnabled = false project.isCaptchaProject = false - await this.projectService.update(id, project) + await this.projectService.update({ id }, project) return 'CAPTCHA project deleted successfully' } catch (e) { @@ -957,10 +809,7 @@ export class ProjectController { throw new BadRequestException("The provided 'to' date is incorrect") } - const project = await this.projectService.findOne({ - where: { id: pid }, - relations: ['admin', 'share'], - }) + const project = await this.projectService.getFullProject(pid) if (!project) { throw new NotFoundException('Project not found') @@ -974,6 +823,76 @@ export class ProjectController { await this.projectService.removeDataFromClickhouse(pid, from, to) } + @ApiBearerAuth() + @Post('organisation/:orgId') + @HttpCode(200) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) + async addProject( + @Param('orgId') orgId: string, + @Body() addProjectDTO: ProjectIdDto, + @CurrentUserId() uid: string, + ) { + this.logger.log( + { uid, orgId, addProjectDTO }, + 'POST /project/organisation/:orgId', + ) + + const organisation = await this.organisationService.findOne({ + where: { id: orgId }, + relations: ['members', 'members.user', 'projects'], + }) + + if (_isEmpty(organisation)) { + throw new NotFoundException( + `Organisation with ID ${orgId} does not exist`, + ) + } + + await this.organisationService.validateManageAccess(organisation, uid) + + return this.projectService.addProjectToOrganisation( + organisation.id, + addProjectDTO.projectId, + ) + } + + @ApiBearerAuth() + @Delete('organisation/:orgId/:projectId') + @HttpCode(204) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) + async removeProject( + @Param('orgId') orgId: string, + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + ) { + this.logger.log( + { uid, orgId, projectId }, + 'DELETE /organisation/:orgId/project/:projectId', + ) + + const organisation = await this.organisationService.findOne({ + where: { id: orgId }, + relations: ['members', 'members.user', 'projects'], + }) + + if (_isEmpty(organisation)) { + throw new NotFoundException( + `Organisation with ID ${orgId} does not exist`, + ) + } + + await this.organisationService.validateManageAccess(organisation, uid) + + await this.projectService.removeProjectFromOrganisation( + organisation.id, + projectId, + ) + } + @Delete('/reset-filters/:pid') @ApiResponse({ status: 200 }) @UseGuards(JwtAccessTokenGuard, RolesGuard) @@ -992,10 +911,7 @@ export class ProjectController { ) } - const project = await this.projectService.findOne({ - where: { id: pid }, - relations: ['admin', 'share'], - }) + const project = await this.projectService.getFullProject(pid) if (!project) { throw new NotFoundException('Project not found') @@ -1078,11 +994,7 @@ export class ProjectController { continue } - const project = await this.projectService.findOne({ - where: { id: pid }, - relations: ['admin', 'share', 'share.user'], - select: ['id', 'admin', 'share'], - }) + const project = await this.projectService.getFullProject(pid) if (_isEmpty(project)) { this.logger.warn(`Project with ID ${pid} does not exist`) @@ -1187,11 +1099,7 @@ export class ProjectController { } const user = await this.userService.findOne({ where: { id: uid } }) - const project = await this.projectService.findOne({ - where: { id: pid }, - relations: ['admin', 'share', 'share.user'], - select: ['id', 'admin', 'share'], - }) + const project = await this.projectService.getFullProject(pid) if (_isEmpty(project)) { throw new NotFoundException(`Project with ID ${pid} does not exist`) @@ -1308,6 +1216,7 @@ export class ProjectController { throw new NotFoundException(`Share with ID ${shareId} does not exist`) } + // TODO: ORG this.projectService.allowedToManage(share.project, uid, user.roles) const adminShare = _find( @@ -1424,9 +1333,10 @@ export class ProjectController { throw new BadRequestException('Invalid token.') } - const project = await this.projectService.getProjectById( - actionToken.newValue, - ) + const project = await this.projectService.findOne({ + where: { id: actionToken.newValue }, + relations: ['admin'], + }) if (!project) { throw new NotFoundException('Project not found.') @@ -1458,9 +1368,9 @@ export class ProjectController { throw new BadRequestException('Invalid token.') } - const project = await this.projectService.getProjectById( - actionToken.newValue, - ) + const project = await this.projectService.findOne({ + where: { id: actionToken.newValue }, + }) if (!project) { throw new NotFoundException('Project not found.') @@ -1480,10 +1390,7 @@ export class ProjectController { 'DELETE /project/:projectId/subscribers/:subscriberId', ) - const project = await this.projectService.findOne({ - where: { id: params.projectId }, - relations: ['share', 'share.user', 'admin'], - }) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found') @@ -1521,10 +1428,7 @@ export class ProjectController { ) { this.logger.log({ params, body }, 'POST /project/:projectId/subscribers') - const project = await this.projectService.findOne({ - where: { id: params.projectId }, - relations: ['share', 'share.user', 'admin'], - }) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found') @@ -1572,7 +1476,7 @@ export class ProjectController { ): Promise { this.logger.log({ projectId }, 'GET /project/password/:projectId') - const project = await this.projectService.getProjectById(projectId) + const project = await this.projectService.getFullProject(projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -1597,7 +1501,10 @@ export class ProjectController { { params, queries }, 'GET /project/:projectId/subscribers/invite', ) - const project = await this.projectService.getProjectById(params.projectId) + + const project = await this.projectService.findOne({ + where: { id: params.projectId }, + }) if (!project) { throw new NotFoundException('Project not found.') @@ -1640,10 +1547,7 @@ export class ProjectController { ) { this.logger.log({ params, queries }, 'GET /project/:projectId/subscribers') - const project = await this.projectService.findOne({ - where: { id: params.projectId }, - relations: ['share', 'share.user', 'admin'], - }) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found') @@ -1666,10 +1570,7 @@ export class ProjectController { 'PATCH /project/:projectId/subscribers/:subscriberId', ) - const project = await this.projectService.findOne({ - where: { id: params.projectId }, - relations: ['share', 'share.user', 'admin'], - }) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found') @@ -1717,19 +1618,13 @@ export class ProjectController { 'The provided Project ID (pid) is incorrect', ) } - const user = await this.userService.findOne({ where: { id: uid } }) - const project = await this.projectService.findOne({ - where: { id }, - relations: ['admin'], - select: ['id'], - }) + + const project = await this.projectService.getOwnProject(id, uid) if (_isEmpty(project)) { throw new NotFoundException(`Project with ID ${id} does not exist`) } - this.projectService.allowedToManage(project, uid, user.roles) - const queries = [ 'DELETE FROM analytics WHERE pid={pid:FixedString(12)}', 'DELETE FROM customEV WHERE pid={pid:FixedString(12)}', @@ -1760,7 +1655,7 @@ export class ProjectController { try { if (project.isCaptchaProject) { project.isAnalyticsProject = false - await this.projectService.update(id, project) + await this.projectService.update({ id }, project) } else { await this.projectService.deleteMultipleShare(`project = "${id}"`) await this.projectService.delete(id) @@ -1866,6 +1761,47 @@ export class ProjectController { res.send(html) } + @ApiBearerAuth() + @Patch('/:id/organisation') + @HttpCode(204) + @Auth([], true) + async updateOrganisation( + @Param('id') id: string, + @Body() body: ProjectOrganisationDto, + @CurrentUserId() uid: string, + ) { + this.logger.log({ body }, 'PATCH /project/:id/organisation') + + if (!uid) { + throw new UnauthorizedException('Please auth first') + } + + const project = await this.projectService.getFullProject(id) + + if (_isEmpty(project)) { + throw new NotFoundException() + } + + this.projectService.allowedToManage(project, uid) + + if (body.organisationId) { + const canManage = await this.organisationService.canManageOrganisation( + body.organisationId, + uid, + ) + + if (!canManage) { + throw new ForbiddenException( + 'You do not have permission to manage this organisation', + ) + } + } + + await this.projectService.update({ id }, { + organisation: { id: body.organisationId || null }, + } as Project) + } + @ApiBearerAuth() @Put('/:id') @HttpCode(200) @@ -1886,10 +1822,7 @@ export class ProjectController { } this.projectService.validateProject(projectDTO) - const project = await this.projectService.findOne({ - where: { id }, - relations: ['admin', 'share', 'share.user'], - }) + const project = await this.projectService.getFullProject(id) const user = await this.userService.findOne({ where: { id: uid } }) if (_isEmpty(project)) { @@ -1943,9 +1876,8 @@ export class ProjectController { } // @ts-expect-error - await this.projectService.update(id, _omit(project, ['share', 'admin'])) + await this.projectService.update({ id }, _omit(project, ['share', 'admin'])) - // await updateProjectRedis(id, project) await deleteProjectRedis(id) return _omit(project, ['admin', 'passwordHash', 'share']) @@ -1971,11 +1903,7 @@ export class ProjectController { ) } - const project = await this.projectService.findOne({ - where: { id: pid }, - relations: ['admin', 'share', 'share.user'], - select: ['id', 'admin', 'share'], - }) + const project = await this.projectService.getFullProject(pid) if (_isEmpty(project)) { throw new NotFoundException(`Project with ID ${pid} does not exist`) @@ -1989,58 +1917,13 @@ export class ProjectController { await this.projectService.deleteShare(shareId) } - @ApiOperation({ summary: 'Get monitors for all user projects' }) - @ApiBearerAuth() - @ApiOkResponse({ type: MonitorEntity }) - @Get('/monitors') - @Auth([]) - public async getAllMonitors( - @CurrentUserId() userId: string, - @Query('take') take: number | undefined, - @Query('skip') skip: number | undefined, - ): Promise> { - this.logger.log({ userId, take, skip }, 'GET /project/monitors') - - const projects = await this.projectService.find({ - where: { - admin: { id: userId }, - }, - }) - - if (_isEmpty(projects)) { - return { - results: [], - total: 0, - page_total: 0, - } - } - - const pids = _map(projects, project => project.id) - - const result = await this.projectService.paginateMonitors( - { - take, - skip, - }, - { project: In(pids) }, - ) - - // @ts-expect-error - result.results = _map(result.results, monitor => ({ - ..._omit(monitor, ['project']), - projectId: monitor.project.id, - })) - - return result - } - @ApiBearerAuth() @Get('/:id') @Auth([], true, true) @ApiResponse({ status: 200, type: Project }) async getOne( @Param('id') id: string, - @CurrentUserId() uid: string, + @CurrentUserId() userId: string, @Headers() headers: { 'x-password'?: string }, ): Promise { this.logger.log({ id }, 'GET /project/:id') @@ -2050,38 +1933,76 @@ export class ProjectController { ) } - const project = await this.projectService.findOne({ - where: { id }, - relations: ['admin', 'share', 'share.user', 'funnels'], - }) + const project = await this.projectService.getFullProject(id, null, [ + 'funnels', + ]) if (_isEmpty(project)) { throw new NotFoundException('Project was not found in the database') } - if (project.isPasswordProtected && _isEmpty(headers['x-password'])) { + let allowedToViewNoPassword = false + + try { + this.projectService.allowedToView(project, userId) + allowedToViewNoPassword = true + } catch { + allowedToViewNoPassword = false + } + + if ( + !allowedToViewNoPassword && + project.isPasswordProtected && + _isEmpty(headers['x-password']) + ) { return { isPasswordProtected: true, id: project.id, } } - this.projectService.allowedToView(project, uid, headers['x-password']) + this.projectService.allowedToView(project, userId, headers['x-password']) - const isDataExists = !_isEmpty( - await this.projectService.getPIDsWhereAnalyticsDataExists([id]), - ) + const [isDataExists, isErrorDataExists] = await Promise.all([ + !_isEmpty( + await this.projectService.getPIDsWhereAnalyticsDataExists([id]), + ), + !_isEmpty(await this.projectService.getPIDsWhereErrorsDataExists([id])), + ]) - const isErrorDataExists = !_isEmpty( - await this.projectService.getPIDsWhereErrorsDataExists([id]), - ) + let role + let isAccessConfirmed = true + + if (userId) { + const userShare = project.share?.find(share => share.user?.id === userId) + const organisationMembership = project.organisation?.members?.find( + member => member.user?.id === userId, + ) + + if (project.admin?.id === userId) { + role = 'owner' + } else if (userShare) { + role = userShare.role + isAccessConfirmed = userShare.confirmed + } else if (organisationMembership) { + role = organisationMembership.role + isAccessConfirmed = organisationMembership.confirmed + } + } return { - ..._omit(project, ['admin', 'passwordHash', 'share']), - isOwner: uid === project.admin?.id, + ..._omit(project, [ + 'admin', + 'passwordHash', + 'organisation', + role !== 'owner' && role !== 'admin' && 'share', + ]), + isAccessConfirmed, isLocked: !!project.admin?.dashboardBlockReason, isDataExists, isErrorDataExists, + organisationId: project.organisation?.id, + role, } } @@ -2095,10 +2016,7 @@ export class ProjectController { @CurrentUserId() userId: string, @Headers() headers: { 'x-password'?: string }, ) { - const project = await this.projectService.findProject(params.projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2123,10 +2041,7 @@ export class ProjectController { @Body() body: CreateProjectViewDto, @CurrentUserId() userId: string, ) { - const project = await this.projectService.findProject(params.projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2165,10 +2080,7 @@ export class ProjectController { @CurrentUserId() userId: string, @Headers() headers: { 'x-password'?: string }, ) { - const project = await this.projectService.findProject(params.projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2190,10 +2102,7 @@ export class ProjectController { @Body() body: UpdateProjectViewDto, @CurrentUserId() userId: string, ) { - const project = await this.projectService.findProject(params.projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2240,10 +2149,7 @@ export class ProjectController { @Param() params: ProjectViewIdsDto, @CurrentUserId() userId: string, ) { - const project = await this.projectService.findProject(params.projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found') @@ -2279,10 +2185,7 @@ export class ProjectController { @CurrentUserId() userId: string, @Headers() headers: { 'x-password'?: string }, ) { - const project = await this.projectService.findProject(params.projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(params.projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2307,10 +2210,7 @@ export class ProjectController { ): Promise> { this.logger.log({ userId, take, skip }, 'GET /project/:projectId/monitors') - const project = await this.projectService.findProject(projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2345,10 +2245,7 @@ export class ProjectController { @Body() body: CreateMonitorHttpRequestDTO, @CurrentUserId() userId: string, ): Promise { - const project = await this.projectService.findProject(projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2383,22 +2280,13 @@ export class ProjectController { @Param('monitorId') monitorId: number, @CurrentUserId() userId: string, ): Promise { - const project = await this.projectService.findProject(projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(projectId) if (!project) { throw new NotFoundException('Project not found.') } - const user = await this.userService.findUserV2(userId, ['roles']) - - if (!user) { - throw new NotFoundException('User not found.') - } - - this.projectService.allowedToManage(project, userId, user.roles) + this.projectService.allowedToManage(project, userId) const monitor = await this.projectService.getMonitor(monitorId) @@ -2419,11 +2307,8 @@ export class ProjectController { @Param('monitorId') monitorId: number, @Body() body: UpdateMonitorHttpRequestDTO, @CurrentUserId() userId: string, - ): Promise { - const project = await this.projectService.findProject(projectId, [ - 'admin', - 'share', - ]) + ): Promise { + const project = await this.projectService.getFullProject(projectId) if (!project) { throw new NotFoundException('Project not found.') @@ -2462,10 +2347,7 @@ export class ProjectController { @Param('monitorId') monitorId: number, @CurrentUserId() userId: string, ): Promise { - const project = await this.projectService.findProject(projectId, [ - 'admin', - 'share', - ]) + const project = await this.projectService.getFullProject(projectId) if (!project) { throw new NotFoundException('Project not found.') diff --git a/backend/apps/cloud/src/project/project.module.ts b/backend/apps/cloud/src/project/project.module.ts index f370df0c7..d0945a5f2 100644 --- a/backend/apps/cloud/src/project/project.module.ts +++ b/backend/apps/cloud/src/project/project.module.ts @@ -17,6 +17,7 @@ import { ProjectViewEntity } from './entity/project-view.entity' import { ProjectViewCustomEventEntity } from './entity/project-view-custom-event.entity' import { MonitorConsumer } from './consumers/monitor.consumer' import { MonitorEntity } from './entity/monitor.entity' +import { OrganisationModule } from '../organisation/organisation.module' @Module({ imports: [ @@ -30,6 +31,7 @@ import { MonitorEntity } from './entity/monitor.entity' MonitorEntity, ]), forwardRef(() => UserModule), + forwardRef(() => OrganisationModule), HttpModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/backend/apps/cloud/src/project/project.service.ts b/backend/apps/cloud/src/project/project.service.ts index 7fc6dd1c4..4fba8b7fc 100644 --- a/backend/apps/cloud/src/project/project.service.ts +++ b/backend/apps/cloud/src/project/project.service.ts @@ -12,8 +12,10 @@ import { InjectRepository } from '@nestjs/typeorm' import { FindManyOptions, FindOneOptions, - FindOptionsWhere, Repository, + DeepPartial, + Brackets, + FindOptionsWhere, } from 'typeorm' import { customAlphabet } from 'nanoid' import handlebars from 'handlebars' @@ -88,6 +90,8 @@ import { CreateMonitorHttpRequestDTO } from './dto/create-monitor.dto' import { MonitorEntity } from './entity/monitor.entity' import { UpdateMonitorHttpRequestDTO } from './dto/update-monitor.dto' import { HttpRequestOptions } from './interfaces/http-request-options.interface' +import { Organisation } from '../organisation/entity/organisation.entity' +import { OrganisationRole } from '../organisation/entity/organisation-member.entity' dayjs.extend(utc) @@ -133,24 +137,35 @@ const deleteProjectsRedis = async (ids: string[]) => { await Promise.all(_map(ids, deleteProjectRedis)) } -export const processProjectUser = (project: Project): Project => { - const { share } = project - - for (let j = 0; j < _size(share); ++j) { - const { user } = share[j] - - if (user) { - // @ts-expect-error _pick(user, ['email']) is partial but share[j].user expects full User entity - share[j].user = _pick(user, ['email']) +export const processProjectUser = ( + project: Project, + properties: Array<'share' | 'organisation.members'> = ['share'], +): Project => { + _map(properties, property => { + const array = + property === 'share' ? project.share : project.organisation?.members + + if (array) { + for (let j = 0; j < _size(array); ++j) { + const { user } = array[j] + + if (user) { + // @ts-expect-error _pick(user, ['email']) is partial but array[j].user expects full User entity + array[j].user = _pick(user, ['email', 'id']) + } + } } - } + }) return project } -const processProjectsUser = (projects: Project[]): Project[] => { +const processProjectsUser = ( + projects: Project[], + properties: Array<'share' | 'organisation.members'> = ['share'], +): Project[] => { for (let i = 0; i < _size(projects); ++i) { - projects[i] = processProjectUser(projects[i]) + projects[i] = processProjectUser(projects[i], properties) } return projects @@ -300,7 +315,6 @@ export class ProjectService { private projectsRepository: Repository, @InjectRepository(ProjectShare) private projectShareRepository: Repository, - private userService: UserService, @InjectRepository(ProjectSubscriber) private readonly projectSubscriberRepository: Repository, @InjectRepository(Funnel) @@ -311,6 +325,7 @@ export class ProjectService { private readonly mailerService: MailerService, private readonly httpService: HttpService, private readonly logger: AppLoggerService, + private readonly userService: UserService, @InjectQueue('monitor') private monitorQueue: Queue, ) {} @@ -322,6 +337,14 @@ export class ProjectService { project = await this.projectsRepository .createQueryBuilder('project') .leftJoinAndSelect('project.admin', 'admin') + .leftJoinAndSelect('project.organisation', 'organisation') + .leftJoinAndSelect( + 'organisation.members', + 'organisationMembers', + 'organisationMembers.confirmed = :confirmed', + { confirmed: true }, + ) + .leftJoinAndSelect('organisationMembers.user', 'organisationUser') .select([ 'project.origins', 'project.active', @@ -336,6 +359,9 @@ export class ProjectService { 'admin.roles', 'admin.dashboardBlockReason', 'admin.isAccountBillingSuspended', + 'organisation.id', + 'organisationMembers.role', + 'organisationUser.id', ]) .where('project.id = :pid', { pid }) .getOne() @@ -372,49 +398,141 @@ export class ProjectService { return project as Project } + // Instead of passing relations to the findOne method every time, this method returns a project + // with necessary relations needed for the allowedToView and allowedToManage methods + async getFullProject( + pid: string, + userId?: string, + additionalRelations: string[] = [], + ) { + const query: FindOneOptions = { + where: { id: pid }, + relations: [ + 'share', + 'share.user', + 'admin', + 'organisation', + 'organisation.members', + 'organisation.members.user', + ...additionalRelations, + ], + select: { + share: { + id: true, + role: true, + confirmed: true, + user: { + id: true, + email: true, + }, + }, + admin: { + id: true, + roles: true, + dashboardBlockReason: true, + isAccountBillingSuspended: true, + }, + organisation: { + id: true, + members: { + id: true, + role: true, + user: { + id: true, + }, + }, + }, + }, + } + + if (userId) { + query.where = { + id: pid, + admin: { id: userId }, + } as FindOptionsWhere + } + + return this.projectsRepository.findOne(query) + } + async paginate( options: PaginationOptionsInterface, - where?: FindOptionsWhere | FindOptionsWhere[], + userId: string, + search?: string, ): Promise> { - const [results, total] = await this.projectsRepository.findAndCount({ - take: options.take || 100, - skip: options.skip || 0, - where, - order: { - name: 'ASC', - }, - relations: ['share', 'share.user', 'funnels'], - }) + const queryBuilder = this.projectsRepository + .createQueryBuilder('project') + .leftJoinAndSelect('project.admin', 'admin') + .leftJoinAndSelect('project.share', 'share') + .leftJoinAndSelect('share.user', 'sharedUser') + .leftJoinAndSelect('project.funnels', 'funnels') + .leftJoinAndSelect('project.organisation', 'organisation') + .leftJoinAndSelect('organisation.members', 'organisationMembers') + .leftJoinAndSelect('organisationMembers.user', 'organisationUser') + .where( + new Brackets(qb => { + qb.where('admin.id = :userId', { userId }) + .orWhere('share.user.id = :userId', { userId }) + .orWhere( + 'organisationMembers.user.id = :userId AND organisationMembers.confirmed = :confirmed', + { userId, confirmed: true }, + ) + }), + ) + + if (search?.trim()) { + queryBuilder.andWhere('LOWER(project.name) LIKE LOWER(:search)', { + search: `%${search.trim()}%`, + }) + } + + queryBuilder + .orderBy('project.name', 'ASC') + .skip(options.skip || 0) + .take(options.take || 100) + + const [results, total] = await queryBuilder.getManyAndCount() return new Pagination({ - results: processProjectsUser(results), + results: processProjectsUser(results, ['share', 'organisation.members']), total, }) } - async paginateShared( + async paginateForOrganisation( options: PaginationOptionsInterface, - where: FindOptionsWhere | FindOptionsWhere[], - ): Promise> { - const [results, total] = await this.projectShareRepository.findAndCount({ - take: options.take || 100, - skip: options.skip || 0, - where, - order: { - project: { - name: 'ASC', - }, - }, - relations: [ - 'project', - 'project.admin', - 'project.funnels', - 'project.share', - 'project.share.user', - ], - }) + userId: string, + search?: string, + ): Promise> { + const queryBuilder = this.projectsRepository + .createQueryBuilder('project') + .select(['project.id', 'project.name']) + .leftJoin('project.admin', 'admin') + .leftJoin('project.share', 'share') + .where('project.organisation IS NULL') + .andWhere( + new Brackets(qb => { + qb.where('admin.id = :userId', { userId }).orWhere( + 'share.user.id = :userId AND share.role = :adminRole', + { userId, adminRole: Role.admin }, + ) + }), + ) - return new Pagination({ + if (search?.trim()) { + queryBuilder.andWhere('LOWER(project.name) LIKE LOWER(:search)', { + search: `%${search.trim()}%`, + }) + } + + queryBuilder + .orderBy('project.name', 'ASC') + .skip(options.skip || 0) + .take(options.take || 100) + + const [results, total] = await queryBuilder.getManyAndCount() + + return new Pagination({ results, total, }) @@ -424,19 +542,22 @@ export class ProjectService { return this.projectsRepository.count() } - async create(project: ProjectDTO | Project): Promise { + async create(project: DeepPartial) { return this.projectsRepository.save(project) } - async update(id: string, projectDTO: ProjectDTO | Project): Promise { - return this.projectsRepository.update(id, projectDTO) + async update( + where: FindOptionsWhere, + projectDTO: ProjectDTO | Project, + ) { + return this.projectsRepository.update(where, projectDTO) } - async delete(id: string): Promise { + async delete(id: string) { return this.projectsRepository.delete(id) } - async deleteMultiple(pids: string[]): Promise { + async deleteMultiple(pids: string[]) { return this.projectsRepository .createQueryBuilder() .delete() @@ -444,7 +565,7 @@ export class ProjectService { .execute() } - async deleteMultipleShare(where: string): Promise { + async deleteMultipleShare(where: string) { return this.projectShareRepository .createQueryBuilder() .delete() @@ -456,11 +577,11 @@ export class ProjectService { return this.projectShareRepository.save(share) } - async deleteShare(id: string): Promise { + async deleteShare(id: string) { return this.projectShareRepository.delete(id) } - async updateShare(id: string, share: ProjectShare | object): Promise { + async updateShare(id: string, share: ProjectShare | object) { return this.projectShareRepository.update(id, share) } @@ -496,7 +617,11 @@ export class ProjectService { if ( project.public || uid === project.admin?.id || - _findIndex(project.share, ({ user }) => user?.id === uid) !== -1 + _findIndex(project.share, ({ user }) => user?.id === uid) !== -1 || + _findIndex( + project.organisation?.members, + member => member.user?.id === uid, + ) !== -1 ) { return null } @@ -531,6 +656,13 @@ export class ProjectService { _findIndex( project.share, share => share.user?.id === uid && share.role === Role.admin, + ) !== -1 || + _findIndex( + project.organisation?.members, + member => + member.user?.id === uid && + (member.role === OrganisationRole.admin || + member.role === OrganisationRole.owner), ) !== -1 ) { return null @@ -922,16 +1054,6 @@ export class ProjectService { await this.clearProjectsRedisCache(user.id) } - async getProject(projectId: string, userId: string) { - return this.projectsRepository.findOne({ - where: { - id: projectId, - admin: { id: userId }, - }, - relations: ['admin'], - }) - } - async getPIDsWhereAnalyticsDataExists( projectIds: string[], ): Promise { @@ -1075,13 +1197,6 @@ export class ProjectService { ) } - async getProjectById(projectId: string) { - return this.projectsRepository.findOne({ - where: { id: projectId }, - relations: ['admin'], - }) - } - async findOneSubscriber(where: FindOneOptions['where']) { return this.projectSubscriberRepository.findOne({ where }) } @@ -1332,14 +1447,14 @@ export class ProjectService { await Promise.all([ document.fonts.ready, ...selectors.map(img => { - // Image has already finished loading, let’s see if it worked + // Image has already finished loading, let's see if it worked if (img.complete) { // Image loaded and has presence if (img.naturalHeight !== 0) return // Image failed, so it has no height throw new Error('Image failed to load') } - // Image hasn’t loaded yet, added an event listener to know when it does + // Image hasn't loaded yet, added an event listener to know when it does // eslint-disable-next-line consistent-return return new Promise((resolve, reject) => { img.addEventListener('load', resolve) @@ -1398,10 +1513,6 @@ export class ProjectService { await this.projectsRepository.update({ id }, data) } - async findProject(id: string, relations: string[]) { - return this.projectsRepository.findOne({ relations, where: { id } }) - } - filterUnsupportedColumns( filters: CreateProjectViewDto['filters'], ): CreateProjectViewDto['filters'] { @@ -1529,7 +1640,7 @@ export class ProjectService { description, httpOptions, }: UpdateMonitorHttpRequestDTO, - ): Promise { + ): Promise { await this.monitorRepository.update( { id: monitorId }, { @@ -1547,7 +1658,7 @@ export class ProjectService { ) } - async deleteMonitor(monitorId: number): Promise { + async deleteMonitor(monitorId: number) { return this.monitorRepository.delete(monitorId) } @@ -1583,4 +1694,44 @@ export class ProjectService { await job.remove() } } + + async addProjectToOrganisation(organisationId: string, projectId: string) { + const project = await this.findOne({ + where: { id: projectId }, + }) + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`) + } + + return this.update({ id: projectId }, { + organisation: { + id: organisationId, + } as Organisation, + } as Project) + } + + async removeProjectFromOrganisation( + organisationId: string, + projectId: string, + ) { + const project = await this.findOne({ + where: { id: projectId }, + relations: ['organisation'], + }) + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`) + } + + if (project.organisation?.id !== organisationId) { + throw new BadRequestException( + `Project with ID ${projectId} is not in organisation with ID ${organisationId}`, + ) + } + + return this.update({ id: projectId }, { + organisation: null, + } as Project) + } } diff --git a/backend/apps/cloud/src/user/entities/user.entity.ts b/backend/apps/cloud/src/user/entities/user.entity.ts index 459feaf4a..4146f4a88 100644 --- a/backend/apps/cloud/src/user/entities/user.entity.ts +++ b/backend/apps/cloud/src/user/entities/user.entity.ts @@ -16,6 +16,7 @@ import { Comment } from '../../marketplace/comments/entities/comment.entity' import { CommentReply } from '../../marketplace/comments/entities/comment-reply.entity' import { Complaint } from '../../marketplace/complaints/entities/complaint.entity' import { RefreshToken } from './refresh-token.entity' +import { OrganisationMember } from '../../organisation/entity/organisation-member.entity' export enum PlanCode { none = 'none', @@ -451,4 +452,7 @@ export class User { @OneToMany(() => RefreshToken, refreshToken => refreshToken.user) @JoinTable() refreshTokens: RefreshToken[] + + @OneToMany(() => OrganisationMember, membership => membership.user) + organisationMemberships: OrganisationMember[] } diff --git a/backend/apps/cloud/src/user/user.controller.ts b/backend/apps/cloud/src/user/user.controller.ts index 7d825d965..d0c428825 100644 --- a/backend/apps/cloud/src/user/user.controller.ts +++ b/backend/apps/cloud/src/user/user.controller.ts @@ -37,7 +37,7 @@ import { catchError, firstValueFrom, map, of } from 'rxjs' import { Markup } from 'telegraf' import { JwtAccessTokenGuard } from '../auth/guards' -import { Public, Roles, CurrentUserId } from '../auth/decorators' +import { Public, Roles, CurrentUserId, Auth } from '../auth/decorators' import { TelegramService } from '../integrations/telegram/telegram.service' import { UserService } from './user.service' import { ProjectService, deleteProjectRedis } from '../project/project.service' @@ -76,6 +76,7 @@ import { } from '../common/utils' import { IUsageInfo, IMetaInfo } from './interfaces' import { ReportFrequency } from '../project/enums' +import { OrganisationService } from '../organisation/organisation.service' dayjs.extend(utc) @@ -94,30 +95,33 @@ export class UserController { private readonly logger: AppLoggerService, private readonly telegramService: TelegramService, private readonly httpService: HttpService, + private readonly organisationService: OrganisationService, ) {} @ApiBearerAuth() @Get('/me') @UseGuards(RolesGuard) @Roles(UserType.CUSTOMER, UserType.ADMIN) - async me(@CurrentUserId() uid: string): Promise> { + async me(@CurrentUserId() uid: string) { this.logger.log({ uid }, 'GET /user/me') - const sharedProjects = await this.projectService.findShare({ - where: { - user: { - id: uid, - }, - }, - relations: ['project'], - }) - const user = this.userService.omitSensitiveData( - await this.userService.findOne({ where: { id: uid } }), - ) + const [sharedProjects, organisationMemberships, user, totalMonthlyEvents] = + await Promise.all([ + this.authService.getSharedProjectsForUser(uid), + this.authService.getOrganisationsForUser(uid), + this.userService.findOne({ where: { id: uid } }), + this.projectService.getRedisCount(uid), + ]) - user.sharedProjects = sharedProjects + const sanitizedUser = this.userService.omitSensitiveData(user) - return user + sanitizedUser.sharedProjects = sharedProjects + sanitizedUser.organisationMemberships = organisationMemberships + + return { + user: sanitizedUser, + totalMonthlyEvents, + } } @ApiBearerAuth() @@ -346,6 +350,11 @@ export class UserController { await this.actionTokensService.deleteMultiple(`userId="${id}"`) await this.userService.deleteAllRefreshTokens(id) await this.projectService.deleteMultipleShare(`userId="${id}"`) + await this.organisationService.deleteMemberships({ + user: { + id, + }, + }) await this.userService.delete(id) return 'accountDeleted' @@ -405,6 +414,11 @@ export class UserController { await this.actionTokensService.deleteMultiple(`userId="${id}"`) await this.userService.deleteAllRefreshTokens(id) await this.projectService.deleteMultipleShare(`userId="${id}"`) + await this.organisationService.deleteMemberships({ + user: { + id, + }, + }) await this.userService.delete(id) } catch (e) { this.logger.error(e) @@ -427,67 +441,185 @@ export class UserController { } @ApiBearerAuth() - @Delete('/share/:shareId') + @Delete('/share/:actionId') @HttpCode(204) @UseGuards(JwtAccessTokenGuard, RolesGuard) @Roles(UserType.CUSTOMER, UserType.ADMIN) @ApiResponse({ status: 204, description: 'Empty body' }) async deleteShare( - @Param('shareId') shareId: string, + @Param('actionId') actionId: string, @CurrentUserId() uid: string, - ): Promise { - this.logger.log({ uid, shareId }, 'DELETE /user/share/:shareId') + ) { + this.logger.log({ uid, actionId }, 'DELETE /user/share/:actionId') + + let isActionToken = false + + const actionToken = await this.actionTokensService.getActionToken(actionId) + + if (actionToken) { + isActionToken = true + } const share = await this.projectService.findOneShare({ - where: { id: shareId }, + where: { id: actionToken?.newValue || actionId }, relations: ['user', 'project'], }) if (_isEmpty(share)) { - throw new BadRequestException('The provided share ID is not valid') + throw new BadRequestException( + 'This invitation does not exist or is no longer valid', + ) } if (share.user?.id !== uid) { - throw new BadRequestException('You are not allowed to delete this share') + throw new BadRequestException( + 'You are not allowed to reject this invitation', + ) } - await this.projectService.deleteShare(shareId) + await this.projectService.deleteShare(share.id) await deleteProjectRedis(share.project.id) - return 'shareDeleted' + if (isActionToken) { + await this.actionTokensService.delete(actionId) + } } @ApiBearerAuth() - @Get('/share/:shareId') + @Get('/share/:actionId') @HttpCode(204) @UseGuards(JwtAccessTokenGuard, RolesGuard) @Roles(UserType.CUSTOMER, UserType.ADMIN) @ApiResponse({ status: 204, description: 'Empty body' }) async acceptShare( - @Param('shareId') shareId: string, + @Param('actionId') actionId: string, @CurrentUserId() uid: string, - ): Promise { - this.logger.log({ uid, shareId }, 'GET /user/share/:shareId') + ) { + this.logger.log({ uid, actionId }, 'GET /user/share/:actionId') + + let isActionToken = false + + const actionToken = await this.actionTokensService.getActionToken(actionId) + + if (actionToken) { + isActionToken = true + } const share = await this.projectService.findOneShare({ - where: { id: shareId }, + where: { id: actionToken?.newValue || actionId }, relations: ['user', 'project'], }) if (_isEmpty(share)) { - throw new BadRequestException('The provided share ID is not valid') + throw new BadRequestException( + 'This invitation does not exist or is no longer valid', + ) } if (share.user?.id !== uid) { - throw new BadRequestException('You are not allowed to delete this share') + throw new BadRequestException( + 'You are not allowed to accept this invitation', + ) } - share.confirmed = true - - await this.projectService.updateShare(shareId, share) + await this.projectService.updateShare(share.id, { + confirmed: true, + }) await deleteProjectRedis(share.project.id) - return 'shareAccepted' + if (isActionToken) { + await this.actionTokensService.delete(actionId) + } + } + + @ApiBearerAuth() + @Delete('/organisation/:actionId') + @HttpCode(204) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Roles(UserType.CUSTOMER, UserType.ADMIN) + @ApiResponse({ status: 204, description: 'Empty body' }) + async rejectOrganisationInvitation( + @Param('actionId') actionId: string, + @CurrentUserId() uid: string, + ) { + this.logger.log({ uid, actionId }, 'DELETE /user/organisation/:actionId') + + let isActionToken = false + + const actionToken = await this.actionTokensService.getActionToken(actionId) + + if (actionToken) { + isActionToken = true + } + + const membership = await this.organisationService.findOneMembership({ + where: { id: actionToken?.newValue || actionId }, + relations: ['user'], + }) + + if (_isEmpty(membership)) { + throw new BadRequestException( + 'This invitation does not exist or is no longer valid', + ) + } + + if (membership.user?.id !== uid) { + throw new BadRequestException( + 'You are not allowed to reject this invitation from this account', + ) + } + + await this.organisationService.deleteMembership(membership.id) + + if (isActionToken) { + await this.actionTokensService.delete(actionId) + } + } + + @ApiBearerAuth() + @Post('/organisation/:actionId') + @HttpCode(204) + @UseGuards(JwtAccessTokenGuard, RolesGuard) + @Auth([], true) + @ApiResponse({ status: 204, description: 'Empty body' }) + async acceptOrganisationInvitation( + @Param('actionId') actionId: string, + @CurrentUserId() uid: string, + ): Promise { + this.logger.log({ uid, actionId }, 'POST /user/organisation/:actionId') + + let isActionToken = false + + const actionToken = await this.actionTokensService.getActionToken(actionId) + + if (actionToken) { + isActionToken = true + } + + const membership = await this.organisationService.findOneMembership({ + where: { id: actionToken?.newValue || actionId }, + relations: ['user'], + }) + + if (_isEmpty(membership)) { + throw new BadRequestException( + 'This invitation does not exist or is no longer valid', + ) + } + + if (membership.user?.id !== uid) { + throw new BadRequestException( + 'You are not allowed to accept this invitation from this account', + ) + } + + await this.organisationService.updateMembership(membership.id, { + confirmed: true, + }) + + if (isActionToken) { + await this.actionTokensService.delete(actionId) + } } @ApiBearerAuth() diff --git a/backend/apps/cloud/src/user/user.module.ts b/backend/apps/cloud/src/user/user.module.ts index b7bbd6967..8e2d8463a 100644 --- a/backend/apps/cloud/src/user/user.module.ts +++ b/backend/apps/cloud/src/user/user.module.ts @@ -15,6 +15,7 @@ import { ProjectModule } from '../project/project.module' import { RefreshToken } from './entities/refresh-token.entity' import { DeleteFeedback } from './entities/delete-feedback.entity' import { Message } from '../integrations/telegram/entities/message.entity' +import { OrganisationModule } from '../organisation/organisation.module' @Module({ imports: [ @@ -24,6 +25,7 @@ import { Message } from '../integrations/telegram/entities/message.entity' forwardRef(() => AuthModule), AppLoggerModule, ProjectModule, + OrganisationModule, PayoutsModule, HttpModule, ], diff --git a/backend/apps/community/src/auth/auth.controller.ts b/backend/apps/community/src/auth/auth.controller.ts index 6d46eedbf..3de52a9de 100644 --- a/backend/apps/community/src/auth/auth.controller.ts +++ b/backend/apps/community/src/auth/auth.controller.ts @@ -56,7 +56,7 @@ export class AuthController { await checkRateLimit(ip, 'login', 10, 1800) - const user = await this.authService.validateUser(body.email, body.password) + const user = await this.authService.getBasicUser(body.email, body.password) if (!user) { throw new ConflictException(i18n.t('auth.invalidCredentials')) diff --git a/backend/apps/community/src/auth/auth.service.ts b/backend/apps/community/src/auth/auth.service.ts index 8bb1f0c7d..b0842aa7e 100644 --- a/backend/apps/community/src/auth/auth.service.ts +++ b/backend/apps/community/src/auth/auth.service.ts @@ -48,7 +48,7 @@ export class AuthService { ) } - public async validateUser( + async getBasicUser( email: string, password: string, ): Promise { diff --git a/backend/apps/community/src/project/project.controller.ts b/backend/apps/community/src/project/project.controller.ts index 870907245..aaf297378 100644 --- a/backend/apps/community/src/project/project.controller.ts +++ b/backend/apps/community/src/project/project.controller.ts @@ -148,20 +148,22 @@ export class ProjectController { {}, ) - const results = _map(formatted, p => ({ - ..._omit(p, ['passwordHash']), - funnels: funnelsMap[p.id], - isOwner: true, + const results = _map(formatted, project => ({ + ..._omit(project, ['passwordHash']), + funnels: funnelsMap[project.id], + isDataExists: _includes(pidsWithData, project?.id), + isErrorDataExists: _includes(pidsWithErrorData, project?.id), + + // these ones are not used, but we need to keep them for compatibility with the Cloud version + role: userId ? 'owner' : 'viewer', isLocked: false, - isDataExists: _includes(pidsWithData, p?.id), - isErrorDataExists: _includes(pidsWithErrorData, p?.id), + isAccessConfirmed: true, })) return { results, page_total: _size(formatted), total: _size(formatted), - totalMonthlyEvents: 0, // not needed as it's selfhosed } } @@ -538,7 +540,7 @@ export class ProjectController { @ApiResponse({ status: 200, type: Project }) async getOne( @Param('id') id: string, - @CurrentUserId() uid: string, + @CurrentUserId() userId: string, @Headers() headers: { 'x-password'?: string }, ): Promise { this.logger.log({ id }, 'GET /project/:id') @@ -554,14 +556,27 @@ export class ProjectController { throw new NotFoundException('Project was not found in the database') } - if (project.isPasswordProtected && _isEmpty(headers['x-password'])) { + let allowedToViewNoPassword = false + + try { + this.projectService.allowedToView(project, userId) + allowedToViewNoPassword = true + } catch { + allowedToViewNoPassword = false + } + + if ( + !allowedToViewNoPassword && + project.isPasswordProtected && + _isEmpty(headers['x-password']) + ) { return { isPasswordProtected: true, id: project.id, } } - this.projectService.allowedToView(project, uid, headers['x-password']) + this.projectService.allowedToView(project, userId, headers['x-password']) const isDataExists = !_isEmpty( await this.projectService.getPIDsWhereAnalyticsDataExists([id]), @@ -578,6 +593,11 @@ export class ProjectController { funnels: this.projectService.formatFunnelsFromClickhouse(funnels), isDataExists, isErrorDataExists, + + // these ones are not used, but we need to keep them for compatibility with the Cloud version + role: userId ? 'owner' : 'viewer', + isLocked: false, + isAccessConfirmed: true, }) } diff --git a/backend/apps/community/src/user/user.controller.ts b/backend/apps/community/src/user/user.controller.ts index 0a9d4a8c2..684f06ff7 100644 --- a/backend/apps/community/src/user/user.controller.ts +++ b/backend/apps/community/src/user/user.controller.ts @@ -34,10 +34,14 @@ export class UserController { @Get('/me') @UseGuards(RolesGuard) @Roles(UserType.CUSTOMER, UserType.ADMIN) - async me(@CurrentUserId() user_id: string): Promise { + async me( + @CurrentUserId() user_id: string, + ): Promise<{ user: SelfhostedUser }> { this.logger.log({ user_id }, 'GET /user/me') - return getSelfhostedUser() + return { + user: await getSelfhostedUser(), + } } @ApiBearerAuth() diff --git a/backend/migrations/mysql/2024_12_14.sql b/backend/migrations/mysql/2024_12_14.sql new file mode 100644 index 000000000..efa0f3406 --- /dev/null +++ b/backend/migrations/mysql/2024_12_14.sql @@ -0,0 +1,23 @@ +ALTER TABLE action_token MODIFY COLUMN `action` enum('0','1','2','3','4','5','6') NOT NULL AFTER newValue; + +CREATE TABLE `organisation` ( + `id` varchar(36) NOT NULL, + `name` varchar(50) NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `organisation_member` ( + `id` varchar(36) NOT NULL, + `role` enum('owner','admin','viewer') NOT NULL, + `confirmed` tinyint NOT NULL DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `userId` varchar(36) DEFAULT NULL, + `organisationId` varchar(36) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE project ADD COLUMN `organisationId` varchar(36) DEFAULT NULL AFTER `botsProtectionLevel`; + diff --git a/web/app/App.tsx b/web/app/App.tsx index e6f639e85..bf2b9de14 100644 --- a/web/app/App.tsx +++ b/web/app/App.tsx @@ -8,29 +8,28 @@ import _startsWith from 'lodash/startsWith' import _endsWith from 'lodash/endsWith' import 'dayjs/locale/uk' -import Header from 'components/Header' -import Footer from 'components/Footer' +import Header from '~/components/Header' +import Footer from '~/components/Footer' import { Toaster } from 'sonner' -import { getAccessToken } from 'utils/accessToken' -import { authActions } from 'redux/reducers/auth' -import sagaActions from 'redux/sagas/actions' -import { StateType, useAppDispatch } from 'redux/store' -import { isBrowser } from 'redux/constants' -import routesPath from 'utils/routes' -import { getPageMeta } from 'utils/server' -import { authMe } from './api' - -const minimalFooterPages = ['/projects', '/dashboard', '/contact', '/captchas'] - -interface IApp { +import { getAccessToken } from '~/utils/accessToken' +import { authActions } from '~/lib/reducers/auth' +import { StateType, useAppDispatch } from '~/lib/store' +import { isBrowser, isSelfhosted } from '~/lib/constants' +import routesPath from '~/utils/routes' +import { getPageMeta } from '~/utils/server' +import { authMe, getGeneralStats, getInstalledExtensions, getLastPost, getPaymentMetainfo } from './api' +import UIActions from '~/lib/reducers/ui' +import { logout, shouldShowLowEventsBanner } from '~/utils/auth' + +interface AppProps { ssrTheme: 'dark' | 'light' ssrAuthenticated: boolean } const TITLE_BLACKLIST = ['/projects/', '/captchas/', '/blog'] -const App: React.FC = ({ ssrTheme, ssrAuthenticated }) => { +const App = ({ ssrTheme, ssrAuthenticated }: AppProps) => { const dispatch = useAppDispatch() const { pathname } = useLocation() const { t } = useTranslation('common') @@ -46,14 +45,43 @@ const App: React.FC = ({ ssrTheme, ssrAuthenticated }) => { (async () => { if (accessToken && !reduxAuthenticated) { try { - const me = await authMe() - dispatch(authActions.loginSuccessful(me)) - } catch (e) { + const { user, totalMonthlyEvents } = await authMe() + dispatch(authActions.authSuccessful(user)) + + if (shouldShowLowEventsBanner(totalMonthlyEvents, user.maxEventsCount)) { + dispatch(UIActions.setShowNoEventsLeftBanner(true)) + } + + if (!isSelfhosted) { + const extensions = await getInstalledExtensions() + dispatch(UIActions.setExtensions(extensions)) + } + } catch (reason) { dispatch(authActions.logout()) - dispatch(sagaActions.logout(false, false)) + logout() + console.error(`[ERROR] Error while getting user: ${reason}`) } } + if (!isSelfhosted) { + const [metainfo, lastBlogPost, generalStats] = await Promise.all([ + getPaymentMetainfo(), + getLastPost(), + getGeneralStats(), + ]) + dispatch(UIActions.setMetainfo(metainfo)) + dispatch(UIActions.setLastBlogPost(lastBlogPost)) + dispatch(UIActions.setGeneralStats(generalStats)) + } + + // yield put(sagaActions.loadMetainfo()) + + // const lastBlogPost: { + // title: string + // handle: string + // } = yield call(getLastPost) + // yield put(UIActions.setLastBlogPost(lastBlogPost)) + dispatch(authActions.finishLoading()) })() }, [reduxAuthenticated]) // eslint-disable-line @@ -67,8 +95,6 @@ const App: React.FC = ({ ssrTheme, ssrAuthenticated }) => { document.title = title }, [t, pathname]) - const isMinimalFooter = _some(minimalFooterPages, (page) => _includes(pathname, page)) - const isReferralPage = _startsWith(pathname, '/ref/') const isProjectViewPage = _startsWith(pathname, '/projects/') && @@ -100,7 +126,7 @@ const App: React.FC = ({ ssrTheme, ssrAuthenticated }) => { duration: 5000, }} /> - {!isReferralPage && !isProjectViewPage &&