feat(invitation): Improve invitation flow - Milestone 2 (#6804)
From PR: #6626 Resolves #6763 Resolves #6055 Resolves #6782 ## GTK I retain the 'Invite by link' feature to prevent any breaking changes. We could make the invitation by link optional through an admin setting, allowing users to rely solely on personal invitations. ## Todo - [x] Add an expiration date to an invitation - [x] Allow to renew an invitation to postpone the expiration date - [x] Refresh the UI - [x] Add the new personal token in the link sent to new user - [x] Display an error if a user tries to use an expired invitation - [x] Display an error if a user uses another mail than the one in the invitation --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -14,7 +14,7 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
|
||||
import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler';
|
||||
import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
@ -35,11 +35,15 @@ export class GraphqlQueryFindOneResolverService {
|
||||
): Promise<ObjectRecord | undefined> {
|
||||
const { authContext, objectMetadataItem, info, objectMetadataCollection } =
|
||||
options;
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
const dataSource =
|
||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
|
||||
authContext.workspace.id,
|
||||
objectMetadataItem.nameSingular,
|
||||
);
|
||||
|
||||
const repository = await dataSource.getRepository<ObjectRecord>(
|
||||
objectMetadataItem.nameSingular,
|
||||
);
|
||||
|
||||
const objectMetadataMap = generateObjectMetadataMap(
|
||||
objectMetadataCollection,
|
||||
);
|
||||
@ -89,6 +93,7 @@ export class GraphqlQueryFindOneResolverService {
|
||||
relations,
|
||||
limit,
|
||||
authContext,
|
||||
dataSource,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compu
|
||||
import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils';
|
||||
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
|
||||
import { Query } from 'src/engine/api/rest/core/types/query.type';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
|
||||
@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory';
|
||||
import {
|
||||
GraphqlApiType,
|
||||
RestApiService,
|
||||
} from 'src/engine/api/rest/rest-api.service';
|
||||
import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
|
||||
@Injectable()
|
||||
export class RestApiMetadataService {
|
||||
|
||||
@ -21,6 +21,7 @@ export enum AppTokenType {
|
||||
CodeChallenge = 'CODE_CHALLENGE',
|
||||
AuthorizationCode = 'AUTHORIZATION_CODE',
|
||||
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
|
||||
InvitationToken = 'INVITATION_TOKEN',
|
||||
}
|
||||
|
||||
@Entity({ name: 'appToken', schema: 'core' })
|
||||
@ -37,8 +38,8 @@ export class AppToken {
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: Relation<User>;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
@Column({ nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.appTokens, {
|
||||
onDelete: 'CASCADE',
|
||||
@ -73,4 +74,7 @@ export class AppToken {
|
||||
@Field()
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
context: { email: string } | null;
|
||||
}
|
||||
|
||||
@ -12,7 +12,8 @@ import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controller
|
||||
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
|
||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
@ -50,6 +51,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
]),
|
||||
HttpModule,
|
||||
TokenModule,
|
||||
UserWorkspaceModule,
|
||||
WorkspaceModule,
|
||||
OnboardingModule,
|
||||
@ -65,9 +67,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
providers: [
|
||||
SignInUpService,
|
||||
AuthService,
|
||||
TokenService,
|
||||
JwtAuthStrategy,
|
||||
AuthResolver,
|
||||
TokenService,
|
||||
GoogleAPIsService,
|
||||
AppTokenService,
|
||||
],
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import { CanActivate } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { CanActivate } from '@nestjs/common';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
import { TokenService } from './services/token.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { TokenService } from './token/services/token.service';
|
||||
|
||||
describe('AuthResolver', () => {
|
||||
let resolver: AuthResolver;
|
||||
|
||||
@ -37,7 +37,7 @@ import { VerifyInput } from './dto/verify.input';
|
||||
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
|
||||
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { TokenService } from './token/services/token.service';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
|
||||
@ -17,10 +17,10 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
|
||||
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
|
||||
import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard';
|
||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
|
||||
@Controller('auth/google-apis')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
|
||||
@ -13,8 +13,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
|
||||
import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard';
|
||||
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
|
||||
@Controller('auth/google')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -34,8 +34,14 @@ export class GoogleAuthController {
|
||||
@Get('redirect')
|
||||
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
|
||||
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
|
||||
const { firstName, lastName, email, picture, workspaceInviteHash } =
|
||||
req.user;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
} = req.user;
|
||||
|
||||
const user = await this.authService.signInUp({
|
||||
email,
|
||||
@ -43,6 +49,7 @@ export class GoogleAuthController {
|
||||
lastName,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
fromSSO: true,
|
||||
});
|
||||
|
||||
|
||||
@ -14,8 +14,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
|
||||
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
|
||||
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
|
||||
@Controller('auth/microsoft')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -39,8 +39,14 @@ export class MicrosoftAuthController {
|
||||
@Req() req: MicrosoftRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { firstName, lastName, email, picture, workspaceInviteHash } =
|
||||
req.user;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
} = req.user;
|
||||
|
||||
const user = await this.authService.signInUp({
|
||||
email,
|
||||
@ -48,6 +54,7 @@ export class MicrosoftAuthController {
|
||||
lastName,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
fromSSO: true,
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
|
||||
import { VerifyAuthController } from './verify-auth.controller';
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
|
||||
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
|
||||
@Controller('auth/verify')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
|
||||
@ -19,6 +19,11 @@ export class SignUpInput {
|
||||
@IsOptional()
|
||||
workspaceInviteHash?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
workspacePersonalInviteToken?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class WorkspaceInviteTokenInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(10)
|
||||
inviteToken: string;
|
||||
}
|
||||
@ -12,11 +12,20 @@ export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const workspaceInviteHash = request.query.inviteHash;
|
||||
const workspacePersonalInviteToken = request.query.inviteToken;
|
||||
|
||||
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
|
||||
request.params.workspaceInviteHash = workspaceInviteHash;
|
||||
}
|
||||
|
||||
if (
|
||||
workspacePersonalInviteToken &&
|
||||
typeof workspacePersonalInviteToken === 'string'
|
||||
) {
|
||||
request.params.workspacePersonalInviteToken =
|
||||
workspacePersonalInviteToken;
|
||||
}
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,20 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const workspaceInviteHash = request.query.inviteHash;
|
||||
const workspacePersonalInviteToken = request.query.inviteToken;
|
||||
|
||||
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
|
||||
request.params.workspaceInviteHash = workspaceInviteHash;
|
||||
}
|
||||
|
||||
if (
|
||||
workspacePersonalInviteToken &&
|
||||
typeof workspacePersonalInviteToken === 'string'
|
||||
) {
|
||||
request.params.workspacePersonalInviteToken =
|
||||
workspacePersonalInviteToken;
|
||||
}
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
@ -32,14 +32,13 @@ import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity'
|
||||
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
|
||||
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@ -94,6 +93,7 @@ export class AuthService {
|
||||
email,
|
||||
password,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
@ -104,6 +104,7 @@ export class AuthService {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
workspaceInviteHash?: string | null;
|
||||
workspacePersonalInviteToken?: string | null;
|
||||
picture?: string | null;
|
||||
fromSSO: boolean;
|
||||
}) {
|
||||
@ -113,6 +114,7 @@ export class AuthService {
|
||||
firstName,
|
||||
lastName,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
picture,
|
||||
fromSSO,
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
|
||||
describe('SignInUpService', () => {
|
||||
let service: SignInUpService;
|
||||
@ -29,6 +30,10 @@ describe('SignInUpService', () => {
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {},
|
||||
|
||||
@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import FileType from 'file-type';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
import { isDefined } from 'class-validator';
|
||||
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
@ -27,6 +28,7 @@ import {
|
||||
} from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
|
||||
export type SignInUpServiceInput = {
|
||||
email: string;
|
||||
@ -34,6 +36,7 @@ export type SignInUpServiceInput = {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
workspaceInviteHash?: string | null;
|
||||
workspacePersonalInviteToken?: string | null;
|
||||
picture?: string | null;
|
||||
fromSSO: boolean;
|
||||
};
|
||||
@ -45,6 +48,8 @@ export class SignInUpService {
|
||||
private readonly fileUploadService: FileUploadService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
@ -56,6 +61,7 @@ export class SignInUpService {
|
||||
async signInUp({
|
||||
email,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
@ -111,6 +117,7 @@ export class SignInUpService {
|
||||
email,
|
||||
passwordHash,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
@ -134,6 +141,7 @@ export class SignInUpService {
|
||||
email,
|
||||
passwordHash,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
@ -141,19 +149,25 @@ export class SignInUpService {
|
||||
}: {
|
||||
email: string;
|
||||
passwordHash: string | undefined;
|
||||
workspaceInviteHash: string;
|
||||
workspaceInviteHash: string | null;
|
||||
workspacePersonalInviteToken: string | null | undefined;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
picture: SignInUpServiceInput['picture'];
|
||||
existingUser: User | null;
|
||||
}) {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
inviteHash: workspaceInviteHash,
|
||||
const isNewUser = !isDefined(existingUser);
|
||||
let user = existingUser;
|
||||
|
||||
const workspace = await this.findWorkspaceAndValidateInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Invit hash is invalid',
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
@ -165,32 +179,76 @@ export class SignInUpService {
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
|
||||
existingUser,
|
||||
workspace,
|
||||
);
|
||||
if (isNewUser) {
|
||||
const imagePath = await this.uploadPicture(picture, workspace.id);
|
||||
|
||||
return Object.assign(existingUser, updatedUser);
|
||||
const userToCreate = this.userRepository.create({
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
defaultAvatarUrl: imagePath,
|
||||
canImpersonate: false,
|
||||
passwordHash,
|
||||
defaultWorkspace: workspace,
|
||||
});
|
||||
|
||||
user = await this.userRepository.save(userToCreate);
|
||||
}
|
||||
|
||||
const imagePath = await this.uploadPicture(picture, workspace.id);
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const userToCreate = this.userRepository.create({
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
defaultAvatarUrl: imagePath,
|
||||
canImpersonate: false,
|
||||
passwordHash,
|
||||
defaultWorkspace: workspace,
|
||||
});
|
||||
const updatedUser = workspacePersonalInviteToken
|
||||
? await this.userWorkspaceService.addUserToWorkspaceByInviteToken(
|
||||
workspacePersonalInviteToken,
|
||||
user,
|
||||
)
|
||||
: await this.userWorkspaceService.addUserToWorkspace(user, workspace);
|
||||
|
||||
const user = await this.userRepository.save(userToCreate);
|
||||
if (isNewUser) {
|
||||
await this.activateOnboardingForNewUser(user, workspace, {
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
}
|
||||
|
||||
await this.userWorkspaceService.create(user.id, workspace.id);
|
||||
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
|
||||
return Object.assign(user, updatedUser);
|
||||
}
|
||||
|
||||
private async findWorkspaceAndValidateInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
}) {
|
||||
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
|
||||
throw new Error('No invite token or hash provided');
|
||||
}
|
||||
|
||||
if (!workspacePersonalInviteToken && workspaceInviteHash) {
|
||||
return (
|
||||
(await this.workspaceRepository.findOneBy({
|
||||
inviteHash: workspaceInviteHash,
|
||||
})) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
const appToken = await this.userWorkspaceService.validateInvitation(
|
||||
workspacePersonalInviteToken,
|
||||
email,
|
||||
);
|
||||
|
||||
return appToken?.workspace;
|
||||
}
|
||||
|
||||
private async activateOnboardingForNewUser(
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
{ firstName, lastName }: { firstName: string; lastName: string },
|
||||
) {
|
||||
await this.onboardingService.setOnboardingConnectAccountPending({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
@ -204,8 +262,6 @@ export class SignInUpService {
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async signUpOnNewWorkspace({
|
||||
|
||||
@ -16,6 +16,7 @@ export type GoogleRequest = Omit<
|
||||
email: string;
|
||||
picture: string | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -36,6 +37,12 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
...options,
|
||||
state: JSON.stringify({
|
||||
workspaceInviteHash: req.params.workspaceInviteHash,
|
||||
...(req.params.workspacePersonalInviteToken
|
||||
? {
|
||||
workspacePersonalInviteToken:
|
||||
req.params.workspacePersonalInviteToken,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -61,6 +68,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
lastName: name.familyName,
|
||||
picture: photos?.[0]?.value,
|
||||
workspaceInviteHash: state.workspaceInviteHash,
|
||||
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
|
||||
@ -20,6 +20,7 @@ export type MicrosoftRequest = Omit<
|
||||
email: string;
|
||||
picture: string | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -40,6 +41,12 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
...options,
|
||||
state: JSON.stringify({
|
||||
workspaceInviteHash: req.params.workspaceInviteHash,
|
||||
...(req.params.workspacePersonalInviteToken
|
||||
? {
|
||||
workspacePersonalInviteToken:
|
||||
req.params.workspacePersonalInviteToken,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -75,6 +82,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
lastName: name.familyName,
|
||||
picture: photos?.[0]?.value,
|
||||
workspaceInviteHash: state.workspaceInviteHash,
|
||||
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
|
||||
@ -175,6 +175,33 @@ export class TokenService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateInvitationToken(workspaceId: string, email: string) {
|
||||
const expiresIn = this.environmentService.get(
|
||||
'INVITATION_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for invitation token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const invitationToken = this.appTokenRepository.create({
|
||||
workspaceId,
|
||||
expiresAt,
|
||||
type: AppTokenType.InvitationToken,
|
||||
value: crypto.randomBytes(32).toString('hex'),
|
||||
context: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return this.appTokenRepository.save(invitationToken);
|
||||
}
|
||||
|
||||
async generateLoginToken(email: string): Promise<AuthToken> {
|
||||
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
|
||||
@ -416,7 +443,7 @@ export class TokenService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!codeChallengeAppToken) {
|
||||
if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
|
||||
throw new AuthException(
|
||||
'code verifier doesnt match the challenge',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
@ -750,7 +777,7 @@ export class TokenService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
if (!token || !token.userId) {
|
||||
throw new AuthException(
|
||||
'Token is invalid',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
@ -0,0 +1,26 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule,
|
||||
TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'),
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
EmailModule,
|
||||
],
|
||||
providers: [TokenService, JwtAuthStrategy],
|
||||
exports: [TokenService],
|
||||
})
|
||||
export class TokenModule {}
|
||||
@ -13,19 +13,19 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
FeatureFlagModule,
|
||||
StripeModule,
|
||||
UserWorkspaceModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[
|
||||
BillingSubscription,
|
||||
BillingSubscriptionItem,
|
||||
Workspace,
|
||||
UserWorkspace,
|
||||
FeatureFlagEntity,
|
||||
],
|
||||
'core',
|
||||
|
||||
@ -6,10 +6,10 @@ import { Repository } from 'typeorm';
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { assert } from 'src/utils/assert';
|
||||
|
||||
export enum WebhookEvent {
|
||||
@ -24,10 +24,11 @@ export class BillingPortalWorkspaceService {
|
||||
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
) {}
|
||||
|
||||
@ -42,8 +43,9 @@ export class BillingPortalWorkspaceService {
|
||||
? frontBaseUrl + successUrlPath
|
||||
: frontBaseUrl;
|
||||
|
||||
const quantity =
|
||||
(await this.userWorkspaceService.getUserCount(workspace.id)) || 1;
|
||||
const quantity = await this.userWorkspaceRepository.countBy({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const stripeCustomerId = (
|
||||
await this.billingSubscriptionRepository.findOneBy({
|
||||
|
||||
@ -39,6 +39,7 @@ import { llmTracingModuleFactory } from 'src/engine/core-modules/llm-tracing/llm
|
||||
import { ServerlessModule } from 'src/engine/core-modules/serverless/serverless.module';
|
||||
import { serverlessModuleFactory } from 'src/engine/core-modules/serverless/serverless-module.factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
|
||||
import { FileModule } from './file/file.module';
|
||||
import { ClientConfigModule } from './client-config/client-config.module';
|
||||
@ -59,6 +60,7 @@ import { AnalyticsModule } from './analytics/analytics.module';
|
||||
TimelineCalendarEventModule,
|
||||
UserModule,
|
||||
WorkspaceModule,
|
||||
WorkspaceInvitationModule,
|
||||
AISQLQueryModule,
|
||||
PostgresCredentialsModule,
|
||||
WorkflowTriggerApiModule,
|
||||
@ -114,6 +116,7 @@ import { AnalyticsModule } from './analytics/analytics.module';
|
||||
TimelineCalendarEventModule,
|
||||
UserModule,
|
||||
WorkspaceModule,
|
||||
WorkspaceInvitationModule,
|
||||
],
|
||||
})
|
||||
export class CoreEngineModule {}
|
||||
|
||||
@ -150,6 +150,10 @@ export class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
FILE_TOKEN_EXPIRES_IN = '1d';
|
||||
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
INVITATION_TOKEN_EXPIRES_IN = '30d';
|
||||
|
||||
// Auth
|
||||
@IsUrl({ require_tld: false })
|
||||
@IsOptional()
|
||||
|
||||
@ -6,6 +6,7 @@ import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-dem
|
||||
import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-workspace/jobs/data-seed-demo-workspace.job';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { WorkspaceQueryRunnerJobModule } from 'src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { UpdateSubscriptionJob } from 'src/engine/core-modules/billing/jobs/update-subscription.job';
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
@ -39,6 +40,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
BillingModule,
|
||||
UserWorkspaceModule,
|
||||
WorkspaceModule,
|
||||
AuthModule,
|
||||
MessagingModule,
|
||||
CalendarModule,
|
||||
CalendarEventParticipantManagerModule,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
describe('OpenApiService', () => {
|
||||
let service: OpenApiService;
|
||||
|
||||
@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { OpenAPIV3_1 } from 'openapi-types';
|
||||
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
|
||||
import {
|
||||
computeMetadataSchemaComponents,
|
||||
@ -33,7 +34,6 @@ import {
|
||||
getFindOneResponse200,
|
||||
getUpdateOneResponse200,
|
||||
} from 'src/engine/core-modules/open-api/utils/responses.utils';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
import { getServerUrl } from 'src/utils/get-server-url';
|
||||
|
||||
@ -6,18 +6,24 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'),
|
||||
NestjsQueryTypeOrmModule.forFeature(
|
||||
[User, UserWorkspace, AppToken],
|
||||
'core',
|
||||
),
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceInvitationModule,
|
||||
],
|
||||
services: [UserWorkspaceService],
|
||||
}),
|
||||
|
||||
@ -11,6 +11,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceInviteTokenInput } from 'src/engine/core-modules/auth/dto/workspace-invite-token.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver(() => UserWorkspace)
|
||||
@ -18,9 +20,8 @@ export class UserWorkspaceResolver {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => User)
|
||||
@ -36,6 +37,22 @@ export class UserWorkspaceResolver {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
workspace.id,
|
||||
user.email,
|
||||
);
|
||||
|
||||
return await this.userWorkspaceService.addUserToWorkspace(user, workspace);
|
||||
}
|
||||
|
||||
@Mutation(() => User)
|
||||
async addUserToWorkspaceByInviteToken(
|
||||
@AuthUser() user: User,
|
||||
@Args() workspaceInviteTokenInput: WorkspaceInviteTokenInput,
|
||||
) {
|
||||
return this.userWorkspaceService.addUserToWorkspaceByInviteToken(
|
||||
workspaceInviteTokenInput.inviteToken,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,11 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
|
||||
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
constructor(
|
||||
@ -20,8 +25,11 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
) {
|
||||
super(userWorkspaceRepository);
|
||||
@ -105,6 +113,41 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
});
|
||||
}
|
||||
|
||||
async validateInvitation(inviteToken: string, email: string) {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: inviteToken,
|
||||
type: AppTokenType.InvitationToken,
|
||||
},
|
||||
relations: ['workspace'],
|
||||
});
|
||||
|
||||
if (!appToken) {
|
||||
throw new Error('Invalid invitation token');
|
||||
}
|
||||
|
||||
if (appToken.context?.email !== email) {
|
||||
throw new Error('Email does not match the invitation');
|
||||
}
|
||||
|
||||
if (new Date(appToken.expiresAt) < new Date()) {
|
||||
throw new Error('Invitation expired');
|
||||
}
|
||||
|
||||
return appToken;
|
||||
}
|
||||
|
||||
async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) {
|
||||
const appToken = await this.validateInvitation(inviteToken, user.email);
|
||||
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
appToken.workspace.id,
|
||||
user.email,
|
||||
);
|
||||
|
||||
return await this.addUserToWorkspace(user, appToken.workspace);
|
||||
}
|
||||
|
||||
public async getUserCount(workspaceId): Promise<number | undefined> {
|
||||
return await this.userWorkspaceRepository.countBy({
|
||||
workspaceId,
|
||||
@ -120,4 +163,18 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async checkUserWorkspaceExistsByEmail(email: string, workspaceId: string) {
|
||||
return this.userWorkspaceRepository.exists({
|
||||
where: {
|
||||
workspaceId,
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
|
||||
import { ArrayUnique, IsArray, IsEmail } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class SendInviteLinkInput {
|
||||
export class SendInvitationsInput {
|
||||
@Field(() => [String])
|
||||
@IsArray()
|
||||
@IsEmail({}, { each: true })
|
||||
@ -0,0 +1,17 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto';
|
||||
|
||||
@ObjectType()
|
||||
export class SendInvitationsOutput {
|
||||
@Field(() => Boolean, {
|
||||
description: 'Boolean that confirms query was dispatched',
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@Field(() => [String])
|
||||
errors: Array<string>;
|
||||
|
||||
@Field(() => [WorkspaceInvitation])
|
||||
result: Array<WorkspaceInvitation>;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('WorkspaceInvitation')
|
||||
export class WorkspaceInvitation {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
email: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
expiresAt: Date;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
import { WorkspaceInvitationService } from './workspace-invitation.service';
|
||||
|
||||
describe('WorkspaceInvitationService', () => {
|
||||
let service: WorkspaceInvitationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceInvitationService,
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspace, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: OnboardingService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceInvitationService>(
|
||||
WorkspaceInvitationService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,293 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { SendInviteLinkEmail } from 'twenty-emails';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output';
|
||||
import {
|
||||
WorkspaceInvitationException,
|
||||
WorkspaceInvitationExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class WorkspaceInvitationService {
|
||||
constructor(
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly tokenService: TokenService,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
) {}
|
||||
|
||||
private async getOneWorkspaceInvitation(workspaceId: string, email: string) {
|
||||
return await this.appTokenRepository
|
||||
.createQueryBuilder('appToken')
|
||||
.where('"appToken"."workspaceId" = :workspaceId', {
|
||||
workspaceId,
|
||||
})
|
||||
.andWhere('"appToken".type = :type', {
|
||||
type: AppTokenType.InvitationToken,
|
||||
})
|
||||
.andWhere('"appToken".context->>\'email\' = :email', { email })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
castAppTokenToWorkspaceInvitation(appToken: AppToken) {
|
||||
if (appToken.type !== AppTokenType.InvitationToken) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`Token type must be "${AppTokenType.InvitationToken}"`,
|
||||
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`Invitation corrupted: Missing email in context`,
|
||||
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: appToken.id,
|
||||
email: appToken.context.email,
|
||||
expiresAt: appToken.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async createWorkspaceInvitation(email: string, workspace: Workspace) {
|
||||
const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation(
|
||||
workspace.id,
|
||||
email.toLowerCase(),
|
||||
);
|
||||
|
||||
if (maybeWorkspaceInvitation) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`${email} already invited`,
|
||||
WorkspaceInvitationExceptionCode.INVITATION_ALREADY_EXIST,
|
||||
);
|
||||
}
|
||||
|
||||
const isUserAlreadyInWorkspace = await this.userWorkspaceRepository.exists({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isUserAlreadyInWorkspace) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`${email} is already in the workspace`,
|
||||
WorkspaceInvitationExceptionCode.USER_ALREADY_EXIST,
|
||||
);
|
||||
}
|
||||
|
||||
return this.tokenService.generateInvitationToken(workspace.id, email);
|
||||
}
|
||||
|
||||
async loadWorkspaceInvitations(workspace: Workspace) {
|
||||
const appTokens = await this.appTokenRepository.find({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
type: AppTokenType.InvitationToken,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
select: {
|
||||
value: false,
|
||||
},
|
||||
});
|
||||
|
||||
return appTokens.map(this.castAppTokenToWorkspaceInvitation);
|
||||
}
|
||||
|
||||
async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
id: appTokenId,
|
||||
workspaceId,
|
||||
type: AppTokenType.InvitationToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appToken) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
await this.appTokenRepository.delete(appToken.id);
|
||||
|
||||
return 'success';
|
||||
}
|
||||
|
||||
async invalidateWorkspaceInvitation(workspaceId: string, email: string) {
|
||||
const appToken = await this.getOneWorkspaceInvitation(workspaceId, email);
|
||||
|
||||
if (appToken) {
|
||||
await this.appTokenRepository.delete(appToken.id);
|
||||
}
|
||||
}
|
||||
|
||||
async resendWorkspaceInvitation(
|
||||
appTokenId: string,
|
||||
workspace: Workspace,
|
||||
sender: User,
|
||||
) {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
id: appTokenId,
|
||||
workspaceId: workspace.id,
|
||||
type: AppTokenType.InvitationToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appToken || !appToken.context || !('email' in appToken.context)) {
|
||||
throw new WorkspaceInvitationException(
|
||||
'Invalid appToken',
|
||||
WorkspaceInvitationExceptionCode.INVALID_INVITATION,
|
||||
);
|
||||
}
|
||||
|
||||
await this.appTokenRepository.delete(appToken.id);
|
||||
|
||||
return this.sendInvitations([appToken.context.email], workspace, sender);
|
||||
}
|
||||
|
||||
async sendInvitations(
|
||||
emails: string[],
|
||||
workspace: Workspace,
|
||||
sender: User,
|
||||
usePersonalInvitation = true,
|
||||
): Promise<SendInvitationsOutput> {
|
||||
if (!workspace?.inviteHash) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['Workspace invite hash not found'],
|
||||
result: [],
|
||||
};
|
||||
}
|
||||
|
||||
const invitationsPr = await Promise.allSettled(
|
||||
emails.map(async (email) => {
|
||||
if (usePersonalInvitation) {
|
||||
const appToken = await this.createWorkspaceInvitation(
|
||||
email,
|
||||
workspace,
|
||||
);
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new WorkspaceInvitationException(
|
||||
'Invalid email',
|
||||
WorkspaceInvitationExceptionCode.EMAIL_MISSING,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isPersonalInvitation: true as const,
|
||||
appToken,
|
||||
email: appToken.context.email,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isPersonalInvitation: false as const,
|
||||
email,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||
|
||||
for (const invitation of invitationsPr) {
|
||||
if (invitation.status === 'fulfilled') {
|
||||
const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`);
|
||||
|
||||
if (invitation.value.isPersonalInvitation) {
|
||||
link.searchParams.set('inviteToken', invitation.value.appToken.value);
|
||||
}
|
||||
const emailData = {
|
||||
link: link.toString(),
|
||||
workspace: { name: workspace.displayName, logo: workspace.logo },
|
||||
sender: { email: sender.email, firstName: sender.firstName },
|
||||
serverUrl: this.environmentService.get('SERVER_URL'),
|
||||
};
|
||||
|
||||
const emailTemplate = SendInviteLinkEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: invitation.value.email,
|
||||
subject: 'Join your team on Twenty',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.onboardingService.setOnboardingInviteTeamPending({
|
||||
workspaceId: workspace.id,
|
||||
value: false,
|
||||
});
|
||||
|
||||
const result = invitationsPr.reduce<{
|
||||
errors: string[];
|
||||
result: ReturnType<
|
||||
typeof this.workspaceInvitationService.createWorkspaceInvitation
|
||||
>['status'] extends 'rejected'
|
||||
? never
|
||||
: ReturnType<
|
||||
typeof this.workspaceInvitationService.appTokenToWorkspaceInvitation
|
||||
>;
|
||||
}>(
|
||||
(acc, invitation) => {
|
||||
if (invitation.status === 'rejected') {
|
||||
acc.errors.push(invitation.reason?.message ?? 'Unknown error');
|
||||
} else {
|
||||
acc.result.push(
|
||||
invitation.value.isPersonalInvitation
|
||||
? this.castAppTokenToWorkspaceInvitation(
|
||||
invitation.value.appToken,
|
||||
)
|
||||
: { email: invitation.value.email },
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ errors: [], result: [] },
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.errors.length === 0,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceInvitationException extends CustomException {
|
||||
code: WorkspaceInvitationExceptionCode;
|
||||
constructor(message: string, code: WorkspaceInvitationExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum WorkspaceInvitationExceptionCode {
|
||||
INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',
|
||||
INVITATION_CORRUPTED = 'INVITATION_CORRUPTED',
|
||||
INVITATION_ALREADY_EXIST = 'INVITATION_ALREADY_EXIST',
|
||||
USER_ALREADY_EXIST = 'USER_ALREADY_EXIST',
|
||||
INVALID_INVITATION = 'INVALID_INVITATION',
|
||||
EMAIL_MISSING = 'EMAIL_MISSING',
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'),
|
||||
TokenModule,
|
||||
OnboardingModule,
|
||||
],
|
||||
exports: [WorkspaceInvitationService],
|
||||
providers: [WorkspaceInvitationService, WorkspaceInvitationResolver],
|
||||
})
|
||||
export class WorkspaceInvitationModule {}
|
||||
@ -0,0 +1,66 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
|
||||
import { SendInvitationsInput } from './dtos/send-invitations.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver()
|
||||
export class WorkspaceInvitationResolver {
|
||||
constructor(
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => String)
|
||||
async deleteWorkspaceInvitation(
|
||||
@Args('appTokenId') appTokenId: string,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.workspaceInvitationService.deleteWorkspaceInvitation(
|
||||
appTokenId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => SendInvitationsOutput)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async resendWorkspaceInvitation(
|
||||
@Args('appTokenId') appTokenId: string,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.workspaceInvitationService.resendWorkspaceInvitation(
|
||||
appTokenId,
|
||||
workspace,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceInvitation])
|
||||
async findWorkspaceInvitations(@AuthWorkspace() workspace: Workspace) {
|
||||
return this.workspaceInvitationService.loadWorkspaceInvitations(workspace);
|
||||
}
|
||||
|
||||
@Mutation(() => SendInvitationsOutput)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async sendInvitations(
|
||||
@Args() sendInviteLinkInput: SendInvitationsInput,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<SendInvitationsOutput> {
|
||||
return await this.workspaceInvitationService.sendInvitations(
|
||||
sendInviteLinkInput.emails,
|
||||
workspace,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class SendInviteLink {
|
||||
@Field(() => Boolean, {
|
||||
description: 'Boolean that confirms query was dispatched',
|
||||
})
|
||||
success: boolean;
|
||||
}
|
||||
@ -11,6 +11,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
@ -61,6 +62,10 @@ describe('WorkspaceService', () => {
|
||||
provide: OnboardingService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceInvitationService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { render } from '@react-email/render';
|
||||
import { SendInviteLinkEmail } from 'twenty-emails';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
|
||||
import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity';
|
||||
import {
|
||||
Workspace,
|
||||
WorkspaceActivationStatus,
|
||||
@ -25,6 +20,7 @@ import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-
|
||||
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private userWorkspaceService: UserWorkspaceService;
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@ -33,13 +29,13 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly workspaceManagerService: WorkspaceManagerService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
async activateWorkspace(user: User, data: ActivateWorkspaceInput) {
|
||||
@ -66,7 +62,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
existingWorkspace.activationStatus !==
|
||||
WorkspaceActivationStatus.PENDING_CREATION
|
||||
) {
|
||||
throw new Error('Worspace is not pending creation');
|
||||
throw new Error('Workspace is not pending creation');
|
||||
}
|
||||
|
||||
await this.workspaceRepository.update(user.defaultWorkspaceId, {
|
||||
@ -123,53 +119,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
|
||||
}
|
||||
|
||||
async sendInviteLink(
|
||||
emails: string[],
|
||||
workspace: Workspace,
|
||||
sender: User,
|
||||
): Promise<SendInviteLink> {
|
||||
if (!workspace?.inviteHash) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||
const inviteLink = `${frontBaseURL}/invite/${workspace.inviteHash}`;
|
||||
|
||||
for (const email of emails) {
|
||||
const emailData = {
|
||||
link: inviteLink,
|
||||
workspace: { name: workspace.displayName, logo: workspace.logo },
|
||||
sender: { email: sender.email, firstName: sender.firstName },
|
||||
serverUrl: this.environmentService.get('SERVER_URL'),
|
||||
};
|
||||
const emailTemplate = SendInviteLinkEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
subject: 'Join your team on Twenty',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
await this.onboardingService.setOnboardingInviteTeamPending({
|
||||
workspaceId: workspace.id,
|
||||
value: false,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async reassignOrRemoveUserDefaultWorkspace(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
|
||||
@ -18,6 +18,7 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
|
||||
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
|
||||
import { Workspace } from './workspace.entity';
|
||||
@ -42,6 +43,7 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
DataSourceModule,
|
||||
OnboardingModule,
|
||||
TypeORMModule,
|
||||
WorkspaceInvitationModule,
|
||||
],
|
||||
services: [WorkspaceService],
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
|
||||
@ -19,8 +19,6 @@ import { FileService } from 'src/engine/core-modules/file/services/file.service'
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
|
||||
import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity';
|
||||
import { SendInviteLinkInput } from 'src/engine/core-modules/workspace/dtos/send-invite-link.input';
|
||||
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
@ -138,18 +136,4 @@ export class WorkspaceResolver {
|
||||
|
||||
return workspace.logo ?? '';
|
||||
}
|
||||
|
||||
@Mutation(() => SendInviteLink)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async sendInviteLink(
|
||||
@Args() sendInviteLinkInput: SendInviteLinkInput,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<SendInviteLink> {
|
||||
return await this.workspaceService.sendInviteLink(
|
||||
sendInviteLinkInput.emails,
|
||||
workspace,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -3,7 +3,7 @@ import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
||||
|
||||
Reference in New Issue
Block a user