feat: oauth for chrome extension (#4870)
Previously we had to create a separate API key to give access to chrome extension so we can make calls to the DB. This PR includes logic to initiate a oauth flow with PKCE method which redirects to the `Authorise` screen to give access to server tokens. Implemented in this PR- 1. make `redirectUrl` a non-nullable parameter 2. Add `NODE_ENV` to environment variable service 3. new env variable `CHROME_EXTENSION_REDIRECT_URL` on server side 4. strict checks for redirectUrl 5. try catch blocks on utils db query methods 6. refactor Apollo Client to handle `unauthorized` condition 7. input field to enter server url (for self-hosting) 8. state to show user if its already connected 9. show error if oauth flow is cancelled by user Follow up PR - Renew token logic --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -1,184 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { H2Title } from '@/ui/display/typography/components/H2Title';
|
||||
import { Button } from '@/ui/input/button/Button';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Toggle } from '@/ui/input/components/Toggle';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledContainer = styled.div<{ isToggleOn: boolean }>`
|
||||
width: 400px;
|
||||
margin: 0 auto;
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
padding: ${({ theme }) => theme.spacing(10)};
|
||||
overflow: hidden;
|
||||
transition: height 0.3s ease;
|
||||
|
||||
height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')};
|
||||
max-height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')};
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.header`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledImgLogo = styled.img`
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMain = styled.main`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.footer`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledTitleContainer = styled.div`
|
||||
flex: 0 0 80%;
|
||||
`;
|
||||
|
||||
const StyledToggleContainer = styled.div`
|
||||
flex: 0 0 20%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const StyledSection = styled.div<{ showSection: boolean }>`
|
||||
transition:
|
||||
max-height 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
overflow: hidden;
|
||||
max-height: ${({ showSection }) => (showSection ? '200px' : '0')};
|
||||
`;
|
||||
|
||||
const StyledButtonHorizontalContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ApiKeyForm = () => {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [route, setRoute] = useState('');
|
||||
const [showSection, setShowSection] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getState = async () => {
|
||||
const localStorage = await chrome.storage.local.get();
|
||||
|
||||
if (isDefined(localStorage.apiKey)) {
|
||||
setApiKey(localStorage.apiKey);
|
||||
}
|
||||
|
||||
if (isDefined(localStorage.serverBaseUrl)) {
|
||||
setShowSection(true);
|
||||
setRoute(localStorage.serverBaseUrl);
|
||||
}
|
||||
};
|
||||
|
||||
void getState();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.VITE_SERVER_BASE_URL !== route) {
|
||||
chrome.storage.local.set({ serverBaseUrl: route });
|
||||
} else {
|
||||
chrome.storage.local.set({ serverBaseUrl: '' });
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
const handleValidateKey = () => {
|
||||
chrome.storage.local.set({ apiKey });
|
||||
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleGenerateClick = () => {
|
||||
window.open(`${import.meta.env.VITE_FRONT_BASE_URL}/settings/developers`);
|
||||
};
|
||||
|
||||
const handleGoToTwenty = () => {
|
||||
window.open(`${import.meta.env.VITE_FRONT_BASE_URL}`);
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
setShowSection(!showSection);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer isToggleOn={showSection}>
|
||||
<StyledHeader>
|
||||
<StyledImgLogo
|
||||
src="/logo/32-32.svg"
|
||||
alt="Twenty Logo"
|
||||
onClick={handleGoToTwenty}
|
||||
/>
|
||||
</StyledHeader>
|
||||
<StyledMain>
|
||||
<H2Title
|
||||
title="Connect your account"
|
||||
description="Input your key to link the extension to your workspace."
|
||||
/>
|
||||
<TextInput
|
||||
label="Api key"
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder="My API key"
|
||||
/>
|
||||
<StyledButtonHorizontalContainer>
|
||||
<Button
|
||||
title="Generate a key"
|
||||
fullWidth={true}
|
||||
variant="primary"
|
||||
accent="default"
|
||||
size="small"
|
||||
position="standalone"
|
||||
soon={false}
|
||||
disabled={false}
|
||||
onClick={handleGenerateClick}
|
||||
/>
|
||||
<Button
|
||||
title="Validate key"
|
||||
fullWidth={true}
|
||||
variant="primary"
|
||||
accent="default"
|
||||
size="small"
|
||||
position="standalone"
|
||||
soon={false}
|
||||
disabled={apiKey === ''}
|
||||
onClick={handleValidateKey}
|
||||
/>
|
||||
</StyledButtonHorizontalContainer>
|
||||
</StyledMain>
|
||||
|
||||
<StyledFooter>
|
||||
<StyledTitleContainer>
|
||||
<H2Title
|
||||
title="Custom route"
|
||||
description="For developers interested in self-hosting or local testing of the extension."
|
||||
/>
|
||||
</StyledTitleContainer>
|
||||
<StyledToggleContainer>
|
||||
<Toggle value={showSection} onChange={handleToggle} />
|
||||
</StyledToggleContainer>
|
||||
</StyledFooter>
|
||||
|
||||
<StyledSection showSection={showSection}>
|
||||
{showSection && (
|
||||
<TextInput
|
||||
label="Route"
|
||||
value={route}
|
||||
onChange={setRoute}
|
||||
placeholder="My Route"
|
||||
/>
|
||||
)}
|
||||
</StyledSection>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type Variant = 'primary' | 'secondary';
|
||||
|
||||
type MainButtonProps = {
|
||||
title: string;
|
||||
fullWidth?: boolean;
|
||||
width?: number;
|
||||
variant?: Variant;
|
||||
soon?: boolean;
|
||||
} & React.ComponentProps<'button'>;
|
||||
|
||||
const StyledButton = styled.button<
|
||||
Pick<MainButtonProps, 'fullWidth' | 'width' | 'variant'>
|
||||
>`
|
||||
align-items: center;
|
||||
background: ${({ theme, variant, disabled }) => {
|
||||
if (disabled === true) {
|
||||
return theme.background.secondary;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.background.radialGradient;
|
||||
case 'secondary':
|
||||
return theme.background.primary;
|
||||
default:
|
||||
return theme.background.primary;
|
||||
}
|
||||
}};
|
||||
border: 1px solid;
|
||||
border-color: ${({ theme, disabled, variant }) => {
|
||||
if (disabled === true) {
|
||||
return theme.background.transparent.lighter;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.background.transparent.light;
|
||||
case 'secondary':
|
||||
return theme.border.color.medium;
|
||||
default:
|
||||
return theme.background.primary;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
${({ theme, disabled }) => {
|
||||
if (disabled === true) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `box-shadow: ${theme.boxShadow.light};`;
|
||||
}}
|
||||
color: ${({ theme, variant, disabled }) => {
|
||||
if (disabled === true) {
|
||||
return theme.font.color.light;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.grayScale.gray0;
|
||||
case 'secondary':
|
||||
return theme.font.color.primary;
|
||||
default:
|
||||
return theme.font.color.primary;
|
||||
}
|
||||
}};
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
|
||||
width: ${({ fullWidth, width }) =>
|
||||
fullWidth ? '100%' : width ? `${width}px` : 'auto'};
|
||||
${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'secondary':
|
||||
return `
|
||||
&:hover {
|
||||
background: ${theme.background.tertiary};
|
||||
}
|
||||
`;
|
||||
default:
|
||||
return `
|
||||
&:hover {
|
||||
background: ${theme.background.radialGradientHover}};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
export const MainButton = ({
|
||||
title,
|
||||
width,
|
||||
fullWidth = false,
|
||||
variant = 'primary',
|
||||
type,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: MainButtonProps) => {
|
||||
return (
|
||||
<StyledButton
|
||||
className={className}
|
||||
{...{ disabled, fullWidth, width, onClick, type, variant }}
|
||||
>
|
||||
{title}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user