feat: user can have multiple workspaces (backend) (#4036)
* create user-workspace mapping * user-workspace service and integration * invite condition on sign-up/sign-in * save/update defaultWorkspace on signup * add unique decorator on user-workspace entity * remove resolver permissions * Fixes * Fixes * Fix tests * Fixes --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -72,14 +72,24 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
|||||||
},
|
},
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data?.checkUserExists.exists) {
|
if (data?.checkUserExists.exists) {
|
||||||
setSignInUpMode(SignInUpMode.SignIn);
|
isMatchingLocation(AppPath.Invite)
|
||||||
|
? setSignInUpMode(SignInUpMode.Invite)
|
||||||
|
: setSignInUpMode(SignInUpMode.SignIn);
|
||||||
} else {
|
} else {
|
||||||
setSignInUpMode(SignInUpMode.SignUp);
|
isMatchingLocation(AppPath.Invite)
|
||||||
|
? setSignInUpMode(SignInUpMode.Invite)
|
||||||
|
: setSignInUpMode(SignInUpMode.SignUp);
|
||||||
}
|
}
|
||||||
setSignInUpStep(SignInUpStep.Password);
|
setSignInUpStep(SignInUpStep.Password);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]);
|
}, [
|
||||||
|
isMatchingLocation,
|
||||||
|
setSignInUpStep,
|
||||||
|
checkUserExistsQuery,
|
||||||
|
form,
|
||||||
|
setSignInUpMode,
|
||||||
|
]);
|
||||||
|
|
||||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||||
async (data) => {
|
async (data) => {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { GoogleGmailAuthController } from 'src/core/auth/controllers/google-gmai
|
|||||||
import { VerifyAuthController } from 'src/core/auth/controllers/verify-auth.controller';
|
import { VerifyAuthController } from 'src/core/auth/controllers/verify-auth.controller';
|
||||||
import { TokenService } from 'src/core/auth/services/token.service';
|
import { TokenService } from 'src/core/auth/services/token.service';
|
||||||
import { GoogleGmailService } from 'src/core/auth/services/google-gmail.service';
|
import { GoogleGmailService } from 'src/core/auth/services/google-gmail.service';
|
||||||
|
import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
|
||||||
|
|
||||||
import { AuthResolver } from './auth.resolver';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ const jwtModule = JwtModule.registerAsync({
|
|||||||
TypeORMModule,
|
TypeORMModule,
|
||||||
TypeOrmModule.forFeature([Workspace, User, RefreshToken], 'core'),
|
TypeOrmModule.forFeature([Workspace, User, RefreshToken], 'core'),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
|
UserWorkspaceModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
GoogleAuthController,
|
GoogleAuthController,
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export class AuthResolver {
|
|||||||
@Mutation(() => LoginToken)
|
@Mutation(() => LoginToken)
|
||||||
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
|
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
|
||||||
const user = await this.authService.signUp(signUpInput);
|
const user = await this.authService.signUp(signUpInput);
|
||||||
|
|
||||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||||
|
|
||||||
return { loginToken };
|
return { loginToken };
|
||||||
@ -120,6 +121,8 @@ export class AuthResolver {
|
|||||||
verifyInput.loginToken,
|
verifyInput.loginToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert(email, 'Invalid token', ForbiddenException);
|
||||||
|
|
||||||
const result = await this.authService.verify(email);
|
const result = await this.authService.verify(email);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
|
|||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
import { EmailService } from 'src/integrations/email/email.service';
|
import { EmailService } from 'src/integrations/email/email.service';
|
||||||
|
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
@ -28,6 +29,10 @@ describe('AuthService', () => {
|
|||||||
provide: UserService,
|
provide: UserService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: UserWorkspaceService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: WorkspaceManagerService,
|
provide: WorkspaceManagerService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
|
|||||||
import { EmailService } from 'src/integrations/email/email.service';
|
import { EmailService } from 'src/integrations/email/email.service';
|
||||||
import { UpdatePassword } from 'src/core/auth/dto/update-password.entity';
|
import { UpdatePassword } from 'src/core/auth/dto/update-password.entity';
|
||||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||||
|
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
|
||||||
|
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
@ -54,6 +55,7 @@ export class AuthService {
|
|||||||
private readonly workspaceRepository: Repository<Workspace>,
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
@InjectRepository(User, 'core')
|
@InjectRepository(User, 'core')
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly userWorkspaceService: UserWorkspaceService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
@ -95,11 +97,16 @@ export class AuthService {
|
|||||||
if (!firstName) firstName = '';
|
if (!firstName) firstName = '';
|
||||||
if (!lastName) lastName = '';
|
if (!lastName) lastName = '';
|
||||||
|
|
||||||
const existingUser = await this.userRepository.findOneBy({
|
const existingUser = await this.userRepository.findOne({
|
||||||
email: email,
|
where: {
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
relations: ['defaultWorkspace'],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(!existingUser, 'This user already exists', ForbiddenException);
|
if (existingUser && !workspaceInviteHash) {
|
||||||
|
assert(!existingUser, 'This user already exists', ForbiddenException);
|
||||||
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const isPasswordValid = PASSWORD_REGEX.test(password);
|
const isPasswordValid = PASSWORD_REGEX.test(password);
|
||||||
@ -157,6 +164,31 @@ export class AuthService {
|
|||||||
imagePath = paths[0];
|
imagePath = paths[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingUser && workspaceInviteHash) {
|
||||||
|
const userWorkspaceExists =
|
||||||
|
await this.userWorkspaceService.checkUserWorkspaceExists(
|
||||||
|
existingUser.id,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userWorkspaceExists) {
|
||||||
|
await this.userWorkspaceService.create(existingUser.id, workspace.id);
|
||||||
|
|
||||||
|
await this.userWorkspaceService.createWorkspaceMember(
|
||||||
|
workspace.id,
|
||||||
|
existingUser,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await this.userRepository.save({
|
||||||
|
id: existingUser.id,
|
||||||
|
defaultWorkspace: workspace,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign(existingUser, updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
const userToCreate = this.userRepository.create({
|
const userToCreate = this.userRepository.create({
|
||||||
email: email,
|
email: email,
|
||||||
firstName: firstName,
|
firstName: firstName,
|
||||||
@ -169,9 +201,8 @@ export class AuthService {
|
|||||||
|
|
||||||
const user = await this.userRepository.save(userToCreate);
|
const user = await this.userRepository.save(userToCreate);
|
||||||
|
|
||||||
if (workspaceInviteHash) {
|
await this.userWorkspaceService.create(user.id, workspace.id);
|
||||||
await this.userService.createWorkspaceMember(user);
|
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { User } from 'src/core/user/user.entity';
|
||||||
|
import { Workspace } from 'src/core/workspace/workspace.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'userWorkspace', schema: 'core' })
|
||||||
|
@ObjectType('UserWorkspace')
|
||||||
|
@Unique('IndexOnUserIdAndWorkspaceIdUnique', ['userId', 'workspaceId'])
|
||||||
|
export class UserWorkspace {
|
||||||
|
@IDField(() => ID)
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@JoinColumn({ name: 'workspaceId' })
|
||||||
|
workspace: Workspace;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
workspaceId: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
@UpdateDateColumn({ type: 'timestamp with time zone' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
@Column('timestamp with time zone')
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||||
|
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||||
|
|
||||||
|
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||||
|
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
|
||||||
|
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
|
||||||
|
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
NestjsQueryGraphQLModule.forFeature({
|
||||||
|
imports: [
|
||||||
|
NestjsQueryTypeOrmModule.forFeature([UserWorkspace], 'core'),
|
||||||
|
TypeORMModule,
|
||||||
|
DataSourceModule,
|
||||||
|
],
|
||||||
|
services: [UserWorkspaceService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
exports: [UserWorkspaceService],
|
||||||
|
providers: [UserWorkspaceService],
|
||||||
|
})
|
||||||
|
export class UserWorkspaceModule {}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
|
||||||
|
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||||
|
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
||||||
|
import { User } from 'src/core/user/user.entity';
|
||||||
|
|
||||||
|
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserWorkspace, 'core')
|
||||||
|
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||||
|
private readonly dataSourceService: DataSourceService,
|
||||||
|
private readonly typeORMService: TypeORMService,
|
||||||
|
) {
|
||||||
|
super(userWorkspaceRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(userId: string, workspaceId: string): Promise<UserWorkspace> {
|
||||||
|
const userWorkspace = this.userWorkspaceRepository.create({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.userWorkspaceRepository.save(userWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorkspaceMember(workspaceId: string, user: User) {
|
||||||
|
const dataSourceMetadata =
|
||||||
|
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceDataSource =
|
||||||
|
await this.typeORMService.connectToDataSource(dataSourceMetadata);
|
||||||
|
|
||||||
|
await workspaceDataSource?.query(
|
||||||
|
`INSERT INTO ${dataSourceMetadata.schema}."workspaceMember"
|
||||||
|
("nameFirstName", "nameLastName", "colorScheme", "userId", "userEmail", "avatarUrl")
|
||||||
|
VALUES ('${user.firstName}', '${user.lastName}', 'Light', '${
|
||||||
|
user.id
|
||||||
|
}', '${user.email}', '${user.defaultAvatarUrl ?? ''}')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserWorkspaces(userId: string): Promise<UserWorkspace[]> {
|
||||||
|
return this.userWorkspaceRepository.find({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkUserWorkspaceExists(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<UserWorkspace | null> {
|
||||||
|
return this.userWorkspaceRepository.findOneBy({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,7 +49,10 @@ export class UserService extends TypeOrmQueryService<User> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(workspaceMembers.length === 1, 'WorkspaceMember not found');
|
assert(
|
||||||
|
workspaceMembers.length === 1,
|
||||||
|
'WorkspaceMember not found or too many found',
|
||||||
|
);
|
||||||
|
|
||||||
const userWorkspaceMember = new WorkspaceMember();
|
const userWorkspaceMember = new WorkspaceMember();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserWorkspaces1707778127558 implements MigrationInterface {
|
||||||
|
name = 'AddUserWorkspaces1707778127558';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "core"."userWorkspace" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"userId" uuid NOT NULL REFERENCES core.user(id),
|
||||||
|
"workspaceId" uuid NOT NULL REFERENCES core.workspace(id),
|
||||||
|
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
"deletedAt" TIMESTAMP
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."user" DROP CONSTRAINT "FK_2ec910029395fa7655621c88908"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user