diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index dd75ebaf3..397cc7600 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -8,6 +8,7 @@ import { ChromeExtensionSidecarProvider } from '@/chrome-extension-sidecar/compo import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider'; import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect'; import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider'; +import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect'; import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect'; import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider'; import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect'; @@ -49,6 +50,7 @@ export const AppRouterProviders = () => { + diff --git a/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx index 2888811b3..c869ee9a0 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx @@ -5,8 +5,6 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { AppPath } from '@/types/AppPath'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { useNavigateApp } from '~/hooks/useNavigateApp'; @@ -14,9 +12,7 @@ import { useNavigateApp } from '~/hooks/useNavigateApp'; export const VerifyLoginTokenEffect = () => { const [searchParams] = useSearchParams(); const loginToken = searchParams.get('loginToken'); - const errorMessage = searchParams.get('errorMessage'); - const { enqueueSnackBar } = useSnackBar(); const isLogged = useIsLogged(); const navigate = useNavigateApp(); const { verifyLoginToken } = useVerifyLogin(); @@ -26,13 +22,6 @@ export const VerifyLoginTokenEffect = () => { ); useEffect(() => { - if (isDefined(errorMessage)) { - enqueueSnackBar(errorMessage, { - dedupeKey: 'get-auth-tokens-from-login-token-failed-dedupe-key', - variant: SnackBarVariant.Error, - }); - } - if (!clientConfigLoaded) return; if (isDefined(loginToken)) { diff --git a/packages/twenty-front/src/modules/error-handler/components/ErrorMessageEffect.tsx b/packages/twenty-front/src/modules/error-handler/components/ErrorMessageEffect.tsx new file mode 100644 index 000000000..3147faa7f --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/components/ErrorMessageEffect.tsx @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useSearchParams } from 'react-router-dom'; +import { isDefined } from 'twenty-shared/utils'; + +export const ErrorMessageEffect = () => { + const { enqueueSnackBar } = useSnackBar(); + const [searchParams, setSearchParams] = useSearchParams(); + const errorMessage = searchParams.get('errorMessage'); + + useEffect(() => { + if (isDefined(errorMessage)) { + enqueueSnackBar(errorMessage, { + dedupeKey: 'error-message-dedupe-key', + variant: SnackBarVariant.Error, + }); + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete('errorMessage'); + setSearchParams(newSearchParams); + } + }, [enqueueSnackBar, errorMessage, searchParams, setSearchParams]); + + return <>; +}; diff --git a/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx b/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx index 1a0ff0583..7c9c85a62 100644 --- a/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx +++ b/packages/twenty-front/src/pages/settings/accounts/SettingsAccounts.tsx @@ -1,5 +1,3 @@ -import { useRecoilValue } from 'recoil'; - import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; @@ -14,6 +12,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { useLingui } from '@lingui/react/macro'; +import { useRecoilValue } from 'recoil'; import { H2Title } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 0138dbfd0..27a439fa9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -20,6 +20,7 @@ import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dt import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException, AuthExceptionCode, @@ -53,7 +54,6 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 15574a13f..f5bf40a3b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -118,15 +118,16 @@ export class GoogleAPIsAuthController { }) .toString(), ); - } catch (err) { + } catch (error) { return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( - err, - workspace ?? { + this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace: workspace ?? { subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), customDomain: null, }, - ), + pathname: '/verify', + }), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index c3e5bcf55..9104479f5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -118,14 +118,16 @@ export class GoogleAuthController { billingCheckoutSessionState, }), ); - } catch (err) { + } catch (error) { return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( - err, - this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( - currentWorkspace, - ), - ), + this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace: + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( + currentWorkspace, + ), + pathname: '/verify', + }), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index c0e062804..9614d7134 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -125,15 +125,16 @@ export class MicrosoftAPIsAuthController { }) .toString(), ); - } catch (err) { + } catch (error) { return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( - err, - workspace ?? { + this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace: workspace ?? { subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), customDomain: null, }, - ), + pathname: '/verify', + }), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 414951dc7..07b8610cf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -119,14 +119,16 @@ export class MicrosoftAuthController { billingCheckoutSessionState, }), ); - } catch (err) { + } catch (error) { return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( - err, - this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( - currentWorkspace, - ), - ), + this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace: + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( + currentWorkspace, + ), + pathname: '/verify', + }), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index a1ca4b777..a7c43ebeb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -19,25 +19,25 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; +import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard'; import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard'; import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard'; -import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; +import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; +import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; 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 { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter'; -import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; -import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; -import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Controller('auth') export class SSOAuthController { @@ -157,14 +157,16 @@ export class SSOAuthController { workspace: currentWorkspace, }), ); - } catch (err) { + } catch (error) { return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( - err, - this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( - workspaceIdentityProvider?.workspace, - ), - ), + this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace: + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( + workspaceIdentityProvider?.workspace, + ), + pathname: '/verify', + }), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-rest-api-exception.filter.ts index 6ce2873f5..8e452f438 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-rest-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-rest-api-exception.filter.ts @@ -14,10 +14,18 @@ export class AuthRestApiExceptionFilter implements ExceptionFilter { private readonly httpExceptionHandlerService: HttpExceptionHandlerService, ) {} - catch(exception: AuthException, host: ArgumentsHost) { + catch(exception: AuthException | Error, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); + if (!(exception instanceof AuthException)) { + return this.httpExceptionHandlerService.handleError( + exception, + response, + 500, + ); + } + switch (exception.code) { case AuthExceptionCode.USER_NOT_FOUND: case AuthExceptionCode.CLIENT_NOT_FOUND: @@ -43,6 +51,7 @@ export class AuthRestApiExceptionFilter implements ExceptionFilter { case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED: case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED: case AuthExceptionCode.SIGNUP_DISABLED: + case AuthExceptionCode.WORKSPACE_NOT_FOUND: return this.httpExceptionHandlerService.handleError( exception, response, diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts index 0d32c2fa4..32f5db0aa 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts @@ -1,14 +1,20 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( @@ -17,6 +23,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( constructor( private readonly guardRedirectService: GuardRedirectService, private readonly twentyConfigService: TwentyConfigService, + private readonly transientTokenService: TransientTokenService, + private readonly domainManagerService: DomainManagerService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) { super(); } @@ -47,6 +57,30 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( return (await super.canActivate(context)) as boolean; } catch (err) { + if (err.status === 401) { + const request = context.switchToHttp().getRequest(); + const state = JSON.parse(request.query.state); + + const workspace = await this.getWorkspaceFromTransientToken( + state.transientToken, + ); + + const redirectErrorUrl = + this.domainManagerService.computeRedirectErrorUrl( + 'We cannot connect to your Google account, please try again with more permissions, or a valid account', + { + subdomain: workspace.subdomain, + customDomain: workspace.customDomain, + isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false, + }, + '/settings/accounts', + ); + + context.switchToHttp().getResponse().redirect(redirectErrorUrl); + + return false; + } + this.guardRedirectService.dispatchErrorFromGuard( context, err, @@ -58,4 +92,24 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( return false; } } + + private async getWorkspaceFromTransientToken( + transientToken: string, + ): Promise { + const { workspaceId } = + await this.transientTokenService.verifyTransientToken(transientToken); + + const workspace = await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + + if (!workspace) { + throw new AuthException( + `Error extracting workspace from transientToken for Google APIs connect for ${workspaceId}`, + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + return workspace; + } } diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts index 5696b732f..3e47ae93d 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts @@ -128,10 +128,11 @@ export class DomainManagerService { computeRedirectErrorUrl( errorMessage: string, workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType, + pathname: string, ) { const url = this.buildWorkspaceURL({ workspace, - pathname: '/verify', + pathname, searchParams: { errorMessage }, }); diff --git a/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts b/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts index deda56193..9f85f2958 100644 --- a/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts @@ -2,7 +2,10 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { Request } from 'express'; -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 { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -25,15 +28,19 @@ export class GuardRedirectService { customDomain: string | null; isCustomDomainEnabled?: boolean; }, + pathname = '/verify', ) { if ('contextType' in context && context.contextType === 'graphql') { throw error; } - context - .switchToHttp() - .getResponse() - .redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace)); + context.switchToHttp().getResponse().redirect( + this.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace, + pathname, + }), + ); } getSubdomainAndCustomDomainFromContext(context: ExecutionContext) { @@ -58,7 +65,11 @@ export class GuardRedirectService { } private captureException(err: Error | CustomException, workspaceId?: string) { - if (err instanceof AuthException) return; + if ( + err instanceof AuthException && + err.code !== AuthExceptionCode.INTERNAL_SERVER_ERROR + ) + return; this.exceptionHandlerService.captureExceptions([err], { workspace: { @@ -67,24 +78,30 @@ export class GuardRedirectService { }); } - getRedirectErrorUrlAndCaptureExceptions( - err: Error | CustomException, + getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace, + pathname, + }: { + error: Error | AuthException; workspace: { id?: string; subdomain: string; customDomain: string | null; isCustomDomainEnabled?: boolean; - }, - ) { - this.captureException(err, workspace.id); + }; + pathname: string; + }) { + this.captureException(error, workspace.id); return this.domainManagerService.computeRedirectErrorUrl( - err instanceof AuthException ? err.message : 'Unknown error', + error instanceof AuthException ? error.message : 'Unknown error', { subdomain: workspace.subdomain, customDomain: workspace.customDomain, isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false, }, + pathname, ); } }