From a453d53656287a6757ee723f7a2985b9adb7e542 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Thu, 13 Nov 2025 12:01:06 +0100 Subject: [PATCH] Fix Snackbar crashing the whole app (#15774) This PR fixes a crash of the app when a Snackbar tries to render an object. image What happened : the backend sent a MessageDescriptor in `extensions.userFriendlyMessage` while it should have sent a translated string. But it is a problem that the Snackbar can end up rendering objects and crashing the whole app because it is a the highest level in the tree. So this PR hardens many points to avoid future bugs : - Sanitize what is rendered by the Snackbar to avoid any crash - Translate any MessageDescriptor object that could end up in the frontend - Fix the places in the backend where a MessageDescriptor wasn't translated and sent directly to the frontend in `use-graphql-error-handler.hook.ts` Fixes https://github.com/twentyhq/twenty/issues/15685 Fixes https://github.com/twentyhq/core-team-issues/issues/1869 --- .../snack-bar-manager/components/SnackBar.tsx | 17 +++++++++---- .../snack-bar-manager/hooks/useSnackBar.ts | 1 + .../sanitizeMessageToRenderInSnackbar.ts | 24 +++++++++++++++++++ ...et-error-message-from-apollo-error.util.ts | 16 +++++++++---- .../hooks/use-graphql-error-handler.hook.ts | 12 ++++++++-- 5 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/sanitizeMessageToRenderInSnackbar.ts diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx index 833a943031..048ed520e2 100644 --- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx @@ -1,3 +1,4 @@ +import { sanitizeMessageToRenderInSnackbar } from '@/ui/feedback/snack-bar-manager/utils/sanitizeMessageToRenderInSnackbar'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; @@ -196,13 +197,19 @@ export const SnackBar = ({ } }; + const sanitizedMessage = sanitizeMessageToRenderInSnackbar(message); + const sanitizedDetailedMessage = + sanitizeMessageToRenderInSnackbar(detailedMessage); + return ( {icon} - {message} + {sanitizedMessage ?? ''} {!!onCancel && } @@ -220,8 +227,8 @@ export const SnackBar = ({ )} - {detailedMessage && ( - {detailedMessage} + {isDefined(sanitizedDetailedMessage) && ( + {sanitizedDetailedMessage} )} {link && {link.text}} diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts index e060a36446..558f875d0e 100644 --- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts @@ -142,6 +142,7 @@ export const useSnackBar = () => { : apolloError ? getErrorMessageFromApolloError(apolloError) : t`An error occurred.`; + setSnackBarQueue({ id: uuidv4(), message: errorMessage, diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/sanitizeMessageToRenderInSnackbar.ts b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/sanitizeMessageToRenderInSnackbar.ts new file mode 100644 index 0000000000..c361c8b50d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/sanitizeMessageToRenderInSnackbar.ts @@ -0,0 +1,24 @@ +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; + +export const sanitizeMessageToRenderInSnackbar = ( + messageToRenderInSnackbar: any, +) => { + if (!isDefined(messageToRenderInSnackbar)) { + return null; + } else if ( + typeof messageToRenderInSnackbar === 'string' || + typeof messageToRenderInSnackbar === 'string' || + typeof messageToRenderInSnackbar === 'boolean' + ) { + return `${messageToRenderInSnackbar}`; + } else if (typeof messageToRenderInSnackbar === 'object') { + try { + return JSON.stringify(messageToRenderInSnackbar); + } catch { + return t`Cannot display message`; + } + } else { + return t`Cannot display message`; + } +}; diff --git a/packages/twenty-front/src/utils/get-error-message-from-apollo-error.util.ts b/packages/twenty-front/src/utils/get-error-message-from-apollo-error.util.ts index e851da9214..fae7fc6662 100644 --- a/packages/twenty-front/src/utils/get-error-message-from-apollo-error.util.ts +++ b/packages/twenty-front/src/utils/get-error-message-from-apollo-error.util.ts @@ -1,14 +1,20 @@ import { type ApolloError } from '@apollo/client'; +import { type MessageDescriptor } from '@lingui/core'; import { t } from '@lingui/core/macro'; +import { type Nullable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; export const getErrorMessageFromApolloError = (error: ApolloError): string => { - if (!isDefined(error.graphQLErrors?.[0]?.extensions?.userFriendlyMessage)) { + const userFriendlyMessage = error.graphQLErrors?.[0]?.extensions + ?.userFriendlyMessage as Nullable; + + if (!isDefined(userFriendlyMessage)) { return t`An error occurred.`; } - return ( - (error.graphQLErrors[0].extensions?.userFriendlyMessage as string) ?? - t`An error occurred.` - ); + if (typeof userFriendlyMessage === 'object' && 'id' in userFriendlyMessage) { + return t(userFriendlyMessage); + } + + return userFriendlyMessage; }; diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts index 70b17b076d..30ed5ee939 100644 --- a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts @@ -248,6 +248,9 @@ export const useGraphQLErrorHandlerHook = < onValidate: ({ context, validateFn, params: { documentAST, schema } }) => { const errors = validateFn(schema, documentAST); + const userLocale = context.req.locale ?? SOURCE_LOCALE; + const i18n = options.i18nService.getI18nInstance(userLocale); + if (Array.isArray(errors) && errors.length > 0) { const headers = context.req.headers; const currentMetadataVersion = context.req.workspaceMetadataVersion; @@ -267,9 +270,12 @@ export const useGraphQLErrorHandlerHook = < options.metricsService.incrementCounter({ key: MetricsKeys.SchemaVersionMismatch, }); + throw new GraphQLError(SCHEMA_MISMATCH_ERROR, { extensions: { - userFriendlyMessage: msg`Your workspace has been updated with a new data model. Please refresh the page.`, + userFriendlyMessage: i18n._( + msg`Your workspace has been updated with a new data model. Please refresh the page.`, + ), }, }); } @@ -297,7 +303,9 @@ export const useGraphQLErrorHandlerHook = < throw new GraphQLError(APP_VERSION_MISMATCH_ERROR, { extensions: { code: APP_VERSION_MISMATCH_CODE, - userFriendlyMessage: msg`Your app version is out of date. Please refresh the page to continue.`, + userFriendlyMessage: i18n._( + msg`Your app version is out of date. Please refresh the page to continue.`, + ), }, }); }