feat(auth): enhance error handling for sign-up and existing user checks (#14953)
Refactored error handling in `useSignUpInNewWorkspace` and `useSignInUp` hooks to provide user-friendly messages. Added validation for existing users during sign-up, updating server-side logic to throw specific exceptions (`USER_ALREADY_EXIST`). Fix https://twenty-v7.sentry.io/issues/6686753138/events/latest/?environment=prod&project=4507072499810304&query=is%3Aunresolved%20issue.priority%3A%5Bhigh%2C%20medium%5D%20QueryFailedError&referrer=latest-event&sort=date --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -173,7 +173,7 @@ export const SignInUpGlobalScopeForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
continueWithCredentials();
|
||||
await continueWithCredentials();
|
||||
};
|
||||
|
||||
const onEmailChange = (email: string) => {
|
||||
|
||||
@@ -19,9 +19,11 @@ import { AppPath } from 'twenty-shared/types';
|
||||
import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams';
|
||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
|
||||
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
|
||||
@@ -55,28 +57,33 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
setSignInUpStep(SignInUpStep.Email);
|
||||
}, [setSignInUpStep]);
|
||||
|
||||
const errorMsgUserAlreadyExist = t`An error occurred while checking user existence`;
|
||||
const continueWithCredentials = useCallback(async () => {
|
||||
const token = await readCaptchaToken();
|
||||
if (!form.getValues('email')) {
|
||||
return;
|
||||
}
|
||||
checkUserExistsQuery({
|
||||
variables: {
|
||||
email: form.getValues('email').toLowerCase().trim(),
|
||||
captchaToken: token,
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ apolloError: error });
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setSignInUpMode(
|
||||
data?.checkUserExists.exists
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp,
|
||||
);
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { data } = await checkUserExistsQuery({
|
||||
variables: {
|
||||
email: form.getValues('email').toLowerCase().trim(),
|
||||
captchaToken: token,
|
||||
},
|
||||
});
|
||||
|
||||
setSignInUpMode(
|
||||
data?.checkUserExists.exists
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp,
|
||||
);
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
...(error instanceof ApolloError
|
||||
? { apolloError: error }
|
||||
: { message: errorMsgUserAlreadyExist }),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
readCaptchaToken,
|
||||
form,
|
||||
@@ -84,6 +91,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
enqueueErrorSnackBar,
|
||||
setSignInUpStep,
|
||||
setSignInUpMode,
|
||||
errorMsgUserAlreadyExist,
|
||||
]);
|
||||
|
||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||
|
||||
+22
-16
@@ -1,32 +1,38 @@
|
||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { type ApolloError } from '@apollo/client';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { useSignUpInNewWorkspaceMutation } from '~/generated-metadata/graphql';
|
||||
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
export const useSignUpInNewWorkspace = () => {
|
||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation();
|
||||
|
||||
const createWorkspace = async ({ newTab } = { newTab: true }) => {
|
||||
await signUpInNewWorkspaceMutation({
|
||||
onCompleted: async (data) => {
|
||||
return await redirectToWorkspaceDomain(
|
||||
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
|
||||
AppPath.Verify,
|
||||
{
|
||||
loginToken: data.signUpInNewWorkspace.loginToken.token,
|
||||
},
|
||||
newTab ? '_blank' : '_self',
|
||||
);
|
||||
},
|
||||
onError: (error: ApolloError) => {
|
||||
enqueueErrorSnackBar({ apolloError: error });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { data } = await signUpInNewWorkspaceMutation();
|
||||
assertIsDefinedOrThrow(data?.signUpInNewWorkspace);
|
||||
return await redirectToWorkspaceDomain(
|
||||
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
|
||||
AppPath.Verify,
|
||||
{
|
||||
loginToken: data.signUpInNewWorkspace.loginToken.token,
|
||||
},
|
||||
newTab ? '_blank' : '_self',
|
||||
);
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
...(error instanceof ApolloError
|
||||
? { apolloError: error }
|
||||
: { message: t`Workspace creation failed` }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+16
-5
@@ -15,6 +15,15 @@ import { billingState } from '@/client-config/states/billingState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { messages } from '../../../../locales/generated/en';
|
||||
|
||||
i18n.load({
|
||||
[SOURCE_LOCALE]: messages,
|
||||
});
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
|
||||
const mockCurrentUser = {
|
||||
id: 'fake-user-id',
|
||||
@@ -45,11 +54,13 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider>
|
||||
<RecoilRoot initializeState={initializeState}>
|
||||
<MemoryRouter>
|
||||
<SnackBarComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'test-scope-id' }}
|
||||
>
|
||||
{children}
|
||||
</SnackBarComponentInstanceContext.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<SnackBarComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'test-scope-id' }}
|
||||
>
|
||||
{children}
|
||||
</SnackBarComponentInstanceContext.Provider>
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
</MockedProvider>
|
||||
|
||||
@@ -30,4 +30,5 @@ export const AuthExceptionCode = appendCommonExceptionCode({
|
||||
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
|
||||
TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
|
||||
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
|
||||
USER_ALREADY_EXIST: 'USER_ALREADY_EXIST',
|
||||
} as const);
|
||||
|
||||
@@ -61,7 +61,6 @@ import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/
|
||||
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.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 { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -75,6 +74,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
|
||||
import { LoginToken } from './dto/login-token.entity';
|
||||
@@ -99,8 +99,6 @@ import { AuthService } from './services/auth.service';
|
||||
)
|
||||
export class AuthResolver {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserWorkspace)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
@InjectRepository(AppToken)
|
||||
@@ -345,7 +343,7 @@ export class AuthResolver {
|
||||
),
|
||||
);
|
||||
|
||||
const user = await this.userService.getUserByEmail(email);
|
||||
const user = await this.userService.findUserByEmailOrThrow(email);
|
||||
|
||||
await this.twoFactorAuthenticationService.validateStrategy(
|
||||
user.id,
|
||||
@@ -430,11 +428,9 @@ export class AuthResolver {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: signUpInput.email,
|
||||
},
|
||||
});
|
||||
const existingUser = await this.userService.findUserByEmail(
|
||||
signUpInput.email,
|
||||
);
|
||||
|
||||
const { userData } = this.authService.formatUserDataPayload(
|
||||
{
|
||||
@@ -636,7 +632,7 @@ export class AuthResolver {
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
): Promise<{ user: User; userWorkspace: UserWorkspace }> {
|
||||
const user = await this.userService.getUserByEmail(email);
|
||||
const user = await this.userService.findUserByEmailOrThrow(email);
|
||||
|
||||
await this.authService.checkIsEmailVerified(user.isEmailVerified);
|
||||
|
||||
|
||||
+3
-9
@@ -36,10 +36,10 @@ import {
|
||||
IdentityProviderType,
|
||||
WorkspaceSSOIdentityProvider,
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@Controller('auth')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@@ -49,10 +49,8 @@ export class SSOAuthController {
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
|
||||
private readonly userService: UserService,
|
||||
private readonly sSOService: SSOService,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(WorkspaceSSOIdentityProvider)
|
||||
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
|
||||
) {}
|
||||
@@ -180,11 +178,7 @@ export class SSOAuthController {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: payload.email,
|
||||
},
|
||||
});
|
||||
const existingUser = await this.userService.findUserByEmail(payload.email);
|
||||
|
||||
const { userData } = this.authService.formatUserDataPayload(
|
||||
payload,
|
||||
|
||||
@@ -57,12 +57,12 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
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 { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@@ -280,9 +280,7 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
const user = await this.userService.findUserByEmail(email);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
user,
|
||||
@@ -378,9 +376,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async checkUserExists(email: string): Promise<CheckUserExistOutput> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
const user = await this.userService.findUserByEmail(email);
|
||||
|
||||
const isUserExist = isDefined(user);
|
||||
|
||||
@@ -777,9 +773,7 @@ export class AuthService {
|
||||
? await this.countAvailableWorkspacesByEmail(email)
|
||||
: 0;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
const existingUser = await this.userService.findUserByEmail(email);
|
||||
|
||||
if (
|
||||
!workspaceId &&
|
||||
|
||||
+34
-15
@@ -8,19 +8,23 @@ import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
import { ResetPasswordService } from './reset-password.service';
|
||||
|
||||
// To avoid dynamic import issues in Jest
|
||||
jest.mock('@react-email/render', () => ({
|
||||
render: jest.fn().mockImplementation(async (template, options) => {
|
||||
render: jest.fn().mockImplementation(async (_, options) => {
|
||||
if (options?.plainText) {
|
||||
return 'Plain Text Email';
|
||||
}
|
||||
@@ -31,7 +35,7 @@ jest.mock('@react-email/render', () => ({
|
||||
|
||||
describe('ResetPasswordService', () => {
|
||||
let service: ResetPasswordService;
|
||||
let userRepository: Repository<User>;
|
||||
let userService: UserService;
|
||||
let workspaceRepository: Repository<Workspace>;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
let emailService: EmailService;
|
||||
@@ -43,8 +47,11 @@ describe('ResetPasswordService', () => {
|
||||
providers: [
|
||||
ResetPasswordService,
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useClass: Repository,
|
||||
provide: UserService,
|
||||
useValue: {
|
||||
findUserByEmailOrThrow: jest.fn(),
|
||||
findUserByIdOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace),
|
||||
@@ -91,7 +98,7 @@ describe('ResetPasswordService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<ResetPasswordService>(ResetPasswordService);
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
userService = module.get<UserService>(UserService);
|
||||
workspaceRepository = module.get<Repository<Workspace>>(
|
||||
getRepositoryToken(Workspace),
|
||||
);
|
||||
@@ -113,7 +120,7 @@ describe('ResetPasswordService', () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.spyOn(userService, 'findUserByEmailOrThrow')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||
jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
|
||||
@@ -135,7 +142,11 @@ describe('ResetPasswordService', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(userService, 'findUserByEmailOrThrow')
|
||||
.mockRejectedValue(
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.generatePasswordResetToken(
|
||||
@@ -155,7 +166,7 @@ describe('ResetPasswordService', () => {
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.spyOn(userService, 'findUserByEmailOrThrow')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
@@ -177,7 +188,7 @@ describe('ResetPasswordService', () => {
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.spyOn(userService, 'findUserByEmailOrThrow')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOneBy')
|
||||
@@ -204,7 +215,11 @@ describe('ResetPasswordService', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(userService, 'findUserByEmailOrThrow')
|
||||
.mockRejectedValue(
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.sendEmailPasswordResetLink(
|
||||
@@ -229,7 +244,7 @@ describe('ResetPasswordService', () => {
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockToken as AppToken);
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.spyOn(userService, 'findUserByIdOrThrow')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
|
||||
const result = await service.validatePasswordResetToken('validToken');
|
||||
@@ -251,7 +266,7 @@ describe('ResetPasswordService', () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.spyOn(userService, 'findUserByIdOrThrow')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
|
||||
|
||||
@@ -265,7 +280,11 @@ describe('ResetPasswordService', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(userService, 'findUserByIdOrThrow')
|
||||
.mockRejectedValue(
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.invalidatePasswordResetToken('nonexistent'),
|
||||
|
||||
+19
-44
@@ -29,39 +29,32 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class ResetPasswordService {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(AppToken)
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
async generatePasswordResetToken(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
): Promise<PasswordResetToken> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
const user = await this.userService.findUserByEmailOrThrow(
|
||||
email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
const expiresIn = this.twentyConfigService.get(
|
||||
'PASSWORD_RESET_TOKEN_EXPIRES_IN',
|
||||
@@ -123,16 +116,10 @@ export class ResetPasswordService {
|
||||
email: string,
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
): Promise<EmailPasswordResetLink> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
const user = await this.userService.findUserByEmailOrThrow(
|
||||
email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: resetToken.workspaceId,
|
||||
@@ -170,11 +157,11 @@ export class ResetPasswordService {
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
const subject = i18n._(resetPasswordMsg);
|
||||
|
||||
this.emailService.send({
|
||||
await this.emailService.send({
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
to: user.email,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
@@ -207,16 +194,10 @@ export class ResetPasswordService {
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: token.userId,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
const user = await this.userService.findUserByIdOrThrow(
|
||||
token.userId,
|
||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -227,20 +208,14 @@ export class ResetPasswordService {
|
||||
async invalidatePasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<InvalidatePassword> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: userId,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
const user = await this.userService.findUserByIdOrThrow(
|
||||
userId,
|
||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
await this.appTokenRepository.update(
|
||||
{
|
||||
userId,
|
||||
userId: user.id,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,7 +30,6 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
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 { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
@@ -38,6 +37,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@@ -465,6 +465,15 @@ export class SignInUpService {
|
||||
newUserParams: SignInUpNewUserPayload,
|
||||
authParams: AuthProviderWithPasswordType['authParams'],
|
||||
) {
|
||||
await this.userService.findUserByEmailOrThrow(
|
||||
newUserParams.email,
|
||||
new AuthException(
|
||||
'User already exist',
|
||||
AuthExceptionCode.USER_ALREADY_EXIST,
|
||||
{ userFriendlyMessage: t`User already exists` },
|
||||
),
|
||||
);
|
||||
|
||||
return this.saveNewUser(
|
||||
await this.computePartialUserFromUserPayload(newUserParams, authParams),
|
||||
await this.setDefaultImpersonateAndAccessFullAdminPanel(),
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
|
||||
case AuthExceptionCode.SIGNUP_DISABLED:
|
||||
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
|
||||
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
|
||||
case AuthExceptionCode.USER_ALREADY_EXIST:
|
||||
throw new ForbiddenError(exception);
|
||||
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
|
||||
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
|
||||
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
|
||||
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
|
||||
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
|
||||
case AuthExceptionCode.USER_ALREADY_EXIST:
|
||||
return 403;
|
||||
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
|
||||
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ export class EmailVerificationService {
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userService.getUserByEmail(email);
|
||||
const user = await this.userService.findUserByEmailOrThrow(email);
|
||||
|
||||
if (user.isEmailVerified) {
|
||||
throw new EmailVerificationException(
|
||||
|
||||
+5
-3
@@ -34,7 +34,7 @@ const createMockLoginTokenService = () => ({
|
||||
});
|
||||
|
||||
const createMockUserService = () => ({
|
||||
getUserByEmail: jest.fn(),
|
||||
findUserByEmailOrThrow: jest.fn(),
|
||||
});
|
||||
|
||||
const createMockDomainManagerService = () => ({
|
||||
@@ -128,7 +128,7 @@ describe('TwoFactorAuthenticationResolver', () => {
|
||||
domainManagerService.getWorkspaceByOriginOrDefaultWorkspace.mockResolvedValue(
|
||||
mockWorkspace,
|
||||
);
|
||||
userService.getUserByEmail.mockResolvedValue(mockUser);
|
||||
userService.findUserByEmailOrThrow.mockResolvedValue(mockUser);
|
||||
twoFactorAuthenticationService.initiateStrategyConfiguration.mockResolvedValue(
|
||||
'otpauth://totp/Twenty:test@example.com?secret=SECRETKEY&issuer=Twenty',
|
||||
);
|
||||
@@ -146,7 +146,9 @@ describe('TwoFactorAuthenticationResolver', () => {
|
||||
expect(
|
||||
domainManagerService.getWorkspaceByOriginOrDefaultWorkspace,
|
||||
).toHaveBeenCalledWith(origin);
|
||||
expect(userService.getUserByEmail).toHaveBeenCalledWith(mockUser.email);
|
||||
expect(userService.findUserByEmailOrThrow).toHaveBeenCalledWith(
|
||||
mockUser.email,
|
||||
);
|
||||
expect(
|
||||
twoFactorAuthenticationService.initiateStrategyConfiguration,
|
||||
).toHaveBeenCalledWith(
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export class TwoFactorAuthenticationResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userService.getUserByEmail(userEmail);
|
||||
const user = await this.userService.findUserByEmailOrThrow(userEmail);
|
||||
|
||||
const uri =
|
||||
await this.twoFactorAuthenticationService.initiateStrategyConfiguration(
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import type { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import type { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
let userRepository: Repository<User>;
|
||||
let workspaceService: WorkspaceService;
|
||||
let twentyORMGlobalManager: TwentyORMGlobalManager;
|
||||
let userRoleService: UserRoleService;
|
||||
|
||||
const mockWorkspaceMemberRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
} as unknown as WorkspaceRepository<WorkspaceMemberWorkspaceEntity>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceService,
|
||||
useValue: { deleteWorkspace: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
getRepositoryForWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserRoleService,
|
||||
useValue: {
|
||||
validateUserWorkspaceIsNotUniqueAdminOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
userRoleService = module.get<UserRoleService>(UserRoleService);
|
||||
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
|
||||
TwentyORMGlobalManager,
|
||||
);
|
||||
workspaceService = module.get<WorkspaceService>(WorkspaceService);
|
||||
});
|
||||
|
||||
describe('loadWorkspaceMember', () => {
|
||||
it('returns null when workspace is not active/suspended', async () => {
|
||||
// isWorkspaceActiveOrSuspendedSpy.mockReturnValue(false);
|
||||
|
||||
const res = await service.loadWorkspaceMember(
|
||||
{ id: 'u1' } as User,
|
||||
{ id: 'w1' } as Workspace,
|
||||
);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(
|
||||
twentyORMGlobalManager.getRepositoryForWorkspace,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches from workspace member repo when workspace active', async () => {
|
||||
jest.spyOn(mockWorkspaceMemberRepo, 'findOne').mockResolvedValue({
|
||||
id: 'wm1',
|
||||
userId: 'u1',
|
||||
} as WorkspaceMemberWorkspaceEntity);
|
||||
|
||||
jest
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
const res = await service.loadWorkspaceMember(
|
||||
{ id: 'u1' } as User,
|
||||
{
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as Workspace,
|
||||
);
|
||||
|
||||
expect(
|
||||
twentyORMGlobalManager.getRepositoryForWorkspace,
|
||||
).toHaveBeenCalledWith('w1', 'workspaceMember');
|
||||
expect(mockWorkspaceMemberRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
});
|
||||
expect(res).toEqual({ id: 'wm1', userId: 'u1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadWorkspaceMembers', () => {
|
||||
it('returns [] when workspace is not active/suspended', async () => {
|
||||
const res = await service.loadWorkspaceMembers({
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.INACTIVE,
|
||||
} as Workspace);
|
||||
|
||||
expect(res).toEqual([]);
|
||||
expect(
|
||||
twentyORMGlobalManager.getRepositoryForWorkspace,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches members withDeleted flag', async () => {
|
||||
jest
|
||||
.spyOn(mockWorkspaceMemberRepo, 'find')
|
||||
.mockResolvedValue([{ id: 'wm1' } as WorkspaceMemberWorkspaceEntity]);
|
||||
jest
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
const res = await service.loadWorkspaceMembers(
|
||||
{
|
||||
id: 'w2',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as Workspace,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockWorkspaceMemberRepo.find).toHaveBeenCalledWith({
|
||||
withDeleted: true,
|
||||
});
|
||||
expect(res).toEqual([{ id: 'wm1' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDeletedWorkspaceMembersOnly', () => {
|
||||
it('returns [] when workspace is not active/suspended', async () => {
|
||||
const res = await service.loadDeletedWorkspaceMembersOnly({
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.INACTIVE,
|
||||
} as Workspace);
|
||||
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetches only deleted members with withDeleted:true', async () => {
|
||||
jest
|
||||
.spyOn(mockWorkspaceMemberRepo, 'find')
|
||||
.mockResolvedValue([
|
||||
{ id: 'wm-del' } as WorkspaceMemberWorkspaceEntity,
|
||||
]);
|
||||
jest
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
await service.loadDeletedWorkspaceMembersOnly({
|
||||
id: 'w3',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as Workspace);
|
||||
|
||||
expect(mockWorkspaceMemberRepo.find).toHaveBeenCalledWith({
|
||||
where: { deletedAt: expect.any(Object) },
|
||||
withDeleted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserByEmailOrThrow', () => {
|
||||
it('returns user when found', async () => {
|
||||
const user = { id: 'u1', email: 'a@b.com' } as User;
|
||||
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(user);
|
||||
|
||||
await expect(service.findUserByEmailOrThrow('a@b.com')).resolves.toEqual(
|
||||
user,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when not found', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
await expect(
|
||||
service.findUserByEmailOrThrow('none@b.com'),
|
||||
).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserByEmail', () => {
|
||||
it('returns the user when found', async () => {
|
||||
const user: Partial<User> = { id: 'u1', email: 'john@doe.com' };
|
||||
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(user);
|
||||
|
||||
const result = await service.findUserByEmail('john@doe.com');
|
||||
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: 'john@doe.com' },
|
||||
});
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
const result = await service.findUserByEmail('missing@doe.com');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUserAccessToWorkspaceOrThrow', () => {
|
||||
it('resolves when user has access', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue({ id: 'u1' });
|
||||
await expect(
|
||||
service.hasUserAccessToWorkspaceOrThrow('u1', 'w1'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'u1', userWorkspaces: { workspaceId: 'w1' } },
|
||||
relations: { userWorkspaces: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws AuthException when user has no access', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
await expect(
|
||||
service.hasUserAccessToWorkspaceOrThrow('u2', 'w2'),
|
||||
).rejects.toBeInstanceOf(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markEmailAsVerified', () => {
|
||||
it('sets isEmailVerified and saves', async () => {
|
||||
const user = { id: 'u1', isEmailVerified: false } as User;
|
||||
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(user);
|
||||
(userRepository.save as jest.Mock).mockImplementation(
|
||||
async (u: User) => u,
|
||||
);
|
||||
|
||||
const res = await service.markEmailAsVerified('u1');
|
||||
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'u1' },
|
||||
});
|
||||
expect(res.isEmailVerified).toBe(true);
|
||||
expect(userRepository.save).toHaveBeenCalledWith({
|
||||
id: 'u1',
|
||||
isEmailVerified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when user not found', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
await expect(service.markEmailAsVerified('nope')).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
const wmForUser = (userId: string) =>
|
||||
({ id: 'wm-1', userId }) as WorkspaceMemberWorkspaceEntity;
|
||||
|
||||
it('throws mapped PermissionsException when cannot unassign last admin', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'u1',
|
||||
userWorkspaces: [{ id: 'uw1', workspaceId: 'w1' }],
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(mockWorkspaceMemberRepo, 'find')
|
||||
.mockResolvedValue([
|
||||
wmForUser('u1'),
|
||||
{ id: 'wm-2', userId: 'uX' } as WorkspaceMemberWorkspaceEntity,
|
||||
]);
|
||||
jest
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(
|
||||
mockWorkspaceMemberRepo as unknown as WorkspaceRepository<WorkspaceMemberWorkspaceEntity>,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(userRoleService, 'validateUserWorkspaceIsNotUniqueAdminOrThrow')
|
||||
.mockRejectedValue(
|
||||
new PermissionsException(
|
||||
'x',
|
||||
PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.deleteUser('u1')).rejects.toBeInstanceOf(
|
||||
PermissionsException,
|
||||
);
|
||||
await expect(service.deleteUser('u1')).rejects.toMatchObject({
|
||||
code: PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER,
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes workspace member and workspace when user is sole member', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'u2',
|
||||
userWorkspaces: [{ id: 'uw2', workspaceId: 'w2' }],
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(mockWorkspaceMemberRepo, 'find')
|
||||
.mockResolvedValue([wmForUser('u2')]);
|
||||
jest
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
const res = await service.deleteUser('u2');
|
||||
|
||||
expect(mockWorkspaceMemberRepo.delete).toHaveBeenCalledWith({
|
||||
userId: 'u2',
|
||||
});
|
||||
expect(workspaceService.deleteWorkspace).toHaveBeenCalledWith('w2');
|
||||
expect(res).toMatchObject({ id: 'u2' });
|
||||
});
|
||||
|
||||
it('throws when user not found', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
await expect(service.deleteUser('missing')).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserById', () => {
|
||||
it('returns the user when found', async () => {
|
||||
const user = { id: 'u42' } as User;
|
||||
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(user);
|
||||
|
||||
const result = await service.findUserById('u42');
|
||||
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'u42' },
|
||||
});
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await service.findUserById('missing');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserByIdOrThrow', () => {
|
||||
it('returns user when found', async () => {
|
||||
const user = { id: 'u99' } as User;
|
||||
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(user);
|
||||
|
||||
await expect(service.findUserByIdOrThrow('u99')).resolves.toEqual(user);
|
||||
});
|
||||
|
||||
it('throws provided error when not found', async () => {
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
|
||||
const error = new Error('not found');
|
||||
|
||||
await expect(service.findUserByIdOrThrow('nope', error)).rejects.toThrow(
|
||||
error,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import assert from 'assert';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
@@ -188,26 +189,40 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
);
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
async findUserByEmailOrThrow(email: string, error?: Error) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
|
||||
assertIsDefinedOrThrow(user, error);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return await this.userRepository.findOne({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(user);
|
||||
async findUserById(id: string) {
|
||||
return await this.userRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findUserByIdOrThrow(id: string, error?: Error) {
|
||||
const user = await this.findUserById(id);
|
||||
|
||||
assertIsDefinedOrThrow(user, error);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async markEmailAsVerified(userId: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(user);
|
||||
const user = await this.findUserByIdOrThrow(userId);
|
||||
|
||||
user.isEmailVerified = true;
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ import { UserService } from './services/user.service';
|
||||
UserRoleModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
exports: [UserService, WorkspaceMemberTranspiler],
|
||||
providers: [UserService, UserResolver, WorkspaceMemberTranspiler],
|
||||
|
||||
Reference in New Issue
Block a user