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

@ -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>