diff --git a/packages/twenty-front/public/images/integrations/oauth-modal-header.png b/packages/twenty-front/public/images/integrations/oauth-modal-header.png new file mode 100644 index 0000000000..beeba0b0a0 Binary files /dev/null and b/packages/twenty-front/public/images/integrations/oauth-modal-header.png differ diff --git a/packages/twenty-front/src/pages/auth/Authorize.tsx b/packages/twenty-front/src/pages/auth/Authorize.tsx index 539b4fad88..96d2a7a9ae 100644 --- a/packages/twenty-front/src/pages/auth/Authorize.tsx +++ b/packages/twenty-front/src/pages/auth/Authorize.tsx @@ -1,5 +1,5 @@ import { styled } from '@linaria/react'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { AppPath } from 'twenty-shared/types'; @@ -8,94 +8,200 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { useQuery, useMutation } from '@apollo/client/react'; import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; -import { Avatar } from 'twenty-ui/display'; +import { + Avatar, + H1Title, + H1TitleFontColor, + IconDatabase, + IconRefresh, + IconUserCircle, + type IconComponent, +} from 'twenty-ui/display'; import { MainButton } from 'twenty-ui/input'; +import { ModalContent } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { AuthorizeAppDocument, FindApplicationRegistrationByClientIdDocument, } from '~/generated-metadata/graphql'; import { useNavigateApp } from '~/hooks/useNavigateApp'; -const StyledContainer = styled.div` - align-items: center; - display: flex; - flex-direction: column; - height: 100dvh; - justify-content: center; - width: 100%; -`; - -const StyledAppsContainer = styled.div` - align-items: center; - display: flex; - flex-direction: row; - gap: ${themeCssVariables.spacing[4]}; - justify-content: center; -`; - -const StyledText = styled.div` - color: ${themeCssVariables.font.color.primary}; - font-family: 'Inter'; - font-size: ${themeCssVariables.font.size.lg}; - font-weight: ${themeCssVariables.font.weight.semiBold}; - padding: ${themeCssVariables.spacing[6]} 0px; -`; - const StyledCardWrapper = styled.div` - align-items: center; + --oauth-modal-content-max-width: calc( + ${themeCssVariables.modal.size.md.width} + ${themeCssVariables.spacing[32]} + ); + background-color: ${themeCssVariables.background.primary}; border-radius: ${themeCssVariables.border.radius.md}; box-shadow: ${themeCssVariables.boxShadow.strong}; display: flex; flex-direction: column; + max-width: min( + 100%, + calc( + var(--oauth-modal-content-max-width) + ${themeCssVariables.spacing[20]} + ) + ); + overflow: hidden; + width: fit-content; +`; + +const StyledHeader = styled.div` + align-items: center; + background-image: url('/images/integrations/oauth-modal-header.png'); + background-position: center; + background-size: cover; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + height: ${themeCssVariables.spacing[30]}; justify-content: center; - padding: ${themeCssVariables.spacing[6]}; - width: 400px; + width: 100%; +`; + +const StyledAppLogoTile = styled.div` + align-items: center; + backdrop-filter: ${themeCssVariables.blur.strong}; + background: ${themeCssVariables.background.primary}; + border-radius: ${themeCssVariables.border.radius.md}; + box-shadow: ${themeCssVariables.boxShadow.strong}; + box-sizing: border-box; + display: flex; + flex-shrink: 0; + height: ${themeCssVariables.spacing[12]}; + justify-content: center; + padding: ${themeCssVariables.spacing[1]}; + width: ${themeCssVariables.spacing[12]}; +`; + +const StyledAppLogo = styled.img` + border-radius: ${themeCssVariables.border.radius.sm}; + height: ${themeCssVariables.spacing[10]}; + object-fit: cover; + width: ${themeCssVariables.spacing[10]}; +`; + +const StyledLinkIconContainer = styled.div` + align-items: center; + background: ${themeCssVariables.background.primary}; + border-radius: ${themeCssVariables.border.radius.rounded}; + box-shadow: ${themeCssVariables.boxShadow.strong}; + color: ${themeCssVariables.font.color.primary}; + display: flex; + flex-shrink: 0; + height: ${themeCssVariables.spacing[6]}; + justify-content: center; + width: ${themeCssVariables.spacing[6]}; +`; + +const StyledOAuthTitle = styled(H1Title)` + margin: 0; + max-width: min(100%, var(--oauth-modal-content-max-width)); + padding-bottom: ${themeCssVariables.spacing[1]}; + text-wrap: balance; + width: max-content; `; const StyledButtonContainer = styled.div` display: grid; - gap: 10px; - grid-template-columns: 1fr 1fr; + gap: ${themeCssVariables.spacing[3]}; + grid-template-columns: repeat( + 2, + minmax(${themeCssVariables.spacing[0]}, 1fr) + ); + margin-top: ${themeCssVariables.spacing[8]}; width: 100%; `; +const StyledCancelLinkContainer = styled.div` + min-width: 0; + + a { + display: block; + } +`; + +const StyledAuthorizeButton = styled(MainButton)` + box-shadow: none; +`; + +const StyledPermissionSection = styled.div` + display: flex; + flex-direction: column; + margin-top: ${themeCssVariables.spacing[6]}; + width: 100%; +`; + +const StyledPermissionIntro = styled.p` + color: ${themeCssVariables.font.color.primary}; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; + line-height: ${themeCssVariables.text.lineHeight.lg}; + margin: 0; + padding: 0 0 ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[1]}; +`; + const StyledScopeList = styled.ul` + display: flex; + flex-direction: column; list-style: none; - margin: 0 0 ${themeCssVariables.spacing[4]} 0; + margin: 0; padding: 0; width: 100%; `; const StyledScopeItem = styled.li` - border-bottom: 1px solid ${themeCssVariables.border.color.light}; + align-items: center; color: ${themeCssVariables.font.color.secondary}; + display: flex; + font-family: ${themeCssVariables.font.family}; font-size: ${themeCssVariables.font.size.md}; - padding: ${themeCssVariables.spacing[1]} 0; + gap: ${themeCssVariables.spacing[2]}; + line-height: ${themeCssVariables.text.lineHeight.lg}; + padding-left: ${themeCssVariables.spacing[2]}; - &:last-child { - border-bottom: none; + & + & { + border-top: 1px solid ${themeCssVariables.border.color.light}; + margin-top: ${themeCssVariables.spacing[3]}; + padding-top: ${themeCssVariables.spacing[3]}; } + + span { + min-width: 0; + } +`; + +const StyledScopeIcon = styled.div` + align-items: center; + color: ${themeCssVariables.color.blue}; + display: flex; + flex-shrink: 0; + justify-content: center; `; const StyledErrorText = styled.div` color: ${themeCssVariables.color.red}; font-size: ${themeCssVariables.font.size.sm}; - padding: ${themeCssVariables.spacing[2]} 0; + margin-top: ${themeCssVariables.spacing[4]}; text-align: center; width: 100%; `; +const OAUTH_SCOPE_ICONS: { [scope: string]: IconComponent | undefined } = { + api: IconDatabase, + profile: IconUserCircle, +}; + export const Authorize = () => { const { t } = useLingui(); + const { theme } = useContext(ThemeContext); const navigate = useNavigateApp(); const [searchParam] = useSearchParams(); const { redirect } = useRedirect(); const oauthScopeLabels: { [scope: string]: string | undefined } = { - api: t`Access workspace data`, + api: t`Access your workspace data`, profile: t`Read your profile`, }; @@ -158,16 +264,19 @@ export const Authorize = () => { if (isDefined(queryError)) { return ( - + - - Something went wrong - - - {t`Unable to load application details. Please try again later.`} - + + Something went wrong} + fontColor={H1TitleFontColor.Primary} + /> + + {t`Unable to load application details. Please try again later.`} + + - + ); } @@ -182,69 +291,87 @@ export const Authorize = () => { const showLogoImage = isNonEmptyString(appLogoUrl) && !hasLogoError; return ( - + - - twenty-icon - link-icon - {showLogoImage ? ( - {appName} setHasLogoError(true)} - /> - ) : ( - + + + + + + + + {showLogoImage ? ( + setHasLogoError(true)} + /> + ) : ( + + )} + + + + Connect {appName} to your account} + fontColor={H1TitleFontColor.Primary} + /> + {requestedScopes.length > 0 && ( + + + {appName} would like to: + + + {requestedScopes.map((scope) => { + const ScopeIcon = OAUTH_SCOPE_ICONS[scope] ?? IconDatabase; + + return ( + + + + + {oauthScopeLabels[scope] ?? scope} + + ); + })} + + )} - - - {appName} wants to access your account - - {requestedScopes.length > 0 && ( - - {requestedScopes.map((scope) => ( - - {oauthScopeLabels[scope] ?? scope} - - ))} - - )} - {authorizeError && {authorizeError}} - - - {authorizeError} + )} + + + + + + + - - - + + - + ); }; diff --git a/packages/twenty-front/src/pages/auth/__stories__/Authorize.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/Authorize.stories.tsx new file mode 100644 index 0000000000..22a68a2586 --- /dev/null +++ b/packages/twenty-front/src/pages/auth/__stories__/Authorize.stories.tsx @@ -0,0 +1,161 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { HttpResponse, graphql } from 'msw'; +import { within } from 'storybook/test'; + +import { AppPath } from 'twenty-shared/types'; +import { Authorize } from '~/pages/auth/Authorize'; +import { + PageDecorator, + type PageDecoratorArgs, +} from '~/testing/decorators/PageDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { getOperationName } from '~/utils/getOperationName'; + +import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser'; +import { FindApplicationRegistrationByClientIdDocument } from '~/generated-metadata/graphql'; + +const MOCKED_REDIRECT_URL = 'https://chatgpt.com/connector'; + +const buildHandlers = ( + application: { + id: string; + name: string; + logoUrl: string | null; + oAuthScopes: string[]; + websiteUrl: string | null; + } | null, +) => [ + graphql.query( + getOperationName(FindApplicationRegistrationByClientIdDocument) ?? '', + () => + HttpResponse.json({ + data: { + findApplicationRegistrationByClientId: application + ? { + __typename: 'PublicApplicationRegistration', + ...application, + } + : null, + }, + }), + ), + graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => + HttpResponse.json({ + data: null, + errors: [ + { + message: 'Unauthorized', + extensions: { + code: 'UNAUTHENTICATED', + response: { + statusCode: 401, + message: 'Unauthorized', + }, + }, + }, + ], + }), + ), + ...graphqlMocks.handlers, +]; + +const meta: Meta = { + title: 'Pages/Auth/Authorize', + component: Authorize, + decorators: [PageDecorator], + args: { + routePath: AppPath.Authorize, + routeParams: {}, + additionalRoutes: [AppPath.NotFound], + }, + parameters: { + cookie: '', + }, +}; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = { + args: { + searchParams: { + client_id: 'default-client-id', + redirect_uri: MOCKED_REDIRECT_URL, + state: 'mocked-state', + }, + }, + parameters: { + msw: { + handlers: buildHandlers({ + id: 'application-id-default', + name: 'ChatGPT', + logoUrl: null, + oAuthScopes: ['api', 'profile'], + websiteUrl: null, + }), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Connect ChatGPT to your account', undefined, { + timeout: 5000, + }); + }, +}; + +export const WithApiScopeOnly: Story = { + args: { + searchParams: { + client_id: 'api-only-client-id', + redirect_uri: MOCKED_REDIRECT_URL, + }, + }, + parameters: { + msw: { + handlers: buildHandlers({ + id: 'application-id-api-only', + name: 'Internal Tool', + logoUrl: null, + oAuthScopes: ['api'], + websiteUrl: null, + }), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText( + 'Connect Internal Tool to your account', + undefined, + { timeout: 5000 }, + ); + }, +}; + +export const WithLongAppName: Story = { + args: { + searchParams: { + client_id: 'long-name-client-id', + redirect_uri: MOCKED_REDIRECT_URL, + }, + }, + parameters: { + msw: { + handlers: buildHandlers({ + id: 'application-id-long-name', + name: 'Custom Workspace Automation Suite', + logoUrl: null, + oAuthScopes: ['api', 'profile'], + websiteUrl: null, + }), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText( + 'Connect Custom Workspace Automation Suite to your account', + undefined, + { timeout: 5000 }, + ); + }, +}; diff --git a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx index afda7b9336..8173739104 100644 --- a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx @@ -38,6 +38,7 @@ export type PageDecoratorArgs = { routePath: string; routeParams: RouteParams; additionalRoutes?: string[]; + searchParams?: RouteParams; }; export type RouteParams = { @@ -55,12 +56,18 @@ export const isRouteParams = (obj: any): obj is RouteParams => { export const computeLocation = ( routePath: string, routeParams?: RouteParams, + searchParams?: RouteParams, ) => { + const search = searchParams + ? `?${new URLSearchParams(searchParams).toString()}` + : ''; + return { pathname: routePath.replace( /:(\w+)/g, (paramName) => routeParams?.[paramName] ?? '', ), + search, }; }; @@ -151,13 +158,16 @@ export const PageDecorator: Decorator<{ routePath: string; routeParams: RouteParams; additionalRoutes?: string[]; + searchParams?: RouteParams; }> = (Story, { args }) => { return ( );