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:
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -25,6 +25,8 @@ export enum AppPath {
|
||||
// Impersonate
|
||||
Impersonate = '/impersonate/:userId',
|
||||
|
||||
Authorize = '/authorize',
|
||||
|
||||
// 404 page not found
|
||||
NotFoundWildcard = '*',
|
||||
NotFound = '/not-found',
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
128
packages/twenty-front/src/pages/auth/Authorize.tsx
Normal file
128
packages/twenty-front/src/pages/auth/Authorize.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user