Improve userFriendlyMessage devX (#16815)

Two challenges with error messages
- always provide a useful/meaningful error message for the end user
instead of the generic one. eg: show "Wrong password" and not "An error
occured"
- avoid technical details unless error regards a technical feature. eg:
show "An error occured" and not "Invalid post-hook payload."; but do
show "Invalid issuer URL." as it occurs while configuring SSO

What this PR does
- Make userFriendlyMessage mandatory for widely used
GraphqlQueryRunnerException and CommonQueryRunnerException, so that
developers are forced to ask themselves what the error message should
be, and as it contains very wide error codes (eg: "Bad request") which
should not be mapped to just one default message
- Keep userFriendlyMessage optional for service-specific exceptions (eg:
workflowStepExecutorException), but convert the error code to
userFriendlyMessage mapper to a switch case function with a typecheck
ensuring that all codes are mapped to a message. These default messages
are still overridable where they are thrown.
This commit is contained in:
Marie
2025-12-30 10:08:43 +01:00
committed by GitHub
parent 7522ff6675
commit 19c9f957b1
135 changed files with 1672 additions and 845 deletions
@@ -1,55 +1,13 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
const authExceptionUserFriendlyMessages: Record<
keyof typeof AuthExceptionCode,
MessageDescriptor
> = {
USER_NOT_FOUND: msg`User not found.`,
USER_WORKSPACE_NOT_FOUND: msg`User workspace not found.`,
EMAIL_NOT_VERIFIED: msg`Email is not verified.`,
CLIENT_NOT_FOUND: msg`Client not found.`,
WORKSPACE_NOT_FOUND: msg`Workspace not found.`,
APPLICATION_NOT_FOUND: msg`Application not found.`,
INVALID_INPUT: msg`Invalid input provided.`,
FORBIDDEN_EXCEPTION: msg`You do not have permission to perform this action.`,
INSUFFICIENT_SCOPES: msg`Insufficient permissions.`,
UNAUTHENTICATED: msg`You must be authenticated to perform this action.`,
INVALID_DATA: msg`Invalid data provided.`,
OAUTH_ACCESS_DENIED: msg`OAuth access was denied.`,
SSO_AUTH_FAILED: msg`Single sign-on authentication failed.`,
USE_SSO_AUTH: msg`Please use single sign-on to authenticate.`,
SIGNUP_DISABLED: msg`Sign up is disabled.`,
GOOGLE_API_AUTH_DISABLED: msg`Google API authentication is disabled.`,
MICROSOFT_API_AUTH_DISABLED: msg`Microsoft API authentication is disabled.`,
MISSING_ENVIRONMENT_VARIABLE: msg`A required configuration is missing.`,
INVALID_JWT_TOKEN_TYPE: msg`Invalid authentication token.`,
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED: msg`Two-factor authentication setup is required.`,
TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED: msg`Two-factor authentication verification is required.`,
USER_ALREADY_EXISTS: msg`A user with this email already exists.`,
INTERNAL_SERVER_ERROR: msg`An unexpected error occurred.`,
};
export class AuthException extends CustomException<
keyof typeof AuthExceptionCode
> {
constructor(
message: string,
code: keyof typeof AuthExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? authExceptionUserFriendlyMessages[code],
});
}
}
export const AuthExceptionCode = appendCommonExceptionCode({
USER_NOT_FOUND: 'USER_NOT_FOUND',
USER_WORKSPACE_NOT_FOUND: 'USER_WORKSPACE_NOT_FOUND',
@@ -76,3 +34,70 @@ export const AuthExceptionCode = appendCommonExceptionCode({
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
USER_ALREADY_EXISTS: 'USER_ALREADY_EXISTS',
} as const);
const getAuthExceptionUserFriendlyMessage = (
code: keyof typeof AuthExceptionCode,
) => {
switch (code) {
case AuthExceptionCode.USER_NOT_FOUND:
return msg`User not found.`;
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
return msg`User workspace not found.`;
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
return msg`Email is not verified.`;
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
return msg`Workspace not found.`;
case AuthExceptionCode.APPLICATION_NOT_FOUND:
return msg`Application not found.`;
case AuthExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
return msg`You do not have permission to perform this action.`;
case AuthExceptionCode.INSUFFICIENT_SCOPES:
return msg`Insufficient permissions.`;
case AuthExceptionCode.UNAUTHENTICATED:
return msg`You must be authenticated to perform this action.`;
case AuthExceptionCode.OAUTH_ACCESS_DENIED:
return msg`OAuth access was denied.`;
case AuthExceptionCode.SSO_AUTH_FAILED:
return msg`Single sign-on authentication failed.`;
case AuthExceptionCode.USE_SSO_AUTH:
return msg`Please use single sign-on to authenticate.`;
case AuthExceptionCode.SIGNUP_DISABLED:
return msg`Sign up is disabled.`;
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
return msg`Google API authentication is disabled.`;
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
return msg`Microsoft API authentication is disabled.`;
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
return msg`A required configuration is missing.`;
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
return msg`Two-factor authentication setup is required.`;
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
return msg`Two-factor authentication verification is required.`;
case AuthExceptionCode.USER_ALREADY_EXISTS:
return msg`A user with this email already exists.`;
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.CLIENT_NOT_FOUND:
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
return STANDARD_ERROR_MESSAGE;
default:
assertUnreachable(code);
}
};
export class AuthException extends CustomException<
keyof typeof AuthExceptionCode
> {
constructor(
message: string,
code: keyof typeof AuthExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getAuthExceptionUserFriendlyMessage(code),
});
}
}
@@ -8,10 +8,10 @@ import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -191,7 +191,7 @@ export class AuthService {
'Wrong password',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
{
userFriendlyMessage: msg`Wrong password`,
userFriendlyMessage: msg`Wrong password.`,
},
);
}
@@ -357,6 +357,9 @@ export class AuthService {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Email is required.`,
},
);
}
@@ -603,6 +606,9 @@ export class AuthService {
throw new AuthException(
'Password is too weak',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Password is too weak.`,
},
);
}
@@ -650,6 +656,9 @@ export class AuthService {
throw new AuthException(
'Workspace does not exist',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Workspace does not exist.`,
},
);
}
@@ -1,5 +1,7 @@
import { randomUUID } from 'crypto';
import { msg } from '@lingui/core/macro';
import {
AuthException,
AuthExceptionCode,
@@ -12,15 +14,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { JwtAuthStrategy } from './jwt.auth.strategy';
jest.mock('twenty-shared/utils', () => ({
...jest.requireActual('twenty-shared/utils'),
assertIsDefinedOrThrow: jest.fn((value, error) => {
if (value === null || value === undefined) {
throw error;
}
}),
}));
describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;
let workspaceRepository: any;
@@ -230,7 +223,9 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspaceEntity not found', expect.any(String)),
new AuthException('UserWorkspaceEntity not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
);
try {
@@ -269,7 +264,9 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspaceEntity not found', expect.any(String)),
new AuthException('UserWorkspaceEntity not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
);
try {
@@ -345,7 +342,9 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('Application not found', expect.any(String)),
new AuthException('Application not found', expect.any(String), {
userFriendlyMessage: msg`Application not found.`,
}),
);
try {
@@ -552,6 +551,7 @@ describe('JwtAuthStrategy', () => {
new AuthException(
'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
{ userFriendlyMessage: msg`User workspace not found.` },
),
);
});