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 (
-
+
-
-
-
- {showLogoImage ? (
-
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 (
);