diff --git a/packages/twenty-front/public/images/integrations/chrome-icon.svg b/packages/twenty-front/public/images/integrations/chrome-icon.svg
new file mode 100644
index 000000000..4ff6ab6ba
--- /dev/null
+++ b/packages/twenty-front/public/images/integrations/chrome-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/twenty-front/public/images/integrations/link-apps.svg b/packages/twenty-front/public/images/integrations/link-apps.svg
new file mode 100644
index 000000000..556edacf2
--- /dev/null
+++ b/packages/twenty-front/public/images/integrations/link-apps.svg
@@ -0,0 +1,19 @@
+
diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx
index 032e95571..62d6f16c6 100644
--- a/packages/twenty-front/src/App.tsx
+++ b/packages/twenty-front/src/App.tsx
@@ -5,10 +5,12 @@ import { VerifyEffect } from '@/auth/components/VerifyEffect';
import { billingState } from '@/client-config/states/billingState.ts';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
+import { BlankLayout } from '@/ui/layout/page/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
+import Authorize from '~/pages/auth/Authorize';
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
@@ -59,8 +61,8 @@ export const App = () => {
-
-
+
+ }>
} />
} />
} />
@@ -199,8 +201,11 @@ export const App = () => {
}
/>
} />
-
-
+
+ }>
+ } />
+
+
>
);
};
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index fac67dfd2..0ef6cf46d 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -58,6 +58,11 @@ export type AuthTokens = {
tokens: AuthTokenPair;
};
+export type AuthorizeApp = {
+ __typename?: 'AuthorizeApp';
+ redirectUrl: Scalars['String'];
+};
+
export type Billing = {
__typename?: 'Billing';
billingFreeTrialDurationInDays?: Maybe;
@@ -105,6 +110,12 @@ export type ClientConfig = {
telemetry: Telemetry;
};
+export type CreateRemoteServerInput = {
+ foreignDataWrapperOptions: Scalars['JSON'];
+ foreignDataWrapperType: Scalars['String'];
+ userMappingOptions?: InputMaybe;
+};
+
export type CursorPaging = {
/** Paginate after opaque cursor */
after?: InputMaybe;
@@ -127,6 +138,13 @@ export type EmailPasswordResetLink = {
success: Scalars['Boolean'];
};
+export type ExchangeAuthCode = {
+ __typename?: 'ExchangeAuthCode';
+ accessToken: AuthToken;
+ loginToken: AuthToken;
+ refreshToken: AuthToken;
+};
+
export type FeatureFlag = {
__typename?: 'FeatureFlag';
id: Scalars['ID'];
@@ -250,12 +268,15 @@ export type LoginToken = {
export type Mutation = {
__typename?: 'Mutation';
activateWorkspace: Workspace;
+ authorizeApp: AuthorizeApp;
challenge: LoginToken;
checkoutSession: SessionEntity;
createOneObject: Object;
createOneRefreshToken: RefreshToken;
+ createOneRemoteServer: RemoteServer;
deleteCurrentWorkspace: Workspace;
deleteOneObject: Object;
+ deleteOneRemoteServer: RemoteServer;
deleteUser: User;
emailPasswordResetLink: EmailPasswordResetLink;
generateApiKeyToken: ApiKeyToken;
@@ -282,6 +303,12 @@ export type MutationActivateWorkspaceArgs = {
};
+export type MutationAuthorizeAppArgs = {
+ clientId: Scalars['String'];
+ codeChallenge: Scalars['String'];
+};
+
+
export type MutationChallengeArgs = {
email: Scalars['String'];
password: Scalars['String'];
@@ -294,11 +321,21 @@ export type MutationCheckoutSessionArgs = {
};
+export type MutationCreateOneRemoteServerArgs = {
+ input: CreateRemoteServerInput;
+};
+
+
export type MutationDeleteOneObjectArgs = {
input: DeleteOneObjectInput;
};
+export type MutationDeleteOneRemoteServerArgs = {
+ input: RemoteServerIdInput;
+};
+
+
export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String'];
};
@@ -425,6 +462,9 @@ export type Query = {
clientConfig: ClientConfig;
currentUser: User;
currentWorkspace: Workspace;
+ exchangeAuthorizationCode: ExchangeAuthCode;
+ findManyRemoteServersByType: Array;
+ findOneRemoteServerById: RemoteServer;
findWorkspaceFromInviteHash: Workspace;
getProductPrices: ProductPricesEntity;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
@@ -452,6 +492,22 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = {
};
+export type QueryExchangeAuthorizationCodeArgs = {
+ authorizationCode: Scalars['String'];
+ codeVerifier: Scalars['String'];
+};
+
+
+export type QueryFindManyRemoteServersByTypeArgs = {
+ input: RemoteServerTypeInput;
+};
+
+
+export type QueryFindOneRemoteServerByIdArgs = {
+ input: RemoteServerIdInput;
+};
+
+
export type QueryFindWorkspaceFromInviteHashArgs = {
inviteHash: Scalars['String'];
};
@@ -564,6 +620,14 @@ export type RemoteServer = {
updatedAt: Scalars['DateTime'];
};
+export type RemoteServerIdInput = {
+ /** The id of the record. */
+ id: Scalars['ID'];
+};
+
+export type RemoteServerTypeInput = {
+ foreignDataWrapperType: Scalars['String'];
+};
export type RemoteTable = {
__typename?: 'RemoteTable';
name: Scalars['String'];
@@ -969,6 +1033,14 @@ export type AuthTokenFragmentFragment = { __typename?: 'AuthToken', token: strin
export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } };
+export type AuthorizeAppMutationVariables = Exact<{
+ clientId: Scalars['String'];
+ codeChallenge: Scalars['String'];
+}>;
+
+
+export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __typename?: 'AuthorizeApp', redirectUrl: string } };
+
export type ChallengeMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
@@ -1492,6 +1564,40 @@ export function useTrackMutation(baseOptions?: Apollo.MutationHookOptions;
export type TrackMutationResult = Apollo.MutationResult;
export type TrackMutationOptions = Apollo.BaseMutationOptions;
+export const AuthorizeAppDocument = gql`
+ mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
+ authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
+ redirectUrl
+ }
+}
+ `;
+export type AuthorizeAppMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useAuthorizeAppMutation__
+ *
+ * To run a mutation, you first call `useAuthorizeAppMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useAuthorizeAppMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [authorizeAppMutation, { data, loading, error }] = useAuthorizeAppMutation({
+ * variables: {
+ * clientId: // value for 'clientId'
+ * codeChallenge: // value for 'codeChallenge'
+ * },
+ * });
+ */
+export function useAuthorizeAppMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(AuthorizeAppDocument, options);
+ }
+export type AuthorizeAppMutationHookResult = ReturnType;
+export type AuthorizeAppMutationResult = Apollo.MutationResult;
+export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions;
export const ChallengeDocument = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/authorizeApp.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/authorizeApp.ts
new file mode 100644
index 000000000..5beaadc81
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/authorizeApp.ts
@@ -0,0 +1,9 @@
+import { gql } from '@apollo/client';
+
+export const AUTHORIZE_APP = gql`
+ mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
+ authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
+ redirectUrl
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts
index 464760d7a..fe66bd4d6 100644
--- a/packages/twenty-front/src/modules/types/AppPath.ts
+++ b/packages/twenty-front/src/modules/types/AppPath.ts
@@ -25,6 +25,8 @@ export enum AppPath {
// Impersonate
Impersonate = '/impersonate/:userId',
+ Authorize = '/authorize',
+
// 404 page not found
NotFoundWildcard = '*',
NotFound = '/not-found',
diff --git a/packages/twenty-front/src/modules/ui/layout/page/BlankLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/BlankLayout.tsx
new file mode 100644
index 000000000..7836cd39d
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/layout/page/BlankLayout.tsx
@@ -0,0 +1,31 @@
+import { Outlet } from 'react-router-dom';
+import { css, Global, useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+
+const StyledLayout = styled.div`
+ background: ${({ theme }) => theme.background.noisy};
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ position: relative;
+ scrollbar-width: 4px;
+ width: 100%;
+`;
+
+export const BlankLayout = () => {
+ const theme = useTheme();
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
index f000fa031..7213c614a 100644
--- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
@@ -1,4 +1,5 @@
-import { ReactNode, useMemo } from 'react';
+import { useMemo } from 'react';
+import { Outlet } from 'react-router-dom';
import { css, Global, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
@@ -63,11 +64,7 @@ const StyledMainContainer = styled.div`
overflow: hidden;
`;
-type DefaultLayoutProps = {
- children: ReactNode;
-};
-
-export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
+export const DefaultLayout = () => {
const onboardingStatus = useOnboardingStatus();
const isMobile = useIsMobile();
const isSettingsPage = useIsSettingsPage();
@@ -125,12 +122,16 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
- {children}
+
+
+
>
) : (
- {children}
+
+
+
)}
diff --git a/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx b/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx
index c2b857836..028f3f1db 100644
--- a/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx
+++ b/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx
@@ -42,8 +42,8 @@ export const ScrollWrapper = ({
({ set }) =>
(overlayScroll: OverlayScrollbars) => {
const target = overlayScroll.elements().scrollOffsetElement;
- set(scrollTopState(), target.scrollTop);
- set(scrollLeftState(), target.scrollLeft);
+ set(scrollTopState, target.scrollTop);
+ set(scrollLeftState, target.scrollLeft);
},
[],
);
diff --git a/packages/twenty-front/src/pages/auth/Authorize.tsx b/packages/twenty-front/src/pages/auth/Authorize.tsx
new file mode 100644
index 000000000..48cc55cd9
--- /dev/null
+++ b/packages/twenty-front/src/pages/auth/Authorize.tsx
@@ -0,0 +1,128 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import styled from '@emotion/styled';
+import { MainButton } from 'tsup.ui.index';
+
+import { AppPath } from '@/types/AppPath';
+import { useAuthorizeAppMutation } from '~/generated/graphql';
+import { isDefined } from '~/utils/isDefined';
+
+type App = { id: string; name: string; logo: string };
+
+const StyledContainer = styled.div`
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ height: 100vh;
+ justify-content: center;
+ width: 100%;
+`;
+
+const StyledAppsContainer = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: ${({ theme }) => theme.spacing(4)};
+ justify-content: center;
+`;
+
+const StyledText = styled.div`
+ color: ${({ theme }) => theme.font.color.primary};
+ font-family: 'Inter';
+ font-size: ${({ theme }) => theme.font.size.lg};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+ padding: ${({ theme }) => theme.spacing(6)} 0px;
+`;
+
+const StyledCardWrapper = styled.div`
+ display: flex;
+ background-color: ${({ theme }) => theme.background.primary};
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 400px;
+ padding: ${({ theme }) => theme.spacing(6)};
+ box-shadow: ${({ theme }) => theme.boxShadow.strong};
+ border-radius: ${({ theme }) => theme.border.radius.md};
+`;
+
+const StyledButtonContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ width: 100%;
+`;
+const Authorize = () => {
+ const navigate = useNavigate();
+ const [searchParam] = useSearchParams();
+ //TODO: Replace with db call for registered third party apps
+ const [apps] = useState([
+ {
+ id: 'chrome',
+ name: 'Chrome Extension',
+ logo: 'images/integrations/chrome-icon.svg',
+ },
+ ]);
+ const [app, setApp] = useState();
+ const clientId = searchParam.get('clientId');
+ const codeChallenge = searchParam.get('codeChallenge');
+
+ useEffect(() => {
+ const app = apps.find((app) => app.id === clientId);
+ if (!isDefined(app)) navigate(AppPath.NotFound);
+ else setApp(app);
+ //eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const [authorizeApp] = useAuthorizeAppMutation();
+ const handleAuthorize = async () => {
+ if (isDefined(clientId) && isDefined(codeChallenge)) {
+ await authorizeApp({
+ variables: {
+ clientId,
+ codeChallenge,
+ },
+ onCompleted: (data) => {
+ window.location.href = data.authorizeApp.redirectUrl;
+ },
+ onError: (error) => {
+ throw Error(error.message);
+ },
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {app?.name} wants to access your account
+
+ navigate(AppPath.Index)}
+ fullWidth
+ />
+
+
+
+
+ );
+};
+
+export default Authorize;
diff --git a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx
index c3df0d1fb..e8be9da07 100644
--- a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx
+++ b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx
@@ -50,11 +50,11 @@ export const PageDecorator: Decorator<{
-
-
+
+ }>
} />
-
-
+
+