feat(twenty-front/workspace-menu): improve workspace menu (#10642)
New workspace menu
This commit is contained in:
@ -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: {},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 &
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user