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,21 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ApiKeyForm } from '@/api-key/components/ApiKeyForm';
|
||||
import { Loader } from '@/ui/display/loader/components/Loader';
|
||||
import { MainButton } from '@/ui/input/button/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.noisy};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
width: 400px;
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledActionContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
width: 300px;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const Options = () => {
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [serverBaseUrl, setServerBaseUrl] = useState(
|
||||
import.meta.env.VITE_SERVER_BASE_URL,
|
||||
);
|
||||
const authenticate = () => {
|
||||
setIsAuthenticating(true);
|
||||
setError('');
|
||||
chrome.runtime.sendMessage({ action: 'CONNECT' }, ({ status, message }) => {
|
||||
if (status === true) {
|
||||
setIsAuthenticated(true);
|
||||
setIsAuthenticating(false);
|
||||
chrome.storage.local.set({ isAuthenticated: true });
|
||||
} else {
|
||||
setError(message);
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getState = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (store.serverBaseUrl !== '') {
|
||||
setServerBaseUrl(store.serverBaseUrl);
|
||||
} else {
|
||||
setServerBaseUrl(import.meta.env.VITE_SERVER_BASE_URL);
|
||||
}
|
||||
|
||||
if (store.isAuthenticated === true) setIsAuthenticated(true);
|
||||
};
|
||||
void getState();
|
||||
}, []);
|
||||
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
setServerBaseUrl(value);
|
||||
setError('');
|
||||
chrome.storage.local.set({ serverBaseUrl: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ApiKeyForm />
|
||||
</StyledContainer>
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<img src="/logo/32-32.svg" alt="twenty-logo" height={64} width={64} />
|
||||
<StyledActionContainer>
|
||||
<TextInput
|
||||
label="Server URL"
|
||||
value={serverBaseUrl}
|
||||
onChange={handleBaseUrlChange}
|
||||
placeholder="My base server URL"
|
||||
error={error}
|
||||
fullWidth
|
||||
/>
|
||||
{isAuthenticating ? (
|
||||
<Loader />
|
||||
) : isAuthenticated ? (
|
||||
<StyledLabel>Connected!</StyledLabel>
|
||||
) : (
|
||||
<>
|
||||
<MainButton
|
||||
title="Connect your account"
|
||||
onClick={() => authenticate()}
|
||||
fullWidth
|
||||
/>
|
||||
<MainButton
|
||||
title="Sign up"
|
||||
variant="secondary"
|
||||
onClick={() => window.open(`${serverBaseUrl}`, '_blank')}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledActionContainer>
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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