diff --git a/apps/api/package.json b/apps/api/package.json index 34e2f32e1..8881bac29 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@impler/api", - "version": "0.26.1", + "version": "0.27.0", "author": "implerhq", "license": "MIT", "private": true, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 08edc4d72..e01af6b85 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,7 @@ import { EnvironmentModule } from './app/environment/environment.module'; import { ActivityModule } from './app/activity/activity.module'; import { UserModule } from './app/user/user.module'; import { ImportJobsModule } from 'app/import-jobs/import-jobs.module'; +import { TeamModule } from 'app/team/team.module'; const modules: Array | ForwardReference> = [ ProjectModule, @@ -31,6 +32,7 @@ const modules: Array | ForwardRefe EnvironmentModule, ActivityModule, ImportJobsModule, + TeamModule, ]; const providers = [Logger]; diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 5f46de16e..8c1fc13e3 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -13,7 +13,7 @@ import { UseInterceptors, } from '@nestjs/common'; -import { IJwtPayload } from '@impler/shared'; +import { constructQueryString, IJwtPayload, UserRolesEnum } from '@impler/shared'; import { AuthService } from './services/auth.service'; import { IStrategyResponse } from '@shared/types/auth.types'; import { CONSTANTS, COOKIE_CONFIG } from '@shared/constants'; @@ -37,8 +37,6 @@ import { OnboardUser, RegisterUser, ResetPassword, - LoginUserCommand, - RegisterUserCommand, ResetPasswordCommand, RequestForgotPassword, RequestForgotPasswordCommand, @@ -76,7 +74,10 @@ export class AuthController { @Get('/github/callback') @UseGuards(AuthGuard('github')) - async githubCallback(@StrategyUser() strategyUser: IStrategyResponse, @Res() response: Response) { + async githubCallback( + @Res({ passthrough: true }) response: Response, + @StrategyUser() strategyUser: IStrategyResponse + ) { if (!strategyUser || !strategyUser.token) { return response.redirect(`${process.env.WEB_BASE_URL}/auth/signin?error=AuthenticationError`); } @@ -88,11 +89,7 @@ export class AuthController { if (strategyUser.showAddProject) { queryObj.showAddProject = true; } - for (const key in queryObj) { - if (queryObj.hasOwnProperty(key)) { - url += `${url.includes('?') ? '&' : '?'}${key}=${queryObj[key]}`; - } - } + url += constructQueryString(queryObj); response.cookie(CONSTANTS.AUTH_COOKIE_NAME, strategyUser.token, { ...COOKIE_CONFIG, @@ -133,7 +130,7 @@ export class AuthController { @Post('/register') async register(@Body() body: RegisterUserDto, @Res() response: Response) { - const registeredUser = await this.registerUser.execute(RegisterUserCommand.create(body)); + const registeredUser = await this.registerUser.execute(body); response.cookie(CONSTANTS.AUTH_COOKIE_NAME, registeredUser.token, { ...COOKIE_CONFIG, @@ -170,15 +167,21 @@ export class AuthController { }, user.email ); + + const userApiKey = projectWithEnvironment.environment.apiKeys.find( + (apiKey) => apiKey._userId.toString() === user._id + ); + const token = this.authService.getSignedToken( { _id: user._id, firstName: user.firstName, lastName: user.lastName, email: user.email, + role: userApiKey.role as UserRolesEnum, profilePicture: user.profilePicture, isEmailVerified: user.isEmailVerified, - accessToken: projectWithEnvironment.environment.apiKeys[0].key, + accessToken: projectWithEnvironment.environment.key, }, projectWithEnvironment.project._id ); @@ -192,12 +195,11 @@ export class AuthController { @Post('/login') async login(@Body() body: LoginUserDto, @Res() response: Response) { - const loginUser = await this.loginUser.execute( - LoginUserCommand.create({ - email: body.email, - password: body.password, - }) - ); + const loginUser = await this.loginUser.execute({ + email: body.email, + password: body.password, + invitationId: body.invitationId, + }); response.cookie(CONSTANTS.AUTH_COOKIE_NAME, loginUser.token, { ...COOKIE_CONFIG, diff --git a/apps/api/src/app/auth/dtos/login-user.dto.ts b/apps/api/src/app/auth/dtos/login-user.dto.ts index 69507fd71..ca5eeb303 100644 --- a/apps/api/src/app/auth/dtos/login-user.dto.ts +++ b/apps/api/src/app/auth/dtos/login-user.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsString, IsEmail } from 'class-validator'; +import { IsDefined, IsString, IsEmail, IsOptional } from 'class-validator'; export class LoginUserDto { @ApiProperty({ @@ -15,4 +15,11 @@ export class LoginUserDto { @IsString() @IsDefined() password: string; + + @ApiProperty({ + description: 'InvitationId to accept invitation later on', + }) + @IsString() + @IsOptional() + invitationId?: string; } diff --git a/apps/api/src/app/auth/dtos/register-user.dto.ts b/apps/api/src/app/auth/dtos/register-user.dto.ts index 76625a563..108541f68 100644 --- a/apps/api/src/app/auth/dtos/register-user.dto.ts +++ b/apps/api/src/app/auth/dtos/register-user.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsString, IsEmail } from 'class-validator'; +import { IsDefined, IsString, IsEmail, IsOptional } from 'class-validator'; export class RegisterUserDto { @ApiProperty({ @@ -29,4 +29,11 @@ export class RegisterUserDto { @IsString() @IsDefined() password: string; + + @ApiProperty({ + description: 'InvitationId to accept invitation later on', + }) + @IsString() + @IsOptional() + invitationId?: string; } diff --git a/apps/api/src/app/auth/services/auth.service.ts b/apps/api/src/app/auth/services/auth.service.ts index 619d8c240..30e2faef8 100644 --- a/apps/api/src/app/auth/services/auth.service.ts +++ b/apps/api/src/app/auth/services/auth.service.ts @@ -2,7 +2,7 @@ import * as bcrypt from 'bcryptjs'; import { JwtService } from '@nestjs/jwt'; import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { IJwtPayload } from '@impler/shared'; +import { IJwtPayload, UserRolesEnum } from '@impler/shared'; import { CONSTANTS, LEAD_SIGNUP_USING } from '@shared/constants'; import { UserEntity, UserRepository, EnvironmentRepository } from '@impler/dal'; import { UserNotFoundException } from '@shared/exceptions/user-not-found.exception'; @@ -31,6 +31,7 @@ export class AuthService { lastName: profile.lastName, signupMethod: LEAD_SIGNUP_USING.GITHUB, profilePicture: profile.avatar_url, + role: UserRolesEnum.ADMIN, ...(provider ? { tokens: [provider] } : {}), }; user = await this.userRepository.create(userObj); @@ -54,6 +55,7 @@ export class AuthService { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: apiKey?.role as UserRolesEnum, profilePicture: user.profilePicture, accessToken: apiKey?.apiKey, isEmailVerified: user.isEmailVerified, @@ -89,6 +91,7 @@ export class AuthService { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: apiKey.role as UserRolesEnum, accessToken: apiKey?.apiKey, isEmailVerified: user.isEmailVerified, }, @@ -109,6 +112,7 @@ export class AuthService { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: apiKey.role as UserRolesEnum, accessToken: apiKey?.apiKey, isEmailVerified: user.isEmailVerified, }, @@ -122,6 +126,7 @@ export class AuthService { firstName: string; lastName: string; email: string; + role?: UserRolesEnum; isEmailVerified: boolean; profilePicture?: string; accessToken?: string; @@ -134,6 +139,7 @@ export class AuthService { { _id: user._id, _projectId, + role: user.role, firstName: user.firstName, lastName: user.lastName, email: user.email, @@ -173,23 +179,7 @@ export class AuthService { const environment = await this.environmentRepository.findByApiKey(apiKey); if (!environment) throw new UnauthorizedException('API Key not found!'); - const key = environment.apiKeys.find((i) => i.key === apiKey); - if (!key) throw new UnauthorizedException('API Key not found!'); - - const user = await this.getUser({ _id: key._userId }); - if (!user) throw new UnauthorizedException('User not found!'); - - return this.getSignedToken( - { - _id: user._id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - accessToken: apiKey, - isEmailVerified: user.isEmailVerified, - }, - environment._projectId - ); + if (apiKey !== environment.key) throw new UnauthorizedException('API Key not found!'); } async generateUserToken(user: UserEntity) { @@ -201,6 +191,7 @@ export class AuthService { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: apiKey.role as UserRolesEnum, accessToken: apiKey?.apiKey, isEmailVerified: user.isEmailVerified, }, diff --git a/apps/api/src/app/auth/usecases/index.ts b/apps/api/src/app/auth/usecases/index.ts index e1c0d2d0e..2e36c5fc1 100644 --- a/apps/api/src/app/auth/usecases/index.ts +++ b/apps/api/src/app/auth/usecases/index.ts @@ -7,9 +7,7 @@ import { RequestForgotPassword } from './request-forgot-password/request-forgot- import { Verify } from './verify/verify.usecase'; import { ResendOTP } from './resend-otp/resend-otp.usecase'; -import { LoginUserCommand } from './login-user/login-user.command'; import { OnboardUserCommand } from './onboard-user/onboard-user.command'; -import { RegisterUserCommand } from './register-user/register-user.command'; import { ResetPasswordCommand } from './reset-password/reset-password.command'; import { RequestForgotPasswordCommand } from './request-forgot-password/request-forgot-pasword.command'; @@ -38,11 +36,5 @@ export const USE_CASES = [ // ]; +export { OnboardUserCommand, ResetPasswordCommand, RequestForgotPasswordCommand }; export { Verify, RegisterUser, LoginUser, RequestForgotPassword, ResetPassword, OnboardUser, ResendOTP, UpdateUser }; -export { - LoginUserCommand, - OnboardUserCommand, - RegisterUserCommand, - ResetPasswordCommand, - RequestForgotPasswordCommand, -}; diff --git a/apps/api/src/app/auth/usecases/login-user/login-user.command.ts b/apps/api/src/app/auth/usecases/login-user/login-user.command.ts index fea8e890e..3eb591b3e 100644 --- a/apps/api/src/app/auth/usecases/login-user/login-user.command.ts +++ b/apps/api/src/app/auth/usecases/login-user/login-user.command.ts @@ -1,12 +1,5 @@ -import { IsDefined, IsEmail, IsString } from 'class-validator'; -import { BaseCommand } from '@shared/commands/base.command'; - -export class LoginUserCommand extends BaseCommand { - @IsEmail() - @IsDefined() +export class LoginUserCommand { email: string; - - @IsString() - @IsDefined() password: string; + invitationId?: string; } diff --git a/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts b/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts index 7cd76c150..051fe81a2 100644 --- a/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts +++ b/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts @@ -5,7 +5,7 @@ import { EmailService } from '@impler/services'; import { LoginUserCommand } from './login-user.command'; import { AuthService } from '../../services/auth.service'; import { EnvironmentRepository, UserRepository } from '@impler/dal'; -import { EMAIL_SUBJECT, SCREENS } from '@impler/shared'; +import { EMAIL_SUBJECT, SCREENS, UserRolesEnum } from '@impler/shared'; import { generateVerificationCode } from '@shared/helpers/common.helper'; @Injectable() @@ -59,14 +59,20 @@ export class LoginUser { const apiKey = await this.environmentRepository.getApiKeyForUserId(user._id); + let screen = SCREENS.ONBOARD; + if (command.invitationId) screen = SCREENS.INVIATAION; + else if (!user.isEmailVerified) screen = SCREENS.VERIFY; + else if (apiKey) screen = SCREENS.HOME; + return { - screen: !user.isEmailVerified ? SCREENS.VERIFY : apiKey ? SCREENS.HOME : SCREENS.ONBOARD, + screen, token: this.authService.getSignedToken( { _id: user._id, email: user.email, firstName: user.firstName, lastName: user.lastName, + role: apiKey?.role as UserRolesEnum, profilePicture: user.profilePicture, accessToken: apiKey?.apiKey, isEmailVerified: user.isEmailVerified, diff --git a/apps/api/src/app/auth/usecases/register-user/register-user.command.ts b/apps/api/src/app/auth/usecases/register-user/register-user.command.ts index 6bbeaa58a..5d6a3995e 100644 --- a/apps/api/src/app/auth/usecases/register-user/register-user.command.ts +++ b/apps/api/src/app/auth/usecases/register-user/register-user.command.ts @@ -1,20 +1,7 @@ -import { IsDefined, IsEmail, IsString } from 'class-validator'; -import { BaseCommand } from '@shared/commands/base.command'; - -export class RegisterUserCommand extends BaseCommand { - @IsString() - @IsDefined() +export class RegisterUserCommand { firstName: string; - - @IsString() - @IsDefined() lastName: string; - - @IsEmail() - @IsDefined() email: string; - - @IsString() - @IsDefined() password: string; + invitationId?: string; } diff --git a/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts b/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts index 6d617ede3..4df901053 100644 --- a/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts +++ b/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts @@ -5,7 +5,7 @@ import { UserRepository } from '@impler/dal'; import { EmailService } from '@impler/services'; import { LEAD_SIGNUP_USING } from '@shared/constants'; -import { SCREENS, EMAIL_SUBJECT } from '@impler/shared'; +import { SCREENS, EMAIL_SUBJECT, UserRolesEnum } from '@impler/shared'; import { AuthService } from 'app/auth/services/auth.service'; import { RegisterUserCommand } from './register-user.command'; import { generateVerificationCode } from '@shared/helpers/common.helper'; @@ -29,6 +29,7 @@ export class RegisterUser { const passwordHash = await bcrypt.hash(command.password, 10); const verificationCode = generateVerificationCode(); + const isEmailVerified = command.invitationId ? true : this.emailService.isConnected() ? false : true; const user = await this.userRepository.create({ email: command.email, @@ -37,7 +38,7 @@ export class RegisterUser { password: passwordHash, signupMethod: LEAD_SIGNUP_USING.EMAIL, verificationCode, - isEmailVerified: this.emailService.isConnected() ? false : true, + isEmailVerified, }); const token = this.authService.getSignedToken({ @@ -45,9 +46,17 @@ export class RegisterUser { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: user.role as UserRolesEnum, isEmailVerified: user.isEmailVerified, }); + if (command.invitationId) { + return { + screen: SCREENS.INVIATAION, + token, + }; + } + if (this.emailService.isConnected()) { const emailContents = this.emailService.getEmailContent({ type: 'VERIFICATION_EMAIL', diff --git a/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts b/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts index 244b62249..45f166f02 100644 --- a/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts +++ b/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { UserRepository } from '@impler/dal'; -import { EMAIL_SUBJECT } from '@impler/shared'; +import { EMAIL_SUBJECT, UserRolesEnum } from '@impler/shared'; import { EmailService } from '@impler/services'; import { UpdateUserCommand } from './update-user.command'; import { AuthService } from 'app/auth/services/auth.service'; @@ -56,6 +56,7 @@ export class UpdateUser { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: user.role as UserRolesEnum, isEmailVerified: user.isEmailVerified, }); diff --git a/apps/api/src/app/auth/usecases/verify/verify.usecase.ts b/apps/api/src/app/auth/usecases/verify/verify.usecase.ts index d5326be98..3d820375f 100644 --- a/apps/api/src/app/auth/usecases/verify/verify.usecase.ts +++ b/apps/api/src/app/auth/usecases/verify/verify.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { SCREENS } from '@impler/shared'; +import { SCREENS, UserRolesEnum } from '@impler/shared'; import { VerifyCommand } from './verify.command'; import { AuthService } from 'app/auth/services/auth.service'; import { UserRepository, EnvironmentRepository } from '@impler/dal'; @@ -41,6 +41,7 @@ export class Verify { email: user.email, firstName: user.firstName, lastName: user.lastName, + role: apiKey?.role as UserRolesEnum, isEmailVerified: true, accessToken: apiKey?.apiKey, }); diff --git a/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts b/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts index e33644d66..820416827 100644 --- a/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts +++ b/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts @@ -4,12 +4,12 @@ import { Injectable, HttpStatus, HttpException, UnauthorizedException } from '@n import { APIMessages } from '@shared/constants'; import { SchemaDto } from 'app/common/dtos/Schema.dto'; +import { PaymentAPIService } from '@impler/services'; import { ValidRequestCommand } from './valid-request.command'; import { ProjectRepository, TemplateRepository, UserEntity } from '@impler/dal'; import { UniqueColumnException } from '@shared/exceptions/unique-column.exception'; -import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; -import { PaymentAPIService } from '@impler/services'; import { AVAILABLE_BILLABLEMETRIC_CODE_ENUM, ColumnTypesEnum } from '@impler/shared'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; @Injectable() export class ValidRequest { diff --git a/apps/api/src/app/environment/dtos/api-key.dto.ts b/apps/api/src/app/environment/dtos/api-key.dto.ts index 70ee2e1ca..8a1dc6f10 100644 --- a/apps/api/src/app/environment/dtos/api-key.dto.ts +++ b/apps/api/src/app/environment/dtos/api-key.dto.ts @@ -2,7 +2,7 @@ import { IsString } from 'class-validator'; export class ApiKey { @IsString() - key: string; + role: string; @IsString() _userId: string; diff --git a/apps/api/src/app/environment/dtos/environment-response.dto.ts b/apps/api/src/app/environment/dtos/environment-response.dto.ts index eef9b9826..1a9b7d6f1 100644 --- a/apps/api/src/app/environment/dtos/environment-response.dto.ts +++ b/apps/api/src/app/environment/dtos/environment-response.dto.ts @@ -14,6 +14,11 @@ export class EnvironmentResponseDto { @IsString() public _projectId: string; + @ApiProperty() + @IsDefined() + @IsString() + public key: string; + @IsArray() @ValidateNested({ each: true }) @Type(() => ApiKey) diff --git a/apps/api/src/app/environment/usecases/create-environment/create-environment.command.ts b/apps/api/src/app/environment/usecases/create-environment/create-environment.command.ts index d7711d0bf..9b3ad11eb 100644 --- a/apps/api/src/app/environment/usecases/create-environment/create-environment.command.ts +++ b/apps/api/src/app/environment/usecases/create-environment/create-environment.command.ts @@ -1,3 +1,5 @@ import { ProjectCommand } from '../../../shared/commands/project.command'; -export class CreateEnvironmentCommand extends ProjectCommand {} +export class CreateEnvironmentCommand extends ProjectCommand { + isOwner?: boolean; +} diff --git a/apps/api/src/app/environment/usecases/create-environment/create-environment.usecase.ts b/apps/api/src/app/environment/usecases/create-environment/create-environment.usecase.ts index f61362e79..cd99b00e9 100644 --- a/apps/api/src/app/environment/usecases/create-environment/create-environment.usecase.ts +++ b/apps/api/src/app/environment/usecases/create-environment/create-environment.usecase.ts @@ -13,13 +13,16 @@ export class CreateEnvironment { async execute(command: CreateEnvironmentCommand) { const key = await this.generateUniqueApiKey.execute(); - const environment = await this.environmentRepository.create({ _projectId: command.projectId, + key, apiKeys: [ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore // _id will be added automatically { - key, _userId: command._userId, + role: command.role, + isOwner: !!command.isOwner, }, ], }); diff --git a/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts b/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts index 2f6634d40..feebd77f3 100644 --- a/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts +++ b/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { IJwtPayload } from '@impler/shared'; +import { IJwtPayload, UserRolesEnum } from '@impler/shared'; import { EnvironmentRepository } from '@impler/dal'; import { AuthService } from 'app/auth/services/auth.service'; import { GenerateUniqueApiKey } from '../generate-api-key/generate-api-key.usecase'; @@ -31,6 +31,7 @@ export class RegenerateAPIKey { email: userInfo.email, firstName: userInfo.firstName, lastName: userInfo.lastName, + role: userInfo.role as UserRolesEnum, profilePicture: userInfo.profilePicture, isEmailVerified: userInfo.isEmailVerified, accessToken, diff --git a/apps/api/src/app/import-jobs/dtos/create-userjob.dto.ts b/apps/api/src/app/import-jobs/dtos/create-userjob.dto.ts index b95b25af7..2bb91f7d0 100644 --- a/apps/api/src/app/import-jobs/dtos/create-userjob.dto.ts +++ b/apps/api/src/app/import-jobs/dtos/create-userjob.dto.ts @@ -12,4 +12,8 @@ export class CreateUserJobDto { @IsString() @IsOptional() extra?: string; + + @IsString() + @IsOptional() + authHeaderValue?: string; } diff --git a/apps/api/src/app/import-jobs/import-jobs.controller.ts b/apps/api/src/app/import-jobs/import-jobs.controller.ts index afea25fb0..89c66b48e 100644 --- a/apps/api/src/app/import-jobs/import-jobs.controller.ts +++ b/apps/api/src/app/import-jobs/import-jobs.controller.ts @@ -2,20 +2,20 @@ import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { Body, Controller, Delete, Get, Param, ParseArrayPipe, Post, Put, UseGuards } from '@nestjs/common'; import { CreateUserJob, - GetColumnSchemaMapping, - CreateJobMapping, UpdateUserJob, + CreateJobMapping, + GetColumnSchemaMapping, GetUserJob, UserJobPause, + UserJobDelete, UserJobResume, UserJobTerminate, - UserJobDelete, } from './usecase'; import { ACCESS_KEY_NAME } from '@impler/shared'; import { JwtAuthGuard } from '@shared/framework/auth.gaurd'; import { UpdateJobDto, CreateUserJobDto, UpdateJobMappingDto } from './dtos'; -@ApiTags('Import-Jobs') +@ApiTags('Import Jobs') @Controller('/import-jobs') @UseGuards(JwtAuthGuard) @ApiSecurity(ACCESS_KEY_NAME) @@ -35,12 +35,13 @@ export class ImportJobsController { @Post(':templateId') @ApiOperation({ summary: 'Create User-Job' }) @ApiSecurity(ACCESS_KEY_NAME) - async createUserJobRoute(@Param('templateId') templateId: string, @Body() createUserJobData: CreateUserJobDto) { + async createUserJobRoute(@Param('templateId') templateId: string, @Body() jobData: CreateUserJobDto) { return this.createUserJob.execute({ _templateId: templateId, - url: createUserJobData.url, - extra: createUserJobData.extra, - externalUserId: createUserJobData.externalUserId, + url: jobData.url, + extra: jobData.extra, + externalUserId: jobData.externalUserId, + authHeaderValue: jobData.authHeaderValue, }); } diff --git a/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.command.ts b/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.command.ts index 65af3a32a..c6e8ca3de 100644 --- a/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.command.ts +++ b/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.command.ts @@ -1,6 +1,7 @@ export class CreateUserJobCommand { url: string; + extra?: string; _templateId: string; externalUserId?: string; - extra?: string; + authHeaderValue?: string; } diff --git a/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts b/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts index e9aaec84b..b21433b1c 100644 --- a/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts +++ b/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts @@ -12,7 +12,13 @@ export class CreateUserJob { private readonly userJobRepository: UserJobRepository ) {} - async execute({ _templateId, url, externalUserId, extra }: CreateUserJobCommand): Promise { + async execute({ + url, + extra, + _templateId, + externalUserId, + authHeaderValue, + }: CreateUserJobCommand): Promise { const mimeType = await this.rssService.getMimeType(url); if (mimeType === FileMimeTypesEnum.XML || mimeType === FileMimeTypesEnum.TEXTXML) { const { rssKeyHeading } = await this.rssService.parseRssFeed(url); @@ -23,9 +29,10 @@ export class CreateUserJob { return await this.userJobRepository.create({ url, + extra, + authHeaderValue, headings: rssKeyHeading, _templateId: _templateId, - extra, externalUserId: externalUserId || (formattedExtra as unknown as Record)?.externalUserId, }); } else { diff --git a/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-delete.usecase.ts b/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-delete.usecase.ts index e4901b9ea..d870f3fb0 100644 --- a/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-delete.usecase.ts +++ b/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-delete.usecase.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { BadRequestException } from '@nestjs/common'; import { NameService } from '@impler/services'; import { UserJobEntity, UserJobRepository } from '@impler/dal'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; @Injectable() export class UserJobDelete { @@ -13,14 +13,22 @@ export class UserJobDelete { ) {} async execute({ externalUserId, _jobId }: { externalUserId: string; _jobId: string }): Promise { - const deletedUserJob = await this.userJobRepository.delete({ _id: _jobId, externalUserId }); + const userJobToDelete = await this.userJobRepository.findOne({ _id: _jobId, externalUserId }); - if (!deletedUserJob) { - throw new BadRequestException(`Unable to Delete UserJob with id ${_jobId}`); + if (!userJobToDelete) { + throw new DocumentNotFoundException( + 'Userjob', + _jobId, + `Userjob with JobId ${_jobId} and externalUserId ${externalUserId} not found` + ); } - this.schedulerRegistry.deleteCronJob(this.nameService.getCronName(_jobId)); + try { + this.schedulerRegistry.deleteCronJob(this.nameService.getCronName(_jobId)); + } catch (error) {} - return deletedUserJob; + await this.userJobRepository.delete({ _id: _jobId }); + + return userJobToDelete; } } diff --git a/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-pause.usecase.ts b/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-pause.usecase.ts index 1213dba84..a4b13a26c 100644 --- a/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-pause.usecase.ts +++ b/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob-pause.usecase.ts @@ -14,12 +14,14 @@ export class UserJobPause { ) {} async execute(_jobId: string): Promise { - const userJob = this.schedulerRegistry.getCronJob(this.nameService.getCronName(_jobId)); - + const userJob = await this.userJobRepository.findById(_jobId); if (!userJob) { - throw new DocumentNotFoundException(`Userjob`, _jobId); + throw new DocumentNotFoundException('Userjob', _jobId); } - userJob.stop(); + + const userCronJob = this.schedulerRegistry.getCronJob(this.nameService.getCronName(_jobId)); + + userCronJob.stop(); const updatedUserJob = await this.userJobRepository.findOneAndUpdate( { _id: _jobId }, diff --git a/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob.resume.usecsae.ts b/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob.resume.usecsae.ts index f6bf1c96a..fdb57ad4f 100644 --- a/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob.resume.usecsae.ts +++ b/apps/api/src/app/import-jobs/usecase/userjob-usecase/userjob.resume.usecsae.ts @@ -18,14 +18,14 @@ export class UserJobResume { ) {} async execute(_jobId: string): Promise { - const userJob = await this.userJobRepository.findOne({ _id: _jobId }); + const userJob = await this.userJobRepository.findById(_jobId); if (!userJob) { throw new DocumentNotFoundException(`Userjob`, _jobId); } if (userJob.status !== UserJobImportStatusEnum.PAUSED) { - throw new BadRequestException(`Job ${_jobId} is not paused. Current status: ${userJob.status}`); + throw new BadRequestException(`Userjob with id ${_jobId} is not paused. Current status: ${userJob.status}`); } const cronExpression = userJob.cron; @@ -46,10 +46,6 @@ export class UserJobResume { { returnDocument: 'after' } ); - if (!updatedUserJob) { - throw new DocumentNotFoundException(`No User Job was found with the given _jobId ${_jobId}`, _jobId); - } - return updatedUserJob; } } diff --git a/apps/api/src/app/project/project.controller.ts b/apps/api/src/app/project/project.controller.ts index 44869e8b3..1ce4fee8e 100644 --- a/apps/api/src/app/project/project.controller.ts +++ b/apps/api/src/app/project/project.controller.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import { ApiOperation, ApiTags, ApiOkResponse, ApiSecurity } from '@nestjs/swagger'; import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; -import { ACCESS_KEY_NAME, Defaults, IJwtPayload, PaginationResult } from '@impler/shared'; +import { ACCESS_KEY_NAME, Defaults, IJwtPayload, PaginationResult, UserRolesEnum } from '@impler/shared'; import { UserSession } from '@shared/framework/user.decorator'; import { ValidateMongoId } from '@shared/validations/valid-mongo-id.validation'; @@ -51,7 +51,7 @@ export class ProjectController { @ApiOkResponse({ type: [ProjectResponseDto], }) - getProjects(@UserSession() user: IJwtPayload): Promise { + getProjects(@UserSession() user: IJwtPayload) { return this.getProjectsUsecase.execute(user._id); } @@ -122,15 +122,20 @@ export class ProjectController { }), user.email ); + const userApiKey = projectWithEnvironment.environment.apiKeys.find( + (apiKey) => apiKey._userId.toString() === user._id + ); + const token = this.authService.getSignedToken( { _id: user._id, firstName: user.firstName, lastName: user.lastName, email: user.email, + role: userApiKey.role as UserRolesEnum, profilePicture: user.profilePicture, isEmailVerified: user.isEmailVerified, - accessToken: projectWithEnvironment.environment.apiKeys[0].key, + accessToken: projectWithEnvironment.environment.key, }, projectWithEnvironment.project._id ); @@ -152,15 +157,18 @@ export class ProjectController { @Res({ passthrough: true }) res: Response ) { const projectEnvironment = await this.getEnvironment.execute(projectId); + const userApiKey = projectEnvironment.apiKeys.find((apiKey) => apiKey._userId.toString() === user._id.toString()); + const token = this.authService.getSignedToken( { _id: user._id, firstName: user.firstName, lastName: user.lastName, email: user.email, + role: userApiKey.role as UserRolesEnum, isEmailVerified: user.isEmailVerified, profilePicture: user.profilePicture, - accessToken: projectEnvironment.apiKeys[0].key, + accessToken: projectEnvironment.key, }, projectEnvironment._projectId ); @@ -169,7 +177,7 @@ export class ProjectController { domain: process.env.COOKIE_DOMAIN, }); - return; + return {}; } @Put(':projectId') diff --git a/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts b/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts index 18b7bcac4..5a3caa49c 100644 --- a/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts +++ b/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { ProjectRepository } from '@impler/dal'; -import { ColumnTypesEnum, IntegrationEnum } from '@impler/shared'; -import { CreateProjectCommand } from './create-project.command'; import { CreateEnvironment } from 'app/environment/usecases'; +import { CreateProjectCommand } from './create-project.command'; import { CreateTemplate, UpdateTemplateColumns } from 'app/template/usecases'; +import { ColumnTypesEnum, IntegrationEnum, UserRolesEnum } from '@impler/shared'; @Injectable() export class CreateProject { @@ -21,6 +21,8 @@ export class CreateProject { const environment = await this.createEnvironment.execute({ projectId: project._id, _userId: command._userId, + role: UserRolesEnum.ADMIN, + isOwner: true, }); if (command.onboarding) { diff --git a/apps/api/src/app/project/usecases/get-environment/get-environment.usecase.ts b/apps/api/src/app/project/usecases/get-environment/get-environment.usecase.ts index d1befbb0f..908aac480 100644 --- a/apps/api/src/app/project/usecases/get-environment/get-environment.usecase.ts +++ b/apps/api/src/app/project/usecases/get-environment/get-environment.usecase.ts @@ -18,6 +18,7 @@ export class GetEnvironment { return { _id: environment._id, _projectId: environment._projectId, + key: environment.key, apiKeys: environment.apiKeys, }; } diff --git a/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts b/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts index 46719c385..e095884ce 100644 --- a/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts +++ b/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts @@ -1,14 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { ProjectRepository } from '@impler/dal'; -import { ProjectResponseDto } from '../../dtos/project-response.dto'; +import { EnvironmentRepository } from '@impler/dal'; @Injectable() export class GetProjects { - constructor(private projectRepository: ProjectRepository) {} + constructor(private environmentRepository: EnvironmentRepository) {} - async execute(_userId: string): Promise { - const response = await this.projectRepository.getUserProjects(_userId); - - return response; + async execute(_userId: string) { + return this.environmentRepository.getUserEnvironmentProjects(_userId); } } diff --git a/apps/api/src/app/shared/commands/project.command.ts b/apps/api/src/app/shared/commands/project.command.ts index fdc8f4983..b2d5aef71 100644 --- a/apps/api/src/app/shared/commands/project.command.ts +++ b/apps/api/src/app/shared/commands/project.command.ts @@ -1,7 +1,11 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsEnum, IsNotEmpty } from 'class-validator'; import { AuthenticatedCommand } from './authenticated.command'; +import { UserRolesEnum } from '@impler/shared'; export abstract class ProjectCommand extends AuthenticatedCommand { @IsNotEmpty() readonly projectId: string; + + @IsEnum(UserRolesEnum) + readonly role: UserRolesEnum; } diff --git a/apps/api/src/app/shared/framework/auth.gaurd.ts b/apps/api/src/app/shared/framework/auth.gaurd.ts index bfa296e06..c40f6fed2 100644 --- a/apps/api/src/app/shared/framework/auth.gaurd.ts +++ b/apps/api/src/app/shared/framework/auth.gaurd.ts @@ -36,11 +36,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { if (req.headers && req.headers[ACCESS_KEY_NAME]) { const accessKey = req.headers[ACCESS_KEY_NAME]; - const tokenResult = await this.authService.apiKeyAuthenticate(accessKey); - req.cookies = { - ...(req.cookies || {}), - [CONSTANTS.AUTH_COOKIE_NAME]: tokenResult, - }; + await this.authService.apiKeyAuthenticate(accessKey); return true; } else if (req.cookies && req.cookies[CONSTANTS.AUTH_COOKIE_NAME]) { diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts index 79ced6731..abcf5c2ee 100644 --- a/apps/api/src/app/shared/shared.module.ts +++ b/apps/api/src/app/shared/shared.module.ts @@ -18,6 +18,7 @@ import { BubbleDestinationRepository, UserJobRepository, JobMappingRepository, + ProjectInvitationRepository, } from '@impler/dal'; import { CSVFileService2, ExcelFileService } from './services/file/file.service'; import { @@ -46,6 +47,7 @@ const DAL_MODELS = [ JobMappingRepository, UserJobRepository, SchedulerRegistry, + ProjectInvitationRepository, ]; const UTILITY_SERVICES = [CSVFileService2, FileNameService, NameService, ExcelFileService]; diff --git a/apps/api/src/app/team/dto/delete-team-member.dto.ts b/apps/api/src/app/team/dto/delete-team-member.dto.ts new file mode 100644 index 000000000..4d3f38f28 --- /dev/null +++ b/apps/api/src/app/team/dto/delete-team-member.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class RemoveTeammemberDto { + @ApiProperty({ + description: 'Id of the Project in the environment', + }) + @IsString() + projectId: string; + + @ApiProperty({ + description: 'Id of the user who has to be deleted from the environment', + }) + @IsString() + userId: string; +} diff --git a/apps/api/src/app/team/dto/invtation.dto.ts b/apps/api/src/app/team/dto/invtation.dto.ts new file mode 100644 index 000000000..3c5a5deae --- /dev/null +++ b/apps/api/src/app/team/dto/invtation.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsString } from 'class-validator'; + +export class InvitationDto { + @ApiProperty({ + description: 'Name of the project to be invited into', + }) + @IsString() + projectName: string; + + @ApiProperty({ + description: 'Id of the project to be invited into', + }) + @IsString() + projectId: string; + + @ApiProperty({ + description: 'List of Emails that will recieve the invitation', + }) + @IsArray() + invitationEmailsTo: string[]; + + @ApiProperty({ + description: 'The role that the invited members will be assigned to', + }) + @IsString() + role: string; +} diff --git a/apps/api/src/app/team/dto/update-team-member.dto.ts b/apps/api/src/app/team/dto/update-team-member.dto.ts new file mode 100644 index 000000000..1e93441c1 --- /dev/null +++ b/apps/api/src/app/team/dto/update-team-member.dto.ts @@ -0,0 +1,11 @@ +import { UserRolesEnum } from '@impler/shared'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; + +export class UpdateTeamMemberDto { + @ApiProperty({ + description: 'New Role of the User that to be asssigned', + }) + @IsEnum(UserRolesEnum) + role: UserRolesEnum; +} diff --git a/apps/api/src/app/team/team.controller.ts b/apps/api/src/app/team/team.controller.ts new file mode 100644 index 000000000..b65846cc2 --- /dev/null +++ b/apps/api/src/app/team/team.controller.ts @@ -0,0 +1,148 @@ +import { Response } from 'express'; +import { ApiOperation } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; + +import { IJwtPayload } from '@impler/shared'; +import { + Invite, + SentInvitations, + GetInvitation, + AcceptInvitation, + ListTeamMembers, + UpdateTeamMember, + RemoveTeamMember, + RevokeInvitation, + DeclineInvitation, + TeamMemberMeta, +} from './usecase'; +import { JwtAuthGuard } from '@shared/framework/auth.gaurd'; +import { CONSTANTS, COOKIE_CONFIG } from '@shared/constants'; +import { UserSession } from '@shared/framework/user.decorator'; +import { InvitationDto } from './dto/invtation.dto'; +import { UpdateTeamMemberDto } from './dto/update-team-member.dto'; + +@Controller('team') +export class TeamController { + constructor( + private invite: Invite, + private sentInvitations: SentInvitations, + private getInvitation: GetInvitation, + private acceptInvitation: AcceptInvitation, + private listTeamMembers: ListTeamMembers, + private updateTeamMember: UpdateTeamMember, + private removeTeamMember: RemoveTeamMember, + private revokeInvitation: RevokeInvitation, + private declineInvitation: DeclineInvitation, + private teamMemberMeta: TeamMemberMeta + ) {} + + @Get('/members') + @ApiOperation({ + summary: + 'List out the members who have accepted the project invitation and now a part of a team and working on the same project', + }) + async listTeamMembersRoute(@UserSession() user: IJwtPayload) { + return await this.listTeamMembers.exec(user._projectId); + } + + @Get('/invitations') + @ApiOperation({ + summary: 'Fetch Team members details who have got the invitation', + }) + @UseGuards(JwtAuthGuard) + async sentProjectInvitationRoute(@UserSession() user: IJwtPayload) { + return this.sentInvitations.exec({ + email: user.email, + projectId: user._projectId, + }); + } + + @Post() + @ApiOperation({ + summary: 'Invite Other Team Members to the Project', + }) + @UseGuards(JwtAuthGuard) + async projectInvitationRoute(@UserSession() user: IJwtPayload, @Body() invitationDto: InvitationDto) { + return await this.invite.exec({ + invitatedBy: user.email, + projectName: invitationDto.projectName, + invitationEmailsTo: invitationDto.invitationEmailsTo, + role: invitationDto.role, + userName: user.firstName, + projectId: invitationDto.projectId, + }); + } + + @Put('/:memberId') + @ApiOperation({ + summary: 'Change the role of a particular team member', + }) + async updateTeamMemberRoleRoute( + @Param('memberId') memberId: string, + @Body() updateTeamMemberData: UpdateTeamMemberDto + ) { + return this.updateTeamMember.exec(memberId, updateTeamMemberData); + } + + @Delete('/:memberId') + @ApiOperation({ + summary: 'Remove a team member from the project', + }) + async removeTeamMemberRoute(@Param('memberId') memberId: string) { + return await this.removeTeamMember.exec(memberId); + } + + // invitation routes + @Get('/:invitationId') + @ApiOperation({ + summary: 'Fetch an already sent invitation when the user tries to accept the invitation', + }) + async getProjectInvitationRoute(@Param('invitationId') invitationId: string) { + return this.getInvitation.exec(invitationId); + } + + @Post('/:invitationId/accept') + @ApiOperation({ + summary: 'Accept a sent Invitation', + }) + async acceptInvitationRoute( + @UserSession() user: IJwtPayload, + @Query('invitationId') invitationId: string, + @Res({ passthrough: true }) response: Response + ) { + const { accessToken, screen } = await this.acceptInvitation.exec({ invitationId, user }); + response.cookie(CONSTANTS.AUTH_COOKIE_NAME, accessToken, { + ...COOKIE_CONFIG, + domain: process.env.COOKIE_DOMAIN, + }); + + return { screen }; + } + + @Delete('/:invitationId/decline') + @ApiOperation({ + summary: 'Decline an Invitation', + }) + async declineInvitationRoute(@Param('invitationId') invitationId: string, @UserSession() user: IJwtPayload) { + return this.declineInvitation.exec({ + invitationId, + user, + }); + } + + @Get(':projectId/members') + @ApiOperation({ + summary: 'Fetch Meta Related to TeamMembers in Current Plan', + }) + async teamMembersMetaRoute(@Param('projectId') projectId: string) { + return this.teamMemberMeta.exec(projectId); + } + + @Delete('/:invitationId/revoke') + @ApiOperation({ + summary: 'Revoke sent invitation', + }) + async cancelInvitationRoute(@Param('invitationId') invitationId: string) { + return await this.revokeInvitation.exec(invitationId); + } +} diff --git a/apps/api/src/app/team/team.module.ts b/apps/api/src/app/team/team.module.ts new file mode 100644 index 000000000..ae4b0c42c --- /dev/null +++ b/apps/api/src/app/team/team.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecase'; +import { TeamController } from './team.controller'; +import { SharedModule } from '@shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [TeamController], +}) +export class TeamModule {} diff --git a/apps/api/src/app/team/usecase/accept-invitation/accept-invitation.usecase.ts b/apps/api/src/app/team/usecase/accept-invitation/accept-invitation.usecase.ts new file mode 100644 index 000000000..99bd881e4 --- /dev/null +++ b/apps/api/src/app/team/usecase/accept-invitation/accept-invitation.usecase.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { AuthService } from 'app/auth/services/auth.service'; +import { EmailService, PaymentAPIService } from '@impler/services'; +import { EMAIL_SUBJECT, IJwtPayload, SCREENS, UserRolesEnum } from '@impler/shared'; +import { + ProjectEntity, + ProjectRepository, + EnvironmentRepository, + ProjectInvitationEntity, + ProjectInvitationRepository, +} from '@impler/dal'; +import { LEAD_SIGNUP_USING } from '@shared/constants'; +import { LeadService } from '@shared/services/lead.service'; +import { captureException } from '@shared/helpers/common.helper'; + +@Injectable() +export class AcceptInvitation { + constructor( + private leadService: LeadService, + private authService: AuthService, + private emailService: EmailService, + private projectRepository: ProjectRepository, + private paymentAPIService: PaymentAPIService, + private environmentRepository: EnvironmentRepository, + private projectInvitationRepository: ProjectInvitationRepository + ) {} + + async exec({ invitationId, user }: { invitationId: string; user: IJwtPayload }) { + const invitation = await this.projectInvitationRepository.findOne({ _id: invitationId }); + const environment = await this.environmentRepository.findOne({ + _projectId: invitation._projectId, + }); + const userProjects = await this.environmentRepository.count({ + 'apiKeys._userId': user._id, + }); + if (userProjects < 1) await this.registerUser(user); + + const project = await this.projectRepository.findOne({ _id: environment._projectId }); + + await this.sendEmails(invitation, project); + + await this.environmentRepository.addApiKey(environment._id, user._id, invitation.role); + + await this.projectInvitationRepository.delete({ _id: invitationId }); + + const accessToken = this.authService.getSignedToken( + { + _id: user._id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + role: invitation.role as UserRolesEnum, + isEmailVerified: true, + profilePicture: user.profilePicture, + accessToken: environment.key, + }, + invitation._projectId + ); + + return { + accessToken, + screen: SCREENS.HOME, + }; + } + async sendEmails(invitation: ProjectInvitationEntity, project: ProjectEntity) { + const emailContentsSender = this.emailService.getEmailContent({ + type: 'ACCEPT_PROJECT_INVITATION_RECIEVER_EMAIL', + data: { + acceptedBy: invitation.invitationToEmail, + invitedBy: invitation.invitedBy, + projectName: project.name, + }, + }); + const emailContentsReciever = this.emailService.getEmailContent({ + type: 'ACCEPT_PROJECT_INVITATION_SENDER_EMAIL', + data: { + acceptedBy: invitation.invitationToEmail, + invitedBy: invitation.invitedBy, + projectName: project.name, + }, + }); + + await this.emailService.sendEmail({ + to: invitation.invitationToEmail, + subject: EMAIL_SUBJECT.INVITATION_ACCEPTED, + html: emailContentsSender, + from: process.env.EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + + await this.emailService.sendEmail({ + to: invitation.invitedBy, + subject: EMAIL_SUBJECT.INVITATION_ACCEPTED, + html: emailContentsReciever, + from: process.env.EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + } + async registerUser(user: IJwtPayload) { + try { + const userData = { + name: user.firstName + ' ' + user.lastName, + email: user.email, + externalId: user.email, + }; + await this.paymentAPIService.createUser(userData); + await this.leadService.createLead({ + 'First Name': user.firstName, + 'Last Name': user.lastName, + 'Lead Email': user.email, + 'Lead Source': 'Invitation', + 'Mentioned Role': user.role, + 'Signup Method': LEAD_SIGNUP_USING.EMAIL, + 'Company Size': user.companySize, + }); + } catch (error) { + captureException(error); + } + } +} diff --git a/apps/api/src/app/team/usecase/decline-invitation/decline-invitation.usecase.ts b/apps/api/src/app/team/usecase/decline-invitation/decline-invitation.usecase.ts new file mode 100644 index 000000000..47d1f501d --- /dev/null +++ b/apps/api/src/app/team/usecase/decline-invitation/decline-invitation.usecase.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; + +import { AuthService } from 'app/auth/services/auth.service'; +import { EMAIL_SUBJECT, IJwtPayload, SCREENS, UserRolesEnum } from '@impler/shared'; +import { EnvironmentRepository, ProjectInvitationRepository, ProjectRepository } from '@impler/dal'; +import { EmailService } from '@impler/services'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; + +@Injectable() +export class DeclineInvitation { + constructor( + private authService: AuthService, + private environmentRepository: EnvironmentRepository, + private projectInvitationRepository: ProjectInvitationRepository, + private projectRepository: ProjectRepository, + private emailService: EmailService + ) {} + + async exec({ invitationId, user }: { invitationId: string; user: IJwtPayload }) { + const invitation = await this.projectInvitationRepository.findOne({ _id: invitationId }); + const environment = await this.environmentRepository.findOne({ + _projectId: invitation._projectId, + }); + + const project = await this.projectRepository.findOne({ _id: environment._projectId }); + + if (!invitation) throw new DocumentNotFoundException('Invitation', invitationId); + + await this.projectInvitationRepository.delete({ _id: invitationId }); + + const emailContents = this.emailService.getEmailContent({ + type: 'DECLINE_INVITATION_EMAIL', + data: { + declinedBy: invitation.invitationToEmail, + invitedBy: invitation.invitationToEmail, + projectName: project.name, + }, + }); + await this.emailService.sendEmail({ + to: invitation.invitedBy, + subject: `${invitation.invitationToEmail} ${EMAIL_SUBJECT.INVITATION_DECLINED}`, + html: emailContents, + from: process.env.EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + + const accessToken = this.authService.getSignedToken({ + _id: user._id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + role: UserRolesEnum.ADMIN, + isEmailVerified: true, + profilePicture: user.profilePicture, + accessToken: environment.key, + }); + + return { + accessToken, + screen: SCREENS.ONBOARD, + }; + } +} diff --git a/apps/api/src/app/team/usecase/delete-team-member/delete-team-member.usecase.ts b/apps/api/src/app/team/usecase/delete-team-member/delete-team-member.usecase.ts new file mode 100644 index 000000000..e59c88056 --- /dev/null +++ b/apps/api/src/app/team/usecase/delete-team-member/delete-team-member.usecase.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { EnvironmentRepository } from '@impler/dal'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; + +@Injectable() +export class RemoveTeamMember { + constructor(private environmentRepository: EnvironmentRepository) {} + + async exec(memberId: string) { + const teamMember = await this.environmentRepository.getTeamMemberDetails(memberId); + if (!teamMember) throw new DocumentNotFoundException('TeamMember', memberId); + + await this.environmentRepository.removeTeamMember(memberId); + + return teamMember; + } +} diff --git a/apps/api/src/app/team/usecase/get-invitation/get-invitation.usecase.ts b/apps/api/src/app/team/usecase/get-invitation/get-invitation.usecase.ts new file mode 100644 index 000000000..a45809de4 --- /dev/null +++ b/apps/api/src/app/team/usecase/get-invitation/get-invitation.usecase.ts @@ -0,0 +1,16 @@ +import { ProjectInvitationRepository } from '@impler/dal'; +import { BadRequestException, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetInvitation { + constructor(private projectInvitationRepository: ProjectInvitationRepository) {} + + async exec(invitationId: string) { + const invitationData = await this.projectInvitationRepository.getInvitationData(invitationId); + if (!invitationData) { + throw new BadRequestException('Invitation not found or token is invalid.'); + } + + return invitationData; + } +} diff --git a/apps/api/src/app/team/usecase/index.ts b/apps/api/src/app/team/usecase/index.ts new file mode 100644 index 000000000..b7ebb3e0d --- /dev/null +++ b/apps/api/src/app/team/usecase/index.ts @@ -0,0 +1,44 @@ +import { Invite } from './invite/invite.usecase'; +import { GenerateUniqueApiKey } from 'app/environment/usecases'; +import { GetInvitation } from './get-invitation/get-invitation.usecase'; +import { SentInvitations } from './sent-invitation/sent-invitation.usecase'; +import { TeamMemberMeta } from './team-member-meta/team-member-meta.usecase'; +import { ListTeamMembers } from './list-team-members/list-team-members.usecase'; +import { RevokeInvitation } from './revoke-invitation/revoke-invitation.usecase'; +import { AcceptInvitation } from './accept-invitation/accept-invitation.usecase'; +import { RemoveTeamMember } from './delete-team-member/delete-team-member.usecase'; +import { DeclineInvitation } from './decline-invitation/decline-invitation.usecase'; +import { UpdateTeamMember } from './update-team-member-role/update-team-member.usecase'; +import { PaymentAPIService } from '@impler/services'; +import { LeadService } from '@shared/services/lead.service'; + +export const USE_CASES = [ + Invite, + SentInvitations, + GetInvitation, + AcceptInvitation, + GenerateUniqueApiKey, + ListTeamMembers, + UpdateTeamMember, + RemoveTeamMember, + RevokeInvitation, + DeclineInvitation, + TeamMemberMeta, + LeadService, + PaymentAPIService, + // +]; +export { + Invite, + SentInvitations, + GetInvitation, + AcceptInvitation, + GenerateUniqueApiKey, + ListTeamMembers, + UpdateTeamMember, + RemoveTeamMember, + RevokeInvitation, + DeclineInvitation, + TeamMemberMeta, + PaymentAPIService, +}; diff --git a/apps/api/src/app/team/usecase/invite/invite.command.ts b/apps/api/src/app/team/usecase/invite/invite.command.ts new file mode 100644 index 000000000..07c317584 --- /dev/null +++ b/apps/api/src/app/team/usecase/invite/invite.command.ts @@ -0,0 +1,8 @@ +export class InviteCommand { + projectName: string; + projectId: string; + invitatedBy: string; + invitationEmailsTo: string[]; + role: string; + userName: string; +} diff --git a/apps/api/src/app/team/usecase/invite/invite.usecase.ts b/apps/api/src/app/team/usecase/invite/invite.usecase.ts new file mode 100644 index 000000000..204aa7f43 --- /dev/null +++ b/apps/api/src/app/team/usecase/invite/invite.usecase.ts @@ -0,0 +1,75 @@ +import { randomBytes } from 'crypto'; +import { Injectable, BadRequestException } from '@nestjs/common'; +import { EmailService } from '@impler/services'; +import { EMAIL_SUBJECT } from '@impler/shared'; +import { ProjectInvitationRepository, EnvironmentRepository } from '@impler/dal'; + +import { InviteCommand } from './invite.command'; + +@Injectable() +export class Invite { + constructor( + private emailService: EmailService, + private projectInvitationRepository: ProjectInvitationRepository, + private environmentRepository: EnvironmentRepository + ) {} + + async exec(command: InviteCommand) { + const existingInvitationsCount = await this.projectInvitationRepository.count({ + _projectId: command.projectId, + invitedBy: command.invitatedBy, + }); + + const totalInvitationsCount = existingInvitationsCount + command.invitationEmailsTo.length; + if (totalInvitationsCount > 4) { + throw new BadRequestException( + 'You cannot invite more than 4 emails at a time, including already sent invitations.' + ); + } + + const teamMembers = await this.environmentRepository.getProjectTeamMembers(command.projectId); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const memberEmails = teamMembers.map((teamMember) => teamMember._userId.email); + + for (const invitationEmailTo of command.invitationEmailsTo) { + if (memberEmails.includes(invitationEmailTo)) { + throw new BadRequestException(`The email ${invitationEmailTo} is already a member of the project.`); + } + + const existingInvitation = await this.projectInvitationRepository.findOne({ + invitationToEmail: invitationEmailTo, + _projectId: command.projectId, + }); + + if (existingInvitation) { + throw new BadRequestException(`The email ${invitationEmailTo} has already been invited.`); + } + + const invitation = await this.projectInvitationRepository.create({ + invitationToEmail: invitationEmailTo, + invitedOn: new Date().toDateString(), + role: command.role, + invitedBy: command.invitatedBy, + _projectId: command.projectId, + token: randomBytes(16).toString('hex'), + }); + + const emailContents = this.emailService.getEmailContent({ + type: 'TEAM_INVITATION_EMAIL', + data: { + invitedBy: command.invitatedBy, + projectName: command.projectName, + invitationUrl: `${process.env.WEB_BASE_URL}/auth/invitation/${invitation._id}`, + }, + }); + await this.emailService.sendEmail({ + to: invitationEmailTo, + subject: `${EMAIL_SUBJECT.PROJECT_INVITATION} ${command.projectName}`, + html: emailContents, + from: process.env.EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + } + } +} diff --git a/apps/api/src/app/team/usecase/list-team-members/list-team-members.usecase.ts b/apps/api/src/app/team/usecase/list-team-members/list-team-members.usecase.ts new file mode 100644 index 000000000..f90b39652 --- /dev/null +++ b/apps/api/src/app/team/usecase/list-team-members/list-team-members.usecase.ts @@ -0,0 +1,13 @@ +import { EnvironmentRepository } from '@impler/dal'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ListTeamMembers { + constructor(private environmentRepository: EnvironmentRepository) {} + + async exec(projectId: string) { + const environment = await this.environmentRepository.getProjectTeamMembers(projectId); + + return environment; + } +} diff --git a/apps/api/src/app/team/usecase/revoke-invitation/revoke-invitation.usecase.ts b/apps/api/src/app/team/usecase/revoke-invitation/revoke-invitation.usecase.ts new file mode 100644 index 000000000..3dd96e54c --- /dev/null +++ b/apps/api/src/app/team/usecase/revoke-invitation/revoke-invitation.usecase.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectInvitationRepository } from '@impler/dal'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; + +@Injectable() +export class RevokeInvitation { + constructor(private projectInvitationRepository: ProjectInvitationRepository) {} + + async exec(invitationId: string) { + const invitation = await this.projectInvitationRepository.findOne({ + _id: invitationId, + }); + + if (!invitation) { + throw new DocumentNotFoundException('ProjectInvitations', invitationId); + } + + await this.projectInvitationRepository.delete({ _id: invitation._id }); + + return invitation; + } +} diff --git a/apps/api/src/app/team/usecase/sent-invitation/sent-invitation.usecase.ts b/apps/api/src/app/team/usecase/sent-invitation/sent-invitation.usecase.ts new file mode 100644 index 000000000..ed88b2953 --- /dev/null +++ b/apps/api/src/app/team/usecase/sent-invitation/sent-invitation.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectInvitationRepository } from '@impler/dal'; + +@Injectable() +export class SentInvitations { + constructor(private projectInvitationRepository: ProjectInvitationRepository) {} + + async exec({ email, projectId }: { email: string; projectId: string }) { + const invitations = await this.projectInvitationRepository.find({ + invitedBy: email, + _projectId: projectId, + }); + + const sentInvitations = invitations.map((sentInvitation) => { + const invitationLink = `${process.env.WEB_BASE_URL}/auth/invitation/${sentInvitation._id}`; + + return { + ...sentInvitation, + invitationLink, + }; + }); + + return sentInvitations; + } +} diff --git a/apps/api/src/app/team/usecase/team-member-meta/team-member-meta.usecase.ts b/apps/api/src/app/team/usecase/team-member-meta/team-member-meta.usecase.ts new file mode 100644 index 000000000..3c7fbe24d --- /dev/null +++ b/apps/api/src/app/team/usecase/team-member-meta/team-member-meta.usecase.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { EnvironmentRepository, ProjectInvitationRepository } from '@impler/dal'; +import { PaymentAPIService } from '@impler/services'; + +@Injectable() +export class TeamMemberMeta { + constructor( + private environmentRepository: EnvironmentRepository, + private paymentAPIService: PaymentAPIService, + private projectInvitationRepository: ProjectInvitationRepository + ) {} + + async exec(projectId: string) { + const teamMembers = await this.environmentRepository.getProjectTeamMembers(projectId); + const owner = await this.environmentRepository.getTeamOwnerDetails(projectId); + const invitationCount = await this.projectInvitationRepository.count({ + _projectId: projectId, + }); + + if (owner && owner._userId) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const subscription = await this.paymentAPIService.fetchActiveSubscription(owner._userId.email); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const allocated = subscription.meta.TEAM_MEMBERS; + + const total = teamMembers.length + invitationCount; + + const available = Math.max(allocated - total, 0); + + const result = { available, total, allocated, error: null }; + + if (available <= 0) { + result.error = 'You Have Reached the Limit or Invited the Maximum Team Members'; + } + + return result; + } else { + return { error: 'No owner found', available: null, total: null, allocated: null }; + } + } +} diff --git a/apps/api/src/app/team/usecase/update-team-member-role/update-team-member.command.ts b/apps/api/src/app/team/usecase/update-team-member-role/update-team-member.command.ts new file mode 100644 index 000000000..87eba2135 --- /dev/null +++ b/apps/api/src/app/team/usecase/update-team-member-role/update-team-member.command.ts @@ -0,0 +1,4 @@ +import { UserRolesEnum } from '@impler/shared'; +export class UpdateTeammemberCommand { + role: UserRolesEnum; +} diff --git a/apps/api/src/app/team/usecase/update-team-member-role/update-team-member.usecase.ts b/apps/api/src/app/team/usecase/update-team-member-role/update-team-member.usecase.ts new file mode 100644 index 000000000..1226f4249 --- /dev/null +++ b/apps/api/src/app/team/usecase/update-team-member-role/update-team-member.usecase.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { EnvironmentRepository } from '@impler/dal'; +import { UpdateTeammemberCommand } from './update-team-member.command'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; + +@Injectable() +export class UpdateTeamMember { + constructor(private environmentRepository: EnvironmentRepository) {} + + async exec(memberId: string, updateTeamMemberCommand: UpdateTeammemberCommand) { + const teamMember = await this.environmentRepository.getTeamMemberDetails(memberId); + if (!teamMember) throw new DocumentNotFoundException('Team Member', memberId); + + await this.environmentRepository.updateTeamMember(memberId, { + role: updateTeamMemberCommand.role, + }); + + return Object.assign(teamMember, updateTeamMemberCommand); + } +} diff --git a/apps/api/src/app/template/template.controller.ts b/apps/api/src/app/template/template.controller.ts index d06789fd7..873e13a62 100644 --- a/apps/api/src/app/template/template.controller.ts +++ b/apps/api/src/app/template/template.controller.ts @@ -97,9 +97,13 @@ export class TemplateController { type: TemplateResponseDto, }) async getTemplateDetailsRoute( - @Param('templateId', ValidateMongoId) templateId: string + @UserSession() user: IJwtPayload, + @Param('templateId', ValidateMongoId) _templateId: string ): Promise { - return this.getTemplateDetails.execute(templateId); + return this.getTemplateDetails.execute({ + _templateId, + _projectId: user._projectId, + }); } @Post(':templateId/sample') diff --git a/apps/api/src/app/template/usecases/get-template-details/get-template-details.usecase.ts b/apps/api/src/app/template/usecases/get-template-details/get-template-details.usecase.ts index 724b3b7be..066bd18c6 100644 --- a/apps/api/src/app/template/usecases/get-template-details/get-template-details.usecase.ts +++ b/apps/api/src/app/template/usecases/get-template-details/get-template-details.usecase.ts @@ -8,20 +8,26 @@ import { IntegrationEnum } from '@impler/shared'; export class GetTemplateDetails { constructor(private templateRepository: TemplateRepository) {} - async execute(_id: string): Promise { + async execute({ + _projectId, + _templateId, + }: { + _templateId: string; + _projectId: string; + }): Promise { const template = await this.templateRepository.findOne( - { _id }, + { _id: _templateId, _projectId }, '_projectId name sampleFileUrl _id totalUploads totalInvalidRecords totalRecords mode integration' ); if (!template) { - throw new DocumentNotFoundException('Template', _id); + throw new DocumentNotFoundException('Template', _templateId); } return { - _projectId: template._projectId, + _id: template._id, name: template.name, + _projectId: template._projectId, sampleFileUrl: template.sampleFileUrl, - _id: template._id, totalUploads: template.totalUploads, totalInvalidRecords: template.totalInvalidRecords, totalRecords: template.totalRecords, diff --git a/apps/api/src/app/template/usecases/update-template-columns/update-template-columns.usecase.ts b/apps/api/src/app/template/usecases/update-template-columns/update-template-columns.usecase.ts index 405f45aae..5bc5677fe 100644 --- a/apps/api/src/app/template/usecases/update-template-columns/update-template-columns.usecase.ts +++ b/apps/api/src/app/template/usecases/update-template-columns/update-template-columns.usecase.ts @@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common'; import { APIMessages } from '@shared/constants'; import { PaymentAPIService } from '@impler/services'; -import { ColumnRepository, TemplateRepository } from '@impler/dal'; import { UpdateImageColumns, SaveSampleFile } from '@shared/usecases'; +import { AVAILABLE_BILLABLEMETRIC_CODE_ENUM, ColumnTypesEnum } from '@impler/shared'; +import { ColumnRepository, CustomizationRepository, TemplateRepository } from '@impler/dal'; import { AddColumnCommand } from 'app/column/commands/add-column.command'; import { UniqueColumnException } from '@shared/exceptions/unique-column.exception'; -import { AVAILABLE_BILLABLEMETRIC_CODE_ENUM, ColumnTypesEnum } from '@impler/shared'; import { UpdateCustomization } from '../update-customization/update-customization.usecase'; import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; @@ -17,8 +17,9 @@ export class UpdateTemplateColumns { private columnRepository: ColumnRepository, private paymentAPIService: PaymentAPIService, private templateRepository: TemplateRepository, + private updateImageTemplates: UpdateImageColumns, private updateCustomization: UpdateCustomization, - private updateImageTemplates: UpdateImageColumns + private customizationRepository: CustomizationRepository ) {} async execute(userColumns: AddColumnCommand[], _templateId: string, email: string) { @@ -34,10 +35,16 @@ export class UpdateTemplateColumns { await this.updateImageTemplates.execute(columns, _templateId); const template = await this.templateRepository.findById(_templateId, 'destination'); - await this.updateCustomization.createOrReset(_templateId, { - recordVariables: this.listRecordVariables(userColumns), - destination: template.destination, - }); + const customization = await this.customizationRepository.findOne( + { _templateId }, + 'isRecordFormatUpdated isCombinedFormatUpdated' + ); + if (!customization.isRecordFormatUpdated && !customization.isCombinedFormatUpdated) { + await this.updateCustomization.createOrReset(_templateId, { + recordVariables: this.listRecordVariables(userColumns), + destination: template.destination, + }); + } return columns; } diff --git a/apps/api/src/app/user/usecases/get-active-subscription/get-active-subscription.usecase.ts b/apps/api/src/app/user/usecases/get-active-subscription/get-active-subscription.usecase.ts index e995d8028..be8874bc6 100644 --- a/apps/api/src/app/user/usecases/get-active-subscription/get-active-subscription.usecase.ts +++ b/apps/api/src/app/user/usecases/get-active-subscription/get-active-subscription.usecase.ts @@ -3,14 +3,20 @@ import { Injectable } from '@nestjs/common'; import { DATE_FORMATS } from '@shared/constants'; import { ISubscriptionData } from '@impler/shared'; import { PaymentAPIService } from '@impler/services'; +import { EnvironmentRepository } from '@impler/dal'; @Injectable() export class GetActiveSubscription { - constructor(private paymentApiService: PaymentAPIService) {} - - async execute(userEmail: string): Promise { - const activeSubscription = await this.paymentApiService.fetchActiveSubscription(userEmail); + constructor( + private paymentApiService: PaymentAPIService, + private environmentRepository: EnvironmentRepository + ) {} + async execute(projectId: string): Promise { + const teamOwner = await this.environmentRepository.getTeamOwnerDetails(projectId); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const activeSubscription = await this.paymentApiService.fetchActiveSubscription(teamOwner._userId.email); if (!activeSubscription) { return null; } diff --git a/apps/api/src/app/user/usecases/get-transaction-history/get-transaction-history.usecase.ts b/apps/api/src/app/user/usecases/get-transaction-history/get-transaction-history.usecase.ts index 393fe9114..a6a47cf12 100644 --- a/apps/api/src/app/user/usecases/get-transaction-history/get-transaction-history.usecase.ts +++ b/apps/api/src/app/user/usecases/get-transaction-history/get-transaction-history.usecase.ts @@ -1,6 +1,4 @@ -import * as dayjs from 'dayjs'; import { Injectable } from '@nestjs/common'; -import { DATE_FORMATS } from '@shared/constants'; import { PaymentAPIService } from '@impler/services'; @Injectable() @@ -11,15 +9,11 @@ export class GetTransactionHistory { const transactions = await this.paymentApiService.getTransactionHistory(email); return transactions.map((transactionItem) => ({ - transactionDate: dayjs(transactionItem.transactionDate).format(DATE_FORMATS.COMMON), + transactionDate: transactionItem.transactionDate, planName: transactionItem.planName, transactionStatus: transactionItem.transactionStatus, - membershipDate: transactionItem.membershipDate - ? dayjs(transactionItem.membershipDate).format(DATE_FORMATS.COMMON) - : undefined, - expiryDate: transactionItem.expiryDate - ? dayjs(transactionItem.expiryDate).format(DATE_FORMATS.COMMON) - : undefined, + membershipDate: transactionItem.membershipDate ? transactionItem.membershipDate : undefined, + expiryDate: transactionItem.expiryDate ? transactionItem.expiryDate : undefined, isPlanActive: transactionItem.isPlanActive, charge: transactionItem.charge, amount: transactionItem.amount, diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index f340c13b8..a0a5f8b8f 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -12,11 +12,11 @@ import { ApplyCoupon, Checkout, Subscription, + RetrievePaymentMethods, } from './usecases'; import { JwtAuthGuard } from '@shared/framework/auth.gaurd'; import { IJwtPayload, ACCESS_KEY_NAME } from '@impler/shared'; import { UserSession } from '@shared/framework/user.decorator'; -import { RetrievePaymentMethods } from './usecases/retrive-payment-methods/retrive-payment-methods.usecase'; @ApiTags('User') @Controller('/user') @@ -41,6 +41,7 @@ export class UserController { @ApiOperation({ summary: 'Get Import Count', }) + @UseGuards(JwtAuthGuard) async getImportCountRoute( @UserSession() user: IJwtPayload, @Query('start') start: string, @@ -53,12 +54,12 @@ export class UserController { }); } - @Get('/subscription') + @Get('/:projectId/subscription') @ApiOperation({ summary: 'Get Active Subscription Information', }) - async getActiveSubscriptionRoute(@UserSession() user: IJwtPayload) { - return this.getActiveSubscription.execute(user.email); + async getActiveSubscriptionRoute(@Param('projectId') projectId: string) { + return this.getActiveSubscription.execute(projectId); } @Delete('/subscription') diff --git a/apps/api/src/migrations/shift-environment-key/shift-environment-key.migration.ts b/apps/api/src/migrations/shift-environment-key/shift-environment-key.migration.ts new file mode 100644 index 000000000..101523a2d --- /dev/null +++ b/apps/api/src/migrations/shift-environment-key/shift-environment-key.migration.ts @@ -0,0 +1,64 @@ +import '../../config'; +import { AppModule } from '../../app.module'; +import { NestFactory } from '@nestjs/core'; +import { UserRolesEnum } from '@impler/shared'; +import { EnvironmentRepository, Environment } from '@impler/dal'; + +export async function run() { + console.log('Start migration - moving key to root and adding role to apiKeys'); + + // Initialize the MongoDB connection + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + const environmentRepository = new EnvironmentRepository(); + + const environments = await environmentRepository.find({}); + + const batchOperations = []; + for (const environment of environments) { + if (environment.apiKeys && environment.apiKeys.length > 0) { + const firstApiKey = environment.apiKeys[0]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const key = firstApiKey.key; + + const updatedApiKeys = environment.apiKeys.map((apiKey) => ({ + ...apiKey, + role: UserRolesEnum.ADMIN, + isOwner: true, + })); + + if (key === null || key === undefined) { + console.error(`Skipping environment ${environment._id} due to null key`); + continue; + } + + batchOperations.push({ + updateOne: { + filter: { _id: environment._id }, + update: { + $set: { + key: key, + apiKeys: updatedApiKeys, + }, + }, + }, + }); + } + } + + if (batchOperations.length > 0) { + await Environment.bulkWrite(batchOperations, { + ordered: false, + }); + batchOperations.length = 0; + } + + console.log('End migration - key moved to root and role added to apiKeys'); + + app.close(); + process.exit(0); +} +run(); diff --git a/apps/queue-manager/package.json b/apps/queue-manager/package.json index 07b105506..b19c4e1c3 100644 --- a/apps/queue-manager/package.json +++ b/apps/queue-manager/package.json @@ -1,6 +1,6 @@ { "name": "@impler/queue-manager", - "version": "0.26.1", + "version": "0.27.0", "author": "implerhq", "license": "MIT", "private": true, diff --git a/apps/queue-manager/src/consumers/import-job-data.consumer.ts b/apps/queue-manager/src/consumers/get-import-job-data.consumer.ts similarity index 95% rename from apps/queue-manager/src/consumers/import-job-data.consumer.ts rename to apps/queue-manager/src/consumers/get-import-job-data.consumer.ts index 6ac10954d..2676d5aeb 100644 --- a/apps/queue-manager/src/consumers/import-job-data.consumer.ts +++ b/apps/queue-manager/src/consumers/get-import-job-data.consumer.ts @@ -28,11 +28,12 @@ export class GetImportJobDataConsumer extends BaseConsumer { const data = JSON.parse(message.content) as { _jobId: string }; const importJobHistoryId = this.commonRepository.generateMongoId().toString(); const importedData = await this.getJobImportedData(data._jobId); + const allDataFilePath = this.fileNameService.getAllJsonDataFilePath(importJobHistoryId); await this.convertRecordsToJsonFile(importJobHistoryId, importedData); await this.importJobHistoryRepository.create({ _id: importJobHistoryId, _jobId: data._jobId, - allDataFilePath: this.fileNameService.getAllJsonDataFilePath(importJobHistoryId), + allDataFilePath, status: ImportJobHistoryStatusEnum.PROCESSING, }); const userJobInfo = await this.userJobRepository.getUserJobWithTemplate(data._jobId); @@ -42,7 +43,7 @@ export class GetImportJobDataConsumer extends BaseConsumer { }); if (webhookDestination?.callbackUrl) { - publishToQueue(QueuesEnum.SEND_IMPORT_JOB_DATA, { importJobHistoryId }); + publishToQueue(QueuesEnum.SEND_IMPORT_JOB_DATA, { _jobId: data._jobId, allDataFilePath }); } return; diff --git a/apps/queue-manager/src/consumers/index.ts b/apps/queue-manager/src/consumers/index.ts index 054f1cd12..c76a1c3c7 100644 --- a/apps/queue-manager/src/consumers/index.ts +++ b/apps/queue-manager/src/consumers/index.ts @@ -1,5 +1,5 @@ export * from './send-webhook-data.consumer'; export * from './end-import.consumer'; export * from './send-bubble-data.consumer'; -export * from './import-job-data.consumer'; +export * from './get-import-job-data.consumer'; export * from './send-import-job-data.consumer'; diff --git a/apps/queue-manager/src/consumers/send-import-job-data.consumer.ts b/apps/queue-manager/src/consumers/send-import-job-data.consumer.ts index 4194ff77e..15b137ff3 100644 --- a/apps/queue-manager/src/consumers/send-import-job-data.consumer.ts +++ b/apps/queue-manager/src/consumers/send-import-job-data.consumer.ts @@ -1,23 +1,20 @@ import { - TemplateRepository, - WebhookDestinationRepository, ColumnRepository, + TemplateRepository, ImportJobHistoryRepository, - UserJobEntity, + WebhookDestinationRepository, WebhookLogEntity, - UploadRepository, - WebhookLogRepository, UserJobRepository, } from '@impler/dal'; import { StorageService } from '@impler/services'; import { - ColumnTypesEnum, SendImportJobData, SendImportJobCachedData, replaceVariablesInObject, - FileEncodingsEnum, QueuesEnum, + ColumnTypesEnum, UploadStatusEnum, + FileEncodingsEnum, ColumnDelimiterEnum, } from '@impler/shared'; @@ -30,17 +27,15 @@ const DEFAULT_PAGE = 1; export class SendImportJobDataConsumer extends BaseConsumer { private columnRepository: ColumnRepository = new ColumnRepository(); - private uploadRepository: UploadRepository = new UploadRepository(); private userJobRepository: UserJobRepository = new UserJobRepository(); private templateRepository: TemplateRepository = new TemplateRepository(); - private webhookLogRepository: WebhookLogRepository = new WebhookLogRepository(); private importJobHistoryRepository: ImportJobHistoryRepository = new ImportJobHistoryRepository(); private webhookDestinationRepository: WebhookDestinationRepository = new WebhookDestinationRepository(); private storageService: StorageService = getStorageServiceClass(); async message(message: { content: string }) { const data = JSON.parse(message.content) as SendImportJobData; - const cachedData = data.cache || (await this.getInitialCachedData(data.importJobHistoryId)); + const cachedData = data.cache || (await this.getInitialCachedData(data._jobId, data.allDataFilePath)); let allDataJson: null | any[] = null; if (cachedData && cachedData.callbackUrl) { @@ -53,7 +48,7 @@ export class SendImportJobDataConsumer extends BaseConsumer { } const { sendData, page } = this.buildSendData({ data: allDataJson, - uploadId: data.importJobHistoryId, + uploadId: data._jobId, chunkSize: cachedData.chunkSize, recordFormat: cachedData.recordFormat, chunkFormat: cachedData.chunkFormat, @@ -67,14 +62,14 @@ export class SendImportJobDataConsumer extends BaseConsumer { const response = await this.makeApiCall({ data: sendData, - uploadId: data.importJobHistoryId, + uploadId: data._jobId, page, method: 'POST', url: cachedData.callbackUrl, headers, }); - this.makeResponseEntry(response); + await this.makeResponseEntry(response); const nextPageNumber = this.getNextPageNumber({ totalRecords: allDataJson.length, @@ -85,15 +80,16 @@ export class SendImportJobDataConsumer extends BaseConsumer { if (nextPageNumber) { // Make next call publishToQueue(QueuesEnum.SEND_IMPORT_JOB_DATA, { - importJobHistoryId: data.importJobHistoryId, + _jobId: data._jobId, + allDataFilePath: data.allDataFilePath, cache: { ...cachedData, page: nextPageNumber, }, - }); + } as SendImportJobData); } else { // Processing is done - this.finalizeUpload(data.importJobHistoryId); + this.finalizeUpload(data._jobId); } } } @@ -115,10 +111,10 @@ export class SendImportJobDataConsumer extends BaseConsumer { Math.min(page * chunkSize, data.length) ); - if (multiSelectHeadings && Object.keys(multiSelectHeadings).length > 0) { + if (Array.isArray(multiSelectHeadings) && multiSelectHeadings.length > 0) { slicedData = slicedData.map((obj) => { - Object.keys(multiSelectHeadings).forEach((heading) => { - obj.record[heading] = obj.record[heading] ? obj.record[heading].split(multiSelectHeadings[heading]) : []; + multiSelectHeadings.forEach((heading) => { + obj[heading] = obj[heading] ? (Array.isArray(obj[heading]) ? obj[heading] : obj[heading].split(',')) : []; }); return obj; @@ -144,21 +140,16 @@ export class SendImportJobDataConsumer extends BaseConsumer { }; } - private async getInitialCachedData(_importJobHistoryId: string): Promise { - const importJobHistory = await this.importJobHistoryRepository.getHistoryWithJob(_importJobHistoryId, [ - '_templateId', - ]); - const userJobEmail = await this.userJobRepository.getUserEmailFromJobId(importJobHistory._jobId); + private async getInitialCachedData(_jobId: string, allDataFilePath: string): Promise { + const userJob = await this.userJobRepository.findById(_jobId); + const userJobEmail = await this.userJobRepository.getUserEmailFromJobId(_jobId); const columns = await this.columnRepository.find({ - _templateId: (importJobHistory._jobId as unknown as UserJobEntity)._templateId, + _templateId: userJob._templateId, }); - const templateData = await this.templateRepository.findById( - (importJobHistory._jobId as unknown as UserJobEntity)._templateId, - 'name _projectId code' - ); + const templateData = await this.templateRepository.findById(userJob._templateId, 'name _projectId code'); const webhookDestination = await this.webhookDestinationRepository.findOne({ - _templateId: (importJobHistory._jobId as unknown as UserJobEntity)._templateId, + _templateId: userJob._templateId, }); if (!webhookDestination || !webhookDestination.callbackUrl) { @@ -175,33 +166,29 @@ export class SendImportJobDataConsumer extends BaseConsumer { }); } - this.importJobHistoryRepository.create({ - _jobId: importJobHistory._id, - }); - return { + page: 1, + allDataFilePath, email: userJobEmail, - _templateId: (importJobHistory._jobId as unknown as UserJobEntity)._templateId, + multiSelectHeadings, + extra: userJob.extra, + name: templateData.name, + _templateId: userJob._templateId, + authHeaderValue: userJob.authHeaderValue, callbackUrl: webhookDestination?.callbackUrl, chunkSize: webhookDestination?.chunkSize, - name: templateData.name, - page: 1, - extra: (importJobHistory._jobId as unknown as UserJobEntity).extra, authHeaderName: webhookDestination.authHeaderName, - authHeaderValue: '', - allDataFilePath: importJobHistory.allDataFilePath, defaultValues: JSON.stringify(defaultValueObj), - recordFormat: (importJobHistory._jobId as unknown as UserJobEntity).customRecordFormat, - chunkFormat: (importJobHistory._jobId as unknown as UserJobEntity).customChunkFormat, - multiSelectHeadings, + recordFormat: userJob.customRecordFormat, + chunkFormat: userJob.customChunkFormat, }; } private async makeResponseEntry(data: Partial) { - return await this.webhookLogRepository.create(data); + return this.importJobHistoryRepository.create(data); } - private async finalizeUpload(importJobHistoryId: string) { - return await this.uploadRepository.update({ _id: importJobHistoryId }, { status: UploadStatusEnum.COMPLETED }); + private async finalizeUpload(_jobId: string) { + return await this.userJobRepository.update({ _id: _jobId }, { status: UploadStatusEnum.COMPLETED }); } } diff --git a/apps/web/assets/icons/Cancel.icon.tsx b/apps/web/assets/icons/Cancel.icon.tsx new file mode 100644 index 000000000..3c119e5af --- /dev/null +++ b/apps/web/assets/icons/Cancel.icon.tsx @@ -0,0 +1,21 @@ +import { IconType } from '@types'; +import { IconSizes } from 'config'; + +export const CancelIcon = ({ size = 'sm', color }: IconType) => { + return ( + + + + + ); +}; diff --git a/apps/web/assets/icons/Delete.icon.tsx b/apps/web/assets/icons/Delete.icon.tsx index f68773614..33f4c4018 100644 --- a/apps/web/assets/icons/Delete.icon.tsx +++ b/apps/web/assets/icons/Delete.icon.tsx @@ -1,7 +1,7 @@ import { IconType } from '@types'; import { IconSizes } from 'config'; -export const DeleteIcon = ({ size = 'sm', color }: IconType) => { +export const DeleteIcon = ({ size = 'sm', color = 'red' }: IconType) => { return ( { + return ( + + + + + + + + + + + ); +}; diff --git a/apps/web/assets/icons/Exit.icon.tsx b/apps/web/assets/icons/Exit.icon.tsx new file mode 100644 index 000000000..5c0c940d3 --- /dev/null +++ b/apps/web/assets/icons/Exit.icon.tsx @@ -0,0 +1,18 @@ +import { IconType } from '@types'; +import { IconSizes } from 'config'; + +export const ExitIcon = ({ size = 'sm' }: IconType) => { + return ( + + + + + ); +}; diff --git a/apps/web/assets/icons/Menu.icon.tsx b/apps/web/assets/icons/Menu.icon.tsx new file mode 100644 index 000000000..771acd9da --- /dev/null +++ b/apps/web/assets/icons/Menu.icon.tsx @@ -0,0 +1,18 @@ +import { IconType } from '@types'; +import { IconSizes } from 'config'; +import React from 'react'; + +export const MenuIcon = ({ size = 'sm', color }: IconType) => { + return ( + + + + ); +}; diff --git a/apps/web/assets/icons/People.icon.tsx b/apps/web/assets/icons/People.icon.tsx new file mode 100644 index 000000000..633b2ed91 --- /dev/null +++ b/apps/web/assets/icons/People.icon.tsx @@ -0,0 +1,16 @@ +import { IconType } from '@types'; +import { IconSizes } from 'config'; + +export const PeopleIcon = ({ size = 'sm' }: IconType) => { + return ( + + + + ); +}; diff --git a/apps/web/assets/icons/Swap.icon.tsx b/apps/web/assets/icons/Swap.icon.tsx new file mode 100644 index 000000000..d6dcd4d2d --- /dev/null +++ b/apps/web/assets/icons/Swap.icon.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { IconType } from '@types'; +import { colors, IconSizes } from 'config'; + +export const SwapIcon = ({ size = 'sm', color = colors.green }: IconType) => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/apps/web/components/ConfirmationModal/ConfirmationModal.tsx b/apps/web/components/ConfirmationModal/ConfirmationModal.tsx index 7e79929dc..bf8544078 100644 --- a/apps/web/components/ConfirmationModal/ConfirmationModal.tsx +++ b/apps/web/components/ConfirmationModal/ConfirmationModal.tsx @@ -1,8 +1,9 @@ import React from 'react'; import Lottie from 'lottie-react'; import { modals } from '@mantine/modals'; -import { CONSTANTS, colors } from '@config'; -import { Button, Stack, Text } from '@mantine/core'; +import { CONSTANTS } from '@config'; +import { Button } from '@ui/button'; +import { Stack, Text } from '@mantine/core'; import FailedAnimationData from './failed-animation-data.json'; import SuccessAnimationData from './success-animation-data.json'; @@ -23,7 +24,7 @@ export const ConfirmationModal = ({ status }: ConfirmationModalProps) => { ? CONSTANTS.PAYMENT_SUCCESS_MESSAGE : CONSTANTS.PAYMENT_FAILED_MESSAGE} -
}> ReactNode>> = { [IntegrationEnum.JAVASCRIPT]: { '1) Add Script': ({ embedScriptUrl }) => ( <> Add embed script before closing body tag `} language="javascript" + height={HEIGHTS.WITH_TEXT} + code={``} /> ), '2) Add Import Button': () => ( <> - Import`} language="markup" /> + Import`} + /> ), '3) Initialize Widget': () => ( @@ -36,6 +46,7 @@ export const integrationData: Recordinit method, which initialize the importer. let uuid = generateUuid(); @@ -68,6 +79,7 @@ export const integrationData: Record After initialization, use the following code to show the widget: { window.impler.show({ @@ -86,6 +98,7 @@ ImplerBtn.addEventListener("click", (e) => { <> You can listen for events from the Impler widget: { switch (eventData.type) { @@ -118,6 +131,7 @@ ImplerBtn.addEventListener("click", (e) => { ), 'Data Seeding in File': ({ projectId, templateId, accessToken }) => ( ( ( ( ( ( ( ( ( { @@ -262,6 +284,7 @@ window.impler.show({ 'Complete Code Example': ({ accessToken, embedScriptUrl, projectId, templateId }) => ( <> @@ -340,6 +363,7 @@ window.impler.show({ <> Add embed script before closing body tag @@ -356,13 +380,14 @@ import Script from 'next/script'; /> ), - '2) Install Package': () => , + '2) Install Package': () => , '3) Add Import Button': ({ accessToken, projectId, templateId }) => ( <> Use useImpler hook provided by @impler/react to show an Importer in application ( <> ( <> ( <> ( <> ( <> ( ( <> ( <> ( <> ( <> Add embed script before closing body tag `} language="javascript" + height={HEIGHTS.WITH_TEXT} + code={``} /> ), '2) Install Package': () => ( <> - + ), '3) Use Impler Service': ({ accessToken, projectId, templateId }) => ( <> ( ( ( ( ( ( ( ( ( ( ( ( <> Integrate - + ({ + list: { + width: '100%', + }, + header: { + borderBottom: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`, + }, + row: { + borderBottom: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`, + }, + td: { + textAlign: 'left', + padding: `${theme.spacing.sm} !important`, + }, + th: { + textAlign: 'left', + padding: theme.spacing.xs, + }, + selectedRow: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + }, + emptyText: { + padding: theme.spacing.xl, + color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6], + }, +})); diff --git a/apps/web/components/List/List.tsx b/apps/web/components/List/List.tsx new file mode 100644 index 000000000..959b57e0c --- /dev/null +++ b/apps/web/components/List/List.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Table, Text } from '@mantine/core'; +import { getListStyles } from './List.styles'; + +interface IHeadingItem { + key: string; + title: string; + width?: number | string; + Cell?: (item: T) => React.ReactNode; +} + +interface IListProps { + data?: T[]; + emptyDataText?: string; + selectedItemId?: string; + headings?: IHeadingItem[]; + style?: React.CSSProperties; + extraContent?: React.ReactNode; + onItemClick?: (item: T) => void; +} + +export function List(props: IListProps) { + const { + data, + headings, + emptyDataText = 'No Project Data Found!', + style, + extraContent, + selectedItemId, + onItemClick, + } = props; + const { classes, cx } = getListStyles(); + + const isHeadingsEmpty = !headings || !Array.isArray(headings) || !headings.length; + const isDataEmpty = !data || !Array.isArray(data) || !data.length; + + const ListHeader = () => { + if (isHeadingsEmpty) return null; + + return ( + + {headings.map((heading: IHeadingItem, index: number) => ( + + {heading.title} + + ))} + + ); + }; + + const ListBody = () => { + if (isHeadingsEmpty) return null; + + if (isDataEmpty) + return ( + + + {emptyDataText} + + + ); + + return ( + + {data.map((item: T) => ( + onItemClick && onItemClick(item)} + > + {headings.map((heading: IHeadingItem, fieldIndex: number) => ( + + {typeof heading.Cell === 'function' ? ( + heading.Cell(item) + ) : ( + {item[heading.key as keyof T] as string} + )} + + ))} + + ))} + + ); + }; + + return ( + + + + {extraContent} +
+ ); +} diff --git a/apps/web/components/List/index.tsx b/apps/web/components/List/index.tsx new file mode 100644 index 000000000..28e6197c5 --- /dev/null +++ b/apps/web/components/List/index.tsx @@ -0,0 +1,2 @@ +export * from './List'; +export * from './List.styles'; diff --git a/apps/web/components/ManageProject/ConfirmDeleteProjectModal.tsx b/apps/web/components/ManageProject/ConfirmDeleteProjectModal.tsx new file mode 100644 index 000000000..429c39e6a --- /dev/null +++ b/apps/web/components/ManageProject/ConfirmDeleteProjectModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Text, Flex, Stack } from '@mantine/core'; +import { Button } from '@ui/button'; + +interface ConfirmDeleteProjectProps { + projectName: string; + onDeleteConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDeleteProjectModal({ projectName, onDeleteConfirm, onCancel }: ConfirmDeleteProjectProps) { + return ( + + Are you sure you want to delete the project {projectName}? + + This action cannot be undone. All data associated with this project will be permanently removed. + + + + + + + ); +} diff --git a/apps/web/components/ManageProject/ManageProjectModal.tsx b/apps/web/components/ManageProject/ManageProjectModal.tsx new file mode 100644 index 000000000..f33dd78c5 --- /dev/null +++ b/apps/web/components/ManageProject/ManageProjectModal.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Flex, Text, Stack, TextInput } from '@mantine/core'; + +import { Badge } from '@ui/badge'; +import { Button } from '@ui/button'; +import { List } from '@components/List'; +import { colors, ROLE_BADGES } from '@config'; +import { IconButton } from '@ui/icon-button'; +import { useProject } from '@hooks/useProject'; +import { SwapIcon } from '@assets/icons/Swap.icon'; +import { DeleteIcon } from '@assets/icons/Delete.icon'; +import { InformationIcon } from '@assets/icons/Information.icon'; +import { IProjectPayload, UserRolesEnum } from '@impler/shared'; + +export function ManageProjectModal() { + const { + errors, + onSubmit, + projects, + register, + handleSubmit, + currentProjectId, + onProjectIdChange, + handleDeleteProject, + isCreateProjectLoading, + } = useProject(); + + return ( + + + headings={[ + { + title: 'Project Name', + key: 'name', + width: '60%', + Cell: (item) => ( + + {item.name} + {item.isOwner && ( + + Owner + + )} + + ), + }, + { + title: 'Role', + key: 'role', + width: '10%', + Cell: (item) => ( + + {item.role} + + ), + }, + { + key: '', + width: '5%', + title: 'Actions', + Cell: (item) => ( + + {item._id !== currentProjectId ? ( + onProjectIdChange(item._id as string)}> + + + ) : null} + {item.isOwner ? ( + handleDeleteProject(item._id as string)}> + + + ) : null} + + ), + }, + ]} + data={projects || []} + selectedItemId={currentProjectId} + /> +
+ + + + + + + + You will be switched to the project once created. + + +
+
+ ); +} diff --git a/apps/web/components/ManageProject/index.tsx b/apps/web/components/ManageProject/index.tsx new file mode 100644 index 000000000..1508b9a5a --- /dev/null +++ b/apps/web/components/ManageProject/index.tsx @@ -0,0 +1,2 @@ +export * from './ManageProjectModal'; +export * from './ConfirmDeleteProjectModal'; diff --git a/apps/web/components/TeamMembers/ConfirmDeleteInvitation.tsx b/apps/web/components/TeamMembers/ConfirmDeleteInvitation.tsx new file mode 100644 index 000000000..3c168cf38 --- /dev/null +++ b/apps/web/components/TeamMembers/ConfirmDeleteInvitation.tsx @@ -0,0 +1,23 @@ +import { Stack, Group } from '@mantine/core'; +import { Button } from '@ui/button'; + +interface IConfirmDeleteInvitationProps { + onDeleteConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDeleteInvitation({ onDeleteConfirm, onCancel }: IConfirmDeleteInvitationProps) { + return ( + + Are you sure you want to cancel this invitation ? + + + + + + ); +} diff --git a/apps/web/components/TeamMembers/ConfirmInvitationModal.tsx b/apps/web/components/TeamMembers/ConfirmInvitationModal.tsx new file mode 100644 index 000000000..e2f534c8f --- /dev/null +++ b/apps/web/components/TeamMembers/ConfirmInvitationModal.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Text, Stack, Group } from '@mantine/core'; + +import { Button } from '@ui/button'; +import { useAcceptInvitation } from '@hooks/useAcceptInvitation'; +import { colors } from '@config'; + +interface IConfirmInvitationModalProps { + token: string; + invitedBy: string; + projectName: string; + invitationId: string; +} + +export function ConfirmInvitationModal({ invitationId, token, invitedBy, projectName }: IConfirmInvitationModalProps) { + const { onAcceptClick, isAcceptLoading } = useAcceptInvitation({ + invitationId, + token, + }); + + return ( + + + + {invitedBy} + {' '} + has invited you to join{' '} + + {projectName} + + . Would you like to accept the invitation? + + + + + + + ); +} diff --git a/apps/web/components/TeamMembers/RemoveTeamMemberModal.tsx b/apps/web/components/TeamMembers/RemoveTeamMemberModal.tsx new file mode 100644 index 000000000..fdec13abd --- /dev/null +++ b/apps/web/components/TeamMembers/RemoveTeamMemberModal.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Text, Stack, Group } from '@mantine/core'; +import { Button } from '@ui/button'; +import { colors } from '@config'; + +interface IRemoveTeamMemberModalProps { + userId: string; + userName: string; + onCancel: () => void; + onDeleteConfirm: () => void; +} + +export function RemoveTeamMemberModal({ userName, onDeleteConfirm, onCancel }: IRemoveTeamMemberModalProps) { + return ( + + + Are you sure you want to remove{' '} + + {userName} + {' '} + from the team? + + + + + + + ); +} diff --git a/apps/web/components/TeamMembers/SentInvitationActions.tsx b/apps/web/components/TeamMembers/SentInvitationActions.tsx new file mode 100644 index 000000000..c75980a69 --- /dev/null +++ b/apps/web/components/TeamMembers/SentInvitationActions.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Menu, UnstyledButton, useMantineTheme } from '@mantine/core'; +import { MenuIcon } from '@assets/icons/Menu.icon'; +import { colors } from '@config'; +import { CancelIcon } from '@assets/icons/Cancel.icon'; +import { CopyIcon } from '@assets/icons/Copy.icon'; +import { useSentProjectInvitations } from '@hooks/useSentProjectInvitations'; + +interface IInvitation { + invitationId: string; +} + +export function SentInvitationActions(invitation: IInvitation) { + const { handleCopyInvitationLink, handleCancelInvitation } = useSentProjectInvitations(); + const theme = useMantineTheme(); + + return ( + + + + + + + + } onClick={() => handleCopyInvitationLink(invitation.invitationId)}> + Copy Invitation Link + + } onClick={() => handleCancelInvitation(invitation.invitationId)}> + Revoke Invitation + + + + ); +} diff --git a/apps/web/components/TeamMembers/SentInvitations.tsx b/apps/web/components/TeamMembers/SentInvitations.tsx new file mode 100644 index 000000000..7642fd728 --- /dev/null +++ b/apps/web/components/TeamMembers/SentInvitations.tsx @@ -0,0 +1,51 @@ +import dayjs from 'dayjs'; +import { LoadingOverlay, Stack, Text } from '@mantine/core'; + +import { Table } from '@ui/table'; +import { DATE_FORMATS } from '@config'; +import { AppLayout } from '@layouts/AppLayout'; +import { SentInvitationActions } from './SentInvitationActions'; +import { useSentProjectInvitations } from '@hooks/useSentProjectInvitations'; + +export function SentInvitations() { + const { invitations, isInvitationsLoading } = useSentProjectInvitations(); + + return ( + + + + {!isInvitationsLoading && invitations && ( + + headings={[ + { + title: 'User', + key: 'user', + Cell: (invitation) => {invitation.invitationToEmail}, + }, + { + title: 'Invited On', + key: 'invitedOn', + Cell: (invitation) => ( + {dayjs(invitation.invitedOn).format(DATE_FORMATS.LONG) || 'N/A'} + ), + }, + { + title: 'Role', + key: 'role', + Cell: (invitation) => {invitation.role}, + }, + { + title: 'Actions', + key: 'action', + Cell: (item) => , + }, + ]} + data={invitations || []} + /> + )} + + + ); +} + +SentInvitations.Layout = AppLayout; diff --git a/apps/web/components/TeamMembers/Team.tsx b/apps/web/components/TeamMembers/Team.tsx new file mode 100644 index 000000000..7807a4d29 --- /dev/null +++ b/apps/web/components/TeamMembers/Team.tsx @@ -0,0 +1,74 @@ +import { useState, useContext } from 'react'; +import { Text } from '@mantine/core'; +import { modals } from '@mantine/modals'; + +import { TeamMembers } from './TeamMembers'; +import { SentInvitations } from './SentInvitations'; +import { TeamInvitationModal } from './TeamInvitationModal'; + +import { Button } from '@ui/button'; +import { useAppState } from 'store/app.context'; +import { OutlinedTabs } from '@ui/OutlinedTabs'; +import { useTeamMembers } from '@hooks/useTeamMembers'; +import { AbilityContext } from 'store/ability.context'; +import { ActionsEnum, AppAbility, colors, MODAL_KEYS, SubjectsEnum, TAB_KEYS, TAB_TITLES } from '@config'; +import { useSentProjectInvitations } from '@hooks/useSentProjectInvitations'; + +export function Team() { + const { profileInfo } = useAppState(); + const ability = useContext(AbilityContext); + const { invitationsCount } = useSentProjectInvitations(); + const { teamMembersCount } = useTeamMembers(); + const [activeTab, setActiveTab] = useState(TAB_KEYS.MEMBERS); + + const openInviteModal = () => { + modals.open({ + id: MODAL_KEYS.INVITE_MEMBERS, + modalId: MODAL_KEYS.INVITE_MEMBERS, + title: ( + + Invite new members in{' '} + {profileInfo?.projectName} + + ), + children: , + size: 'lg', + withCloseButton: true, + }); + }; + + const handleTabChange = (value: string) => { + setActiveTab(value); + }; + + const tabItems = [ + { + value: TAB_KEYS.MEMBERS, + title: TAB_TITLES[TAB_KEYS.MEMBERS], + badgeCount: teamMembersCount, + content: , + }, + { + value: TAB_KEYS.SENT_INVITATIONS, + title: TAB_TITLES[TAB_KEYS.SENT_INVITATIONS], + badgeCount: invitationsCount, + content: , + }, + ]; + + const canInvite = ability && ability.can(ActionsEnum.CREATE, SubjectsEnum.TEAM_MEMBERS); + + return ( + + Invite to {profileInfo?.projectName} + + } + /> + ); +} diff --git a/apps/web/components/TeamMembers/TeamInvitationModal.tsx b/apps/web/components/TeamMembers/TeamInvitationModal.tsx new file mode 100644 index 000000000..b95630035 --- /dev/null +++ b/apps/web/components/TeamMembers/TeamInvitationModal.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { modals } from '@mantine/modals'; +import { Controller } from 'react-hook-form'; +import { Alert, Flex, Select, Stack, Text, Skeleton } from '@mantine/core'; + +import { Button } from '@ui/button'; +import { MultiSelect } from '@ui/multi-select'; +import { validateEmails } from '@shared/utils'; +import { InformationIcon } from '@assets/icons/Information.icon'; +import { INVITATION_FORM_ROLES, MODAL_KEYS } from '@config'; +import { useProjectInvitationForm } from '@hooks/useProjectInvitationForm'; +import { useSentProjectInvitations } from '@hooks/useSentProjectInvitations'; + +export function TeamInvitationModal() { + const { refetchInvitations } = useSentProjectInvitations(); + const { + control, + handleSubmit, + errors, + onSubmit, + isProjectInvitationLoading, + teamTeamMemberMeta, + refetchTeamMemberMeta, + isTeamMemberMetaLoading, + } = useProjectInvitationForm({ + refetchInvitations, + }); + const [emailOptions, setEmailOptions] = useState<{ value: string; label: string }[]>([]); + const availableInvites = teamTeamMemberMeta?.available; + + useEffect(() => { + refetchTeamMemberMeta(); + }, [refetchTeamMemberMeta]); + + console.log('avail', availableInvites); + + return ( +
+ + {isTeamMemberMetaLoading ? ( + + ) : ( + }> + + {availableInvites! > 0 ? ( + <> + You can invite {availableInvites} more member(s) in your current plan. + + ) : ( + <>You have reached the maximum number of invitations for your current plan. + )} + + + )} + ( + <> + `+ ${query}`} + onCreate={(query) => { + const item: any = { value: query, label: query }; + setEmailOptions((current) => [...current, item]); + + return item; + }} + error={errors.invitationEmailsTo ? errors.invitationEmailsTo.message : undefined} + value={field.value || []} + onChange={field.onChange} + withinPortal + disabled={availableInvites! <= 0} + /> + + )} + rules={{ + required: 'Email addresses are required', + validate: (value) => { + const emailValidationResult = validateEmails(value); + + if (emailValidationResult !== true) { + return emailValidationResult; + } + + if (Array.isArray(value) && value.length > availableInvites!) { + return `You've only ${availableInvites} member seat(s) left in your current plan`; + } + + return true; + }, + }} + /> + + ( + { + if (role) + updateTeamMemberRole({ + role, + memberId: item._id, + }); + }} + /> + ) : ( + {item.role} + ), + }, + { + title: 'Actions', + key: 'action', + Cell: (item) => { + const isCurrentUserAdmin = profileInfo?.role === UserRolesEnum.ADMIN; + const isTargetUserAdmin = item.role === UserRolesEnum.ADMIN; + + return ability && + ability.can(ActionsEnum.UPDATE, SubjectsEnum.ROLE) && + (isCurrentUserAdmin || !isTargetUserAdmin) ? ( + openDeleteModal(item._id, item._userId.firstName)} + > + + + ) : null; + }, + }, + ]} + data={teamMembersList || []} + /> + ); +} diff --git a/apps/web/components/TeamMembers/index.tsx b/apps/web/components/TeamMembers/index.tsx new file mode 100644 index 000000000..4a44dce65 --- /dev/null +++ b/apps/web/components/TeamMembers/index.tsx @@ -0,0 +1,9 @@ +export * from './TeamMembers'; +export * from './TeamInvitationModal'; +export * from './SentInvitations'; +export * from './Team'; +export * from './SentInvitationActions'; +export * from './ConfirmInvitationModal'; +export * from './ConfirmDeleteInvitation'; +export * from './RemoveTeamMemberModal'; +export * from './SentInvitationActions'; diff --git a/apps/web/components/UpgradePlan/Plans/Plans.tsx b/apps/web/components/UpgradePlan/Plans/Plans.tsx index e3e30888e..2431b1011 100644 --- a/apps/web/components/UpgradePlan/Plans/Plans.tsx +++ b/apps/web/components/UpgradePlan/Plans/Plans.tsx @@ -5,12 +5,13 @@ import { Switch, Stack, Table, Button, Text, Group, useMantineColorScheme } from import useStyles from './Plans.styles'; import { Badge } from '@ui/badge'; import { track } from '@libs/amplitude'; -import { MODAL_KEYS, colors } from '@config'; import { numberFormatter } from '@impler/shared'; import { TickIcon } from '@assets/icons/Tick.icon'; import { CrossIcon } from '@assets/icons/Cross.icon'; import { useCancelPlan } from '@hooks/useCancelPlan'; import { SelectCardModal } from '@components/settings'; +import { TooltipLink } from '@components/guide-point'; +import { DOCUMENTATION_REFERENCE_LINKS, MODAL_KEYS, colors } from '@config'; interface PlansProps { profile: IProfileData; @@ -23,9 +24,13 @@ interface PlanItem { name: string; code: string; price: number; + seats: number; yearlyPrice: number; + autoImport: boolean; + imageImport: boolean; rowsIncluded: number; removeBranding: boolean; + advancedValidations: boolean; extraChargeOverheadTenThusandRecords?: number; } @@ -36,15 +41,23 @@ const plans: Record = { code: 'STARTER', rowsIncluded: 5000, price: 0, + seats: 1, yearlyPrice: 0, + autoImport: false, + imageImport: false, extraChargeOverheadTenThusandRecords: 1, removeBranding: false, + advancedValidations: false, }, { name: 'Growth', code: 'GROWTH-MONTHLY', price: 42, yearlyPrice: 0, + seats: 4, + autoImport: false, + imageImport: false, + advancedValidations: true, rowsIncluded: 500000, extraChargeOverheadTenThusandRecords: 0.7, removeBranding: true, @@ -53,6 +66,10 @@ const plans: Record = { name: 'Scale', code: 'SCALE-MONTHLY', price: 90, + seats: 10, + autoImport: true, + imageImport: true, + advancedValidations: true, yearlyPrice: 0, rowsIncluded: 1500000, extraChargeOverheadTenThusandRecords: 0.5, @@ -65,6 +82,10 @@ const plans: Record = { code: 'STARTER', rowsIncluded: 5000, price: 0, + seats: 1, + autoImport: false, + imageImport: false, + advancedValidations: false, yearlyPrice: 0, extraChargeOverheadTenThusandRecords: 1, removeBranding: false, @@ -73,6 +94,10 @@ const plans: Record = { name: 'Growth', code: 'GROWTH-YEARLY', price: 35, + seats: 4, + autoImport: false, + imageImport: false, + advancedValidations: true, yearlyPrice: 420, rowsIncluded: 6000000, extraChargeOverheadTenThusandRecords: 0.7, @@ -82,6 +107,10 @@ const plans: Record = { name: 'Scale', code: 'SCALE-YEARLY', price: 75, + seats: 10, + advancedValidations: true, + autoImport: true, + imageImport: true, yearlyPrice: 900, rowsIncluded: 18000000, extraChargeOverheadTenThusandRecords: 0.5, @@ -196,6 +225,12 @@ export const Plans = ({ profile, activePlanCode, canceledOn, expiryDate }: Plans ))} + + Team Members + {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( + {plan.seats} + ))} + Theming {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( @@ -205,9 +240,23 @@ export const Plans = ({ profile, activePlanCode, canceledOn, expiryDate }: Plans ))} - Projects + + Custom Validation + {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( - Unlimited + + + + ))} + + + + Output Customization + + {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( + + + ))} @@ -216,21 +265,28 @@ export const Plans = ({ profile, activePlanCode, canceledOn, expiryDate }: Plans {plan.removeBranding ? : } ))} - - Custom Validation + + Advanced Validations + {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( - - - + {plan.advancedValidations ? : } ))} - Output Customization + + Auto Import + {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( - - - + {plan.autoImport ? : } + ))} + + + + Image Import + + {plans[showYearly ? 'yearly' : 'monthly'].map((plan, index) => ( + {plan.imageImport ? : } ))} diff --git a/apps/web/components/UpgradePlan/PlansModal.tsx b/apps/web/components/UpgradePlan/PlansModal.tsx index 000e03d43..d12a0e3c2 100644 --- a/apps/web/components/UpgradePlan/PlansModal.tsx +++ b/apps/web/components/UpgradePlan/PlansModal.tsx @@ -1,5 +1,5 @@ -import { Container, Stack, Title } from '@mantine/core'; import React from 'react'; +import { Container, Stack, Title } from '@mantine/core'; import { Plans } from './Plans'; interface PlanProps { diff --git a/apps/web/components/guide-point/TooltipLink.tsx b/apps/web/components/guide-point/TooltipLink.tsx index c93e1a858..248f02949 100644 --- a/apps/web/components/guide-point/TooltipLink.tsx +++ b/apps/web/components/guide-point/TooltipLink.tsx @@ -12,7 +12,7 @@ interface TooltipLinkProps { iconColor?: string; } -export function TooltipLink({ label = 'Read More', link, iconSize = 'sm' }: TooltipLinkProps) { +export function TooltipLink({ label = 'Know More', link, iconSize = 'sm' }: TooltipLinkProps) { const theme = useMantineColorScheme(); return ( diff --git a/apps/web/components/hoc/HOC.tsx b/apps/web/components/hoc/HOC.tsx new file mode 100644 index 000000000..47e1034c2 --- /dev/null +++ b/apps/web/components/hoc/HOC.tsx @@ -0,0 +1,34 @@ +import { useContext } from 'react'; +import { Stack, Text } from '@mantine/core'; +import { AbilityContext } from 'store/ability.context'; +import { ActionsEnum, AppAbility, SubjectsEnum } from '@config'; +import ImportNotAccessible from 'pages/imports/illustrations/import-not-accessible'; + +interface WithExtraParamsProps { + subject: SubjectsEnum; +} + +export function withProtectedResource

( + WrappedComponent: React.ComponentType

, + hocProps: WithExtraParamsProps +) { + return function EnhancedComponent(props: Omit) { + const ability = useContext(AbilityContext); + + if (ability && !ability.can(ActionsEnum.READ, hocProps.subject)) { + return ( + + + You don't have access to this. + + ); + } + + const newProps = { + ...props, + subject: hocProps.subject, + } as P; + + return ; + }; +} diff --git a/apps/web/components/hoc/index.ts b/apps/web/components/hoc/index.ts new file mode 100644 index 000000000..507936c88 --- /dev/null +++ b/apps/web/components/hoc/index.ts @@ -0,0 +1 @@ +export * from './HOC'; diff --git a/apps/web/components/home/PlanDetails/PlanDetails.tsx b/apps/web/components/home/PlanDetails/PlanDetails.tsx index 2c3a3b5f7..2b5c462bb 100644 --- a/apps/web/components/home/PlanDetails/PlanDetails.tsx +++ b/apps/web/components/home/PlanDetails/PlanDetails.tsx @@ -1,27 +1,41 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { modals } from '@mantine/modals'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { Title, Text, Flex, Button, Skeleton, Stack } from '@mantine/core'; -import { useApp } from '@hooks/useApp'; +import { + CONSTANTS, + MODAL_KEYS, + ROUTES, + colors, + DOCUMENTATION_REFERENCE_LINKS, + ActionsEnum, + SubjectsEnum, + AppAbility, +} from '@config'; +import { Alert } from '@ui/Alert'; import { track } from '@libs/amplitude'; import { numberFormatter } from '@impler/shared'; +import { TooltipLink } from '@components/guide-point'; import { SelectCardModal } from '@components/settings'; import { usePlanDetails } from '@hooks/usePlanDetails'; -import { TooltipLink } from '@components/guide-point'; import { PlansModal } from '@components/UpgradePlan/PlansModal'; -import { CONSTANTS, MODAL_KEYS, ROUTES, colors, DOCUMENTATION_REFERENCE_LINKS } from '@config'; +import { useAppState } from 'store/app.context'; +import { AbilityContext, Can } from 'store/ability.context'; +import { InformationIcon } from '@assets/icons/Information.icon'; export function PlanDetails() { const router = useRouter(); - const { profile } = useApp(); + const { profileInfo } = useAppState(); + useContext(AbilityContext); + const { [CONSTANTS.PLAN_CODE_QUERY_KEY]: selectedPlan, [CONSTANTS.EXPLORE_PLANS_QUERY_LEY]: explorePlans } = router.query; const { activePlanDetails, isActivePlanLoading } = usePlanDetails({ - email: profile?.email ?? '', + projectId: profileInfo?._projectId ?? '', }); const showPlans = useCallback(() => { @@ -34,7 +48,7 @@ export function PlanDetails() { modalId: MODAL_KEYS.PAYMENT_PLANS, children: ( { - if (selectedPlan && profile) { + if (selectedPlan && profileInfo) { modals.open({ size: '2xl', withCloseButton: false, id: MODAL_KEYS.SELECT_CARD, modalId: MODAL_KEYS.SELECT_CARD, - children: , + children: ( + + ), }); router.push(ROUTES.HOME, {}, { shallow: true }); } else if (explorePlans) { showPlans(); } - }, [profile, selectedPlan, router, explorePlans, showPlans]); + }, [profileInfo, selectedPlan, router, explorePlans, showPlans]); if (isActivePlanLoading) return ; @@ -104,70 +120,79 @@ export function PlanDetails() { isLessThanZero || activePlanDetails!.usage.IMPORTED_ROWS > numberOfRecords ? colors.danger : colors.yellow; return ( - numberOfRecords ? colors.danger : colors.yellow - }`, - backgroundColor: backgroundColor + '20', - }} - > - - - - {numberFormatter(activePlanDetails!.usage.IMPORTED_ROWS)} - {'/'} - {numberFormatter(numberOfRecords)} - - - Records Imported - - - - - {activePlanDetails.plan.name} - - - Active Plan - - - {Number(activePlanDetails.plan.charge) ? ( - <> + <> + } p="xs"> + You're viewing details of {profileInfo?.projectName} project + + numberOfRecords ? colors.danger : colors.yellow + }`, + backgroundColor: backgroundColor + '20', + }} + > + + + + {numberFormatter(activePlanDetails!.usage.IMPORTED_ROWS)} + {'/'} + {numberFormatter(numberOfRecords)} + + + Records Imported + + + + + {activePlanDetails.plan.name} + + + Active Plan + + + {Number(activePlanDetails.plan.charge) ? ( + <> + + + {'$' + activePlanDetails.plan.charge} + + + Outstanding Amount + + + + ) : null} + + + <>{activePlanDetails!.expiryDate}</> + + + Expiry Date + + + + - - {'$' + activePlanDetails.plan.charge} - - - Outstanding Amount + + + View all transactions - - ) : null} - - - <>{activePlanDetails!.expiryDate}</> - - - Expiry Date - - - - - - View all transactions - + + + - - + ); } diff --git a/apps/web/components/imports/ImportsList.tsx b/apps/web/components/imports/ImportsList.tsx new file mode 100644 index 000000000..8e2ff7344 --- /dev/null +++ b/apps/web/components/imports/ImportsList.tsx @@ -0,0 +1,93 @@ +import { ChangeEvent } from 'react'; +import { Flex, SimpleGrid, Group, LoadingOverlay, Text, Title, TextInput as Input } from '@mantine/core'; + +import { Button } from '@ui/button'; +import { VARIABLES } from '@config'; +import { Pagination } from '@ui/pagination'; +import { ImportCard } from '@ui/import-card'; +import { useImports } from '@hooks/useImports'; +import { SearchIcon } from '@assets/icons/Search.icon'; + +const NEW_IMPORT_TEXT = 'Create Import'; + +export function ImportsList() { + const { + search, + importsData, + onPageChange, + onCreateClick, + onLimitChange, + onSearchChange, + onDuplicateClick, + isImportsLoading, + isCreateImportLoading, + } = useImports(); + + return ( + + + + Imports + + } + placeholder="Search imports by name..." + defaultValue={search} + onChange={(e: ChangeEvent) => onSearchChange(e.currentTarget.value)} + type="search" + /> + + + + {!importsData?.data?.length && ( + + No imports found, click on {NEW_IMPORT_TEXT} to get started with a new import. + + )} + + {importsData?.data?.length ? ( + <> + {importsData?.data.map((importItem, index) => ( + { + e.preventDefault(); + onDuplicateClick(importItem._id); + }} + totalRecords={importItem.totalRecords} + errorRecords={importItem.totalInvalidRecords} + /> + ))} + + ) : null} + + + + ); +} diff --git a/apps/web/components/imports/forms/CreateImportForm.tsx b/apps/web/components/imports/forms/CreateImportForm.tsx index 1642485b6..29a7a2199 100644 --- a/apps/web/components/imports/forms/CreateImportForm.tsx +++ b/apps/web/components/imports/forms/CreateImportForm.tsx @@ -1,8 +1,8 @@ import { Controller, useForm } from 'react-hook-form'; import { useFocusTrap } from '@mantine/hooks'; import { Stack, TextInput as Input, FocusTrap, Text, Group, useMantineTheme } from '@mantine/core'; -import { Button } from '@ui/button'; +import { Button } from '@ui/button'; import { INTEGRATE_IMPORT } from '@config'; import { IntegrationEnum } from '@impler/shared'; @@ -18,7 +18,11 @@ export function CreateImportForm({ onSubmit }: CreateImportFormProps) { handleSubmit, control, formState: { errors }, - } = useForm({}); + } = useForm({ + defaultValues: { + integration: IntegrationEnum.REACT, + }, + }); return ( @@ -47,8 +51,9 @@ export function CreateImportForm({ onSubmit }: CreateImportFormProps) { key={key} radius="xl" leftIcon={} - variant={field.value === key ? 'filled' : 'outline'} onClick={() => field.onChange(key)} + color={field.value === key ? 'blue' : 'grey'} + variant={field.value === key ? 'filled' : 'outline'} > {name} diff --git a/apps/web/components/settings/AddCard/PaymentMethods/PaymentMethods.tsx b/apps/web/components/settings/AddCard/PaymentMethods/PaymentMethods.tsx index 9082c464e..904a8b437 100644 --- a/apps/web/components/settings/AddCard/PaymentMethods/PaymentMethods.tsx +++ b/apps/web/components/settings/AddCard/PaymentMethods/PaymentMethods.tsx @@ -8,6 +8,7 @@ import { MODAL_KEYS, ROUTES } from '@config'; import { PaymentMethodOption } from './PaymentMethodOption'; interface PaymentMethodsProps { + isAddCardDisabled?: boolean; paymentMethods: ICardData[] | undefined; selectedPaymentMethod: string | undefined; handlePaymentMethodChange: (methodId: string) => void; @@ -15,6 +16,7 @@ interface PaymentMethodsProps { export function PaymentMethods({ paymentMethods, + isAddCardDisabled, selectedPaymentMethod, handlePaymentMethodChange, }: PaymentMethodsProps) { @@ -44,7 +46,7 @@ export function PaymentMethods({ ))} - diff --git a/apps/web/components/settings/AddCard/SelectCardModal.tsx b/apps/web/components/settings/AddCard/SelectCardModal.tsx index 47f0acb15..1775f7ad8 100644 --- a/apps/web/components/settings/AddCard/SelectCardModal.tsx +++ b/apps/web/components/settings/AddCard/SelectCardModal.tsx @@ -16,15 +16,16 @@ interface SelectCardModalProps { export function SelectCardModal({ email, planCode, paymentMethodId }: SelectCardModalProps) { const { + handleProceed, + paymentMethods, appliedCouponCode, + isPurchaseLoading, setAppliedCouponCode, - handlePaymentMethodChange, - handleProceed, + selectedPaymentMethod, isCouponFeatureEnabled, isPaymentMethodsFetching, isPaymentMethodsLoading, - selectedPaymentMethod, - paymentMethods, + handlePaymentMethodChange, } = useSubscribe({ email, planCode, @@ -52,6 +53,7 @@ export function SelectCardModal({ email, planCode, paymentMethodId }: SelectCard @@ -63,8 +65,8 @@ export function SelectCardModal({ email, planCode, paymentMethodId }: SelectCard - diff --git a/apps/web/components/settings/SettingsTab.tsx b/apps/web/components/settings/SettingsTab.tsx index 24033f496..3d55eefc1 100644 --- a/apps/web/components/settings/SettingsTab.tsx +++ b/apps/web/components/settings/SettingsTab.tsx @@ -1,38 +1,51 @@ +import { useContext } from 'react'; import getConfig from 'next/config'; -import { Tabs } from '@mantine/core'; import { useRouter } from 'next/router'; import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; import { UserCards } from './UserCards'; +import { OutlinedTabs } from '@ui/OutlinedTabs'; +import { AbilityContext } from 'store/ability.context'; import { GenerateAccessToken } from './GenerateAccessToken'; +import { ActionsEnum, AppAbility, SubjectsEnum } from '@config'; export function SettingsTab() { const router = useRouter(); const { publicRuntimeConfig } = getConfig(); + const ability = useContext(AbilityContext); const stripePromise = publicRuntimeConfig.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY && loadStripe(publicRuntimeConfig.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); - return ( - - - Access Token - {stripePromise ? Cards : null} - - - - - - - {stripePromise ? ( - + const tabItems = [ + ability && + ability.can(ActionsEnum.READ, SubjectsEnum.ACCESS_TOKEN) && { + value: 'accesstoken', + title: 'Access Token', + content: , + }, + stripePromise && + ability && + ability.can(ActionsEnum.READ, SubjectsEnum.CARDS) && { + value: 'addcard', + title: 'Cards', + content: ( - - ) : null} - - ); + ), + }, + ].filter(Boolean); + + const validTabValues = tabItems.map((item) => item.value); + const defaultTab = router.query.tab as string; + const selectedTab = validTabValues.includes(defaultTab) + ? defaultTab + : ability && ability.can(ActionsEnum.READ, SubjectsEnum.ACCESS_TOKEN) + ? 'accesstoken' + : 'addcard'; + + return ; } diff --git a/apps/web/components/signin/OnboardUserForm.tsx b/apps/web/components/signin/OnboardUserForm.tsx index d80b32ba0..45f723bd0 100644 --- a/apps/web/components/signin/OnboardUserForm.tsx +++ b/apps/web/components/signin/OnboardUserForm.tsx @@ -3,12 +3,12 @@ import { Controller, useForm } from 'react-hook-form'; import { Title, Text, Stack, TextInput, Select, Radio, Group, Container, Flex, Box, FocusTrap } from '@mantine/core'; import { Button } from '@ui/button'; -import { useApp } from '@hooks/useApp'; -import { colors, COMPANY_SIZES, HOW_HEARD_ABOUT_US, PLACEHOLDERS, ROLES } from '@config'; +import { useAppState } from 'store/app.context'; import { useOnboardUserProjectForm } from '@hooks/useOnboardUserProjectForm'; +import { colors, COMPANY_SIZES, HOW_HEARD_ABOUT_US, PLACEHOLDERS, ROLES } from '@config'; export function OnboardUserForm() { - const { profile } = useApp(); + const { profileInfo } = useAppState(); const [role] = useState(ROLES); const [about, setAbout] = useState(HOW_HEARD_ABOUT_US); @@ -29,7 +29,7 @@ export function OnboardUserForm() { <Group position="left"> <span style={{ fontSize: '30px' }}>👋</span> - <span>Welcome {profile?.firstName}</span> + <span>Welcome {profileInfo?.firstName}</span> </Group> diff --git a/apps/web/config/constants.config.ts b/apps/web/config/constants.config.ts index 952025ecc..6705f532c 100644 --- a/apps/web/config/constants.config.ts +++ b/apps/web/config/constants.config.ts @@ -1,14 +1,16 @@ -import { JavaScriptIcon } from '@assets/icons/Javascript.icon'; +import { MongoAbility } from '@casl/ability'; import { ReactIcon } from '@assets/icons/React.icon'; -import { AngularIcon } from '@assets/icons/Angular.icon'; import { BubbleIcon } from '@assets/icons/Bubble.icon'; -import { IntegrationEnum } from '@impler/shared'; +import { AngularIcon } from '@assets/icons/Angular.icon'; +import { JavaScriptIcon } from '@assets/icons/Javascript.icon'; +import { UserRolesEnum, IntegrationEnum } from '@impler/shared'; export const CONSTANTS = { EXPLORE_PLANS_QUERY_LEY: 'explore_plans', PLAN_CODE_QUERY_KEY: 'plan_code', GITHUB_LOGIN_URL: '/v1/auth/github', AUTH_COOKIE_NAME: 'authentication', + INVITATION_URL_COOKIE: 'redirectUrl', AUTHENTICATION_ERROR_CODE: 'AuthenticationError', PROFILE_STORAGE_NAME: 'profile', REACT_DOCUMENTATION_URL: 'https://docs.impler.io/widget/react-component#props', @@ -51,12 +53,18 @@ export const MODAL_KEYS = { VALIDATIONS_OUTPUT: 'VALIDATIONS_OUTPUT', PAYMENT_PLANS: 'PAYMENT_PLANS', PAYMENT_DETAILS_ADD: 'PAYMENT_PLANS', + + INVITE_MEMBERS: 'INVITE_MEMBERS', + ACCEPT_INVITATION: 'ACCEPT_INVITATION', + MANAGE_PROJECT_MODAL: 'MANAGE_PROJECT_MODAL', + CONFIRM_PROJECT_DELETE: 'CONFIRM_PROJECT_DELETE', + CONFIRM_REMOVE_TEAM_MEMBER: 'CONFIRM_REMOVE_TEAM_MEMBER', }; interface IntegrateOption { + key: string; name: IntegrationEnum; Icon: React.ComponentType>; - key: string; } export const INTEGRATE_IMPORT: IntegrateOption[] = [ @@ -112,7 +120,18 @@ export const API_KEYS = { PROJECT_SWITCH: 'PROJECT_SWITCH', PROJECTS_LIST: 'PROJECT_LIST', PROJECT_CREATE: 'PROJECT_CREATE', + PROJECT_DELETE: 'PROJECT_DELETE', PROJECT_ENVIRONMENT: 'PROJECT_ENVIRONMENT', + PROJECT_INVITATION: 'PROJECT_INVITATION', + SENT_TEAM_INVITATIONS: 'SENT_TEAM_INVITATIONS', + GET_TEAM_INVITATIONS: 'GET_TEAM_INVITATIONS', + ACCEPT_TEAM_INVITATION: 'INVITATION_ACCEPTED', + DECLINE_TEAM_INVITATION: 'DECLINE_TEAM_INVITATION', + LIST_TEAM_MEMBERS: 'LIST_TEAM_MEMBERS', + UPDATE_TEAM_MEMBER_ROLE: 'UPDATE_TEAM_MEMBER_ROLE', + DELETE_TEAM_MEMBER: 'DELETE_TEAM_MEMBER', + REVOKE_INVITATION: 'REVOKE_INVITATION', + TEAM_MEMBER_META: 'TEAM_MEMBER_META', LOGOUT: 'LOGOUT', SIGNIN: 'SIGNIN', @@ -172,6 +191,8 @@ export const NOTIFICATION_KEYS = { IMPORT_DELETED: 'IMPORT_DELETED', PROJECT_CREATED: 'PROJECT_CREATED', + PROJECT_DELETED: 'PROJECT_DELETED', + PROJECT_SWITCHED: 'PROJECT_SWITCHED', OUTPUT_UPDATED: 'OUTPUT_UPDATED', DESTINATION_UPDATED: 'DESTINATION_UPDATED', @@ -186,6 +207,20 @@ export const NOTIFICATION_KEYS = { PURCHASE_FAILED: 'PURCHASE_FAILED', COLUMN_ERRROR: 'COLUMN_ERRROR', + INVITATION_ACCEPTED: 'INVITATION_ACCEPTED', + + VALID_INVITATION: 'VALID_INVITATION', + ERROR_FETCHING_INVITATION: 'ERROR_FETCHING_INVITATION', + TEAM_MEMBER_ROLE_UPDATED: 'TEAM_MEMBER_ROLE_UPDATED', + ERROR_CHANGING_MEMBER_ROLE: 'ERROR_CHANGING_MEMBER_ROLE', + TEAM_MEMBER_DELETED: 'TEAM_MEMBER_DELETED', + ERROR_DELETING_TEAM_MEMBER: 'ERROR_DELETING_TEAM_MEMBER', + ERROR_INVITING_TEAM_MEMBER: 'ERROR_INVITING_TEAM_MEMBER', + ERROR_LISTING_TEAM_MEMBERS: 'ERROR_LISTING_TEAM_MEMBERS', + INVITATION_LINK_COPIED: 'INVITATION_LINK_COPIED', + INVITATION_DELETED: 'INVITATION_DELETED', + ERROR_DELETING_INVITATION: 'INVITATION_DELETED', + PERMISSION_DENIED_WHILE_DELETING_PROJECT: 'PERMISSION_DENIED_WHILE_DELETING_PROJECT', }; export const ROUTES = { @@ -198,13 +233,16 @@ export const ROUTES = { REQUEST_FORGOT_PASSWORD: '/auth/reset/request', IMPORTS: '/imports', SETTINGS: '/settings', + TEAM_MEMBERS: '/team-members', ACTIVITIES: '/activities', ADD_CARD: '/settings?tab=addcard&action=addcardmodal', EXPLORE_PLANS: '/?explore_plans=true', + INVITATION: '/auth/invitation/:id', }; export const REGULAR_EXPRESSIONS = { URL: /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm, + EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, }; export const COLUMN_TYPES = [ @@ -269,6 +307,73 @@ export const IMPORT_MODES = [ { value: 'automatic', label: 'Automatic' }, ]; +export const INVITATION_FORM_ROLES = [UserRolesEnum.ADMIN, UserRolesEnum.FINANCE, UserRolesEnum.TECH]; + +export const TAB_KEYS = { + MEMBERS: 'members', + SENT_INVITATIONS: 'sentinvitation', + INVITATION_REQUESTS: 'invitation-requests', +}; + +export const TAB_TITLES = { + [TAB_KEYS.MEMBERS]: 'Members', + [TAB_KEYS.SENT_INVITATIONS]: 'Sent Invitations', + [TAB_KEYS.INVITATION_REQUESTS]: 'Invitation Requests', +}; + +export const MEMBER_ROLE = [UserRolesEnum.ADMIN, UserRolesEnum.TECH, UserRolesEnum.FINANCE]; + +export enum ActionsEnum { + MANAGE = 'manage', + READ = 'read', + CREATE = 'create', + UPDATE = 'update', + BUY = 'buy', +} + +export enum SubjectsEnum { + HOMEPAGE = 'Homepage', + IMPORTS = 'Imports', + ANALYTICS = 'Analytics', + SETTINGS = 'Settings', + PLAN = 'Plan', + FILE = 'File', + TEAM_MEMBERS = 'TeamMembers', + ACCESS_TOKEN = 'AccessToken', + CARDS = 'Cards', + ROLE = 'Role', + ALL = 'all', + DOCUMENTATION = 'Documentation', +} + +export const ROLE_BADGES = { + [UserRolesEnum.ADMIN]: 'cyan', + [UserRolesEnum.TECH]: 'blue', + [UserRolesEnum.FINANCE]: 'green', +}; + +export type AppAbility = MongoAbility<[ActionsEnum, SubjectsEnum]>; + +export const ROLE_BASED_ACCESS = { + [UserRolesEnum.ADMIN]: [{ action: ActionsEnum.MANAGE, subject: SubjectsEnum.ALL }], + [UserRolesEnum.TECH]: [ + { action: ActionsEnum.READ, subject: SubjectsEnum.HOMEPAGE }, + { action: ActionsEnum.CREATE, subject: SubjectsEnum.IMPORTS }, + { action: ActionsEnum.READ, subject: SubjectsEnum.IMPORTS }, + { action: ActionsEnum.READ, subject: SubjectsEnum.ANALYTICS }, + { action: ActionsEnum.READ, subject: SubjectsEnum.SETTINGS }, + { action: ActionsEnum.READ, subject: SubjectsEnum.ACCESS_TOKEN }, + { action: ActionsEnum.READ, subject: SubjectsEnum.TEAM_MEMBERS }, + { action: ActionsEnum.READ, subject: SubjectsEnum.DOCUMENTATION }, + ], + [UserRolesEnum.FINANCE]: [ + { action: ActionsEnum.READ, subject: SubjectsEnum.HOMEPAGE }, + { action: ActionsEnum.READ, subject: SubjectsEnum.SETTINGS }, + { action: ActionsEnum.BUY, subject: SubjectsEnum.PLAN }, + { action: ActionsEnum.READ, subject: SubjectsEnum.CARDS }, + ], +}; + export const DOCUMENTATION_REFERENCE_LINKS = { columnDescription: 'https://docs.impler.io/features/column-description', defaultValue: 'https://docs.impler.io/platform/default-value', @@ -281,7 +386,11 @@ export const DOCUMENTATION_REFERENCE_LINKS = { subscriptionInformation: 'https://docs.impler.io/platform/how-subscription-works', customValidation: 'https://docs.impler.io/features/custom-validation', rangeValidator: 'https://docs.impler.io/validations/advanced#range', + autoImport: 'https://docs.impler.io/features/automated-import', + imageImport: 'https://docs.impler.io/features/import-excel-with-image', + advancedValidations: 'https://docs.impler.io/validations/advanced', lengthValidator: 'https://docs.impler.io/validations/advanced#length', + outputCustomization: 'https://docs.impler.io/features/output-customization', uniqueWithValidator: 'https://docs.impler.io/validations/advanced#unique-across-multiple-fields', }; @@ -313,6 +422,7 @@ export const HOW_HEARD_ABOUT_US = [ { value: 'Bubble.io', label: 'Bubble.io' }, { value: 'Colleague', label: 'Colleague' }, { value: 'Linkdin', label: 'Linkdin' }, + { value: 'Invitation', label: 'Invitation' }, ]; export const PLACEHOLDERS = { @@ -325,3 +435,8 @@ export const PLACEHOLDERS = { source: 'Google Search, Recommendation...', about: 'Google Search', }; + +export const DATE_FORMATS = { + SHORT: 'DD/MM/YYYY', + LONG: 'DD MMM YYYY', +}; diff --git a/apps/web/config/defineAbilities.ts b/apps/web/config/defineAbilities.ts new file mode 100644 index 000000000..83f55b708 --- /dev/null +++ b/apps/web/config/defineAbilities.ts @@ -0,0 +1,14 @@ +import { AbilityBuilder, createMongoAbility } from '@casl/ability'; +import { UserRolesEnum } from '@impler/shared'; +import { AppAbility, ROLE_BASED_ACCESS } from './constants.config'; + +export const defineAbilitiesFor = (role?: string): AppAbility => { + const { can, build } = new AbilityBuilder(createMongoAbility); + const roleBasedAccess = ROLE_BASED_ACCESS[role as UserRolesEnum] || []; + + roleBasedAccess.forEach(({ action, subject }) => { + can(action, subject); + }); + + return build(); +}; diff --git a/apps/web/config/theme.config.ts b/apps/web/config/theme.config.ts index d503d9153..56400f670 100644 --- a/apps/web/config/theme.config.ts +++ b/apps/web/config/theme.config.ts @@ -11,7 +11,10 @@ export const colors = { greenDark: '#008489', yellow: '#F7B801', grey: '#B9BEBD', + red: '#880808', darkGrey: '#454545', + darkBlue: '#5263FA', + lightGrey: '#333333', BGPrimaryDark: '#111111', BGPrimaryLight: '#F3F3F3', diff --git a/apps/web/design-system/Alert/Alert.styles.tsx b/apps/web/design-system/Alert/Alert.styles.tsx index eeb477a01..fc8dcb580 100644 --- a/apps/web/design-system/Alert/Alert.styles.tsx +++ b/apps/web/design-system/Alert/Alert.styles.tsx @@ -1,11 +1,12 @@ -import { createStyles } from '@mantine/core'; +import { createStyles, MantineTheme } from '@mantine/core'; -const getWrapperStyles = (): React.CSSProperties => ({ - alignItems: 'center', -}); - -export default createStyles((): Record => { +export default createStyles((theme: MantineTheme): Record => { return { - wrapper: getWrapperStyles(), + wrapper: { + alignItems: 'center', + }, + icon: { + marginRight: theme.spacing.xs, + }, }; }); diff --git a/apps/web/design-system/OutlinedTabs/OutlineTabs.stories.tsx b/apps/web/design-system/OutlinedTabs/OutlineTabs.stories.tsx new file mode 100644 index 000000000..c3e62867f --- /dev/null +++ b/apps/web/design-system/OutlinedTabs/OutlineTabs.stories.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Meta, Story } from '@storybook/react'; +import { OutlinedTabs } from './OutlinedTabs'; + +export default { + title: 'OutlinedTabs', + component: OutlinedTabs, + argTypes: { + onTabChange: { action: 'tab changed' }, + }, +} as Meta; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + items: [ + { + value: 'tab1', + title: 'Tab 1', + content:

Content for Tab 1
, + }, + { + value: 'tab2', + title: 'Tab 2', + content:
Content for Tab 2
, + }, + { + value: 'tab3', + title: 'Tab 3', + content:
Content for Tab 3
, + }, + ], + defaultValue: 'tab1', +}; diff --git a/apps/web/design-system/OutlinedTabs/OutlinedTabs.style.tsx b/apps/web/design-system/OutlinedTabs/OutlinedTabs.style.tsx new file mode 100644 index 000000000..411d08557 --- /dev/null +++ b/apps/web/design-system/OutlinedTabs/OutlinedTabs.style.tsx @@ -0,0 +1,18 @@ +import { colors } from '@config'; +import { createStyles } from '@mantine/core'; + +const getOutlinedTabStyles = () => ({ + padding: '10px 20px', + borderBottom: '2px solid transparent', + transition: 'color 0.3s ease, border-bottom 0.3s ease', + '&[aria-selected="true"]': { + color: colors.blue, + borderBottom: `2px solid ${colors.blue}`, + }, +}); + +export default createStyles(() => { + return { + tab: getOutlinedTabStyles(), + }; +}); diff --git a/apps/web/design-system/OutlinedTabs/OutlinedTabs.tsx b/apps/web/design-system/OutlinedTabs/OutlinedTabs.tsx new file mode 100644 index 000000000..cd4890fa6 --- /dev/null +++ b/apps/web/design-system/OutlinedTabs/OutlinedTabs.tsx @@ -0,0 +1,66 @@ +import { Tabs as MantineTabs, Flex, Badge } from '@mantine/core'; +import useStyles from './OutlinedTabs.style'; + +interface OutlinedTabItem { + id?: string; + value: string; + title: string; + badgeCount?: number; // Optional badge count + icon?: React.ReactNode; + content: React.ReactNode; +} + +interface OutlinedTabsProps { + items: OutlinedTabItem[]; + value?: string; + keepMounted?: boolean; + defaultValue?: string; + allowTabDeactivation?: boolean; + onTabChange?: (value: string) => void; + inviteButton?: React.ReactNode; // Optional invite button +} + +export function OutlinedTabs({ + items, + value, + onTabChange, + keepMounted, + allowTabDeactivation, + defaultValue, + inviteButton, +}: OutlinedTabsProps) { + const { classes } = useStyles(); + + return ( + + + + {items.map((item) => ( + + {item.title} + {item.badgeCount !== undefined && ( + + {item.badgeCount} + + )} + + ))} + + {inviteButton} + + + {items.map((item) => ( + + {item.content} + + ))} + + ); +} diff --git a/apps/web/design-system/OutlinedTabs/index.tsx b/apps/web/design-system/OutlinedTabs/index.tsx new file mode 100644 index 000000000..aea566eac --- /dev/null +++ b/apps/web/design-system/OutlinedTabs/index.tsx @@ -0,0 +1 @@ +export * from './OutlinedTabs'; diff --git a/apps/web/design-system/button/Button.styles.ts b/apps/web/design-system/button/Button.styles.ts index 6ec466c52..61e6e8a44 100644 --- a/apps/web/design-system/button/Button.styles.ts +++ b/apps/web/design-system/button/Button.styles.ts @@ -9,6 +9,7 @@ const colorsCodes: Record = { green: colors.green, invariant: colors.black, yellow: colors.yellow, + grey: colors.StrokeLight, }; const getRootFilledStyles = (theme: MantineTheme, color: ButtonColors = 'blue', fullWidth?: boolean) => ({ @@ -101,6 +102,14 @@ const getRootOutlineStyles = ( color: colors.white, border: `1px solid ${colors.yellow}`, }), + ...(color === 'grey' && { + backgroundColor: colors.lightGrey, + color: colors.white, + border: `1px solid ${colors.lightGrey}`, + '> svg': { + color: theme.colorScheme === 'dark' ? colors.black : colors.white, + }, + }), color: theme.colorScheme === 'dark' && color === 'invariant' ? colors.black : colors.white, }, }); diff --git a/apps/web/design-system/button/Button.tsx b/apps/web/design-system/button/Button.tsx index 3fe4a8f21..e3e3585ac 100644 --- a/apps/web/design-system/button/Button.tsx +++ b/apps/web/design-system/button/Button.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react'; import useStyles from './Button.styles'; import { Button as MantineButton, MantineSize, ButtonProps as MantineButtonProps } from '@mantine/core'; -export type ButtonColors = 'blue' | 'invariant' | 'red' | 'green' | 'yellow'; +export type ButtonColors = 'blue' | 'invariant' | 'red' | 'green' | 'yellow' | 'grey'; export type ButtonVariants = 'filled' | 'outline'; interface ButtonProps extends MantineButtonProps { diff --git a/apps/web/design-system/icon-button/IconButton.tsx b/apps/web/design-system/icon-button/IconButton.tsx index abc1eb800..473e1d08e 100644 --- a/apps/web/design-system/icon-button/IconButton.tsx +++ b/apps/web/design-system/icon-button/IconButton.tsx @@ -3,19 +3,21 @@ import { Tooltip, UnstyledButton, UnstyledButtonProps } from '@mantine/core'; interface IconButtonProps extends UnstyledButtonProps { label: string; + disabled?: boolean; withArrow?: boolean; onClick?: (e: MouseEvent) => void; } export function IconButton({ label, onClick, + disabled = false, withArrow = true, children, ...buttonProps }: PropsWithChildren) { return ( - + {children} diff --git a/apps/web/hooks/auth/useLogout.tsx b/apps/web/hooks/auth/useLogout.tsx new file mode 100644 index 000000000..18f5fc09a --- /dev/null +++ b/apps/web/hooks/auth/useLogout.tsx @@ -0,0 +1,22 @@ +import { useMutation } from '@tanstack/react-query'; + +import { commonApi } from '@libs/api'; +import { track } from '@libs/amplitude'; +import { API_KEYS } from '@config'; + +interface UseLogoutProps { + onLogout: () => void; +} + +export function useLogout({ onLogout }: UseLogoutProps) { + const { mutate: logout } = useMutation(() => commonApi(API_KEYS.LOGOUT as any, {}), { + onSuccess: () => { + track({ name: 'LOGOUT', properties: {} }); + onLogout(); + }, + }); + + return { + logout, + }; +} diff --git a/apps/web/hooks/auth/useSignin.tsx b/apps/web/hooks/auth/useSignin.tsx index 07040e75d..9784748c5 100644 --- a/apps/web/hooks/auth/useSignin.tsx +++ b/apps/web/hooks/auth/useSignin.tsx @@ -12,9 +12,10 @@ import { IErrorObject, ILoginResponse, SCREENS } from '@impler/shared'; import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; export function useSignin() { - const { push } = useRouter(); + const { push, query } = useRouter(); const { setProfileInfo } = useAppState(); const { register, handleSubmit } = useForm(); + const invitationId = query.invitationId as string | undefined; const [errorMessage, setErrorMessage] = useState(undefined); const { mutate: login, isLoading: isLoginLoading } = useMutation< @@ -34,7 +35,7 @@ export function useSignin() { }, }); setProfileInfo(profileData); - handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push, invitationId ? [invitationId] : []); }, onError(error) { setErrorMessage(error); @@ -42,7 +43,7 @@ export function useSignin() { }); const onLogin = (data: ISigninData) => { - login(data); + login({ ...data, invitationId }); }; return { diff --git a/apps/web/hooks/auth/useSignup.tsx b/apps/web/hooks/auth/useSignup.tsx index 99edb230c..af4db2744 100644 --- a/apps/web/hooks/auth/useSignup.tsx +++ b/apps/web/hooks/auth/useSignup.tsx @@ -2,9 +2,10 @@ import jwt from 'jwt-decode'; import { useState } from 'react'; import { useRouter } from 'next/router'; import { useForm } from 'react-hook-form'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; -import { API_KEYS } from '@config'; +import { notify } from '@libs/notify'; +import { API_KEYS, NOTIFICATION_KEYS, ROUTES } from '@config'; import { commonApi } from '@libs/api'; import { track } from '@libs/amplitude'; import { useAppState } from 'store/app.context'; @@ -17,16 +18,50 @@ interface ISignupFormData { password: string; } +interface ISignupData { + firstName: string; + lastName: string; + email: string; + password: string; + invitationId?: string; +} + export function useSignup() { const { setProfileInfo } = useAppState(); - const { push } = useRouter(); + const { push, query } = useRouter(); const { + setValue, setError, register, handleSubmit, formState: { errors }, } = useForm({}); const [errorMessage, setErrorMessage] = useState(undefined); + const [isInvitationLink, setIsInvitationLink] = useState(); + const invitationId = query.invitationId as string | undefined; + + const { isLoading: isAcceptingInvitation, isError } = useQuery( + [API_KEYS.GET_TEAM_INVITATIONS, invitationId], + () => + commonApi(API_KEYS.GET_TEAM_INVITATIONS as any, { + parameters: [invitationId!], + }), + { + enabled: !!invitationId, + onSuccess: (data) => { + setValue('email', data.email); + setIsInvitationLink(true); + }, + onError: (error) => { + notify(NOTIFICATION_KEYS.ERROR_FETCHING_INVITATION, { + title: 'Problem with Invitation Link', + message: error?.message || 'Error while accepting invitation', + color: 'red', + }); + push(ROUTES.SIGNUP, {}, { shallow: true }); + }, + } + ); const { mutate: signup, isLoading: isSignupLoading } = useMutation< ILoginResponse, @@ -47,7 +82,8 @@ export function useSignup() { id: profileData._id, }, }); - handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); + + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push, invitationId ? [invitationId] : []); }, onError(error) { if (error.error === 'EmailAlreadyExists') { @@ -68,15 +104,20 @@ export function useSignup() { lastName: data.fullName.split(' ')[1], email: data.email, password: data.password, + invitationId, }; signup(signupData); }; return { errors, + isError, register, + invitationId, errorMessage, isSignupLoading, + isInvitationLink, + isAcceptingInvitation, signup: handleSubmit(onSignup), }; } diff --git a/apps/web/hooks/auth/useVerify.tsx b/apps/web/hooks/auth/useVerify.tsx index 99e3f8589..a491f6270 100644 --- a/apps/web/hooks/auth/useVerify.tsx +++ b/apps/web/hooks/auth/useVerify.tsx @@ -5,11 +5,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { commonApi } from '@libs/api'; import { notify } from '@libs/notify'; -import { useApp } from '@hooks/useApp'; import { track } from '@libs/amplitude'; import { API_KEYS, NOTIFICATION_KEYS } from '@config'; import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; import { IErrorObject, IScreenResponse, SCREENS } from '@impler/shared'; +import { useAppState } from 'store/app.context'; interface IVerifyFormData { otp: string; @@ -29,7 +29,7 @@ const RESEND_SECONDS = 120; export function useVerify() { const { push } = useRouter(); - const { profile } = useApp(); + const { profileInfo } = useAppState(); const timerRef = useRef(); const { reset, @@ -79,7 +79,7 @@ export function useVerify() { title: 'Verification code sent!', message: ( <> - Verification code sent successully to {profile?.email} + Verification code sent successully to {profileInfo?.email} ), }); @@ -151,7 +151,7 @@ export function useVerify() { state, verify, errors, - profile, + profileInfo, register, setState, resendOTP, diff --git a/apps/web/hooks/useAcceptInvitation.tsx b/apps/web/hooks/useAcceptInvitation.tsx new file mode 100644 index 000000000..73379a4f5 --- /dev/null +++ b/apps/web/hooks/useAcceptInvitation.tsx @@ -0,0 +1,45 @@ +import { modals } from '@mantine/modals'; +import { useMutation } from '@tanstack/react-query'; + +import { notify } from '@libs/notify'; +import { commonApi } from '@libs/api'; +import { IErrorObject } from '@impler/shared'; +import { API_KEYS, MODAL_KEYS, NOTIFICATION_KEYS } from '@config'; + +interface IUseAcceptInvitationProps { + invitationId: string; + token: string; +} + +export function useAcceptInvitation({ invitationId, token }: IUseAcceptInvitationProps) { + const { mutate: onAcceptClick, isLoading: isAcceptLoading } = useMutation( + [API_KEYS.ACCEPT_TEAM_INVITATION], + () => + commonApi(API_KEYS.ACCEPT_TEAM_INVITATION as any, { + query: { invitationId, token }, + }), + { + onSuccess: (data) => { + notify(NOTIFICATION_KEYS.INVITATION_ACCEPTED, { + title: 'Invitation Accepted', + message: `You have successfully joined the Project ${data.projectName}`, + color: 'green', + }); + modals.closeAll(); + }, + onError: () => { + modals.close(MODAL_KEYS.ACCEPT_INVITATION); + notify('AN ERROR OCCURED', { + title: 'Error', + message: 'An Error occured while accepting invitation', + color: 'red', + }); + }, + } + ); + + return { + onAcceptClick, + isAcceptLoading, + }; +} diff --git a/apps/web/hooks/useApp.tsx b/apps/web/hooks/useApp.tsx deleted file mode 100644 index ad74b1a9d..000000000 --- a/apps/web/hooks/useApp.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useRouter } from 'next/router'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { commonApi } from '@libs/api'; -import { notify } from '@libs/notify'; -import { track } from '@libs/amplitude'; -import { useAppState } from 'store/app.context'; -import { API_KEYS, NOTIFICATION_KEYS, ROUTES } from '@config'; -import { IErrorObject, IProjectPayload, IEnvironmentData } from '@impler/shared'; - -export function useApp() { - const { replace, pathname } = useRouter(); - const queryClient = useQueryClient(); - const { profileInfo, setProfileInfo } = useAppState(); - const { isFetching: isProfileLoading } = useQuery( - [API_KEYS.ME], - () => commonApi(API_KEYS.ME as any, {}), - { - onSuccess(profileData) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.usetifulTags = { userId: profileData?._id }; - setProfileInfo(profileData); - }, - } - ); - const { data: projects, isLoading: isProjectsLoading } = useQuery( - [API_KEYS.PROJECTS_LIST], - () => commonApi(API_KEYS.PROJECTS_LIST as any, {}) - ); - const { mutate: logout } = useMutation([API_KEYS.LOGOUT], () => commonApi(API_KEYS.LOGOUT as any, {}), { - onSuccess: () => { - track({ - name: 'LOGOUT', - properties: {}, - }); - replace(ROUTES.SIGNIN); - }, - }); - const { mutate: switchProject } = useMutation( - [API_KEYS.PROJECT_SWITCH], - (projectId) => commonApi(API_KEYS.PROJECT_SWITCH as any, { parameters: [projectId] }) - ); - const { mutate: createProject, isLoading: isCreateProjectLoading } = useMutation< - { project: IProjectPayload; environment: IEnvironmentData }, - IErrorObject, - ICreateProjectData, - string[] - >([API_KEYS.PROJECT_CREATE], (data) => commonApi(API_KEYS.PROJECT_CREATE as any, { body: data }), { - onSuccess: ({ project, environment }) => { - if (project) { - queryClient.setQueryData([API_KEYS.PROJECTS_LIST], () => { - return [...(projects || []), project]; - }); - track({ - name: 'PROJECT CREATE', - properties: { - duringOnboard: false, - }, - }); - if (profileInfo) { - setProfileInfo({ - ...profileInfo, - _projectId: project._id, - accessToken: environment.apiKeys[0].key, - }); - } - if (![ROUTES.SETTINGS, ROUTES.ACTIVITIES, ROUTES.IMPORTS].includes(pathname)) { - replace(ROUTES.IMPORTS); - } - notify(NOTIFICATION_KEYS.PROJECT_CREATED, { - title: 'Project created', - message: `Project ${project.name} created successfully`, - }); - } - }, - }); - const onProjectIdChange = async (id: string) => { - const project = projects?.find((projectItem) => projectItem._id === id); - if (project && profileInfo) { - setProfileInfo({ - ...profileInfo, - _projectId: id, - }); - switchProject(id); - if (![ROUTES.SETTINGS, ROUTES.ACTIVITIES, ROUTES.IMPORTS].includes(pathname)) { - replace(ROUTES.IMPORTS); - } - track({ - name: 'PROJECT SWITCH', - properties: {}, - }); - } - }; - - return { - logout, - projects, - createProject, - isProfileLoading, - profile: profileInfo, - setProjectId: onProjectIdChange, - isProjectsLoading: isProjectsLoading || isCreateProjectLoading, - }; -} diff --git a/apps/web/hooks/useColumnsEditor.tsx b/apps/web/hooks/useColumnsEditor.tsx index 59040a26a..730d39300 100644 --- a/apps/web/hooks/useColumnsEditor.tsx +++ b/apps/web/hooks/useColumnsEditor.tsx @@ -82,7 +82,7 @@ export function useColumnsEditor({ templateId }: UseSchemaProps) { return setError('columns', { type: 'JSON', message: 'Provided JSON is invalid!' }); } - if (!meta?.IMAGE_UPLOAD) { + if (!meta?.IMAGE_IMPORT) { const imageColumnExists = parsedColumns.some((column: IColumn) => column.type === 'Image'); if (imageColumnExists) { diff --git a/apps/web/hooks/useInvitation.tsx b/apps/web/hooks/useInvitation.tsx new file mode 100644 index 000000000..cf3b72100 --- /dev/null +++ b/apps/web/hooks/useInvitation.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useQuery, useMutation } from '@tanstack/react-query'; + +import { commonApi } from '@libs/api'; +import { notify } from '@libs/notify'; +import { useLogout } from '@hooks/auth/useLogout'; +import { API_KEYS, NOTIFICATION_KEYS, ROUTES } from '@config'; +import { IErrorObject, SCREENS } from '@impler/shared'; +import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; +import { useAppState } from 'store/app.context'; + +export enum ModesEnum { + ACTIVE = 'ACTIVE', + ACCEPT = 'ACCEPT', +} + +export function useInvitation() { + const { profileInfo } = useAppState(); + const { replace, query, push } = useRouter(); + const { logout } = useLogout({ + onLogout: () => replace(ROUTES.SIGNIN), + }); + const [mode, setMode] = useState(ModesEnum.ACTIVE); + const invitationId = query.id as string; + + const { + data: invitationData, + isError: isInvitationError, + isLoading: isInvitationLoading, + } = useQuery( + [API_KEYS.GET_TEAM_INVITATIONS, invitationId], + () => + commonApi(API_KEYS.GET_TEAM_INVITATIONS as any, { + parameters: [invitationId], + }), + { + enabled: !!invitationId, + onError: () => { + setMode(ModesEnum.ACTIVE); + }, + } + ); + + const { mutate: acceptInvitation, isLoading: isAcceptInvitationLoading } = useMutation< + { screen: SCREENS }, + IErrorObject, + void, + [string] + >( + [API_KEYS.ACCEPT_TEAM_INVITATION], + () => + commonApi(API_KEYS.ACCEPT_TEAM_INVITATION as any, { + query: { invitationId }, + }), + { + onSuccess: (data) => { + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); + }, + onError: () => { + notify(NOTIFICATION_KEYS.ERROR_FETCHING_INVITATION, { + title: 'Error', + message: 'An error occurred while accepting the invitation', + color: 'red', + }); + }, + } + ); + + const { mutate: declineInvitation, isLoading: isDeclineInvitationLoading } = useMutation< + { screen: SCREENS }, + IErrorObject, + void, + [string] + >( + [API_KEYS.DECLINE_TEAM_INVITATION], + () => + commonApi(API_KEYS.DECLINE_TEAM_INVITATION as any, { + parameters: [invitationId], + }), + { + onSuccess: (data) => { + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); + }, + onError: () => { + notify(NOTIFICATION_KEYS.ERROR_FETCHING_INVITATION, { + title: 'Error', + message: 'An error occurred while declining the invitation', + color: 'red', + }); + }, + } + ); + + useEffect(() => { + if (profileInfo && invitationData && invitationData.email === profileInfo?.email) { + setMode(ModesEnum.ACCEPT); + } + }, [profileInfo, invitationData]); + + const isLoggedInUser = !!profileInfo; + const isInvitationValid = !!invitationData; + + return { + mode, + logout, + invitationId, + isInvitationLoading, + isInvitationError, + isInvitationValid, + invitationData, + isLoggedInUser, + acceptInvitation, + isAcceptInvitationLoading, + declineInvitation, + isDeclineInvitationLoading, + }; +} diff --git a/apps/web/hooks/useOnboardUserProjectForm.tsx b/apps/web/hooks/useOnboardUserProjectForm.tsx index 190ecd5a2..0ed26cccd 100644 --- a/apps/web/hooks/useOnboardUserProjectForm.tsx +++ b/apps/web/hooks/useOnboardUserProjectForm.tsx @@ -1,10 +1,10 @@ import { useRouter } from 'next/router'; import { useMutation } from '@tanstack/react-query'; - -import { API_KEYS } from '@config'; import { commonApi } from '@libs/api'; import { track } from '@libs/amplitude'; +import { getCookie } from '@shared/utils'; import { useAppState } from 'store/app.context'; +import { API_KEYS, CONSTANTS, ROUTES } from '@config'; import { IErrorObject, IEnvironmentData } from '@impler/shared'; export function useOnboardUserProjectForm() { @@ -41,10 +41,18 @@ export function useOnboardUserProjectForm() { source: onboardData.source, }, }); - push('/'); + const redirectUrl = getCookie(CONSTANTS.INVITATION_URL_COOKIE); + if (redirectUrl) { + push(ROUTES.TEAM_MEMBERS); + } else { + push(ROUTES.HOME); + } }, } ); - return { onboardUser, isUserOnboardLoading }; + return { + onboardUser, + isUserOnboardLoading, + }; } diff --git a/apps/web/hooks/usePlanDetails.tsx b/apps/web/hooks/usePlanDetails.tsx index a9e031308..6dc29ecb8 100644 --- a/apps/web/hooks/usePlanDetails.tsx +++ b/apps/web/hooks/usePlanDetails.tsx @@ -5,32 +5,30 @@ import { IErrorObject } from '@impler/shared'; import { usePlanMetaData } from 'store/planmeta.store.context'; interface UsePlanDetailProps { - email: string; + projectId?: string; } -export function usePlanDetails({ email }: UsePlanDetailProps) { +export function usePlanDetails({ projectId }: UsePlanDetailProps) { const { meta, setPlanMeta } = usePlanMetaData(); - const { data: activePlanDetails, isLoading: isActivePlanLoading } = useQuery< - unknown, - IErrorObject, - ISubscriptionData, - [string, string] - >( - [API_KEYS.FETCH_ACTIVE_SUBSCRIPTION, email], - () => commonApi(API_KEYS.FETCH_ACTIVE_SUBSCRIPTION as any, {}), + const { + data: activePlanDetails, + isLoading: isActivePlanLoading, + refetch: refetchActivePlanDetails, + } = useQuery( + [API_KEYS.FETCH_ACTIVE_SUBSCRIPTION, projectId], + () => + commonApi(API_KEYS.FETCH_ACTIVE_SUBSCRIPTION as any, { + parameters: [projectId!], + }), { onSuccess(data) { if (data && data.meta) { setPlanMeta({ - AUTOMATIC_IMPORTS: data.meta.AUTOMATIC_IMPORTS, - IMAGE_UPLOAD: data.meta.IMAGE_UPLOAD, - IMPORTED_ROWS: data.meta.IMPORTED_ROWS, - REMOVE_BRANDING: data.meta.REMOVE_BRANDING, - ADVANCED_VALIDATORS: data.meta.ADVANCED_VALIDATORS, + ...data.meta, }); } }, - enabled: !!email, + enabled: !!projectId, } ); @@ -38,5 +36,6 @@ export function usePlanDetails({ email }: UsePlanDetailProps) { meta, activePlanDetails, isActivePlanLoading, + refetchActivePlanDetails, }; } diff --git a/apps/web/hooks/useProject.tsx b/apps/web/hooks/useProject.tsx new file mode 100644 index 000000000..276e002ff --- /dev/null +++ b/apps/web/hooks/useProject.tsx @@ -0,0 +1,198 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { modals } from '@mantine/modals'; +import { useForm } from 'react-hook-form'; +import { Flex, Title } from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { commonApi } from '@libs/api'; +import { notify } from '@libs/notify'; +import { track } from '@libs/amplitude'; +import { useAppState } from 'store/app.context'; +import { defineAbilitiesFor } from 'config/defineAbilities'; +import { API_KEYS, MODAL_KEYS, NOTIFICATION_KEYS, ROUTES } from '@config'; +import { IEnvironmentData, IErrorObject, IProjectPayload } from '@impler/shared'; +import { ConfirmDeleteProjectModal, ManageProjectModal } from '@components/ManageProject'; + +export function useProject() { + const queryClient = useQueryClient(); + const { replace } = useRouter(); + + const { setAbility, setProfileInfo, profileInfo } = useAppState(); + + const { + data: profileData, + isFetching: isProfileLoading, + refetch: refetchMeData, + } = useQuery([API_KEYS.ME], () => commonApi(API_KEYS.ME as any, {}), { + onSuccess(data) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.usetifulTags = { userId: data?._id }; + }, + }); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + setError, + } = useForm({ + defaultValues: { + name: '', + }, + }); + + const { data: projects, isLoading: isProjectsLoading } = useQuery( + [API_KEYS.PROJECTS_LIST], + () => commonApi(API_KEYS.PROJECTS_LIST as any, {}) + ); + const { mutate: switchProject } = useMutation( + [API_KEYS.PROJECT_SWITCH], + (projectId) => commonApi(API_KEYS.PROJECT_SWITCH as any, { parameters: [projectId] }), + { + onSuccess() { + refetchMeData(); + queryClient.invalidateQueries([API_KEYS.LIST_TEAM_MEMBERS]); + modals.close(MODAL_KEYS.MANAGE_PROJECT_MODAL); + }, + } + ); + + const { mutate: createProject, isLoading: isCreateProjectLoading } = useMutation< + { project: IProjectPayload; environment: IEnvironmentData }, + IErrorObject, + ICreateProjectData + >([API_KEYS.PROJECT_CREATE], (data) => commonApi(API_KEYS.PROJECT_CREATE as any, { body: data }), { + onSuccess: ({ project }) => { + reset(); + queryClient.setQueryData([API_KEYS.PROJECTS_LIST], () => [...(projects || []), project]); + track({ name: 'PROJECT CREATE', properties: { duringOnboard: false } }); + replace(ROUTES.HOME); + refetchMeData(); + modals.close(MODAL_KEYS.MANAGE_PROJECT_MODAL); + notify(NOTIFICATION_KEYS.PROJECT_CREATED, { + title: 'Project created', + message: `Project ${project.name} created successfully`, + }); + }, + }); + + const { mutate: deleteProject } = useMutation( + [API_KEYS.PROJECT_DELETE], + (projectId) => commonApi(API_KEYS.PROJECT_DELETE as any, { parameters: [projectId] }), + { + onSuccess: (_, deletedProjectId) => { + queryClient.setQueryData( + [API_KEYS.PROJECTS_LIST], + (oldProjects) => oldProjects?.filter((project) => project._id !== deletedProjectId) + ); + notify(NOTIFICATION_KEYS.PROJECT_DELETED, { + title: 'Project deleted', + message: 'Project deleted successfully', + }); + if (profileInfo?._projectId === deletedProjectId) { + const remainingProjects = projects?.filter((project) => project._id !== deletedProjectId); + if (remainingProjects?.length) { + switchProject(remainingProjects[0]._id); + } else { + replace(ROUTES.HOME); + } + } + modals.close(MODAL_KEYS.CONFIRM_PROJECT_DELETE); + refetchMeData(); + }, + } + ); + + const onProjectIdChange = (id: string) => { + const project = projects?.find((item) => item._id === id); + if (project) { + switchProject(id); + track({ name: 'PROJECT SWITCH', properties: {} }); + notify(NOTIFICATION_KEYS.PROJECT_SWITCHED, { + title: 'Project switched', + message: ( + <> + You're switched to {project.name} project. + + ), + color: 'green', + }); + } + }; + + const sortedProjects = projects ? projects.sort((a) => (a._id === profileData?._projectId ? -1 : 1)) : []; + + useEffect(() => { + if (profileData && projects) { + const project = projects?.length ? projects?.find((item) => profileData._projectId === item._id) : undefined; + setProfileInfo({ + ...profileData, + projectName: project?.name || '', + }); + setAbility(defineAbilitiesFor(profileData.role)); + } + }, [profileData, projects, setAbility, setProfileInfo]); + + function onEditImportClick() { + modals.open({ + modalId: MODAL_KEYS.MANAGE_PROJECT_MODAL, + centered: true, + size: 'calc(60vw - 3rem)', + children: , + withCloseButton: true, + title: ( + <> + + Manage Project + + + ), + }); + } + + const handleDeleteProject = (projectId: string) => { + const projectToDelete = projects?.find((project) => project._id === projectId); + if (projectToDelete && projectToDelete.isOwner) { + modals.open({ + title: 'Confirm Project Deletion', + children: ( + { + deleteProject(projectId); + modals.closeAll(); + }} + onCancel={() => modals.closeAll()} + /> + ), + }); + } + }; + + const onSubmit = (data: ICreateProjectData) => { + if (data.name.trim()) { + createProject({ name: data.name.trim() }); + } else { + setError('name', { type: 'manual', message: 'Project name is required' }); + } + }; + + return { + register, + handleSubmit, + errors, + onSubmit, + isProjectsLoading, + isCreateProjectLoading, + projects: sortedProjects, + currentProjectId: profileData?._projectId, + isProfileLoading, + deleteProject, + onProjectIdChange, + onEditImportClick, + handleDeleteProject, + }; +} diff --git a/apps/web/hooks/useProjectInvitationForm.tsx b/apps/web/hooks/useProjectInvitationForm.tsx new file mode 100644 index 000000000..ac26d1540 --- /dev/null +++ b/apps/web/hooks/useProjectInvitationForm.tsx @@ -0,0 +1,108 @@ +import { MODAL_KEYS } from '@config'; +import { modals } from '@mantine/modals'; +import { validateEmails } from '@shared/utils'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { commonApi } from '@libs/api'; +import { API_KEYS } from '@config'; +import { IErrorObject } from '@impler/shared'; +import { useAppState } from 'store/app.context'; + +interface ProjectInvitationData { + invitationEmailsTo: string[]; + role: string; + projectName?: string; + projectId?: string; +} + +interface UseProjectInvitationFormProps { + refetchInvitations: () => void; +} + +export function useProjectInvitationForm({ refetchInvitations }: UseProjectInvitationFormProps) { + const { profileInfo } = useAppState(); + + const { + control, + handleSubmit, + formState: { errors }, + setError, + clearErrors, + } = useForm(); + + const { mutate: projectInvitation, isLoading: isProjectInvitationLoading } = useMutation< + { projectInvitationData: ProjectInvitationData }, + IErrorObject, + ProjectInvitationData + >( + [API_KEYS.PROJECT_INVITATION], + (apiData) => + commonApi(API_KEYS.PROJECT_INVITATION as any, { + body: { + ...apiData, + }, + }), + { + onSuccess: () => { + modals.close(MODAL_KEYS.INVITE_MEMBERS); + refetchInvitations(); + }, + onError: (error: IErrorObject) => { + setError('invitationEmailsTo', { + type: 'manual', + message: error.message, + }); + }, + } + ); + + const onSubmit = (data: ProjectInvitationData) => { + const emailValidationResult = validateEmails(data.invitationEmailsTo); + + if (!emailValidationResult) { + setError('invitationEmailsTo', { type: 'manual', message: emailValidationResult }); + + return; + } + + clearErrors('invitationEmailsTo'); + + projectInvitation({ + invitationEmailsTo: data.invitationEmailsTo, + role: data.role, + projectName: profileInfo?.projectName, + projectId: profileInfo?._projectId, + }); + }; + + const { + data: teamTeamMemberMeta, + refetch: refetchTeamMemberMeta, + isLoading: isTeamMemberMetaLoading, + } = useQuery( + [API_KEYS.TEAM_MEMBER_META, profileInfo?._projectId], + () => + commonApi(API_KEYS.TEAM_MEMBER_META as any, { + parameters: [profileInfo!._projectId], + }), + { + enabled: !!profileInfo?._projectId, + onSuccess: (data) => { + console.log('API DATA', data); + }, + } + ); + + return { + control, + handleSubmit, + errors, + onSubmit, + setError, + clearErrors, + isProjectInvitationLoading, + isTeamMemberMetaLoading, + teamTeamMemberMeta, + refetchTeamMemberMeta, + }; +} diff --git a/apps/web/hooks/useSentProjectInvitations.tsx b/apps/web/hooks/useSentProjectInvitations.tsx new file mode 100644 index 000000000..aea4649ef --- /dev/null +++ b/apps/web/hooks/useSentProjectInvitations.tsx @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { commonApi } from '@libs/api'; +import { API_KEYS, NOTIFICATION_KEYS } from '@config'; +import { IErrorObject } from '@impler/shared'; +import { useClipboard } from '@mantine/hooks'; +import { notify } from '@libs/notify'; +import { ConfirmDeleteInvitation } from '@components/TeamMembers/ConfirmDeleteInvitation'; +import { modals } from '@mantine/modals'; + +export function useSentProjectInvitations() { + const queryClient = useQueryClient(); + const clipboard = useClipboard({ timeout: 1000 }); + + const { + data: invitations, + refetch: refetchInvitations, + isLoading: isInvitationsLoading, + } = useQuery([API_KEYS.SENT_TEAM_INVITATIONS], () => + commonApi(API_KEYS.SENT_TEAM_INVITATIONS as any, {}) + ); + const { mutate: cancelInvitation, isLoading: isCancelInvitationLoading } = useMutation( + (invitationId) => + commonApi(API_KEYS.REVOKE_INVITATION as any, { + parameters: [invitationId], + }), + { + onSuccess: () => { + queryClient.invalidateQueries([API_KEYS.SENT_TEAM_INVITATIONS]); + notify(NOTIFICATION_KEYS.INVITATION_DELETED, { + title: 'Invitation Cancelled', + message: `Invitation Cancelled Successfully`, + color: 'green', + }); + }, + onError: (error: IErrorObject) => { + notify(NOTIFICATION_KEYS.ERROR_DELETING_INVITATION, { + title: 'Error while Deleting Invitation', + message: error.message || 'An Error Occured While Deleting Invitation', + color: 'red', + }); + }, + } + ); + + function handleCopyInvitationLink(invitationId: string) { + const sentInvitations = invitations?.find((invitation) => invitation._id === invitationId); + if (sentInvitations?._id === invitationId) { + clipboard.copy(sentInvitations.invitationLink); + } + notify(NOTIFICATION_KEYS.INVITATION_LINK_COPIED, { + title: 'Invitation Link Copied!', + message: 'Invitation Link Copied', + color: 'green', + }); + } + + function handleCancelInvitation(invitationId: string) { + const sentInvitation = invitations?.find((invitation) => invitation._id === invitationId); + + if (sentInvitation) { + modals.open({ + title: 'Confirm Invitation Cancellation', + children: ( + { + cancelInvitation(invitationId); + modals.closeAll(); + }} + onCancel={() => modals.closeAll()} + /> + ), + }); + } + } + + return { + invitations, + refetchInvitations, + isInvitationsLoading, + invitationsCount: invitations?.length || 0, + handleCopyInvitationLink, + handleCancelInvitation, + cancelInvitation, + isCancelInvitationLoading, + }; +} diff --git a/apps/web/hooks/useSubscribe.tsx b/apps/web/hooks/useSubscribe.tsx index e70e2d73d..ec4de4661 100644 --- a/apps/web/hooks/useSubscribe.tsx +++ b/apps/web/hooks/useSubscribe.tsx @@ -1,13 +1,15 @@ import { useState } from 'react'; import getConfig from 'next/config'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/router'; +import { modals } from '@mantine/modals'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + import { notify } from '@libs/notify'; import { commonApi } from '@libs/api'; -import { API_KEYS, CONSTANTS, NOTIFICATION_KEYS, ROUTES } from '@config'; -import { modals } from '@mantine/modals'; import { ICardData, IErrorObject } from '@impler/shared'; import { ConfirmationModal } from '@components/ConfirmationModal'; +import { API_KEYS, CONSTANTS, NOTIFICATION_KEYS, ROUTES } from '@config'; +import { useAppState } from 'store/app.context'; const { publicRuntimeConfig } = getConfig(); @@ -24,11 +26,12 @@ interface ISubscribeResponse { } export const useSubscribe = ({ email, planCode, paymentMethodId }: UseSubscribeProps) => { - const queryClient = useQueryClient(); const router = useRouter(); + const queryClient = useQueryClient(); + const { profileInfo } = useAppState(); const isCouponFeatureEnabled = publicRuntimeConfig.NEXT_PUBLIC_COUPON_ENABLED; - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(paymentMethodId); const [appliedCouponCode, setAppliedCouponCode] = useState(undefined); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(paymentMethodId); const { data: paymentMethods, @@ -65,7 +68,8 @@ export const useSubscribe = ({ email, planCode, paymentMethodId }: UseSubscribeP }), { onSuccess: (response) => { - queryClient.invalidateQueries([API_KEYS.FETCH_ACTIVE_SUBSCRIPTION, email]); + queryClient.invalidateQueries([API_KEYS.FETCH_ACTIVE_SUBSCRIPTION, profileInfo?._projectId]); + modals.closeAll(); if (response && response.status) { modals.open({ title: @@ -77,12 +81,12 @@ export const useSubscribe = ({ email, planCode, paymentMethodId }: UseSubscribeP } }, onError: (error: IErrorObject) => { + modals.closeAll(); notify(NOTIFICATION_KEYS.PURCHASE_FAILED, { title: 'Purchase Failed', message: error.message, color: 'red', }); - queryClient.invalidateQueries([API_KEYS.FETCH_ACTIVE_SUBSCRIPTION, email]); if (error && error.statusCode) { modals.open({ title: CONSTANTS.SUBSCRIPTION_FAILED_TITLE, @@ -94,7 +98,6 @@ export const useSubscribe = ({ email, planCode, paymentMethodId }: UseSubscribeP ); const handleProceed = () => { - modals.closeAll(); if (selectedPaymentMethod) { subscribe({ email, diff --git a/apps/web/hooks/useSubscriptionInfo.ts b/apps/web/hooks/useSubscriptionInfo.ts index a0485a246..661c51981 100644 --- a/apps/web/hooks/useSubscriptionInfo.ts +++ b/apps/web/hooks/useSubscriptionInfo.ts @@ -12,8 +12,8 @@ export function useSubscriptionInfo() { if (!meta) return COLUMN_TYPES; return COLUMN_TYPES.map((item) => { - if (item.label === 'Image' && item.value === 'Image' && !meta.IMAGE_UPLOAD) { - return { ...item, disabled: true, label: 'Image - Scale Plan Feature' }; + if (item.label === 'Image' && item.value === 'Image' && !meta.IMAGE_IMPORT) { + return { ...item, disabled: true, label: 'Image - Premium Feature' }; } return item; diff --git a/apps/web/hooks/useTeamMembers.tsx b/apps/web/hooks/useTeamMembers.tsx new file mode 100644 index 000000000..0da1e4e01 --- /dev/null +++ b/apps/web/hooks/useTeamMembers.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { modals } from '@mantine/modals'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { notify } from '@libs/notify'; +import { commonApi } from '@libs/api'; +import { IErrorObject } from '@impler/shared'; +import { useAppState } from 'store/app.context'; +import { API_KEYS, NOTIFICATION_KEYS, MODAL_KEYS } from '@config'; +import { RemoveTeamMemberModal } from '@components/TeamMembers/RemoveTeamMemberModal'; + +interface UpdateRoleParams { + memberId: string; + role: string; +} + +export function useTeamMembers() { + const { profileInfo } = useAppState(); + const queryClient = useQueryClient(); + const [teamMembersList, setTeamMembersList] = useState([]); + + const { isLoading: isMembersDataLoading } = useQuery( + [API_KEYS.LIST_TEAM_MEMBERS], + () => commonApi(API_KEYS.LIST_TEAM_MEMBERS as any, {}), + { + onSuccess: (teamMember) => { + const sortedData = teamMember.sort((a, b) => { + const aMatches = a._userId.email === profileInfo?.email ? 1 : 0; + const bMatches = b._userId.email === profileInfo?.email ? 1 : 0; + + return bMatches - aMatches; + }); + setTeamMembersList(sortedData); + }, + onError: (error: IErrorObject) => { + notify(NOTIFICATION_KEYS.ERROR_LISTING_TEAM_MEMBERS, { + title: 'Error Listing Team Members', + message: error.message || 'An Error Occurred while listing Team Members', + color: 'red', + }); + }, + } + ); + + const { mutate: removeTeamMember, isLoading: isTeamMemberDeleting } = useMutation( + (teamMemberId) => + commonApi(API_KEYS.DELETE_TEAM_MEMBER as any, { + parameters: [teamMemberId], + }), + { + onSuccess: () => { + queryClient.invalidateQueries([API_KEYS.LIST_TEAM_MEMBERS]); + notify(NOTIFICATION_KEYS.TEAM_MEMBER_DELETED, { + title: 'Team Member Deleted Successfully', + message: 'Team Member Deleted Successfully', + color: 'green', + }); + }, + onError: (error: IErrorObject) => { + notify(NOTIFICATION_KEYS.ERROR_DELETING_TEAM_MEMBER, { + title: 'Error while Deleting Team Member', + message: error.message || 'An Error Occurred While Deleting Team Member', + color: 'red', + }); + }, + } + ); + + const { mutate: updateTeamMemberRole, isLoading: isTeamMemberRoleUpdating } = useMutation< + IProfileData, + IErrorObject, + UpdateRoleParams + >( + ({ memberId, role }) => + commonApi(API_KEYS.UPDATE_TEAM_MEMBER_ROLE as any, { + body: { role }, + parameters: [memberId], + }), + { + onSuccess: () => { + queryClient.invalidateQueries([API_KEYS.LIST_TEAM_MEMBERS]); + + notify(NOTIFICATION_KEYS.TEAM_MEMBER_ROLE_UPDATED, { + title: 'Role Updated', + message: 'Team Member Role Updated Successfully', + color: 'green', + }); + }, + onError: (error: IErrorObject) => { + notify(NOTIFICATION_KEYS.ERROR_CHANGING_MEMBER_ROLE, { + title: 'Error while Changing Team Member Role', + message: error.message || 'An Error Occurred While Changing Team Member Role', + color: 'red', + }); + }, + } + ); + + const openDeleteModal = (userId: string, userName: string) => { + modals.open({ + title: 'Confirm Team Member Remove', + id: MODAL_KEYS.CONFIRM_REMOVE_TEAM_MEMBER, + children: ( + { + removeTeamMember(userId); + modals.closeAll(); + }} + onCancel={() => modals.closeAll()} + /> + ), + }); + }; + + return { + teamMembersList, + isMembersDataLoading, + teamMembersCount: teamMembersList.length || 0, + openDeleteModal, + isTeamMemberDeleting, + updateTeamMemberRole, + isTeamMemberRoleUpdating, + }; +} diff --git a/apps/web/layouts/AppLayout/AppLayout.tsx b/apps/web/layouts/AppLayout/AppLayout.tsx index 0756863a7..dc3ab9981 100644 --- a/apps/web/layouts/AppLayout/AppLayout.tsx +++ b/apps/web/layouts/AppLayout/AppLayout.tsx @@ -3,24 +3,30 @@ import Image from 'next/image'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import { PropsWithChildren, useRef } from 'react'; -import { Flex, Group, LoadingOverlay, Select, Stack, Title, useMantineColorScheme } from '@mantine/core'; +import { Flex, Group, LoadingOverlay, Text, Stack, Title, UnstyledButton, useMantineColorScheme } from '@mantine/core'; -import { TEXTS } from '@config'; +import { ActionsEnum, colors, ROUTES, SubjectsEnum, TEXTS } from '@config'; import useStyles from './AppLayout.styles'; import { HomeIcon } from '@assets/icons/Home.icon'; +import { PeopleIcon } from '@assets/icons/People.icon'; import { LogoutIcon } from '@assets/icons/Logout.icon'; import { ImportIcon } from '@assets/icons/Import.icon'; import { OutLinkIcon } from '@assets/icons/OutLink.icon'; -import { SettingsIcon } from '@assets/icons/Settings.icon'; -import { ActivitiesIcon } from '@assets/icons/Activities.icon'; import LogoBlack from '@assets/images/full-logo-dark.png'; +import { SettingsIcon } from '@assets/icons/Settings.icon'; import LogoWhite from '@assets/images/full-logo-light.png'; +import { ActivitiesIcon } from '@assets/icons/Activities.icon'; -import { useApp } from '@hooks/useApp'; import { NavItem } from '@ui/nav-item'; -import { UserMenu } from '@ui/user-menu'; import { track } from '@libs/amplitude'; +import { UserMenu } from '@ui/user-menu'; +import { Can } from 'store/ability.context'; +import { useProject } from '@hooks/useProject'; +import { useAppState } from 'store/app.context'; +import { useLogout } from '@hooks/auth/useLogout'; import { ColorSchemeToggle } from '@ui/toggle-color-scheme'; +import { EditProjectIcon } from '@assets/icons/EditImport.icon'; +import { usePlanDetails } from '@hooks/usePlanDetails'; const Support = dynamic(() => import('components/common/Support').then((mod) => mod.Support), { ssr: false, @@ -31,12 +37,17 @@ interface PageProps { } export function AppLayout({ children, pageProps }: PropsWithChildren<{ pageProps: PageProps }>) { - const router = useRouter(); - const { classes } = useStyles(); const navRef = useRef(null); + const { replace, pathname } = useRouter(); const { colorScheme } = useMantineColorScheme(); - const { profile, projects, createProject, logout, setProjectId, isProjectsLoading, isProfileLoading } = useApp(); + + const { logout } = useLogout({ + onLogout: () => replace(ROUTES.SIGNIN), + }); + const { profileInfo } = useAppState(); + usePlanDetails({ projectId: profileInfo?._projectId }); + const { projects, onEditImportClick, isProjectsLoading, isProfileLoading } = useProject(); return ( <> @@ -55,66 +66,74 @@ export function AppLayout({ children, pageProps }: PropsWithChildren<{ pageProps
Impler Logo
- value !== profile?.email || 'Email cannot be the same as the current email', + notSame: (value) => + value !== profileInfo?.email || 'Email cannot be the same as the current email', }, })} required diff --git a/apps/web/pages/imports/[id].tsx b/apps/web/pages/imports/[id].tsx index 97168579f..954701d4f 100644 --- a/apps/web/pages/imports/[id].tsx +++ b/apps/web/pages/imports/[id].tsx @@ -7,12 +7,13 @@ import { ActionIcon, Flex, Group, LoadingOverlay, Title, useMantineTheme, Select import { track } from '@libs/amplitude'; import { useImpler } from '@impler/react'; import { TemplateModeEnum } from '@impler/shared'; -import { IMPORT_MODES, ROUTES, colors } from '@config'; +import { IMPORT_MODES, ROUTES, SubjectsEnum, colors } from '@config'; import { useImportDetails } from '@hooks/useImportDetails'; import { Tabs } from '@ui/Tabs'; import { Button } from '@ui/button'; import { Schema } from '@components/imports/schema'; +import { withProtectedResource } from '@components/hoc'; import { Destination } from '@components/imports/destination'; import { AppLayout } from '@layouts/AppLayout'; @@ -28,7 +29,7 @@ const Validator = dynamic(() => import('@components/imports/validator').then((mo ssr: false, }); -export default function ImportDetails({}) { +function ImportDetails() { const router = useRouter(); const [activeTab, setActiveTab] = useState<'schema' | 'destination' | 'snippet' | 'validator' | 'output'>(); const { colorScheme } = useMantineTheme(); @@ -145,4 +146,20 @@ export default function ImportDetails({}) { ); } -ImportDetails.Layout = AppLayout; +const EnhancedImportDetails = withProtectedResource(ImportDetails, { + subject: SubjectsEnum.IMPORTS, +}); + +export default function ImportDetailsPage() { + return ; +} + +ImportDetailsPage.Layout = AppLayout; + +export async function getServerSideProps() { + return { + props: { + title: 'Import Details', + }, + }; +} diff --git a/apps/web/pages/imports/illustrations/import-not-accessible.tsx b/apps/web/pages/imports/illustrations/import-not-accessible.tsx new file mode 100644 index 000000000..dba0cdead --- /dev/null +++ b/apps/web/pages/imports/illustrations/import-not-accessible.tsx @@ -0,0 +1,541 @@ +export default function ImportNotAccessible() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/pages/imports/index.tsx b/apps/web/pages/imports/index.tsx index 6bf9871c8..177277d70 100644 --- a/apps/web/pages/imports/index.tsx +++ b/apps/web/pages/imports/index.tsx @@ -1,96 +1,14 @@ -import { ChangeEvent } from 'react'; -import { Flex, SimpleGrid, Group, LoadingOverlay, Text, Title, TextInput as Input } from '@mantine/core'; - -import { Button } from '@ui/button'; -import { VARIABLES } from '@config'; -import { Pagination } from '@ui/pagination'; -import { ImportCard } from '@ui/import-card'; -import { useImports } from '@hooks/useImports'; import { AppLayout } from '@layouts/AppLayout'; -import { SearchIcon } from '@assets/icons/Search.icon'; +import { ImportsList } from '@components/imports/ImportsList'; +import { withProtectedResource } from '@components/hoc'; +import { SubjectsEnum } from '@config'; -const NEW_IMPORT_TEXT = 'Create Import'; +const EnhancedImportsList = withProtectedResource(ImportsList, { + subject: SubjectsEnum.IMPORTS, +}); export default function Imports() { - const { - search, - importsData, - onPageChange, - onCreateClick, - onLimitChange, - onSearchChange, - onDuplicateClick, - isImportsLoading, - isCreateImportLoading, - } = useImports(); - - return ( - - - - Imports - - } - placeholder="Search imports by name..." - defaultValue={search} - onChange={(e: ChangeEvent) => onSearchChange(e.currentTarget.value)} - type="search" - /> - - - - {!importsData?.data?.length && ( - - No imports found, click on {NEW_IMPORT_TEXT} to get started with a new import. - - )} - - {importsData?.data?.length ? ( - <> - {importsData?.data.map((importItem, index) => ( - { - e.preventDefault(); - onDuplicateClick(importItem._id); - }} - totalRecords={importItem.totalRecords} - errorRecords={importItem.totalInvalidRecords} - /> - ))} - - ) : null} - - - - ); + return ; } export async function getServerSideProps() { diff --git a/apps/web/pages/team-members/index.tsx b/apps/web/pages/team-members/index.tsx new file mode 100644 index 000000000..b4af2e5e9 --- /dev/null +++ b/apps/web/pages/team-members/index.tsx @@ -0,0 +1,26 @@ +import { Title } from '@mantine/core'; + +import { AppLayout } from '@layouts/AppLayout'; +import { Team } from '@components/TeamMembers'; +import { useInvitation } from '@hooks/useInvitation'; + +export default function TeamMembers() { + useInvitation(); + + return ( + <> + Team Members + + + ); +} + +export async function getServerSideProps() { + return { + props: { + title: 'Team Members', + }, + }; +} + +TeamMembers.Layout = AppLayout; diff --git a/apps/web/pages/transactions/index.tsx b/apps/web/pages/transactions/index.tsx index 8d3e303cc..8d6d4bb5b 100644 --- a/apps/web/pages/transactions/index.tsx +++ b/apps/web/pages/transactions/index.tsx @@ -1,7 +1,8 @@ import Link from 'next/link'; import { Group, LoadingOverlay, Stack, Title } from '@mantine/core'; -import { ROUTES } from '@config'; +import dayjs from 'dayjs'; +import { ROUTES, DATE_FORMATS } from '@config'; import { Table } from '@ui/table'; import { Button } from '@ui/button'; import { Checkbox } from '@ui/checkbox'; @@ -30,6 +31,9 @@ export default function Transactions() { { title: 'Transaction Date', key: 'transactionDate', + Cell(item) { + return dayjs(item.transactionDate).format(DATE_FORMATS.LONG); + }, }, { title: 'Plan Name', @@ -45,10 +49,16 @@ export default function Transactions() { { title: 'Membership Date', key: 'membershipDate', + Cell(item) { + return dayjs(item.membershipDate).format(DATE_FORMATS.LONG); + }, }, { title: 'Expiry Date', key: 'expiryDate', + Cell(item) { + return dayjs(item.expiryDate).format(DATE_FORMATS.LONG); + }, }, { title: 'Is Active', diff --git a/apps/web/shared/helpers.ts b/apps/web/shared/helpers.ts index 304c7e3ed..b64e6e9ed 100644 --- a/apps/web/shared/helpers.ts +++ b/apps/web/shared/helpers.ts @@ -1,14 +1,35 @@ -import { SCREENS } from '@impler/shared'; +import { constructQueryString, SCREENS } from '@impler/shared'; import { ROUTES } from '@config'; -export function handleRouteBasedOnScreenResponse(screen: SCREENS, push: (url: string) => void) { +export function formatUrl(template: string, params: any[], query: Record = {}): string { + let paramIndex = 0; + + const formattedRoute = template.replace(/:(\w+)/g, (match) => { + if (paramIndex < params.length) { + return String(params[paramIndex++]); + } + + return match; + }); + + return formattedRoute + constructQueryString(query); +} + +export function handleRouteBasedOnScreenResponse( + screen: SCREENS, + push: (url: string) => void, + parameters: string[] = [] +) { switch (screen) { case SCREENS.VERIFY: - push(ROUTES.OTP_VERIFY); + push(formatUrl(ROUTES.OTP_VERIFY, parameters)); break; case SCREENS.ONBOARD: push(ROUTES.SIGNUP_ONBOARDING); break; + case SCREENS.INVIATAION: + push(formatUrl(ROUTES.INVITATION, parameters)); + break; case SCREENS.HOME: default: push(ROUTES.HOME); diff --git a/apps/web/shared/utils.ts b/apps/web/shared/utils.ts index a2f1e6c61..731de4aac 100644 --- a/apps/web/shared/utils.ts +++ b/apps/web/shared/utils.ts @@ -1,3 +1,4 @@ +import { REGULAR_EXPRESSIONS } from '@config'; import { MANTINE_COLORS } from '@mantine/core'; /* eslint-disable no-magic-numbers */ @@ -30,3 +31,79 @@ export const getColorForText = (text: string) => { return colors[colorIndex]; }; + +export function validateEmails(emails: any): string | true { + if (!Array.isArray(emails)) { + console.error('Expected an array of emails, but received:', emails); + + return 'Invalid email format'; + } + + const invalidEmails = emails.filter((email) => !REGULAR_EXPRESSIONS.EMAIL.test(email)); + const duplicateEmails = emails.filter((email, index) => emails.indexOf(email) !== index); + + if (invalidEmails.length > 0) { + return `Invalid email addresses: ${invalidEmails.join(', ')}`; + } + + if (duplicateEmails.length > 0) { + return `Duplicate email addresses: ${duplicateEmails.join(', ')}`; + } + + return true; +} + +export function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + + if (parts.length === 2) { + const cookiePart = parts.pop(); + if (cookiePart) { + return cookiePart.split(';').shift(); + } + } + + return undefined; +} + +export function deleteCookie(cookieName: string) { + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; +} + +export function setInvitationRedirectCookie(invitationId: string, token: string) { + const redirectUrl = `/team-members?invitationId=${encodeURIComponent(invitationId)}&token=${encodeURIComponent( + token + )}`; + + document.cookie = `${redirectUrl}=${encodeURIComponent(redirectUrl)}; path=/;`; +} + +interface QueryParams { + [key: string]: string; +} + +export function setRedirectCookie({ + baseUrl, + queryParams, + cookieName, + path = '/', +}: { + baseUrl: string; + queryParams: QueryParams; + cookieName: string; + path?: string; +}): { url: string; cookieName: string } { + const url = new URL(baseUrl); + + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.set(key, encodeURIComponent(value)); + }); + + document.cookie = `${cookieName}=${encodeURIComponent(url.toString())}; path=${path};`; + + return { + url: url.toString(), + cookieName, + }; +} diff --git a/apps/web/store/StoreWrapper.tsx b/apps/web/store/StoreWrapper.tsx index 2dfb72154..2f4fe2ab1 100644 --- a/apps/web/store/StoreWrapper.tsx +++ b/apps/web/store/StoreWrapper.tsx @@ -1,11 +1,20 @@ -import { PropsWithChildren } from 'react'; import AppContextProvider from './app.context'; +import { defineAbilitiesFor } from 'config/defineAbilities'; +import { PropsWithChildren, useState } from 'react'; import { PlanMetaProvider } from './planmeta.store.context'; +import { AbilityContext } from './ability.context'; +import { AppAbility } from '@config'; export function StoreWrapper({ children }: PropsWithChildren) { + const [ability, setAbility] = useState(defineAbilitiesFor()); + return ( - {children} + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + {children} + ); } diff --git a/apps/web/store/ability.context.tsx b/apps/web/store/ability.context.tsx new file mode 100644 index 000000000..c61c53762 --- /dev/null +++ b/apps/web/store/ability.context.tsx @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import { createContextualCan } from '@casl/react'; +import { AppAbility } from '@config'; + +export const AbilityContext = createContext(null); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export const Can = createContextualCan(AbilityContext.Consumer); diff --git a/apps/web/store/app.context.tsx b/apps/web/store/app.context.tsx index 79a1c796d..beb53533a 100644 --- a/apps/web/store/app.context.tsx +++ b/apps/web/store/app.context.tsx @@ -1,14 +1,24 @@ -import React, { createContext, useContext, useState } from 'react'; -import { IAppStore } from '../types'; +import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { AppAbility } from '@config'; -interface AppContextProviderProps extends React.PropsWithChildren, Omit {} +interface AppContextType { + profileInfo?: IProfileData; + setAbility: (ability: AppAbility) => void; + setProfileInfo: (profileInfo?: IProfileData) => void; +} -const AppContext = createContext(null); +const AppContext = createContext(null); -const AppContextProvider = ({ children }: AppContextProviderProps) => { +const AppContextProvider = ({ + children, + setAbility, +}: { + children: ReactNode; + setAbility: (ability: AppAbility) => void; +}) => { const [profileInfo, setProfileInfo] = useState(undefined); - return {children}; + return {children}; }; export function useAppState() { diff --git a/apps/web/types/global.d.ts b/apps/web/types/global.d.ts index 4f8d4a35f..261a7e80b 100644 --- a/apps/web/types/global.d.ts +++ b/apps/web/types/global.d.ts @@ -21,6 +21,7 @@ namespace NodeJS { interface IProfileData { _id: string; + projectName: string; firstName: string; lastName: string; email: string; @@ -60,7 +61,7 @@ interface ISubscriptionData { }; expiryDate: Date; meta: { - IMAGE_UPLOAD: boolean; + IMAGE_IMPORT: boolean; IMPORTED_ROWS: Array<{ flat_fee: number; per_unit: number; @@ -70,6 +71,7 @@ interface ISubscriptionData { REMOVE_BRANDING: boolean; AUTOMATIC_IMPORTS: boolean; ADVANCED_VALIDATORS: boolean; + TEAM_MEMBERS: number; }; } @@ -82,7 +84,7 @@ interface IOnboardUserData { companySize: string; role: string; source: string; - onboarding: boolean; + onboarding?: boolean; } interface ICstringemplateData { @@ -109,6 +111,7 @@ interface Window { interface ISigninData { email: string; password: string; + invitationId?: string; } interface ISignupData { @@ -156,3 +159,21 @@ interface ISubscribeData { email: string; planCode: string; } + +interface SentProjectInvitation { + _id: string; + invitationToEmail: string; + invitedOn: string; + role: string; + invitedBy: string; + token: string; + invitationLink: string; +} + +interface TeamMember { + _id: string; + _userId: IProfileData; + joinedDate: string; + role: string; + isCurrentUser: string; +} diff --git a/apps/web/types/store.types.ts b/apps/web/types/store.types.ts index 01565cd32..9b99c6329 100644 --- a/apps/web/types/store.types.ts +++ b/apps/web/types/store.types.ts @@ -4,7 +4,7 @@ export interface IAppStore { } export interface IPlanMeta { - IMAGE_UPLOAD: boolean; + IMAGE_IMPORT: boolean; IMPORTED_ROWS: Array<{ flat_fee: number; per_unit: number; @@ -14,6 +14,7 @@ export interface IPlanMeta { REMOVE_BRANDING: boolean; AUTOMATIC_IMPORTS: boolean; ADVANCED_VALIDATORS: boolean; + TEAM_MEMBERS: number; } export interface IPlanMetaContext { diff --git a/apps/widget/package.json b/apps/widget/package.json index 2f0a8882a..ccb668eb2 100644 --- a/apps/widget/package.json +++ b/apps/widget/package.json @@ -1,6 +1,6 @@ { "name": "@impler/widget", - "version": "0.26.1", + "version": "0.27.0", "author": "implerhq", "license": "MIT", "private": true, diff --git a/apps/widget/src/hooks/AutoImportPhase1/useAutoImportPhase1.tsx b/apps/widget/src/hooks/AutoImportPhase1/useAutoImportPhase1.tsx index c21aadb38..06a4c8509 100644 --- a/apps/widget/src/hooks/AutoImportPhase1/useAutoImportPhase1.tsx +++ b/apps/widget/src/hooks/AutoImportPhase1/useAutoImportPhase1.tsx @@ -2,12 +2,12 @@ import { useMutation } from '@tanstack/react-query'; import { useForm, SubmitHandler } from 'react-hook-form'; import { notifier } from '@util'; +import { IAutoImportValues } from '@types'; +import { useAppState } from '@store/app.context'; import { useAPIState } from '@store/api.context'; import { useJobsInfo } from '@store/jobinfo.context'; import { useImplerState } from '@store/impler.context'; import { IUserJob, IErrorObject } from '@impler/shared'; -import { IAutoImportValues } from '@types'; -import { useAppState } from '@store/app.context'; interface IUseAutoImportPhase1Props { goNext: () => void; diff --git a/apps/widget/src/util/amplitude/index.ts b/apps/widget/src/util/amplitude/index.ts index 39e3ce728..c1c9a720a 100644 --- a/apps/widget/src/util/amplitude/index.ts +++ b/apps/widget/src/util/amplitude/index.ts @@ -24,7 +24,7 @@ export const startAmplitudeSession = (id: number) => { export const logAmplitudeEvent = (eventType: keyof typeof AMPLITUDE, eventProperties?: any) => { window.amplitude?.track({ event_type: eventType, event_properties: eventProperties }); - if (eventProperties && eventProperties.email) window.amplitude.setUserId(eventProperties.email); + if (eventProperties && eventProperties.email) window.amplitude?.setUserId(eventProperties.email); }; export const resetAmplitude = () => { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 20a14fd52..3849b3303 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -19,7 +19,7 @@ services: api: privileged: true - image: "ghcr.io/implerhq/impler/api:0.26.1" + image: "ghcr.io/implerhq/impler/api:0.27.0" depends_on: - mongodb - rabbitmq @@ -53,7 +53,7 @@ services: - impler queue-manager: - image: "ghcr.io/implerhq/impler/queue-manager:0.26.1" + image: "ghcr.io/implerhq/impler/queue-manager:0.27.0" depends_on: - api - rabbitmq @@ -78,7 +78,7 @@ services: - impler widget: - image: "ghcr.io/implerhq/impler/widget:0.26.1" + image: "ghcr.io/implerhq/impler/widget:0.27.0" depends_on: - api container_name: widget @@ -95,7 +95,7 @@ services: embed: depends_on: - widget - image: "ghcr.io/implerhq/impler/embed:0.26.1" + image: "ghcr.io/implerhq/impler/embed:0.27.0" container_name: embed environment: WIDGET_URL: ${WIDGET_BASE_URL} @@ -107,7 +107,7 @@ services: web: depends_on: - api - image: "ghcr.io/implerhq/impler/web:0.26.1" + image: "ghcr.io/implerhq/impler/web:0.27.0" container_name: web environment: NEXT_PUBLIC_API_BASE_URL: ${API_ROOT_URL} @@ -119,6 +119,7 @@ services: - 4200:4200 networks: - impler + rabbitmq: image: rabbitmq:3-alpine container_name: 'rabbitmq' diff --git a/lerna.json b/lerna.json index c687ca6ad..22222b397 100644 --- a/lerna.json +++ b/lerna.json @@ -3,5 +3,5 @@ "npmClient": "pnpm", "useNx": true, "packages": ["apps/*", "libs/*", "packages/*"], - "version": "0.26.1" + "version": "0.27.0" } diff --git a/libs/dal/package.json b/libs/dal/package.json index 4b73ca9ba..51372c815 100644 --- a/libs/dal/package.json +++ b/libs/dal/package.json @@ -1,9 +1,8 @@ { "name": "@impler/dal", - "version": "0.26.1", + "version": "0.27.0", "author": "implerhq", "license": "MIT", - "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/libs/dal/src/index.ts b/libs/dal/src/index.ts index 216633e50..90f629c70 100644 --- a/libs/dal/src/index.ts +++ b/libs/dal/src/index.ts @@ -17,3 +17,4 @@ export * from './repositories/bubble-destination'; export * from './repositories/jobmapping'; export * from './repositories/user-job'; export * from './repositories/import-job-history'; +export * from './repositories/project-invitation'; diff --git a/libs/dal/src/repositories/environment/environment.entity.ts b/libs/dal/src/repositories/environment/environment.entity.ts index 8146b0724..be283cbee 100644 --- a/libs/dal/src/repositories/environment/environment.entity.ts +++ b/libs/dal/src/repositories/environment/environment.entity.ts @@ -1,6 +1,9 @@ export interface IApiKey { - key: string; + _id: string; + role: string; _userId: string; + joinedOn?: string; + isOwner?: boolean; } export class EnvironmentEntity { @@ -8,5 +11,7 @@ export class EnvironmentEntity { _projectId: string; + key: string; + apiKeys: IApiKey[]; } diff --git a/libs/dal/src/repositories/environment/environment.repository.ts b/libs/dal/src/repositories/environment/environment.repository.ts index a0205ea6a..6735a1ea3 100644 --- a/libs/dal/src/repositories/environment/environment.repository.ts +++ b/libs/dal/src/repositories/environment/environment.repository.ts @@ -1,13 +1,16 @@ +import { Types } from 'mongoose'; +import { UserRolesEnum } from '@impler/shared'; import { BaseRepository } from '../base-repository'; import { EnvironmentEntity } from './environment.entity'; import { Environment } from './environment.schema'; +import { ProjectEntity } from '../project'; export class EnvironmentRepository extends BaseRepository { constructor() { super(Environment, EnvironmentEntity); } - async addApiKey(environmentId: string, key: string, userId: string) { + async addApiKey(environmentId: string, userId: string, role: string) { return await this.update( { _id: environmentId, @@ -15,8 +18,8 @@ export class EnvironmentRepository extends BaseRepository { { $push: { apiKeys: { - key, _userId: userId, + role, }, }, } @@ -25,7 +28,7 @@ export class EnvironmentRepository extends BaseRepository { async findByApiKey(key: string) { return await this.findOne({ - 'apiKeys.key': key, + key, }); } @@ -41,17 +44,111 @@ export class EnvironmentRepository extends BaseRepository { return apiKey ? apiKey.apiKeys[0]._userId : null; } - async getApiKeyForUserId(userId: string) { - const apiKey = await this.findOne({ + async getUserEnvironmentProjects(userId: string): Promise<{ name: string; _id: string; isOwner: boolean }[]> { + const environments = await Environment.find( + { + 'apiKeys._userId': userId, + }, + '_id apiKeys' + ).populate('_projectId', 'name'); + + return environments + .filter((env) => env._projectId) + .map((env) => { + const userApiKey = env.apiKeys.find((apiKey) => apiKey._userId.toString() === userId); + + if (!userApiKey) { + return null; + } + + const project = env._projectId as unknown as ProjectEntity; + + if (!project || !project.name) { + return null; + } + + const result = { + name: project.name, + _id: project._id.toString(), + isOwner: userApiKey.isOwner || false, + role: userApiKey.role as UserRolesEnum, + }; + + return result; + }) + .filter((item): item is { name: string; _id: string; isOwner: boolean; role: UserRolesEnum } => item !== null); + } + + async getApiKeyForUserId(userId: string): Promise<{ projectId: string; apiKey: string; role: string } | null> { + const userEnvironment = await this.findOne({ 'apiKeys._userId': userId, }); - return apiKey - ? { - projectId: apiKey._projectId, - // eslint-disable-next-line no-magic-numbers - apiKey: apiKey.apiKeys[0].key, - } - : null; + if (userEnvironment) { + const userApiKey = userEnvironment.apiKeys.find((apiKey) => apiKey._userId.toString() === userId); + + return { + projectId: userEnvironment._projectId, + apiKey: userEnvironment.key, + role: userApiKey ? userApiKey.role : null, + }; + } + + return null; + } + + async getProjectTeamMembers(projectId: string) { + const environment = await Environment.findOne({ _projectId: projectId }, 'apiKeys').populate( + 'apiKeys._userId', + 'firstName lastName email profilePicture' + ); + + return environment.apiKeys; + } + + async removeTeamMember(memberId: string) { + const result = await Environment.updateOne( + { + 'apiKeys._id': memberId, + }, + { + $pull: { + apiKeys: { + _id: memberId, + }, + }, + } + ); + + return result; + } + async updateTeamMember(memberId: string, { role }: { role: UserRolesEnum }) { + return await Environment.updateOne( + { + 'apiKeys._id': memberId, + }, + { + $set: { + 'apiKeys.$.role': role, + }, + } + ); + } + async getTeamMemberDetails(memberId: string) { + const envApiKeys = await Environment.findOne( + { + 'apiKeys._id': new Types.ObjectId(memberId), + }, + 'apiKeys' + ).populate('apiKeys._userId', 'firstName lastName email profilePicture'); + + return envApiKeys.apiKeys.find((apiKey) => apiKey._id.toString() === memberId); + } + + async getTeamOwnerDetails(projectId: string) { + const teamMembers = await this.getProjectTeamMembers(projectId); + const teamOwner = teamMembers.find((member) => member.isOwner); + + return teamOwner; } } diff --git a/libs/dal/src/repositories/environment/environment.schema.ts b/libs/dal/src/repositories/environment/environment.schema.ts index 7a72e642e..36ea7f3b9 100644 --- a/libs/dal/src/repositories/environment/environment.schema.ts +++ b/libs/dal/src/repositories/environment/environment.schema.ts @@ -1,4 +1,4 @@ -import { Schema, model, models } from 'mongoose'; +import { Model, Schema, model, models } from 'mongoose'; import { EnvironmentEntity } from './environment.entity'; import { schemaOptions } from '../schema-default.options'; @@ -10,16 +10,25 @@ const environmentSchema = new Schema( ref: 'Project', index: true, }, + key: { + type: Schema.Types.String, + unique: true, + }, apiKeys: [ { - key: { - type: Schema.Types.String, - unique: true, - }, + role: String, _userId: { type: Schema.Types.ObjectId, ref: 'User', }, + joinedOn: { + type: Schema.Types.Date, + default: Date.now, + }, + isOwner: { + type: Schema.Types.Boolean, + default: false, + }, }, ], }, @@ -30,4 +39,5 @@ interface IEnvironmentDocument extends EnvironmentEntity, Document { _id: never; } -export const Environment = models.Environment || model('Environment', environmentSchema); +export const Environment = + (models.Environment as Model) || model('Environment', environmentSchema); diff --git a/libs/dal/src/repositories/project-invitation/index.ts b/libs/dal/src/repositories/project-invitation/index.ts new file mode 100644 index 000000000..3aa6fac6c --- /dev/null +++ b/libs/dal/src/repositories/project-invitation/index.ts @@ -0,0 +1,3 @@ +export * from './project-invitation.entity'; +export * from './project-invitation.repository'; +export * from './project-invitation.schema'; diff --git a/libs/dal/src/repositories/project-invitation/project-invitation.entity.ts b/libs/dal/src/repositories/project-invitation/project-invitation.entity.ts new file mode 100644 index 000000000..9d6138a9f --- /dev/null +++ b/libs/dal/src/repositories/project-invitation/project-invitation.entity.ts @@ -0,0 +1,15 @@ +export class ProjectInvitationEntity { + _id: string; + + invitationToEmail: string; + + invitedOn: string; + + role: string; + + invitedBy: string; + + _projectId: string; + + token: string; +} diff --git a/libs/dal/src/repositories/project-invitation/project-invitation.repository.ts b/libs/dal/src/repositories/project-invitation/project-invitation.repository.ts new file mode 100644 index 000000000..f9263669f --- /dev/null +++ b/libs/dal/src/repositories/project-invitation/project-invitation.repository.ts @@ -0,0 +1,24 @@ +import { Types } from 'mongoose'; +import { ProjectEntity } from '../project'; +import { BaseRepository } from '../base-repository'; +import { ProjectInvitation } from './project-invitation.schema'; +import { ProjectInvitationEntity } from './project-invitation.entity'; + +export class ProjectInvitationRepository extends BaseRepository { + constructor() { + super(ProjectInvitation, ProjectInvitationEntity); + } + async getInvitationData(invitationId: string) { + const invitation = await ProjectInvitation.findOne({ _id: new Types.ObjectId(invitationId) }).populate( + '_projectId', + 'name' + ); + if (!invitation) return null; + + return { + email: invitation.invitationToEmail, + invitedBy: invitation.invitedBy, + projectName: (invitation._projectId as unknown as ProjectEntity).name, + }; + } +} diff --git a/libs/dal/src/repositories/project-invitation/project-invitation.schema.ts b/libs/dal/src/repositories/project-invitation/project-invitation.schema.ts new file mode 100644 index 000000000..7053900fa --- /dev/null +++ b/libs/dal/src/repositories/project-invitation/project-invitation.schema.ts @@ -0,0 +1,36 @@ +import { Model, model, models, Schema } from 'mongoose'; +import { schemaOptions } from '../schema-default.options'; +import { ProjectInvitationEntity } from './project-invitation.entity'; + +const projectInvitationSchema = new Schema( + { + invitationToEmail: { + type: Schema.Types.String, + }, + invitedOn: { + type: Schema.Types.String, + }, + role: { + type: Schema.Types.String, + }, + invitedBy: { + type: Schema.Types.String, + }, + token: { + type: Schema.Types.String, + }, + _projectId: { + type: Schema.Types.ObjectId, + ref: 'Project', + }, + }, + { ...schemaOptions } +); + +interface IProjectInvitation extends ProjectInvitationEntity, Document { + _id: never; +} + +export const ProjectInvitation = + (models.ProjectInvitation as Model) || + model('ProjectInvitation', projectInvitationSchema); diff --git a/libs/dal/src/repositories/upload/upload.repository.ts b/libs/dal/src/repositories/upload/upload.repository.ts index ab9264213..537c7d56e 100644 --- a/libs/dal/src/repositories/upload/upload.repository.ts +++ b/libs/dal/src/repositories/upload/upload.repository.ts @@ -2,6 +2,7 @@ import { Types } from 'mongoose'; import { subMonths, subWeeks, subYears, format, subDays } from 'date-fns'; +import { UserEntity } from '../user'; import { Upload } from './upload.schema'; import { Environment } from '../environment'; import { UploadEntity } from './upload.entity'; @@ -302,6 +303,6 @@ export class UploadRepository extends BaseRepository { }, ]); - return environment[0].apiKeys[0]._userId.email; + return (environment[0].apiKeys[0]._userId as unknown as UserEntity).email; } } diff --git a/libs/dal/src/repositories/user-job/user-job.entity.ts b/libs/dal/src/repositories/user-job/user-job.entity.ts index 99e98969b..c2f064956 100644 --- a/libs/dal/src/repositories/user-job/user-job.entity.ts +++ b/libs/dal/src/repositories/user-job/user-job.entity.ts @@ -15,6 +15,8 @@ export class UserJobEntity { status: string; + authHeaderValue: string; + customRecordFormat: string; customChunkFormat: string; diff --git a/libs/dal/src/repositories/user-job/user-job.repository.ts b/libs/dal/src/repositories/user-job/user-job.repository.ts index f971985b8..40e2fc3da 100644 --- a/libs/dal/src/repositories/user-job/user-job.repository.ts +++ b/libs/dal/src/repositories/user-job/user-job.repository.ts @@ -3,6 +3,7 @@ import { UserJobEntity } from './user-job.entity'; import { BaseRepository } from '../base-repository'; import { Environment } from '../environment'; import { TemplateEntity } from '../template'; +import { UserEntity } from '../user/user.entity'; export class UserJobRepository extends BaseRepository { constructor() { @@ -25,6 +26,6 @@ export class UserJobRepository extends BaseRepository { }, ]); - return environment[0].apiKeys[0]._userId.email; + return (environment[0].apiKeys[0]._userId as unknown as UserEntity).email; } } diff --git a/libs/dal/src/repositories/user-job/user-job.schema.ts b/libs/dal/src/repositories/user-job/user-job.schema.ts index efec006c8..add833130 100644 --- a/libs/dal/src/repositories/user-job/user-job.schema.ts +++ b/libs/dal/src/repositories/user-job/user-job.schema.ts @@ -23,6 +23,9 @@ const userJobSchema = new Schema( externalUserId: { type: Schema.Types.String, }, + authHeaderValue: { + type: Schema.Types.String, + }, status: { type: Schema.Types.String, }, diff --git a/libs/dal/src/repositories/user/user.repository.ts b/libs/dal/src/repositories/user/user.repository.ts index 05d417b3c..3936769ed 100644 --- a/libs/dal/src/repositories/user/user.repository.ts +++ b/libs/dal/src/repositories/user/user.repository.ts @@ -29,7 +29,7 @@ export class UserRepository extends BaseRepository { }, ]); - return environment?.[0]?.apiKeys[0]?._userId?.email; + return (environment?.[0]?.apiKeys[0]?._userId as unknown as UserEntity)?.email; } async findUserByToken(token: string) { diff --git a/libs/embed/package.json b/libs/embed/package.json index 84a61b02b..0f7f89d20 100644 --- a/libs/embed/package.json +++ b/libs/embed/package.json @@ -1,6 +1,6 @@ { "name": "@impler/embed", - "version": "0.26.1", + "version": "0.27.0", "private": true, "license": "MIT", "author": "implerhq", diff --git a/libs/services/package.json b/libs/services/package.json index 2776d6818..373d71bef 100644 --- a/libs/services/package.json +++ b/libs/services/package.json @@ -1,9 +1,8 @@ { "name": "@impler/services", - "version": "0.26.1", + "version": "0.27.0", "description": "Reusable services to shared between backend api and queue-manager", "license": "MIT", - "private": true, "author": "implerhq", "repository": "https://github.com/implerhq/impler.io", "types": "dist/index.d.ts", diff --git a/libs/services/src/email/email.service.ts b/libs/services/src/email/email.service.ts index 326528873..932f2885e 100644 --- a/libs/services/src/email/email.service.ts +++ b/libs/services/src/email/email.service.ts @@ -38,6 +38,29 @@ interface IVerificationEmailOptions { firstName: string; } +interface ITeamnvitationEmailOptions { + invitedBy: string; + projectName: string; + invitationUrl: string; +} +interface IAcceptProjectInvitationSenderEmailOptions { + invitedBy: string; + projectName: string; + acceptedBy: string; +} + +interface IAcceptProjectInvitationRecieverEmailOptions { + invitedBy: string; + projectName: string; + acceptedBy: string; +} + +interface IDeclineInvitationEmailOptions { + invitedBy: string; + projectName: string; + declinedBy: string; +} + const EMAIL_CONTENTS = { VERIFICATION_EMAIL: ({ otp, firstName }: IVerificationEmailOptions) => ` @@ -392,7 +415,281 @@ const EMAIL_CONTENTS = {
- `, +`, + + TEAM_INVITATION_EMAIL: ({ invitedBy, projectName, invitationUrl }: ITeamnvitationEmailOptions) => ` + + + + + + + + +
+
+

${invitedBy} invited you to join the project "${projectName}"

+
+ +
+

Hello

+

You have been invited to join the project ${projectName}. Please click the button below to accept the invitation.

+ + + +
+

If you don't know about this request, please ignore this email.

+
+ + `, + + ACCEPT_PROJECT_INVITATION_SENDER_EMAIL: ({ + invitedBy, + projectName, + acceptedBy, + }: IAcceptProjectInvitationSenderEmailOptions) => + ` + + + + + + + +
+ +

Project Invitation Accepted: ${projectName}

+

${acceptedBy} has accepted the invitation to join the project.

+
    +
  • Project Name: ${projectName}
  • +
  • Invited By: ${invitedBy}
  • +
  • Accepted By: ${acceptedBy}
  • +
+ +
+

Need any help? Contact us

+
+
+ +`, + + ACCEPT_PROJECT_INVITATION_RECIEVER_EMAIL: ({ + invitedBy, + projectName, + acceptedBy, + }: IAcceptProjectInvitationRecieverEmailOptions) => + ` + + + + + + + +
+ +

Project Invitation Accepted: ${projectName}

+

You have Successfully join the project.

+
    +
  • Project Name: ${projectName}
  • +
  • Invited By: ${invitedBy}
  • +
  • Accepted By: ${acceptedBy}
  • +
+ +
+

Need any help? Contact us

+
+
+ +`, + DECLINE_INVITATION_EMAIL: ({ invitedBy, projectName, declinedBy }: IDeclineInvitationEmailOptions) => ` + + + + + + + + +
+ +

Project Invitation Declined: ${projectName}

+

${declinedBy} has declined the invitation to join the project.

+
    +
  • Project Name: ${projectName}
  • +
  • Invited By: ${invitedBy}
  • +
  • Declined By: ${declinedBy}
  • +
+ +
+

Need any help? Contact us

+
+
+ +`, }; type EmailContents = @@ -415,6 +712,22 @@ type EmailContents = | { type: 'VERIFICATION_EMAIL'; data: IVerificationEmailOptions; + } + | { + type: 'TEAM_INVITATION_EMAIL'; + data: ITeamnvitationEmailOptions; + } + | { + type: 'ACCEPT_PROJECT_INVITATION_SENDER_EMAIL'; + data: IAcceptProjectInvitationSenderEmailOptions; + } + | { + type: 'ACCEPT_PROJECT_INVITATION_RECIEVER_EMAIL'; + data: IAcceptProjectInvitationRecieverEmailOptions; + } + | { + type: 'DECLINE_INVITATION_EMAIL'; + data: IDeclineInvitationEmailOptions; }; export abstract class EmailService { diff --git a/libs/shared/package.json b/libs/shared/package.json index 85d716402..e2d146f3d 100644 --- a/libs/shared/package.json +++ b/libs/shared/package.json @@ -1,6 +1,6 @@ { "name": "@impler/shared", - "version": "0.26.1", + "version": "0.27.0", "description": "Reusable types and classes to shared between apps and libraries", "license": "MIT", "author": "implerhq", diff --git a/libs/shared/src/entities/UserJob/Userjob.interface.ts b/libs/shared/src/entities/UserJob/Userjob.interface.ts index 700b233f0..06c6ae3c9 100644 --- a/libs/shared/src/entities/UserJob/Userjob.interface.ts +++ b/libs/shared/src/entities/UserJob/Userjob.interface.ts @@ -1,7 +1,7 @@ export interface IUserJob { _id: string; url: string; - _templateId: string; - headings: string[]; cron: string; + headings: string[]; + _templateId: string; } diff --git a/libs/shared/src/types/environment/environment.types.ts b/libs/shared/src/types/environment/environment.types.ts index 8db8027f6..8dd459d1b 100644 --- a/libs/shared/src/types/environment/environment.types.ts +++ b/libs/shared/src/types/environment/environment.types.ts @@ -1,10 +1,12 @@ export interface IApiKeyData { - key: string; + role: string; _userId: string; + joinedOn?: string; } export interface IEnvironmentData { _id: string; _projectId: string; + key: string; apiKeys: IApiKeyData[]; } diff --git a/libs/shared/src/types/project/project.types.ts b/libs/shared/src/types/project/project.types.ts index 31bc2bbff..c14189579 100644 --- a/libs/shared/src/types/project/project.types.ts +++ b/libs/shared/src/types/project/project.types.ts @@ -1,4 +1,12 @@ export interface IProjectPayload { _id: string; name: string; + isOwner: boolean; + role: UserRolesEnum; +} + +export enum UserRolesEnum { + ADMIN = 'Admin', + TECH = 'Tech', + FINANCE = 'Finance', } diff --git a/libs/shared/src/types/subscription/subscription.types.ts b/libs/shared/src/types/subscription/subscription.types.ts index 3a9f63320..6b14edb43 100644 --- a/libs/shared/src/types/subscription/subscription.types.ts +++ b/libs/shared/src/types/subscription/subscription.types.ts @@ -20,6 +20,10 @@ export interface ISubscriptionData { expiryDate: string; meta: { IMPORTED_ROWS: number; + REMOVE_BRANDING: boolean; + AUTOMATIC_IMPORTS: boolean; + ADVANCED_VALIDATORS: boolean; + TEAM_MEMBERS: number; }; } diff --git a/libs/shared/src/types/upload/upload.types.ts b/libs/shared/src/types/upload/upload.types.ts index 92b9eb491..c346e772e 100644 --- a/libs/shared/src/types/upload/upload.types.ts +++ b/libs/shared/src/types/upload/upload.types.ts @@ -99,7 +99,8 @@ export type SendWebhookData = { }; export type SendImportJobData = { - importJobHistoryId: string; + _jobId: string; + allDataFilePath: string; cache?: SendImportJobCachedData; }; diff --git a/libs/shared/src/utils/defaults.ts b/libs/shared/src/utils/defaults.ts index ef2793bb0..cc50709d0 100644 --- a/libs/shared/src/utils/defaults.ts +++ b/libs/shared/src/utils/defaults.ts @@ -10,6 +10,7 @@ export const Defaults = { CHUNK_SIZE: 100, DATE_FORMATS: ['DD/MM/YYYY'], DATE_FORMAT: 'DD/MM/YYYY', + FORMATTED_DATE: ['DD MMM YYYY'], }; export const DEFAULT_VALUES = [ @@ -74,6 +75,7 @@ export enum SCREENS { VERIFY = 'verify', ONBOARD = 'onboard', HOME = 'home', + INVIATAION = 'invitation', } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -83,4 +85,7 @@ export enum EMAIL_SUBJECT { ERROR_SENDING_WEBHOOK_DATA = '🛑 Encountered error while sending webhook data in', VERIFICATION_CODE = 'Your Verification Code for Impler', RESET_PASSWORD = 'Reset Password | Impler', + PROJECT_INVITATION = 'You Have Invited to', + INVITATION_ACCEPTED = 'Invitation Accepted Successfully', + INVITATION_DECLINED = 'Has Declined the Invitation', } diff --git a/package.json b/package.json index 1a26b2f5f..74ef505ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impler.io", - "version": "0.26.1", + "version": "0.27.0", "description": "Open source infrastructure to import data easily", "packageManager": "pnpm@8.9.0", "private": true, diff --git a/packages/angular/package.json b/packages/angular/package.json index b7979f45a..12b6924f4 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@impler/angular", - "version": "0.26.1", + "version": "0.27.0", "description": "Angular library to show CSV Excel Importer in angular applications", "license": "MIT", "author": "implerhq", @@ -52,6 +52,6 @@ "typescript": "^4.4.4" }, "dependencies": { - "@impler/client": "^0.26.1" + "@impler/client": "^0.27.0" } } diff --git a/packages/client/package.json b/packages/client/package.json index 8abf6f963..42ed53f49 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@impler/client", - "version": "0.26.1", + "version": "0.27.0", "description": "API client to be used in end user environments", "license": "MIT", "author": "implerhq", diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index d31bd0222..6d9e53460 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -115,7 +115,7 @@ export interface ISchemaItem { | '<>'; selectValues?: string[]; dateFormats?: string[]; - type?: keyof typeof ColumnTypes; + type?: ValueOf; regex?: string; allowMultiSelect?: boolean; validations?: ValidationType[]; @@ -176,6 +176,8 @@ export type DeepPartial = T extends object } : T; +export type ValueOf = T[keyof T]; + export type CustomTexts = DeepPartial; export interface IUseImplerProps { diff --git a/packages/react/package.json b/packages/react/package.json index 8f006c6fd..0bb32f05b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@impler/react", - "version": "0.26.1", + "version": "0.27.0", "description": "React library to show CSV Excel Importer in react applications", "license": "MIT", "author": "implerhq", @@ -53,6 +53,6 @@ "typescript": "^4.4.4" }, "dependencies": { - "@impler/client": "^0.26.1" + "@impler/client": "^0.27.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d2df0bea..84b52e1ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,6 +367,12 @@ importers: '@amplitude/analytics-browser': specifier: ^2.0.1 version: 2.9.3 + '@casl/ability': + specifier: ^6.7.1 + version: 6.7.1 + '@casl/react': + specifier: ^4.0.0 + version: 4.0.0(@casl/ability@6.7.1)(react@18.2.0) '@emotion/react': specifier: ^11.10.5 version: 11.11.4(@types/react@18.3.3)(react@18.2.0) @@ -424,6 +430,9 @@ importers: chart.js: specifier: ^4.3.0 version: 4.4.3 + dayjs: + specifier: ^1.11.11 + version: 1.11.11 embla-carousel-react: specifier: ^7.0.9 version: 7.1.0(react@18.2.0) @@ -781,7 +790,7 @@ importers: specifier: '>=12.0.0' version: 12.2.17(rxjs@7.8.1)(zone.js@0.11.8) '@impler/client': - specifier: ^0.26.1 + specifier: ^0.27.0 version: link:../client devDependencies: '@angular/compiler': @@ -848,7 +857,7 @@ importers: packages/react: dependencies: '@impler/client': - specifier: ^0.26.1 + specifier: ^0.27.0 version: link:../client react: specifier: '>=16.8.0' @@ -3931,6 +3940,24 @@ packages: resolution: { integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== } + /@casl/ability@6.7.1: + resolution: + { integrity: sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw== } + dependencies: + '@ucast/mongo2js': 1.3.4 + dev: false + + /@casl/react@4.0.0(@casl/ability@6.7.1)(react@18.2.0): + resolution: + { integrity: sha512-ovmI4JfNw7TfVVV+XhAJ//gXgMEkkPJU6YBWFVFZGa8Oikdh8Qxr/sdXcqj71QWEHAGN7aSKMtBE0MZylPUVsg== } + peerDependencies: + '@casl/ability': ^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@casl/ability': 6.7.1 + react: 18.2.0 + dev: false + /@cnakazawa/watch@1.0.4: resolution: { integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ== } @@ -10365,6 +10392,34 @@ packages: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 + /@ucast/core@1.10.2: + resolution: + { integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g== } + dev: false + + /@ucast/js@3.0.4: + resolution: + { integrity: sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q== } + dependencies: + '@ucast/core': 1.10.2 + dev: false + + /@ucast/mongo2js@1.3.4: + resolution: + { integrity: sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA== } + dependencies: + '@ucast/core': 1.10.2 + '@ucast/js': 3.0.4 + '@ucast/mongo': 2.4.3 + dev: false + + /@ucast/mongo@2.4.3: + resolution: + { integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA== } + dependencies: + '@ucast/core': 1.10.2 + dev: false + /@ungap/structured-clone@1.2.0: resolution: { integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== }