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 ( + + + + twenty-icon + link-icon + app-icon + + {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<{ - - + + }> } /> - - + +