feat(twenty-front/workspace-menu): improve workspace menu (#10642)

New workspace menu
This commit is contained in:
Antoine Moreaux
2025-03-17 16:31:31 +01:00
committed by GitHub
parent 78b3b7edab
commit bda835b9f8
28 changed files with 706 additions and 265 deletions

View File

@ -12,6 +12,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AuthResolver } from './auth.resolver';
@ -64,6 +65,10 @@ describe('AuthResolver', () => {
provide: RenewTokenService,
useValue: {},
},
{
provide: SignInUpService,
useValue: {},
},
{
provide: ApiKeyService,
useValue: {},

View File

@ -51,6 +51,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingsPermissions } from 'src/engine/metadata-modules/permissions/constants/settings-permissions.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
@ -75,6 +76,7 @@ export class AuthResolver {
private apiKeyService: ApiKeyService,
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private signInUpService: SignInUpService,
private transientTokenService: TransientTokenService,
private emailVerificationService: EmailVerificationService,
// private oauthService: OAuthService,
@ -258,6 +260,28 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
async signUpInNewWorkspace(
@AuthUser() currentUser: User,
): Promise<SignUpOutput> {
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return {
loginToken,
workspace: {
id: workspace.id,
workspaceUrls: this.domainManagerService.getWorkspaceUrls(workspace),
},
};
}
// @Mutation(() => ExchangeAuthCode)
// async exchangeAuthorizationCode(
// @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,

View File

@ -9,7 +9,7 @@ import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared';
import { APP_LOCALES, isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
@ -174,35 +174,50 @@ export class AuthService {
return user;
}
private async validatePassword(
userData: ExistingUserOrNewUser['userData'],
authParams: Extract<
AuthProviderWithPasswordType['authParams'],
{ provider: 'password' }
>,
) {
if (userData.type === 'newUser') {
userData.newUserPayload.passwordHash =
await this.signInUpService.generateHash(authParams.password);
}
if (userData.type === 'existingUser') {
await this.signInUpService.validatePassword({
password: authParams.password,
passwordHash: userData.existingUser.passwordHash,
});
}
}
private async isAuthProviderEnabledOrThrow(
userData: ExistingUserOrNewUser['userData'],
authParams: AuthProviderWithPasswordType['authParams'],
workspace: Workspace | undefined | null,
) {
if (authParams.provider === 'password') {
await this.validatePassword(userData, authParams);
}
if (isDefined(workspace)) {
workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace);
}
}
async signInUp(
params: SignInUpBaseParams &
ExistingUserOrNewUser &
AuthProviderWithPasswordType,
) {
if (
params.authParams.provider === 'password' &&
params.userData.type === 'newUser'
) {
params.userData.newUserPayload.passwordHash =
await this.signInUpService.generateHash(params.authParams.password);
}
if (
params.authParams.provider === 'password' &&
params.userData.type === 'existingUser'
) {
await this.signInUpService.validatePassword({
password: params.authParams.password,
passwordHash: params.userData.existingUser.passwordHash,
});
}
if (params.workspace) {
workspaceValidator.isAuthEnabledOrThrow(
params.authParams.provider,
params.workspace,
);
}
await this.isAuthProviderEnabledOrThrow(
params.userData,
params.authParams,
params.workspace,
);
if (params.userData.type === 'newUser') {
const partialUserWithPicture =

View File

@ -261,8 +261,11 @@ describe('SignInUpService', () => {
.mockResolvedValue('a-subdomain');
jest
.spyOn(UserRepository, 'save')
.mockResolvedValue({ id: 'newUserId' } as User);
jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any);
jest
.spyOn(userWorkspaceService, 'create')
.mockResolvedValue({} as UserWorkspace);
const result = await service.signInUp(params);
@ -334,6 +337,35 @@ describe('SignInUpService', () => {
);
});
it('should handle signup for existing user on new workspace', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
workspace: null,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'existingUser',
existingUser: { email: 'existinguser@example.com' } as User,
},
};
jest.spyOn(environmentService, 'get').mockReturnValue(false);
jest.spyOn(WorkspaceRepository, 'count').mockResolvedValue(0);
jest.spyOn(WorkspaceRepository, 'create').mockReturnValue({} as Workspace);
jest.spyOn(WorkspaceRepository, 'save').mockResolvedValue({
id: 'newWorkspaceId',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
} as Workspace);
jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any);
const result = await service.signInUp(params);
expect(result.workspace).toBeDefined();
expect(result.user).toBeDefined();
expect(WorkspaceRepository.create).toHaveBeenCalled();
expect(WorkspaceRepository.save).toHaveBeenCalled();
});
it('should assign default role when permissions are enabled', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &

View File

@ -90,7 +90,6 @@ export class SignInUpService {
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType,
) {
// with personal invitation flow
if (params.workspace && params.invitation) {
return {
workspace: params.workspace,
@ -110,14 +109,7 @@ export class SignInUpService {
return { user: updatedUser, workspace: params.workspace };
}
if (params.userData.type === 'newUserWithPicture') {
return await this.signUpOnNewWorkspace(
params.userData.newUserWithPicture,
);
}
// should never happen.
throw new Error('Invalid sign in up params');
return await this.signUpOnNewWorkspace(params.userData);
}
async generateHash(password: string) {
@ -200,24 +192,6 @@ export class SignInUpService {
return updatedUser;
}
private async persistNewUser(
newUser: PartialUserWithPicture,
workspace: Workspace,
) {
const imagePath = await this.uploadPicture(newUser.picture, workspace.id);
delete newUser.picture;
const userToCreate = this.userRepository.create({
...newUser,
defaultAvatarUrl: imagePath,
canAccessFullAdminPanel: false,
canImpersonate: false,
} as Partial<User>);
return await this.userRepository.save(userToCreate);
}
private async throwIfWorkspaceIsNotReadyForSignInUp(
workspace: Workspace,
user: ExistingUserOrPartialUserWithPicture,
@ -254,9 +228,10 @@ export class SignInUpService {
const currentUser =
params.userData.type === 'newUserWithPicture'
? await this.persistNewUser(
? await this.saveNewUser(
params.userData.newUserWithPicture,
params.workspace,
params.workspace.id,
{ canAccessFullAdminPanel: false, canImpersonate: false },
)
: params.userData.existingUser;
@ -299,14 +274,42 @@ export class SignInUpService {
}
}
async signUpOnNewWorkspace(partialUserWithPicture: PartialUserWithPicture) {
const user: PartialUserWithPicture = {
...partialUserWithPicture,
canImpersonate: false,
canAccessFullAdminPanel: false,
};
private async saveNewUser(
newUserWithPicture: PartialUserWithPicture,
workspaceId: string,
{
canImpersonate,
canAccessFullAdminPanel,
}: {
canImpersonate: boolean;
canAccessFullAdminPanel: boolean;
},
) {
const defaultAvatarUrl = await this.uploadPicture(
newUserWithPicture.picture,
workspaceId,
);
const userCreated = this.userRepository.create({
...newUserWithPicture,
defaultAvatarUrl,
canImpersonate,
canAccessFullAdminPanel,
});
if (!user.email) {
return await this.userRepository.save(userCreated);
}
async signUpOnNewWorkspace(
userData: ExistingUserOrPartialUserWithPicture['userData'],
) {
let canImpersonate = false;
let canAccessFullAdminPanel = false;
const email =
userData.type === 'newUserWithPicture'
? userData.newUserWithPicture.email
: userData.existingUser.email;
if (!email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
@ -317,8 +320,8 @@ export class SignInUpService {
const workspacesCount = await this.workspaceRepository.count();
// if the workspace doesn't exist it means it's the first user of the workspace
user.canImpersonate = true;
user.canAccessFullAdminPanel = true;
canImpersonate = true;
canAccessFullAdminPanel = true;
// let the creation of the first workspace
if (workspacesCount > 0) {
@ -329,7 +332,7 @@ export class SignInUpService {
}
}
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(user.email)}`;
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const isLogoUrlValid = async () => {
try {
return (
@ -342,7 +345,7 @@ export class SignInUpService {
};
const logo =
isWorkEmail(user.email) && (await isLogoUrlValid()) ? logoUrl : undefined;
isWorkEmail(email) && (await isLogoUrlValid()) ? logoUrl : undefined;
const workspaceToCreate = this.workspaceRepository.create({
subdomain: await this.domainManagerService.generateSubdomain(),
@ -354,25 +357,24 @@ export class SignInUpService {
const workspace = await this.workspaceRepository.save(workspaceToCreate);
user.defaultAvatarUrl = await this.uploadPicture(
partialUserWithPicture.picture,
workspace.id,
);
const user =
userData.type === 'existingUser'
? userData.existingUser
: await this.saveNewUser(userData.newUserWithPicture, workspace.id, {
canImpersonate,
canAccessFullAdminPanel,
});
const userCreated = this.userRepository.create(user);
await this.userWorkspaceService.create(user.id, workspace.id);
const newUser = await this.userRepository.save(userCreated);
await this.userWorkspaceService.create(newUser.id, workspace.id);
await this.activateOnboardingForUser(newUser, workspace);
await this.activateOnboardingForUser(user, workspace);
await this.onboardingService.setOnboardingInviteTeamPending({
workspaceId: workspace.id,
value: true,
});
return { user: newUser, workspace };
return { user, workspace };
}
async uploadPicture(

View File

@ -203,12 +203,10 @@ export class WorkspaceResolver {
return null;
}
const role = await this.roleService.getRoleById(
return await this.roleService.getRoleById(
workspace.defaultRoleId,
workspace.id,
);
return role;
}
@ResolveField(() => BillingSubscription, { nullable: true })