Update OAuth consent modal design (#20540)

## Summary

Updates the OAuth consent screen to match the provided modal treatment,
including the header artwork, app-to-Twenty logo layout, scope icons,
and a content-hugging title that stays on one line until its max width.

Also preserves `returnToPath` through Google and Microsoft social SSO so
users who sign in from `/authorize` return to the OAuth consent flow
with the original OAuth query parameters intact.

## Reference

![OAuth consent modal
reference](https://raw.githubusercontent.com/twentyhq/twenty/046e02d9b3916e286c6bb4ef1c8045b0df53c074/oauth-modal-reference.png)

## Validation

- `yarn nx test twenty-front
--testFile=src/modules/auth/hooks/__tests__/useAuth.test.tsx
--coverage=false`
- `yarn nx test twenty-server
--testFile=src/engine/core-modules/auth/services/auth.service.spec.ts
--coverage=false`
- `yarn nx typecheck twenty-front`
- `yarn nx typecheck twenty-server`
- `git diff --check`

## Notes

The reference screenshot is linked from a separate branch-hosted image
commit so it renders in the PR body without adding that screenshot to
the product diff.

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas des Francs
2026-05-14 09:15:11 +02:00
committed by GitHub
parent 91b2390ee8
commit 8dd0aa46e3
4 changed files with 405 additions and 107 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+233 -106
View File
@@ -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 (
<StyledContainer>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<StyledCardWrapper>
<StyledText>
<Trans>Something went wrong</Trans>
</StyledText>
<StyledErrorText>
{t`Unable to load application details. Please try again later.`}
</StyledErrorText>
<ModalContent contentPadding={10}>
<StyledOAuthTitle
title={<Trans>Something went wrong</Trans>}
fontColor={H1TitleFontColor.Primary}
/>
<StyledErrorText>
{t`Unable to load application details. Please try again later.`}
</StyledErrorText>
</ModalContent>
</StyledCardWrapper>
</StyledContainer>
</ModalContent>
);
}
@@ -182,69 +291,87 @@ export const Authorize = () => {
const showLogoImage = isNonEmptyString(appLogoUrl) && !hasLogoError;
return (
<StyledContainer>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<StyledCardWrapper>
<StyledAppsContainer>
<img
src="/images/integrations/twenty-logo.svg"
alt="twenty-icon"
height={40}
width={40}
/>
<img
src="/images/integrations/link-apps.svg"
alt="link-icon"
height={60}
width={60}
/>
{showLogoImage ? (
<img
src={appLogoUrl}
alt={appName}
height={40}
width={40}
style={{ borderRadius: '2px' }}
onError={() => setHasLogoError(true)}
/>
) : (
<Avatar
size="xl"
placeholder={appName}
placeholderColorSeed={appName}
type="squared"
<StyledHeader>
<StyledAppLogoTile>
<StyledAppLogo src="/images/integrations/twenty-logo.svg" alt="" />
</StyledAppLogoTile>
<StyledLinkIconContainer aria-hidden>
<IconRefresh
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledLinkIconContainer>
<StyledAppLogoTile>
{showLogoImage ? (
<StyledAppLogo
src={appLogoUrl}
alt=""
onError={() => setHasLogoError(true)}
/>
) : (
<Avatar
size="xl"
placeholder={appName}
placeholderColorSeed={appName}
type="squared"
/>
)}
</StyledAppLogoTile>
</StyledHeader>
<ModalContent contentPadding={10}>
<StyledOAuthTitle
title={<Trans>Connect {appName} to your account</Trans>}
fontColor={H1TitleFontColor.Primary}
/>
{requestedScopes.length > 0 && (
<StyledPermissionSection>
<StyledPermissionIntro>
<Trans>{appName} would like to:</Trans>
</StyledPermissionIntro>
<StyledScopeList>
{requestedScopes.map((scope) => {
const ScopeIcon = OAUTH_SCOPE_ICONS[scope] ?? IconDatabase;
return (
<StyledScopeItem key={scope}>
<StyledScopeIcon>
<ScopeIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledScopeIcon>
<span>{oauthScopeLabels[scope] ?? scope}</span>
</StyledScopeItem>
);
})}
</StyledScopeList>
</StyledPermissionSection>
)}
</StyledAppsContainer>
<StyledText>
<Trans>{appName} wants to access your account</Trans>
</StyledText>
{requestedScopes.length > 0 && (
<StyledScopeList>
{requestedScopes.map((scope) => (
<StyledScopeItem key={scope}>
{oauthScopeLabels[scope] ?? scope}
</StyledScopeItem>
))}
</StyledScopeList>
)}
{authorizeError && <StyledErrorText>{authorizeError}</StyledErrorText>}
<StyledButtonContainer>
<UndecoratedLink to={AppPath.Index}>
<MainButton
title={t`Cancel`}
variant="secondary"
fullWidth
{authorizeError && (
<StyledErrorText>{authorizeError}</StyledErrorText>
)}
<StyledButtonContainer>
<StyledCancelLinkContainer>
<UndecoratedLink to={AppPath.Index} fullWidth>
<MainButton
title={t`Cancel`}
variant="secondary"
fullWidth
disabled={isAuthorizing}
/>
</UndecoratedLink>
</StyledCancelLinkContainer>
<StyledAuthorizeButton
title={isAuthorizing ? t`Authorizing...` : t`Authorize`}
onClick={handleAuthorize}
disabled={isAuthorizing}
fullWidth
/>
</UndecoratedLink>
<MainButton
title={isAuthorizing ? t`Authorizing...` : t`Authorize`}
onClick={handleAuthorize}
disabled={isAuthorizing}
fullWidth
/>
</StyledButtonContainer>
</StyledButtonContainer>
</ModalContent>
</StyledCardWrapper>
</StyledContainer>
</ModalContent>
);
};
@@ -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<PageDecoratorArgs> = {
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<typeof Authorize>;
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 },
);
},
};
@@ -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 (
<RouterProvider
router={createRouter({
Story,
args,
initialEntries: [computeLocation(args.routePath, args.routeParams)],
initialEntries: [
computeLocation(args.routePath, args.routeParams, args.searchParams),
],
})}
/>
);