mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-25 13:02:15 +03:00
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:
parent
52b33b5450
commit
b67957bf94
@ -72,14 +72,24 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
if (data?.checkUserExists.exists) {
|
||||
setSignInUpMode(SignInUpMode.SignIn);
|
||||
isMatchingLocation(AppPath.Invite)
|
||||
? setSignInUpMode(SignInUpMode.Invite)
|
||||
: setSignInUpMode(SignInUpMode.SignIn);
|
||||
} else {
|
||||
setSignInUpMode(SignInUpMode.SignUp);
|
||||
isMatchingLocation(AppPath.Invite)
|
||||
? setSignInUpMode(SignInUpMode.Invite)
|
||||
: setSignInUpMode(SignInUpMode.SignUp);
|
||||
}
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
},
|
||||
});
|
||||
}, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]);
|
||||
}, [
|
||||
isMatchingLocation,
|
||||
setSignInUpStep,
|
||||
checkUserExistsQuery,
|
||||
form,
|
||||
setSignInUpMode,
|
||||
]);
|
||||
|
||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||
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 { TokenService } from 'src/core/auth/services/token.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';
|
||||
|
||||
@ -45,6 +46,7 @@ const jwtModule = JwtModule.registerAsync({
|
||||
TypeORMModule,
|
||||
TypeOrmModule.forFeature([Workspace, User, RefreshToken], 'core'),
|
||||
HttpModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
controllers: [
|
||||
GoogleAuthController,
|
||||
|
@ -91,6 +91,7 @@ export class AuthResolver {
|
||||
@Mutation(() => LoginToken)
|
||||
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
|
||||
const user = await this.authService.signUp(signUpInput);
|
||||
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
|
||||
return { loginToken };
|
||||
@ -120,6 +121,8 @@ export class AuthResolver {
|
||||
verifyInput.loginToken,
|
||||
);
|
||||
|
||||
assert(email, 'Invalid token', ForbiddenException);
|
||||
|
||||
const result = await this.authService.verify(email);
|
||||
|
||||
return result;
|
||||
|
@ -9,6 +9,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
|
||||
import { User } from 'src/core/user/user.entity';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.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 { TokenService } from './token.service';
|
||||
@ -28,6 +29,10 @@ describe('AuthService', () => {
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceManagerService,
|
||||
useValue: {},
|
||||
|
@ -34,6 +34,7 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
|
||||
import { EmailService } from 'src/integrations/email/email.service';
|
||||
import { UpdatePassword } from 'src/core/auth/dto/update-password.entity';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
|
||||
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
@ -54,6 +55,7 @@ export class AuthService {
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly emailService: EmailService,
|
||||
@ -95,11 +97,16 @@ export class AuthService {
|
||||
if (!firstName) firstName = '';
|
||||
if (!lastName) lastName = '';
|
||||
|
||||
const existingUser = await this.userRepository.findOneBy({
|
||||
email: email,
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
relations: ['defaultWorkspace'],
|
||||
});
|
||||
|
||||
assert(!existingUser, 'This user already exists', ForbiddenException);
|
||||
if (existingUser && !workspaceInviteHash) {
|
||||
assert(!existingUser, 'This user already exists', ForbiddenException);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const isPasswordValid = PASSWORD_REGEX.test(password);
|
||||
@ -157,6 +164,31 @@ export class AuthService {
|
||||
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({
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
@ -169,9 +201,8 @@ export class AuthService {
|
||||
|
||||
const user = await this.userRepository.save(userToCreate);
|
||||
|
||||
if (workspaceInviteHash) {
|
||||
await this.userService.createWorkspaceMember(user);
|
||||
}
|
||||
await this.userWorkspaceService.create(user.id, workspace.id);
|
||||
await this.userWorkspaceService.createWorkspaceMember(workspace.id, 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;
|
||||
}
|
||||
|
||||
assert(workspaceMembers.length === 1, 'WorkspaceMember not found');
|
||||
assert(
|
||||
workspaceMembers.length === 1,
|
||||
'WorkspaceMember not found or too many found',
|
||||
);
|
||||
|
||||
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> {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user