mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 03:51:36 +03:00
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:
parent
0a7f82333b
commit
c63ee519ea
@ -74,6 +74,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"clsx": "^1.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"danger-plugin-todos": "^1.3.1",
|
||||
"dataloader": "^2.2.2",
|
||||
"date-fns": "^2.30.0",
|
||||
@ -230,6 +231,7 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/bytes": "^3.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/graphql-fields": "^1.3.6",
|
||||
|
@ -1,2 +1,3 @@
|
||||
VITE_SERVER_BASE_URL=https://api.twenty.com
|
||||
VITE_FRONT_BASE_URL=https://app.twenty.com
|
||||
VITE_MODE=production
|
@ -6,13 +6,13 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js",
|
||||
"clean": "npx rimraf ./dist",
|
||||
"start": "yarn clean && npx vite",
|
||||
"build": "yarn clean && npx tsc && npx vite build",
|
||||
"lint": "npx eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
|
||||
"graphql:generate": "npx graphql-codegen",
|
||||
"fmt": "npx prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
||||
"fmt:fix": "npx prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
|
||||
"clean": "rimraf ./dist",
|
||||
"start": "yarn clean && VITE_MODE=development vite",
|
||||
"build": "yarn clean && tsc && vite build",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
|
||||
"graphql:generate": "graphql-codegen",
|
||||
"fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
||||
"fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/chrome": "^0.0.256"
|
||||
|
@ -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);
|
||||
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,27 +13,24 @@ 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
|
||||
return data.companies.edges.length > 0
|
||||
? isDefined(data.companies.edges[0].node)
|
||||
? data.companies.edges[0].node
|
||||
: null
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createCompany = async (
|
||||
company: CompanyInput,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
||||
input: company,
|
||||
});
|
||||
@ -41,7 +38,4 @@ export const createCompany = async (
|
||||
return data.createCompany.id;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
@ -13,25 +13,24 @@ 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 data.people.edges.length > 0
|
||||
? isDefined(data.people.edges[0].node)
|
||||
? data.people.edges[0].node
|
||||
: null
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createPerson = async (
|
||||
person: PersonInput,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
||||
input: person,
|
||||
});
|
||||
@ -39,7 +38,4 @@ export const createPerson = async (
|
||||
return data.createPerson.id;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
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 (
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<ApiKeyForm />
|
||||
<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> => {
|
||||
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> => {
|
||||
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 {
|
||||
|
@ -10,7 +10,7 @@ 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 { Authorize } from '~/pages/auth/Authorize';
|
||||
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
|
||||
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
||||
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
||||
|
@ -255,6 +255,7 @@ export type Mutation = {
|
||||
deleteOneObject: Object;
|
||||
deleteUser: User;
|
||||
emailPasswordResetLink: EmailPasswordResetLink;
|
||||
exchangeAuthorizationCode: ExchangeAuthCode;
|
||||
generateApiKeyToken: ApiKeyToken;
|
||||
generateJWT: AuthTokens;
|
||||
generateTransientToken: TransientToken;
|
||||
@ -282,7 +283,7 @@ export type MutationActivateWorkspaceArgs = {
|
||||
export type MutationAuthorizeAppArgs = {
|
||||
clientId: Scalars['String'];
|
||||
codeChallenge?: InputMaybe<Scalars['String']>;
|
||||
redirectUrl?: InputMaybe<Scalars['String']>;
|
||||
redirectUrl: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
@ -308,6 +309,13 @@ export type MutationEmailPasswordResetLinkArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationExchangeAuthorizationCodeArgs = {
|
||||
authorizationCode: Scalars['String'];
|
||||
clientSecret?: InputMaybe<Scalars['String']>;
|
||||
codeVerifier?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationGenerateApiKeyTokenArgs = {
|
||||
apiKeyId: Scalars['String'];
|
||||
expiresAt: Scalars['String'];
|
||||
@ -429,7 +437,6 @@ export type Query = {
|
||||
clientConfig: ClientConfig;
|
||||
currentUser: User;
|
||||
currentWorkspace: Workspace;
|
||||
exchangeAuthorizationCode: ExchangeAuthCode;
|
||||
findWorkspaceFromInviteHash: Workspace;
|
||||
getProductPrices: ProductPricesEntity;
|
||||
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
|
||||
@ -457,13 +464,6 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryExchangeAuthorizationCodeArgs = {
|
||||
authorizationCode: Scalars['String'];
|
||||
clientSecret?: InputMaybe<Scalars['String']>;
|
||||
codeVerifier?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryFindWorkspaceFromInviteHashArgs = {
|
||||
inviteHash: Scalars['String'];
|
||||
};
|
||||
@ -988,6 +988,7 @@ export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessT
|
||||
export type AuthorizeAppMutationVariables = Exact<{
|
||||
clientId: Scalars['String'];
|
||||
codeChallenge: Scalars['String'];
|
||||
redirectUrl: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
@ -1517,8 +1518,12 @@ 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) {
|
||||
mutation authorizeApp($clientId: String!, $codeChallenge: String!, $redirectUrl: String!) {
|
||||
authorizeApp(
|
||||
clientId: $clientId
|
||||
codeChallenge: $codeChallenge
|
||||
redirectUrl: $redirectUrl
|
||||
) {
|
||||
redirectUrl
|
||||
}
|
||||
}
|
||||
@ -1540,6 +1545,7 @@ export type AuthorizeAppMutationFn = Apollo.MutationFunction<AuthorizeAppMutatio
|
||||
* variables: {
|
||||
* clientId: // value for 'clientId'
|
||||
* codeChallenge: // value for 'codeChallenge'
|
||||
* redirectUrl: // value for 'redirectUrl'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
@ -1,8 +1,16 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const AUTHORIZE_APP = gql`
|
||||
mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
|
||||
authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
|
||||
mutation authorizeApp(
|
||||
$clientId: String!
|
||||
$codeChallenge: String!
|
||||
$redirectUrl: String!
|
||||
) {
|
||||
authorizeApp(
|
||||
clientId: $clientId
|
||||
codeChallenge: $codeChallenge
|
||||
redirectUrl: $redirectUrl
|
||||
) {
|
||||
redirectUrl
|
||||
}
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ const StyledButtonContainer = styled.div`
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
`;
|
||||
const Authorize = () => {
|
||||
export const Authorize = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParam] = useSearchParams();
|
||||
//TODO: Replace with db call for registered third party apps
|
||||
@ -66,6 +66,7 @@ const Authorize = () => {
|
||||
const [app, setApp] = useState<App>();
|
||||
const clientId = searchParam.get('clientId');
|
||||
const codeChallenge = searchParam.get('codeChallenge');
|
||||
const redirectUrl = searchParam.get('redirectUrl');
|
||||
|
||||
useEffect(() => {
|
||||
const app = apps.find((app) => app.id === clientId);
|
||||
@ -76,18 +77,20 @@ const Authorize = () => {
|
||||
|
||||
const [authorizeApp] = useAuthorizeAppMutation();
|
||||
const handleAuthorize = async () => {
|
||||
if (isDefined(clientId) && isDefined(codeChallenge)) {
|
||||
if (
|
||||
isDefined(clientId) &&
|
||||
isDefined(codeChallenge) &&
|
||||
isDefined(redirectUrl)
|
||||
) {
|
||||
await authorizeApp({
|
||||
variables: {
|
||||
clientId,
|
||||
codeChallenge,
|
||||
redirectUrl,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
window.location.href = data.authorizeApp.redirectUrl;
|
||||
},
|
||||
onError: (error) => {
|
||||
throw Error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -124,5 +127,3 @@ const Authorize = () => {
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Authorize;
|
||||
|
@ -64,4 +64,4 @@ SIGN_IN_PREFILLED=true
|
||||
# API_RATE_LIMITING_TTL=
|
||||
# API_RATE_LIMITING_LIMIT=
|
||||
# MUTATION_MAXIMUM_RECORD_AFFECTED=100
|
||||
# CHROME_EXTENSION_REDIRECT_URL=https://bggmipldbceihilonnbpgoeclgbkblkp.chromiumapps.com
|
||||
# CHROME_EXTENSION_REDIRECT_URL=https://bggmipldbceihilonnbpgoeclgbkblkp.chromiumapp.org
|
||||
|
@ -107,6 +107,17 @@ export class AuthResolver {
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
@Mutation(() => ExchangeAuthCode)
|
||||
async exchangeAuthorizationCode(
|
||||
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
) {
|
||||
const tokens = await this.tokenService.verifyAuthorizationCode(
|
||||
exchangeAuthCodeInput,
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Mutation(() => TransientToken)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async generateTransientToken(
|
||||
@ -152,17 +163,6 @@ export class AuthResolver {
|
||||
return authorizedApp;
|
||||
}
|
||||
|
||||
@Query(() => ExchangeAuthCode)
|
||||
async exchangeAuthorizationCode(
|
||||
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
) {
|
||||
const tokens = await this.tokenService.verifyAuthorizationCode(
|
||||
exchangeAuthCodeInput,
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Mutation(() => AuthTokens)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async generateJWT(
|
||||
|
@ -14,8 +14,7 @@ export class AuthorizeAppInput {
|
||||
@IsOptional()
|
||||
codeChallenge?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
redirectUrl?: string;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ import { PasswordUpdateNotifyEmail } from 'twenty-emails';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface';
|
||||
|
||||
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import {
|
||||
@ -197,9 +199,11 @@ export class AuthService {
|
||||
{
|
||||
id: 'chrome',
|
||||
name: 'Chrome Extension',
|
||||
redirectUrl: `${this.environmentService.get(
|
||||
'CHROME_EXTENSION_REDIRECT_URL',
|
||||
)}`,
|
||||
redirectUrl:
|
||||
this.environmentService.get('NODE_ENV') ===
|
||||
NodeEnvironment.development
|
||||
? authorizeAppInput.redirectUrl
|
||||
: `${this.environmentService.get('CHROME_EXTENSION_REDIRECT_URL')}`,
|
||||
},
|
||||
];
|
||||
|
||||
@ -211,10 +215,14 @@ export class AuthService {
|
||||
throw new NotFoundException(`Invalid client '${clientId}'`);
|
||||
}
|
||||
|
||||
if (!client.redirectUrl && !authorizeAppInput.redirectUrl) {
|
||||
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
|
||||
throw new NotFoundException(`redirectUrl not found for '${clientId}'`);
|
||||
}
|
||||
|
||||
if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
|
||||
throw new ForbiddenException(`redirectUrl mismatch for '${clientId}'`);
|
||||
}
|
||||
|
||||
const authorizationCode = crypto.randomBytes(42).toString('hex');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { EmailDriver } from 'src/engine/integrations/email/interfaces/email.interface';
|
||||
import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface';
|
||||
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { CastToStringArray } from 'src/engine/integrations/environment/decorators/cast-to-string-array.decorator';
|
||||
@ -40,6 +41,10 @@ export class EnvironmentVariables {
|
||||
@IsBoolean()
|
||||
DEBUG_MODE = false;
|
||||
|
||||
@IsEnum(NodeEnvironment)
|
||||
@IsString()
|
||||
NODE_ENV: NodeEnvironment = NodeEnvironment.development;
|
||||
|
||||
@CastToPositiveNumber()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
@ -0,0 +1,4 @@
|
||||
export enum NodeEnvironment {
|
||||
development = 'development',
|
||||
production = 'production',
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
16
yarn.lock
16
yarn.lock
@ -16103,6 +16103,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/crypto-js@npm:^4.2.2":
|
||||
version: 4.2.2
|
||||
resolution: "@types/crypto-js@npm:4.2.2"
|
||||
checksum: 760a2078f36f2a3a1089ef367b0d13229876adcf4bcd6e8824d00d9e9bfad8118dc7e6a3cc66322b083535e12be3a29044ccdc9603bfb12519ff61551a3322c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-color@npm:^2.0.0":
|
||||
version: 2.0.6
|
||||
resolution: "@types/d3-color@npm:2.0.6"
|
||||
@ -23533,6 +23540,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"crypto-js@npm:^4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "crypto-js@npm:4.2.0"
|
||||
checksum: 8fbdf9d56f47aea0794ab87b0eb9833baf80b01a7c5c1b0edc7faf25f662fb69ab18dc2199e2afcac54670ff0cd9607a9045a3f7a80336cccd18d77a55b9fdf0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"crypto-random-string@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "crypto-random-string@npm:2.0.0"
|
||||
@ -46478,6 +46492,7 @@ __metadata:
|
||||
"@types/bcrypt": "npm:^5.0.0"
|
||||
"@types/better-sqlite3": "npm:^7.6.8"
|
||||
"@types/bytes": "npm:^3.1.1"
|
||||
"@types/crypto-js": "npm:^4.2.2"
|
||||
"@types/deep-equal": "npm:^1.0.1"
|
||||
"@types/dompurify": "npm:^3.0.5"
|
||||
"@types/express": "npm:^4.17.13"
|
||||
@ -46534,6 +46549,7 @@ __metadata:
|
||||
concurrently: "npm:^8.2.2"
|
||||
cross-env: "npm:^7.0.3"
|
||||
cross-var: "npm:^1.1.0"
|
||||
crypto-js: "npm:^4.2.0"
|
||||
danger: "npm:^11.3.0"
|
||||
danger-plugin-todos: "npm:^1.3.1"
|
||||
dataloader: "npm:^2.2.2"
|
||||
|
Loading…
Reference in New Issue
Block a user