feat: authorize screen (#4687)

* authorize screen

* lint fix

* add BlankLayout on Authorize route

* typo fix

* route decorator fix

* Unrelated fix

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Aditya Pimpalkar
2024-03-31 11:23:56 +01:00
committed by GitHub
parent aacb3763e7
commit d24d5a9a2e
11 changed files with 320 additions and 18 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" height="48" width="48"><defs><linearGradient id="a" x1="3.2173" y1="15" x2="44.7812" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d93025"/><stop offset="1" stop-color="#ea4335"/></linearGradient><linearGradient id="b" x1="20.7219" y1="47.6791" x2="41.5039" y2="11.6837" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcc934"/><stop offset="1" stop-color="#fbbc04"/></linearGradient><linearGradient id="c" x1="26.5981" y1="46.5015" x2="5.8161" y2="10.506" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1e8e3e"/><stop offset="1" stop-color="#34a853"/></linearGradient></defs><circle cx="24" cy="23.9947" r="12" style="fill:#fff"/><path d="M3.2154,36A24,24,0,1,0,12,3.2154,24,24,0,0,0,3.2154,36ZM34.3923,18A12,12,0,1,1,18,13.6077,12,12,0,0,1,34.3923,18Z" style="fill:none"/><path d="M24,12H44.7812a23.9939,23.9939,0,0,0-41.5639.0029L13.6079,30l.0093-.0024A11.9852,11.9852,0,0,1,24,12Z" style="fill:url(#a)"/><circle cx="24" cy="24" r="9.5" style="fill:#1a73e8"/><path d="M34.3913,30.0029,24.0007,48A23.994,23.994,0,0,0,44.78,12.0031H23.9989l-.0025.0093A11.985,11.985,0,0,1,34.3913,30.0029Z" style="fill:url(#b)"/><path d="M13.6086,30.0031,3.218,12.006A23.994,23.994,0,0,0,24.0025,48L34.3931,30.0029l-.0067-.0068a11.9852,11.9852,0,0,1-20.7778.007Z" style="fill:url(#c)"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,19 @@
<svg width="50" height="18" viewBox="0 0 50 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M49 8.5H1" stroke="black" stroke-width="1.32597" stroke-linecap="round" stroke-dasharray="3.54 3.54"/>
<g filter="url(#filter0_d_22_627)">
<circle cx="25" cy="8.5" r="8" fill="white"/>
<circle cx="25" cy="8.5" r="7.65217" stroke="black" stroke-width="0.695651"/>
</g>
<path d="M23.0341 6.5689C24.1158 5.50666 25.8637 5.50154 26.9524 6.55182L26.2358 7.25372C26.1158 7.37156 26.0811 7.54746 26.1454 7.70116C26.2098 7.85486 26.3628 7.95391 26.5315 7.95391H28.6098H28.7576C28.9889 7.95391 29.175 7.77118 29.175 7.54405V5.35809C29.175 5.19243 29.0741 5.04215 28.9176 4.97896C28.7611 4.91577 28.5819 4.94993 28.4619 5.06776L27.7385 5.7782C26.215 4.30097 23.7611 4.30609 22.2463 5.79528C21.8219 6.21198 21.5158 6.70211 21.328 7.2264C21.2254 7.5116 21.3785 7.82241 21.6671 7.92317C21.9558 8.02393 22.2741 7.87365 22.3767 7.59016C22.5106 7.21786 22.728 6.86776 23.0341 6.5689ZM20.8271 9.45676V9.58655V9.59851V11.6427C20.8271 11.8084 20.928 11.9587 21.0845 12.0218C21.2411 12.085 21.4202 12.0509 21.5402 11.933L22.2637 11.2226C23.7871 12.6998 26.2411 12.6947 27.7558 11.2055C28.1802 10.7888 28.488 10.2987 28.6758 9.77612C28.7785 9.49092 28.6254 9.1801 28.3367 9.07934C28.048 8.97858 27.7298 9.12887 27.6271 9.41236C27.4932 9.78465 27.2758 10.1348 26.9698 10.4336C25.888 11.4959 24.1402 11.501 23.0515 10.4507L23.7663 9.74708C23.8863 9.62925 23.9211 9.45335 23.8567 9.29964C23.7924 9.14594 23.6393 9.04689 23.4706 9.04689H21.3906H21.3785H21.2445C21.0132 9.04689 20.8271 9.22963 20.8271 9.45676Z" fill="black"/>
<defs>
<filter id="filter0_d_22_627" x="16.3043" y="0.5" width="16.6957" height="16.6957" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.695651" dy="0.695651"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_22_627"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_22_627" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -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 = () => {
<PageTitle title={pageTitle} />
<GotoHotkeysEffect />
<CommandMenuEffect />
<DefaultLayout>
<Routes>
<Routes>
<Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyEffect />} />
<Route path={AppPath.SignInUp} element={<SignInUp />} />
<Route path={AppPath.Invite} element={<SignInUp />} />
@@ -199,8 +201,11 @@ export const App = () => {
}
/>
<Route path={AppPath.NotFoundWildcard} element={<NotFound />} />
</Routes>
</DefaultLayout>
</Route>
<Route element={<BlankLayout />}>
<Route path={AppPath.Authorize} element={<Authorize />} />
</Route>
</Routes>
</>
);
};

View File

@@ -58,6 +58,11 @@ export type AuthTokens = {
tokens: AuthTokenPair;
};
export type AuthorizeApp = {
__typename?: 'AuthorizeApp';
redirectUrl: Scalars['String'];
};
export type Billing = {
__typename?: 'Billing';
billingFreeTrialDurationInDays?: Maybe<Scalars['Float']>;
@@ -105,6 +110,12 @@ export type ClientConfig = {
telemetry: Telemetry;
};
export type CreateRemoteServerInput = {
foreignDataWrapperOptions: Scalars['JSON'];
foreignDataWrapperType: Scalars['String'];
userMappingOptions?: InputMaybe<Scalars['JSON']>;
};
export type CursorPaging = {
/** Paginate after opaque cursor */
after?: InputMaybe<Scalars['ConnectionCursor']>;
@@ -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<RemoteServer>;
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<TrackM
export type TrackMutationHookResult = ReturnType<typeof useTrackMutation>;
export type TrackMutationResult = Apollo.MutationResult<TrackMutation>;
export type TrackMutationOptions = Apollo.BaseMutationOptions<TrackMutation, TrackMutationVariables>;
export const AuthorizeAppDocument = gql`
mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
redirectUrl
}
}
`;
export type AuthorizeAppMutationFn = Apollo.MutationFunction<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
/**
* __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<AuthorizeAppMutation, AuthorizeAppMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<AuthorizeAppMutation, AuthorizeAppMutationVariables>(AuthorizeAppDocument, options);
}
export type AuthorizeAppMutationHookResult = ReturnType<typeof useAuthorizeAppMutation>;
export type AuthorizeAppMutationResult = Apollo.MutationResult<AuthorizeAppMutation>;
export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
export const ChallengeDocument = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {

View File

@@ -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
}
}
`;

View File

@@ -25,6 +25,8 @@ export enum AppPath {
// Impersonate
Impersonate = '/impersonate/:userId',
Authorize = '/authorize',
// 404 page not found
NotFoundWildcard = '*',
NotFound = '/not-found',

View File

@@ -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 (
<>
<Global
styles={css`
body {
background: ${theme.background.tertiary};
}
`}
/>
<StyledLayout>
<Outlet />
</StyledLayout>
</>
);
};

View File

@@ -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) => {
<SignInBackgroundMockPage />
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>{children}</AuthModal>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<AppErrorBoundary>{children}</AppErrorBoundary>
<AppErrorBoundary>
<Outlet />
</AppErrorBoundary>
)}
</StyledMainContainer>
</StyledPageContainer>

View File

@@ -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);
},
[],
);

View File

@@ -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<App[]>([
{
id: 'chrome',
name: 'Chrome Extension',
logo: 'images/integrations/chrome-icon.svg',
},
]);
const [app, setApp] = useState<App>();
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 (
<StyledContainer>
<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}
/>
<img src={app?.logo} alt="app-icon" height={40} width={40} />
</StyledAppsContainer>
<StyledText>{app?.name} wants to access your account</StyledText>
<StyledButtonContainer>
<MainButton
title="Cancel"
variant="secondary"
onClick={() => navigate(AppPath.Index)}
fullWidth
/>
<MainButton title="Authorize" onClick={handleAuthorize} fullWidth />
</StyledButtonContainer>
</StyledCardWrapper>
</StyledContainer>
);
};
export default Authorize;

View File

@@ -50,11 +50,11 @@ export const PageDecorator: Decorator<{
<HelmetProvider>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<ObjectMetadataItemsProvider>
<DefaultLayout>
<Routes>
<Routes>
<Route element={<DefaultLayout />}>
<Route path={args.routePath} element={<Story />} />
</Routes>
</DefaultLayout>
</Route>
</Routes>
</ObjectMetadataItemsProvider>
</SnackBarProviderScope>
</HelmetProvider>