mirror of
https://github.com/lingble/twenty.git
synced 2025-11-02 13:47:55 +00: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:
@@ -74,6 +74,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"danger-plugin-todos": "^1.3.1",
|
"danger-plugin-todos": "^1.3.1",
|
||||||
"dataloader": "^2.2.2",
|
"dataloader": "^2.2.2",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
@@ -230,6 +231,7 @@
|
|||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/bytes": "^3.1.1",
|
"@types/bytes": "^3.1.1",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/deep-equal": "^1.0.1",
|
"@types/deep-equal": "^1.0.1",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/graphql-fields": "^1.3.6",
|
"@types/graphql-fields": "^1.3.6",
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
VITE_SERVER_BASE_URL=https://api.twenty.com
|
VITE_SERVER_BASE_URL=https://api.twenty.com
|
||||||
VITE_FRONT_BASE_URL=https://app.twenty.com
|
VITE_FRONT_BASE_URL=https://app.twenty.com
|
||||||
|
VITE_MODE=production
|
||||||
@@ -6,13 +6,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js",
|
"nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js",
|
||||||
"clean": "npx rimraf ./dist",
|
"clean": "rimraf ./dist",
|
||||||
"start": "yarn clean && npx vite",
|
"start": "yarn clean && VITE_MODE=development vite",
|
||||||
"build": "yarn clean && npx tsc && npx vite build",
|
"build": "yarn clean && tsc && vite build",
|
||||||
"lint": "npx eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
|
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
|
||||||
"graphql:generate": "npx graphql-codegen",
|
"graphql:generate": "graphql-codegen",
|
||||||
"fmt": "npx prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
"fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
||||||
"fmt:fix": "npx prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
|
"fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chrome": "^0.0.256"
|
"@types/chrome": "^0.0.256"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import Crypto from 'crypto-js';
|
||||||
|
|
||||||
import { openOptionsPage } from '~/background/utils/openOptionsPage';
|
import { openOptionsPage } from '~/background/utils/openOptionsPage';
|
||||||
|
import { exchangeAuthorizationCode } from '~/db/auth.db';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
// Open options page programmatically in a new tab.
|
// Open options page programmatically in a new tab.
|
||||||
@@ -27,6 +30,11 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
|||||||
case 'openOptionsPage':
|
case 'openOptionsPage':
|
||||||
openOptionsPage();
|
openOptionsPage();
|
||||||
break;
|
break;
|
||||||
|
case 'CONNECT':
|
||||||
|
launchOAuth(({ status, message }) => {
|
||||||
|
sendResponse({ status, message });
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -34,6 +42,81 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
|||||||
return true;
|
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) => {
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
const isDesiredRoute =
|
const isDesiredRoute =
|
||||||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
|
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
|
||||||
|
|||||||
@@ -52,12 +52,13 @@ export const createDefaultButton = (
|
|||||||
Object.assign(div.style, divStyles);
|
Object.assign(div.style, divStyles);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle the click event.
|
||||||
div.addEventListener('click', async (e) => {
|
div.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const store = await chrome.storage.local.get();
|
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 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' });
|
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
|
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
|
||||||
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
|
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
// Inject buttons into the DOM when SPA is reloaded on the resource url.
|
// 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/
|
// 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') {
|
if (message.action === 'TOGGLE') {
|
||||||
toggle();
|
await toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.action === 'AUTHENTICATED') {
|
||||||
|
await authenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
sendResponse('Executing!');
|
sendResponse('Executing!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const IFRAME_WIDTH = '400px';
|
||||||
|
|
||||||
const createIframe = () => {
|
const createIframe = () => {
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.style.background = 'lightgrey';
|
iframe.style.background = 'lightgrey';
|
||||||
iframe.style.height = '100vh';
|
iframe.style.height = '100vh';
|
||||||
iframe.style.width = '400px';
|
iframe.style.width = IFRAME_WIDTH;
|
||||||
iframe.style.position = 'fixed';
|
iframe.style.position = 'fixed';
|
||||||
iframe.style.top = '0px';
|
iframe.style.top = '0px';
|
||||||
iframe.style.right = '-400px';
|
iframe.style.right = `-${IFRAME_WIDTH}`;
|
||||||
iframe.style.zIndex = '9000000000000000000';
|
iframe.style.zIndex = '9000000000000000000';
|
||||||
iframe.style.transition = 'ease-in-out 0.3s';
|
iframe.style.transition = 'ease-in-out 0.3s';
|
||||||
return iframe;
|
return iframe;
|
||||||
@@ -41,33 +48,57 @@ const createIframe = () => {
|
|||||||
|
|
||||||
const handleContentIframeLoadComplete = () => {
|
const handleContentIframeLoadComplete = () => {
|
||||||
//If the pop-out window is already open then we replace loading iframe with our content iframe
|
//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';
|
if (optionsIframe.style.right === '0px') contentIframe.style.right = '0px';
|
||||||
loadingIframe.style.display = 'none';
|
optionsIframe.style.display = 'none';
|
||||||
contentIframe.style.display = 'block';
|
contentIframe.style.display = 'block';
|
||||||
};
|
};
|
||||||
|
|
||||||
//Creating one iframe where we are loading our front end in the background
|
//Creating one iframe where we are loading our front end in the background
|
||||||
const contentIframe = createIframe();
|
const contentIframe = createIframe();
|
||||||
contentIframe.style.display = 'none';
|
contentIframe.style.display = 'none';
|
||||||
|
|
||||||
|
chrome.storage.local.get().then((store) => {
|
||||||
|
if (isDefined(store.loginToken)) {
|
||||||
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
|
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
|
||||||
contentIframe.onload = handleContentIframeLoadComplete;
|
contentIframe.onload = handleContentIframeLoadComplete;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
//Creating this iframe to show as a loading state until the above iframe loads completely
|
const optionsIframe = createIframe();
|
||||||
const loadingIframe = createIframe();
|
optionsIframe.src = chrome.runtime.getURL('options.html');
|
||||||
loadingIframe.src = chrome.runtime.getURL('loading.html');
|
|
||||||
|
|
||||||
document.body.appendChild(loadingIframe);
|
|
||||||
document.body.appendChild(contentIframe);
|
document.body.appendChild(contentIframe);
|
||||||
|
document.body.appendChild(optionsIframe);
|
||||||
|
|
||||||
const toggleIframe = (iframe: HTMLIFrameElement) => {
|
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';
|
iframe.style.right = '0px';
|
||||||
} else if (iframe.style.right === '0px' && iframe.style.display !== 'none') {
|
} else if (iframe.style.right === '0px' && iframe.style.display !== 'none') {
|
||||||
iframe.style.right = '-400px';
|
iframe.style.right = `-${IFRAME_WIDTH}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = async () => {
|
||||||
toggleIframe(loadingIframe);
|
const store = await chrome.storage.local.get();
|
||||||
|
if (isDefined(store.accessToken)) {
|
||||||
toggleIframe(contentIframe);
|
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 (
|
export const fetchCompany = async (
|
||||||
companyfilerInput: CompanyFilterInput,
|
companyfilerInput: CompanyFilterInput,
|
||||||
): Promise<Company | null> => {
|
): Promise<Company | null> => {
|
||||||
try {
|
|
||||||
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
||||||
filter: {
|
filter: {
|
||||||
...companyfilerInput,
|
...companyfilerInput,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (isDefined(data?.companies.edges)) {
|
if (isDefined(data?.companies.edges)) {
|
||||||
return data?.companies.edges.length > 0
|
return data.companies.edges.length > 0
|
||||||
? data?.companies.edges[0].node
|
? isDefined(data.companies.edges[0].node)
|
||||||
|
? data.companies.edges[0].node
|
||||||
|
: null
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCompany = async (
|
export const createCompany = async (
|
||||||
company: CompanyInput,
|
company: CompanyInput,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
try {
|
|
||||||
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
||||||
input: company,
|
input: company,
|
||||||
});
|
});
|
||||||
@@ -41,7 +38,4 @@ export const createCompany = async (
|
|||||||
return data.createCompany.id;
|
return data.createCompany.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,25 +13,24 @@ import { callMutation, callQuery } from '../utils/requestDb';
|
|||||||
export const fetchPerson = async (
|
export const fetchPerson = async (
|
||||||
personFilterData: PersonFilterInput,
|
personFilterData: PersonFilterInput,
|
||||||
): Promise<Person | null> => {
|
): Promise<Person | null> => {
|
||||||
try {
|
|
||||||
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
||||||
filter: {
|
filter: {
|
||||||
...personFilterData,
|
...personFilterData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (isDefined(data?.people.edges)) {
|
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;
|
return null;
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPerson = async (
|
export const createPerson = async (
|
||||||
person: PersonInput,
|
person: PersonInput,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
try {
|
|
||||||
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
||||||
input: person,
|
input: person,
|
||||||
});
|
});
|
||||||
@@ -39,7 +38,4 @@ export const createPerson = async (
|
|||||||
return data.createPerson.id;
|
return data.createPerson.id;
|
||||||
}
|
}
|
||||||
return null;
|
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';
|
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({
|
export default defineManifest({
|
||||||
manifest_version: 3,
|
manifest_version: 3,
|
||||||
name: 'Twenty',
|
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: {
|
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 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;
|
align-items: center;
|
||||||
background: ${({ theme }) => theme.background.noisy};
|
background: ${({ theme }) => theme.background.noisy};
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
justify-content: center;
|
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 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 (
|
return (
|
||||||
|
<StyledWrapper>
|
||||||
<StyledContainer>
|
<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>
|
</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 getApolloClient = async () => {
|
||||||
const { apiKey } = await chrome.storage.local.get('apiKey');
|
const store = await chrome.storage.local.get();
|
||||||
const { serverBaseUrl } = await chrome.storage.local.get('serverBaseUrl');
|
const serverUrl = `${
|
||||||
|
isDefined(store.serverBaseUrl)
|
||||||
|
? store.serverBaseUrl
|
||||||
|
: import.meta.env.VITE_SERVER_BASE_URL
|
||||||
|
}/graphql`;
|
||||||
|
|
||||||
return new ApolloClient({
|
const errorLink = onError(({ graphQLErrors, networkError }) => {
|
||||||
cache: new InMemoryCache(),
|
if (isDefined(graphQLErrors)) {
|
||||||
uri: `${
|
for (const graphQLError of graphQLErrors) {
|
||||||
serverBaseUrl ? serverBaseUrl : import.meta.env.VITE_SERVER_BASE_URL
|
if (graphQLError.message === 'Unauthorized') {
|
||||||
}/graphql`,
|
//TODO: replace this with renewToken mutation
|
||||||
headers: {
|
clearStore();
|
||||||
Authorization: `Bearer ${apiKey}`,
|
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;
|
export default getApolloClient;
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
import { OperationVariables } from '@apollo/client';
|
import { OperationVariables } from '@apollo/client';
|
||||||
import { isUndefined } from '@sniptt/guards';
|
|
||||||
import { DocumentNode } from 'graphql';
|
import { DocumentNode } from 'graphql';
|
||||||
|
|
||||||
import getApolloClient from '~/utils/apolloClient';
|
import getApolloClient from '~/utils/apolloClient';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
export const callQuery = async <T>(
|
export const callQuery = async <T>(
|
||||||
query: DocumentNode,
|
query: DocumentNode,
|
||||||
variables?: OperationVariables,
|
variables?: OperationVariables,
|
||||||
): Promise<T | null> => {
|
): Promise<T | null> => {
|
||||||
|
try {
|
||||||
const client = await getApolloClient();
|
const client = await getApolloClient();
|
||||||
|
const { data } = await client.query<T>({ query, variables });
|
||||||
|
|
||||||
const { data, error } = await client.query<T>({ query, variables });
|
if (isDefined(data)) return data;
|
||||||
|
else return null;
|
||||||
if (!isUndefined(error)) throw new Error(error.message);
|
} catch (error) {
|
||||||
|
return null;
|
||||||
return data ?? null;
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const callMutation = async <T>(
|
export const callMutation = async <T>(
|
||||||
mutation: DocumentNode,
|
mutation: DocumentNode,
|
||||||
variables?: OperationVariables,
|
variables?: OperationVariables,
|
||||||
): Promise<T | null> => {
|
): Promise<T | null> => {
|
||||||
|
try {
|
||||||
const client = await getApolloClient();
|
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);
|
if (isDefined(data)) return data;
|
||||||
|
else return null;
|
||||||
return data ?? null;
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_SERVER_BASE_URL: string;
|
readonly VITE_SERVER_BASE_URL: string;
|
||||||
readonly VITE_FRONT_BASE_URL: string;
|
readonly VITE_FRONT_BASE_URL: string;
|
||||||
|
readonly VITE_MODE: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
|
|||||||
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
|
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
|
||||||
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
|
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
|
||||||
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
|
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 { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
|
||||||
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
||||||
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export type Mutation = {
|
|||||||
deleteOneObject: Object;
|
deleteOneObject: Object;
|
||||||
deleteUser: User;
|
deleteUser: User;
|
||||||
emailPasswordResetLink: EmailPasswordResetLink;
|
emailPasswordResetLink: EmailPasswordResetLink;
|
||||||
|
exchangeAuthorizationCode: ExchangeAuthCode;
|
||||||
generateApiKeyToken: ApiKeyToken;
|
generateApiKeyToken: ApiKeyToken;
|
||||||
generateJWT: AuthTokens;
|
generateJWT: AuthTokens;
|
||||||
generateTransientToken: TransientToken;
|
generateTransientToken: TransientToken;
|
||||||
@@ -282,7 +283,7 @@ export type MutationActivateWorkspaceArgs = {
|
|||||||
export type MutationAuthorizeAppArgs = {
|
export type MutationAuthorizeAppArgs = {
|
||||||
clientId: Scalars['String'];
|
clientId: Scalars['String'];
|
||||||
codeChallenge?: InputMaybe<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 = {
|
export type MutationGenerateApiKeyTokenArgs = {
|
||||||
apiKeyId: Scalars['String'];
|
apiKeyId: Scalars['String'];
|
||||||
expiresAt: Scalars['String'];
|
expiresAt: Scalars['String'];
|
||||||
@@ -429,7 +437,6 @@ export type Query = {
|
|||||||
clientConfig: ClientConfig;
|
clientConfig: ClientConfig;
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
currentWorkspace: Workspace;
|
currentWorkspace: Workspace;
|
||||||
exchangeAuthorizationCode: ExchangeAuthCode;
|
|
||||||
findWorkspaceFromInviteHash: Workspace;
|
findWorkspaceFromInviteHash: Workspace;
|
||||||
getProductPrices: ProductPricesEntity;
|
getProductPrices: ProductPricesEntity;
|
||||||
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
|
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 = {
|
export type QueryFindWorkspaceFromInviteHashArgs = {
|
||||||
inviteHash: Scalars['String'];
|
inviteHash: Scalars['String'];
|
||||||
};
|
};
|
||||||
@@ -988,6 +988,7 @@ export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessT
|
|||||||
export type AuthorizeAppMutationVariables = Exact<{
|
export type AuthorizeAppMutationVariables = Exact<{
|
||||||
clientId: Scalars['String'];
|
clientId: Scalars['String'];
|
||||||
codeChallenge: 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 TrackMutationResult = Apollo.MutationResult<TrackMutation>;
|
||||||
export type TrackMutationOptions = Apollo.BaseMutationOptions<TrackMutation, TrackMutationVariables>;
|
export type TrackMutationOptions = Apollo.BaseMutationOptions<TrackMutation, TrackMutationVariables>;
|
||||||
export const AuthorizeAppDocument = gql`
|
export const AuthorizeAppDocument = gql`
|
||||||
mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
|
mutation authorizeApp($clientId: String!, $codeChallenge: String!, $redirectUrl: String!) {
|
||||||
authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
|
authorizeApp(
|
||||||
|
clientId: $clientId
|
||||||
|
codeChallenge: $codeChallenge
|
||||||
|
redirectUrl: $redirectUrl
|
||||||
|
) {
|
||||||
redirectUrl
|
redirectUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1540,6 +1545,7 @@ export type AuthorizeAppMutationFn = Apollo.MutationFunction<AuthorizeAppMutatio
|
|||||||
* variables: {
|
* variables: {
|
||||||
* clientId: // value for 'clientId'
|
* clientId: // value for 'clientId'
|
||||||
* codeChallenge: // value for 'codeChallenge'
|
* codeChallenge: // value for 'codeChallenge'
|
||||||
|
* redirectUrl: // value for 'redirectUrl'
|
||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { gql } from '@apollo/client';
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
export const AUTHORIZE_APP = gql`
|
export const AUTHORIZE_APP = gql`
|
||||||
mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
|
mutation authorizeApp(
|
||||||
authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
|
$clientId: String!
|
||||||
|
$codeChallenge: String!
|
||||||
|
$redirectUrl: String!
|
||||||
|
) {
|
||||||
|
authorizeApp(
|
||||||
|
clientId: $clientId
|
||||||
|
codeChallenge: $codeChallenge
|
||||||
|
redirectUrl: $redirectUrl
|
||||||
|
) {
|
||||||
redirectUrl
|
redirectUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const StyledButtonContainer = styled.div`
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
const Authorize = () => {
|
export const Authorize = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParam] = useSearchParams();
|
const [searchParam] = useSearchParams();
|
||||||
//TODO: Replace with db call for registered third party apps
|
//TODO: Replace with db call for registered third party apps
|
||||||
@@ -66,6 +66,7 @@ const Authorize = () => {
|
|||||||
const [app, setApp] = useState<App>();
|
const [app, setApp] = useState<App>();
|
||||||
const clientId = searchParam.get('clientId');
|
const clientId = searchParam.get('clientId');
|
||||||
const codeChallenge = searchParam.get('codeChallenge');
|
const codeChallenge = searchParam.get('codeChallenge');
|
||||||
|
const redirectUrl = searchParam.get('redirectUrl');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const app = apps.find((app) => app.id === clientId);
|
const app = apps.find((app) => app.id === clientId);
|
||||||
@@ -76,18 +77,20 @@ const Authorize = () => {
|
|||||||
|
|
||||||
const [authorizeApp] = useAuthorizeAppMutation();
|
const [authorizeApp] = useAuthorizeAppMutation();
|
||||||
const handleAuthorize = async () => {
|
const handleAuthorize = async () => {
|
||||||
if (isDefined(clientId) && isDefined(codeChallenge)) {
|
if (
|
||||||
|
isDefined(clientId) &&
|
||||||
|
isDefined(codeChallenge) &&
|
||||||
|
isDefined(redirectUrl)
|
||||||
|
) {
|
||||||
await authorizeApp({
|
await authorizeApp({
|
||||||
variables: {
|
variables: {
|
||||||
clientId,
|
clientId,
|
||||||
codeChallenge,
|
codeChallenge,
|
||||||
|
redirectUrl,
|
||||||
},
|
},
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
window.location.href = data.authorizeApp.redirectUrl;
|
window.location.href = data.authorizeApp.redirectUrl;
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
throw Error(error.message);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -124,5 +127,3 @@ const Authorize = () => {
|
|||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Authorize;
|
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ SIGN_IN_PREFILLED=true
|
|||||||
# API_RATE_LIMITING_TTL=
|
# API_RATE_LIMITING_TTL=
|
||||||
# API_RATE_LIMITING_LIMIT=
|
# API_RATE_LIMITING_LIMIT=
|
||||||
# MUTATION_MAXIMUM_RECORD_AFFECTED=100
|
# 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 };
|
return { loginToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => ExchangeAuthCode)
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||||
|
) {
|
||||||
|
const tokens = await this.tokenService.verifyAuthorizationCode(
|
||||||
|
exchangeAuthCodeInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => TransientToken)
|
@Mutation(() => TransientToken)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async generateTransientToken(
|
async generateTransientToken(
|
||||||
@@ -152,17 +163,6 @@ export class AuthResolver {
|
|||||||
return authorizedApp;
|
return authorizedApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => ExchangeAuthCode)
|
|
||||||
async exchangeAuthorizationCode(
|
|
||||||
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
|
||||||
) {
|
|
||||||
const tokens = await this.tokenService.verifyAuthorizationCode(
|
|
||||||
exchangeAuthCodeInput,
|
|
||||||
);
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => AuthTokens)
|
@Mutation(() => AuthTokens)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async generateJWT(
|
async generateJWT(
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ export class AuthorizeAppInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
codeChallenge?: string;
|
codeChallenge?: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
redirectUrl: string;
|
||||||
redirectUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { PasswordUpdateNotifyEmail } from 'twenty-emails';
|
|||||||
import { addMilliseconds } from 'date-fns';
|
import { addMilliseconds } from 'date-fns';
|
||||||
import ms from 'ms';
|
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 { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
|
||||||
import { assert } from 'src/utils/assert';
|
import { assert } from 'src/utils/assert';
|
||||||
import {
|
import {
|
||||||
@@ -197,9 +199,11 @@ export class AuthService {
|
|||||||
{
|
{
|
||||||
id: 'chrome',
|
id: 'chrome',
|
||||||
name: 'Chrome Extension',
|
name: 'Chrome Extension',
|
||||||
redirectUrl: `${this.environmentService.get(
|
redirectUrl:
|
||||||
'CHROME_EXTENSION_REDIRECT_URL',
|
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}'`);
|
throw new NotFoundException(`Invalid client '${clientId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client.redirectUrl && !authorizeAppInput.redirectUrl) {
|
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
|
||||||
throw new NotFoundException(`redirectUrl not found for '${clientId}'`);
|
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 authorizationCode = crypto.randomBytes(42).toString('hex');
|
||||||
|
|
||||||
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { EmailDriver } from 'src/engine/integrations/email/interfaces/email.interface';
|
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 { assert } from 'src/utils/assert';
|
||||||
import { CastToStringArray } from 'src/engine/integrations/environment/decorators/cast-to-string-array.decorator';
|
import { CastToStringArray } from 'src/engine/integrations/environment/decorators/cast-to-string-array.decorator';
|
||||||
@@ -40,6 +41,10 @@ export class EnvironmentVariables {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
DEBUG_MODE = false;
|
DEBUG_MODE = false;
|
||||||
|
|
||||||
|
@IsEnum(NodeEnvironment)
|
||||||
|
@IsString()
|
||||||
|
NODE_ENV: NodeEnvironment = NodeEnvironment.development;
|
||||||
|
|
||||||
@CastToPositiveNumber()
|
@CastToPositiveNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@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
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/d3-color@npm:^2.0.0":
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
resolution: "@types/d3-color@npm:2.0.6"
|
resolution: "@types/d3-color@npm:2.0.6"
|
||||||
@@ -23533,6 +23540,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"crypto-random-string@npm:^2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "crypto-random-string@npm:2.0.0"
|
resolution: "crypto-random-string@npm:2.0.0"
|
||||||
@@ -46478,6 +46492,7 @@ __metadata:
|
|||||||
"@types/bcrypt": "npm:^5.0.0"
|
"@types/bcrypt": "npm:^5.0.0"
|
||||||
"@types/better-sqlite3": "npm:^7.6.8"
|
"@types/better-sqlite3": "npm:^7.6.8"
|
||||||
"@types/bytes": "npm:^3.1.1"
|
"@types/bytes": "npm:^3.1.1"
|
||||||
|
"@types/crypto-js": "npm:^4.2.2"
|
||||||
"@types/deep-equal": "npm:^1.0.1"
|
"@types/deep-equal": "npm:^1.0.1"
|
||||||
"@types/dompurify": "npm:^3.0.5"
|
"@types/dompurify": "npm:^3.0.5"
|
||||||
"@types/express": "npm:^4.17.13"
|
"@types/express": "npm:^4.17.13"
|
||||||
@@ -46534,6 +46549,7 @@ __metadata:
|
|||||||
concurrently: "npm:^8.2.2"
|
concurrently: "npm:^8.2.2"
|
||||||
cross-env: "npm:^7.0.3"
|
cross-env: "npm:^7.0.3"
|
||||||
cross-var: "npm:^1.1.0"
|
cross-var: "npm:^1.1.0"
|
||||||
|
crypto-js: "npm:^4.2.0"
|
||||||
danger: "npm:^11.3.0"
|
danger: "npm:^11.3.0"
|
||||||
danger-plugin-todos: "npm:^1.3.1"
|
danger-plugin-todos: "npm:^1.3.1"
|
||||||
dataloader: "npm:^2.2.2"
|
dataloader: "npm:^2.2.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user