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,4 +1,7 @@
|
||||
import Crypto from 'crypto-js';
|
||||
|
||||
import { openOptionsPage } from '~/background/utils/openOptionsPage';
|
||||
import { exchangeAuthorizationCode } from '~/db/auth.db';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
// Open options page programmatically in a new tab.
|
||||
@ -27,6 +30,11 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
||||
case 'openOptionsPage':
|
||||
openOptionsPage();
|
||||
break;
|
||||
case 'CONNECT':
|
||||
launchOAuth(({ status, message }) => {
|
||||
sendResponse({ status, message });
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -34,6 +42,81 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
const generateRandomString = (length: number) => {
|
||||
const charset =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const generateCodeVerifierAndChallenge = () => {
|
||||
const codeVerifier = generateRandomString(32);
|
||||
const hash = Crypto.SHA256(codeVerifier);
|
||||
const codeChallenge = hash
|
||||
.toString(Crypto.enc.Base64)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
};
|
||||
|
||||
const launchOAuth = (
|
||||
callback: ({ status, message }: { status: boolean; message: string }) => void,
|
||||
) => {
|
||||
const { codeVerifier, codeChallenge } = generateCodeVerifierAndChallenge();
|
||||
const redirectUrl = chrome.identity.getRedirectURL();
|
||||
chrome.identity
|
||||
.launchWebAuthFlow({
|
||||
url: `${
|
||||
import.meta.env.VITE_FRONT_BASE_URL
|
||||
}/authorize?clientId=chrome&codeChallenge=${codeChallenge}&redirectUrl=${redirectUrl}`,
|
||||
interactive: true,
|
||||
})
|
||||
.then((responseUrl) => {
|
||||
if (typeof responseUrl === 'string') {
|
||||
const url = new URL(responseUrl);
|
||||
const authorizationCode = url.searchParams.get(
|
||||
'authorizationCode',
|
||||
) as string;
|
||||
exchangeAuthorizationCode({
|
||||
authorizationCode,
|
||||
codeVerifier,
|
||||
}).then((tokens) => {
|
||||
if (isDefined(tokens)) {
|
||||
chrome.storage.local.set({
|
||||
loginToken: tokens.loginToken,
|
||||
});
|
||||
|
||||
chrome.storage.local.set({
|
||||
accessToken: tokens.accessToken,
|
||||
});
|
||||
|
||||
chrome.storage.local.set({
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
|
||||
callback({ status: true, message: '' });
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (isDefined(tabs) && isDefined(tabs[0])) {
|
||||
chrome.tabs.sendMessage(tabs[0].id ?? 0, {
|
||||
action: 'AUTHENTICATED',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
callback({ status: false, message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
const isDesiredRoute =
|
||||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
|
||||
|
||||
@ -52,12 +52,13 @@ export const createDefaultButton = (
|
||||
Object.assign(div.style, divStyles);
|
||||
});
|
||||
|
||||
// Handle the click event.
|
||||
div.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const store = await chrome.storage.local.get();
|
||||
|
||||
// If an api key is not set, the options page opens up to allow the user to configure an api key.
|
||||
if (!store.apiKey) {
|
||||
if (!store.accessToken) {
|
||||
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
|
||||
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
// Inject buttons into the DOM when SPA is reloaded on the resource url.
|
||||
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
|
||||
@ -20,20 +21,26 @@ chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.action === 'TOGGLE') {
|
||||
toggle();
|
||||
await toggle();
|
||||
}
|
||||
|
||||
if (message.action === 'AUTHENTICATED') {
|
||||
await authenticated();
|
||||
}
|
||||
|
||||
sendResponse('Executing!');
|
||||
});
|
||||
|
||||
const IFRAME_WIDTH = '400px';
|
||||
|
||||
const createIframe = () => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.background = 'lightgrey';
|
||||
iframe.style.height = '100vh';
|
||||
iframe.style.width = '400px';
|
||||
iframe.style.width = IFRAME_WIDTH;
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.top = '0px';
|
||||
iframe.style.right = '-400px';
|
||||
iframe.style.right = `-${IFRAME_WIDTH}`;
|
||||
iframe.style.zIndex = '9000000000000000000';
|
||||
iframe.style.transition = 'ease-in-out 0.3s';
|
||||
return iframe;
|
||||
@ -41,33 +48,57 @@ const createIframe = () => {
|
||||
|
||||
const handleContentIframeLoadComplete = () => {
|
||||
//If the pop-out window is already open then we replace loading iframe with our content iframe
|
||||
if (loadingIframe.style.right === '0px') contentIframe.style.right = '0px';
|
||||
loadingIframe.style.display = 'none';
|
||||
if (optionsIframe.style.right === '0px') contentIframe.style.right = '0px';
|
||||
optionsIframe.style.display = 'none';
|
||||
contentIframe.style.display = 'block';
|
||||
};
|
||||
|
||||
//Creating one iframe where we are loading our front end in the background
|
||||
const contentIframe = createIframe();
|
||||
contentIframe.style.display = 'none';
|
||||
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
|
||||
contentIframe.onload = handleContentIframeLoadComplete;
|
||||
|
||||
//Creating this iframe to show as a loading state until the above iframe loads completely
|
||||
const loadingIframe = createIframe();
|
||||
loadingIframe.src = chrome.runtime.getURL('loading.html');
|
||||
chrome.storage.local.get().then((store) => {
|
||||
if (isDefined(store.loginToken)) {
|
||||
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
|
||||
contentIframe.onload = handleContentIframeLoadComplete;
|
||||
}
|
||||
});
|
||||
|
||||
const optionsIframe = createIframe();
|
||||
optionsIframe.src = chrome.runtime.getURL('options.html');
|
||||
|
||||
document.body.appendChild(loadingIframe);
|
||||
document.body.appendChild(contentIframe);
|
||||
document.body.appendChild(optionsIframe);
|
||||
|
||||
const toggleIframe = (iframe: HTMLIFrameElement) => {
|
||||
if (iframe.style.right === '-400px' && iframe.style.display !== 'none') {
|
||||
if (
|
||||
iframe.style.right === `-${IFRAME_WIDTH}` &&
|
||||
iframe.style.display !== 'none'
|
||||
) {
|
||||
iframe.style.right = '0px';
|
||||
} else if (iframe.style.right === '0px' && iframe.style.display !== 'none') {
|
||||
iframe.style.right = '-400px';
|
||||
iframe.style.right = `-${IFRAME_WIDTH}`;
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
toggleIframe(loadingIframe);
|
||||
toggleIframe(contentIframe);
|
||||
const toggle = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (isDefined(store.accessToken)) {
|
||||
toggleIframe(contentIframe);
|
||||
} else {
|
||||
toggleIframe(optionsIframe);
|
||||
}
|
||||
};
|
||||
|
||||
const authenticated = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (isDefined(store.loginToken)) {
|
||||
contentIframe.src = `${
|
||||
import.meta.env.VITE_FRONT_BASE_URL
|
||||
}/verify?loginToken=${store.loginToken.token}`;
|
||||
contentIframe.onload = handleContentIframeLoadComplete;
|
||||
toggleIframe(contentIframe);
|
||||
} else {
|
||||
toggleIframe(optionsIframe);
|
||||
}
|
||||
};
|
||||
|
||||
26
packages/twenty-chrome-extension/src/db/auth.db.ts
Normal file
26
packages/twenty-chrome-extension/src/db/auth.db.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {
|
||||
ExchangeAuthCodeInput,
|
||||
ExchangeAuthCodeResponse,
|
||||
Tokens,
|
||||
} from '~/db/types/auth.types';
|
||||
import { EXCHANGE_AUTHORIZATION_CODE } from '~/graphql/auth/mutations';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { callMutation } from '~/utils/requestDb';
|
||||
|
||||
export const exchangeAuthorizationCode = async (
|
||||
exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
): Promise<Tokens | null> => {
|
||||
const data = await callMutation<ExchangeAuthCodeResponse>(
|
||||
EXCHANGE_AUTHORIZATION_CODE,
|
||||
exchangeAuthCodeInput,
|
||||
);
|
||||
if (isDefined(data?.exchangeAuthorizationCode))
|
||||
return data.exchangeAuthorizationCode;
|
||||
else return null;
|
||||
};
|
||||
|
||||
// export const RenewToken = async (appToken: string): Promise<Tokens | null> => {
|
||||
// const data = await callQuery<Tokens>(RENEW_TOKEN, { appToken });
|
||||
// if (isDefined(data)) return data;
|
||||
// else return null;
|
||||
// };
|
||||
@ -13,35 +13,29 @@ import { callMutation, callQuery } from '../utils/requestDb';
|
||||
export const fetchCompany = async (
|
||||
companyfilerInput: CompanyFilterInput,
|
||||
): Promise<Company | null> => {
|
||||
try {
|
||||
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
||||
filter: {
|
||||
...companyfilerInput,
|
||||
},
|
||||
});
|
||||
if (isDefined(data?.companies.edges)) {
|
||||
return data?.companies.edges.length > 0
|
||||
? data?.companies.edges[0].node
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
||||
filter: {
|
||||
...companyfilerInput,
|
||||
},
|
||||
});
|
||||
if (isDefined(data?.companies.edges)) {
|
||||
return data.companies.edges.length > 0
|
||||
? isDefined(data.companies.edges[0].node)
|
||||
? data.companies.edges[0].node
|
||||
: null
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createCompany = async (
|
||||
company: CompanyInput,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
||||
input: company,
|
||||
});
|
||||
if (isDefined(data)) {
|
||||
return data.createCompany.id;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
||||
input: company,
|
||||
});
|
||||
if (isDefined(data)) {
|
||||
return data.createCompany.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@ -13,33 +13,29 @@ import { callMutation, callQuery } from '../utils/requestDb';
|
||||
export const fetchPerson = async (
|
||||
personFilterData: PersonFilterInput,
|
||||
): Promise<Person | null> => {
|
||||
try {
|
||||
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
||||
filter: {
|
||||
...personFilterData,
|
||||
},
|
||||
});
|
||||
if (isDefined(data?.people.edges)) {
|
||||
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
||||
filter: {
|
||||
...personFilterData,
|
||||
},
|
||||
});
|
||||
if (isDefined(data?.people.edges)) {
|
||||
return data.people.edges.length > 0
|
||||
? isDefined(data.people.edges[0].node)
|
||||
? data.people.edges[0].node
|
||||
: null
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createPerson = async (
|
||||
person: PersonInput,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
||||
input: person,
|
||||
});
|
||||
if (isDefined(data?.createPerson)) {
|
||||
return data.createPerson.id;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
||||
input: person,
|
||||
});
|
||||
if (isDefined(data?.createPerson)) {
|
||||
return data.createPerson.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
20
packages/twenty-chrome-extension/src/db/types/auth.types.ts
Normal file
20
packages/twenty-chrome-extension/src/db/types/auth.types.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type AuthToken = {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type ExchangeAuthCodeInput = {
|
||||
authorizationCode: string;
|
||||
codeVerifier?: string;
|
||||
clientSecret?: string;
|
||||
};
|
||||
|
||||
export type Tokens = {
|
||||
loginToken: AuthToken;
|
||||
accessToken: AuthToken;
|
||||
refreshToken: AuthToken;
|
||||
};
|
||||
|
||||
export type ExchangeAuthCodeResponse = {
|
||||
exchangeAuthorizationCode: Tokens;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,28 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const EXCHANGE_AUTHORIZATION_CODE = gql`
|
||||
mutation ExchangeAuthorizationCode(
|
||||
$authorizationCode: String!
|
||||
$codeVerifier: String
|
||||
$clientSecret: String
|
||||
) {
|
||||
exchangeAuthorizationCode(
|
||||
authorizationCode: $authorizationCode
|
||||
codeVerifier: $codeVerifier
|
||||
clientSecret: $clientSecret
|
||||
) {
|
||||
loginToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
accessToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
refreshToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
20
packages/twenty-chrome-extension/src/graphql/auth/queries.ts
Normal file
20
packages/twenty-chrome-extension/src/graphql/auth/queries.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// import { gql } from '@apollo/client';
|
||||
|
||||
// export const RENEW_TOKEN = gql`
|
||||
// query RenewToken($appToken: String!) {
|
||||
// renewToken(appToken: $appToken) {
|
||||
// loginToken {
|
||||
// token
|
||||
// expiresAt
|
||||
// }
|
||||
// accessToken {
|
||||
// token
|
||||
// expiresAt
|
||||
// }
|
||||
// refreshToken {
|
||||
// token
|
||||
// expiresAt
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// `;
|
||||
@ -2,6 +2,15 @@ import { defineManifest } from '@crxjs/vite-plugin';
|
||||
|
||||
import packageData from '../package.json';
|
||||
|
||||
const host_permissions =
|
||||
process.env.VITE_MODE === 'development'
|
||||
? ['https://www.linkedin.com/*', 'http://localhost:3001/*']
|
||||
: ['https://www.linkedin.com/*'];
|
||||
const external_sites =
|
||||
process.env.VITE_MODE === 'development'
|
||||
? [`https://app.twenty.com/*`, `http://localhost:3001/*`]
|
||||
: [`https://app.twenty.com/*`];
|
||||
|
||||
export default defineManifest({
|
||||
manifest_version: 3,
|
||||
name: 'Twenty',
|
||||
@ -32,11 +41,18 @@ export default defineManifest({
|
||||
},
|
||||
],
|
||||
|
||||
permissions: ['activeTab', 'storage'],
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ['options.html'],
|
||||
matches: ['https://www.linkedin.com/*'],
|
||||
},
|
||||
],
|
||||
|
||||
host_permissions: ['https://www.linkedin.com/*'],
|
||||
permissions: ['activeTab', 'storage', 'identity'],
|
||||
|
||||
host_permissions: host_permissions,
|
||||
|
||||
externally_connectable: {
|
||||
matches: [`https://app.twenty.com/*`, `http://localhost:3001/*`],
|
||||
matches: external_sites,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,18 +1,75 @@
|
||||
import { ApolloClient, InMemoryCache } from '@apollo/client';
|
||||
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client';
|
||||
import { onError } from '@apollo/client/link/error';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const clearStore = () => {
|
||||
chrome.storage.local.remove('loginToken');
|
||||
|
||||
chrome.storage.local.remove('accessToken');
|
||||
|
||||
chrome.storage.local.remove('refreshToken');
|
||||
|
||||
chrome.storage.local.set({ isAuthenticated: false });
|
||||
};
|
||||
|
||||
const getApolloClient = async () => {
|
||||
const { apiKey } = await chrome.storage.local.get('apiKey');
|
||||
const { serverBaseUrl } = await chrome.storage.local.get('serverBaseUrl');
|
||||
const store = await chrome.storage.local.get();
|
||||
const serverUrl = `${
|
||||
isDefined(store.serverBaseUrl)
|
||||
? store.serverBaseUrl
|
||||
: import.meta.env.VITE_SERVER_BASE_URL
|
||||
}/graphql`;
|
||||
|
||||
return new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
uri: `${
|
||||
serverBaseUrl ? serverBaseUrl : import.meta.env.VITE_SERVER_BASE_URL
|
||||
}/graphql`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
const errorLink = onError(({ graphQLErrors, networkError }) => {
|
||||
if (isDefined(graphQLErrors)) {
|
||||
for (const graphQLError of graphQLErrors) {
|
||||
if (graphQLError.message === 'Unauthorized') {
|
||||
//TODO: replace this with renewToken mutation
|
||||
clearStore();
|
||||
return;
|
||||
}
|
||||
switch (graphQLError?.extensions?.code) {
|
||||
case 'UNAUTHENTICATED': {
|
||||
//TODO: replace this with renewToken mutation
|
||||
clearStore();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[GraphQL error]: Message: ${graphQLError.message}, Location: ${
|
||||
graphQLError.locations
|
||||
? JSON.stringify(graphQLError.locations)
|
||||
: graphQLError.locations
|
||||
}, Path: ${graphQLError.path}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefined(networkError)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[Network error]: ${networkError}`);
|
||||
}
|
||||
});
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: serverUrl,
|
||||
headers: isDefined(store.accessToken)
|
||||
? {
|
||||
Authorization: `Bearer ${store.accessToken.token}`,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: from([errorLink, httpLink]),
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export default getApolloClient;
|
||||
|
||||
@ -1,31 +1,36 @@
|
||||
import { OperationVariables } from '@apollo/client';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { DocumentNode } from 'graphql';
|
||||
|
||||
import getApolloClient from '~/utils/apolloClient';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const callQuery = async <T>(
|
||||
query: DocumentNode,
|
||||
variables?: OperationVariables,
|
||||
): Promise<T | null> => {
|
||||
const client = await getApolloClient();
|
||||
try {
|
||||
const client = await getApolloClient();
|
||||
const { data } = await client.query<T>({ query, variables });
|
||||
|
||||
const { data, error } = await client.query<T>({ query, variables });
|
||||
|
||||
if (!isUndefined(error)) throw new Error(error.message);
|
||||
|
||||
return data ?? null;
|
||||
if (isDefined(data)) return data;
|
||||
else return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const callMutation = async <T>(
|
||||
mutation: DocumentNode,
|
||||
variables?: OperationVariables,
|
||||
): Promise<T | null> => {
|
||||
const client = await getApolloClient();
|
||||
try {
|
||||
const client = await getApolloClient();
|
||||
|
||||
const { data, errors } = await client.mutate<T>({ mutation, variables });
|
||||
const { data } = await client.mutate<T>({ mutation, variables });
|
||||
|
||||
if (!isUndefined(errors)) throw new Error(errors[0].message);
|
||||
|
||||
return data ?? null;
|
||||
if (isDefined(data)) return data;
|
||||
else return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SERVER_BASE_URL: string;
|
||||
readonly VITE_FRONT_BASE_URL: string;
|
||||
readonly VITE_MODE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user