mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-31 20:57:55 +00:00 
			
		
		
		
	fix: user has to login every time chrome sidepanel is opened (#5544)
We can pass the auth tokens to our front app via post message, which will also allow us to pass route names to navigate on it
This commit is contained in:
		| @@ -75,7 +75,6 @@ | |||||||
|     "class-transformer": "^0.5.1", |     "class-transformer": "^0.5.1", | ||||||
|     "clsx": "^2.1.1", |     "clsx": "^2.1.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", | ||||||
| @@ -241,8 +240,6 @@ | |||||||
|     "@types/better-sqlite3": "^7.6.8", |     "@types/better-sqlite3": "^7.6.8", | ||||||
|     "@types/bytes": "^3.1.1", |     "@types/bytes": "^3.1.1", | ||||||
|     "@types/chrome": "^0.0.267", |     "@types/chrome": "^0.0.267", | ||||||
|     "@types/crypto-js": "^4.2.2", |  | ||||||
|     "@types/dagre": "^0.7.52", |  | ||||||
|     "@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", | ||||||
|   | |||||||
| @@ -11,10 +11,11 @@ | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "start": { |     "start": { | ||||||
|       "executor": "@nx/vite:dev-server", |       "executor": "nx:run-commands", | ||||||
|  |       "dependsOn": ["build"], | ||||||
|       "options": { |       "options": { | ||||||
|         "buildTarget": "twenty-chrome-extension:build", |         "cwd": "packages/twenty-chrome-extension", | ||||||
|         "hmr": true |         "command": "VITE_MODE=development vite" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "preview": { |     "preview": { | ||||||
|   | |||||||
| @@ -30,17 +30,6 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => { | |||||||
|       }); |       }); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case 'changeSidepanelUrl': { |  | ||||||
|       chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => { |  | ||||||
|         if (isDefined(tab) && isDefined(tab.id)) { |  | ||||||
|           chrome.tabs.sendMessage(tab.id, { |  | ||||||
|             action: 'changeSidepanelUrl', |  | ||||||
|             message, |  | ||||||
|           }); |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|       break; |  | ||||||
|     } |  | ||||||
|     default: |     default: | ||||||
|       break; |       break; | ||||||
|   } |   } | ||||||
| @@ -82,7 +71,15 @@ const setTokenStateFromCookie = (cookie: string) => { | |||||||
|  |  | ||||||
| chrome.cookies.onChanged.addListener(async ({ cookie }) => { | chrome.cookies.onChanged.addListener(async ({ cookie }) => { | ||||||
|   if (cookie.name === 'tokenPair') { |   if (cookie.name === 'tokenPair') { | ||||||
|     setTokenStateFromCookie(cookie.value); |     const store = await chrome.storage.local.get(['clientUrl']); | ||||||
|  |     const clientUrl = isDefined(store.clientUrl) | ||||||
|  |       ? store.clientUrl | ||||||
|  |       : import.meta.env.VITE_FRONT_BASE_URL; | ||||||
|  |     chrome.cookies.get({ name: 'tokenPair', url: `${clientUrl}` }, (cookie) => { | ||||||
|  |       if (isDefined(cookie)) { | ||||||
|  |         setTokenStateFromCookie(cookie.value); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ export const createDefaultButton = ( | |||||||
|     padding: '0 1rem', |     padding: '0 1rem', | ||||||
|     cursor: 'pointer', |     cursor: 'pointer', | ||||||
|     height: '32px', |     height: '32px', | ||||||
|  |     width: 'max-content', | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   Object.assign(div.style, divStyles); |   Object.assign(div.style, divStyles); | ||||||
|   | |||||||
| @@ -75,9 +75,7 @@ export const addCompany = async () => { | |||||||
|   const companyId = await createCompany(companyInputData); |   const companyId = await createCompany(companyInputData); | ||||||
|  |  | ||||||
|   if (isDefined(companyId)) { |   if (isDefined(companyId)) { | ||||||
|     await changeSidePanelUrl( |     await changeSidePanelUrl(`/object/company/${companyId}`); | ||||||
|       `${import.meta.env.VITE_FRONT_BASE_URL}/object/company/${companyId}`, |  | ||||||
|     ); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return companyId; |   return companyId; | ||||||
| @@ -86,16 +84,15 @@ export const addCompany = async () => { | |||||||
| export const insertButtonForCompany = async () => { | export const insertButtonForCompany = async () => { | ||||||
|   const companyButtonDiv = createDefaultButton('twenty-company-btn'); |   const companyButtonDiv = createDefaultButton('twenty-company-btn'); | ||||||
|  |  | ||||||
|   const parentDiv: HTMLDivElement | null = document.querySelector( |   const companyDiv: HTMLDivElement | null = document.querySelector( | ||||||
|     '.org-top-card-primary-actions__inner', |     '.org-top-card__primary-content', | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   if (isDefined(parentDiv)) { |   if (isDefined(companyDiv)) { | ||||||
|     Object.assign(companyButtonDiv.style, { |     Object.assign(companyButtonDiv.style, { | ||||||
|       marginLeft: '.8rem', |       marginTop: '.8rem', | ||||||
|       marginTop: '.4rem', |  | ||||||
|     }); |     }); | ||||||
|     parentDiv.prepend(companyButtonDiv); |     companyDiv.parentElement?.append(companyButtonDiv); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const companyButtonSpan = companyButtonDiv.getElementsByTagName('span')[0]; |   const companyButtonSpan = companyButtonDiv.getElementsByTagName('span')[0]; | ||||||
| @@ -104,19 +101,16 @@ export const insertButtonForCompany = async () => { | |||||||
|   const openCompanyOnSidePanel = (companyId: string) => { |   const openCompanyOnSidePanel = (companyId: string) => { | ||||||
|     companyButtonSpan.textContent = 'View in Twenty'; |     companyButtonSpan.textContent = 'View in Twenty'; | ||||||
|     companyButtonDiv.onClickHandler(async () => { |     companyButtonDiv.onClickHandler(async () => { | ||||||
|       await changeSidePanelUrl( |       await changeSidePanelUrl(`/object/company/${companyId}`); | ||||||
|         `${import.meta.env.VITE_FRONT_BASE_URL}/object/company/${companyId}`, |  | ||||||
|       ); |  | ||||||
|       chrome.runtime.sendMessage({ action: 'openSidepanel' }); |       chrome.runtime.sendMessage({ action: 'openSidepanel' }); | ||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   if (isDefined(company)) { |   if (isDefined(company)) { | ||||||
|     await changeSidePanelUrl( |     await changeSidePanelUrl(`/object/company/${company.id}`); | ||||||
|       `${import.meta.env.VITE_FRONT_BASE_URL}/object/company/${company.id}`, |  | ||||||
|     ); |  | ||||||
|     if (isDefined(company.id)) openCompanyOnSidePanel(company.id); |     if (isDefined(company.id)) openCompanyOnSidePanel(company.id); | ||||||
|   } else { |   } else { | ||||||
|  |     await changeSidePanelUrl(`/objects/companies`); | ||||||
|     companyButtonSpan.textContent = 'Add to Twenty'; |     companyButtonSpan.textContent = 'Add to Twenty'; | ||||||
|  |  | ||||||
|     companyButtonDiv.onClickHandler(async () => { |     companyButtonDiv.onClickHandler(async () => { | ||||||
|   | |||||||
| @@ -86,9 +86,7 @@ export const addPerson = async () => { | |||||||
|   const personId = await createPerson(personData); |   const personId = await createPerson(personData); | ||||||
|  |  | ||||||
|   if (isDefined(personId)) { |   if (isDefined(personId)) { | ||||||
|     await changeSidePanelUrl( |     await changeSidePanelUrl(`/object/person/${personId}`); | ||||||
|       `${import.meta.env.VITE_FRONT_BASE_URL}/object/person/${personId}`, |  | ||||||
|     ); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return personId; |   return personId; | ||||||
| @@ -98,15 +96,13 @@ export const insertButtonForPerson = async () => { | |||||||
|   const personButtonDiv = createDefaultButton('twenty-person-btn'); |   const personButtonDiv = createDefaultButton('twenty-person-btn'); | ||||||
|  |  | ||||||
|   if (isDefined(personButtonDiv)) { |   if (isDefined(personButtonDiv)) { | ||||||
|     const addedProfileDiv: HTMLDivElement | null = document.querySelector( |     const addedProfileDiv = document.querySelector('.artdeco-card > .ph5'); | ||||||
|       '.pv-top-card-v2-ctas__custom', |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     if (isDefined(addedProfileDiv)) { |     if (isDefined(addedProfileDiv)) { | ||||||
|       Object.assign(personButtonDiv.style, { |       Object.assign(personButtonDiv.style, { | ||||||
|         marginRight: '.8rem', |         marginTop: '.8rem', | ||||||
|       }); |       }); | ||||||
|       addedProfileDiv.prepend(personButtonDiv); |       addedProfileDiv.append(personButtonDiv); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const personButtonSpan = personButtonDiv.getElementsByTagName('span')[0]; |     const personButtonSpan = personButtonDiv.getElementsByTagName('span')[0]; | ||||||
| @@ -115,19 +111,16 @@ export const insertButtonForPerson = async () => { | |||||||
|     const openPersonOnSidePanel = (personId: string) => { |     const openPersonOnSidePanel = (personId: string) => { | ||||||
|       personButtonSpan.textContent = 'View in Twenty'; |       personButtonSpan.textContent = 'View in Twenty'; | ||||||
|       personButtonDiv.onClickHandler(async () => { |       personButtonDiv.onClickHandler(async () => { | ||||||
|         await changeSidePanelUrl( |         await changeSidePanelUrl(`/object/person/${personId}`); | ||||||
|           `${import.meta.env.VITE_FRONT_BASE_URL}/object/person/${personId}`, |  | ||||||
|         ); |  | ||||||
|         chrome.runtime.sendMessage({ action: 'openSidepanel' }); |         chrome.runtime.sendMessage({ action: 'openSidepanel' }); | ||||||
|       }); |       }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (isDefined(person)) { |     if (isDefined(person)) { | ||||||
|       await changeSidePanelUrl( |       await changeSidePanelUrl(`/object/person/${person.id}`); | ||||||
|         `${import.meta.env.VITE_FRONT_BASE_URL}/object/person/${person.id}`, |  | ||||||
|       ); |  | ||||||
|       if (isDefined(person.id)) openPersonOnSidePanel(person.id); |       if (isDefined(person.id)) openPersonOnSidePanel(person.id); | ||||||
|     } else { |     } else { | ||||||
|  |       await changeSidePanelUrl(`/objects/people`); | ||||||
|       personButtonSpan.textContent = 'Add to Twenty'; |       personButtonSpan.textContent = 'Add to Twenty'; | ||||||
|       personButtonDiv.onClickHandler(async () => { |       personButtonDiv.onClickHandler(async () => { | ||||||
|         personButtonSpan.textContent = 'Saving...'; |         personButtonSpan.textContent = 'Saving...'; | ||||||
|   | |||||||
| @@ -5,10 +5,23 @@ 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/ | ||||||
| // await insertButtonForCompany(); | // await insertButtonForCompany(); | ||||||
| (async () => { |  | ||||||
|   await insertButtonForCompany(); | const companyRoute = /^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/; | ||||||
|   await insertButtonForPerson(); | const personRoute = /^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/; | ||||||
| })(); |  | ||||||
|  | const executeScript = async () => { | ||||||
|  |   const loc = window.location.href; | ||||||
|  |   switch (true) { | ||||||
|  |     case companyRoute.test(loc): | ||||||
|  |       await insertButtonForCompany(); | ||||||
|  |       break; | ||||||
|  |     case personRoute.test(loc): | ||||||
|  |       await insertButtonForPerson(); | ||||||
|  |       break; | ||||||
|  |     default: | ||||||
|  |       break; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
| // The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/. | // The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/. | ||||||
| // However, there would never be another reload in a single page application unless triggered manually. | // However, there would never be another reload in a single page application unless triggered manually. | ||||||
| @@ -16,8 +29,7 @@ import { isDefined } from '~/utils/isDefined'; | |||||||
| // e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/ | // e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/ | ||||||
| chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => { | chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => { | ||||||
|   if (message.action === 'executeContentScript') { |   if (message.action === 'executeContentScript') { | ||||||
|     await insertButtonForCompany(); |     await executeScript(); | ||||||
|     await insertButtonForPerson(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   sendResponse('Executing!'); |   sendResponse('Executing!'); | ||||||
| @@ -26,8 +38,7 @@ chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => { | |||||||
| chrome.storage.local.onChanged.addListener(async (store) => { | chrome.storage.local.onChanged.addListener(async (store) => { | ||||||
|   if (isDefined(store.accessToken)) { |   if (isDefined(store.accessToken)) { | ||||||
|     if (isDefined(store.accessToken.newValue)) { |     if (isDefined(store.accessToken.newValue)) { | ||||||
|       await insertButtonForCompany(); |       await executeScript(); | ||||||
|       await insertButtonForPerson(); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -0,0 +1,59 @@ | |||||||
|  | import { isDefined } from '~/utils/isDefined'; | ||||||
|  |  | ||||||
|  | const btn = document.getElementById('twenty-settings-btn'); | ||||||
|  | if (!isDefined(btn)) { | ||||||
|  |   const div = document.createElement('div'); | ||||||
|  |   const img = document.createElement('img'); | ||||||
|  |   img.src = | ||||||
|  |     'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII='; | ||||||
|  |   img.height = 20; | ||||||
|  |   img.width = 20; | ||||||
|  |   img.alt = 'Twenty logo'; | ||||||
|  |  | ||||||
|  |   // Write universal styles for the button | ||||||
|  |   const divStyles = { | ||||||
|  |     border: '1px solid black', | ||||||
|  |     borderRadius: '50%', | ||||||
|  |     backgroundColor: 'black', | ||||||
|  |     color: 'white', | ||||||
|  |     fontWeight: '600', | ||||||
|  |     fontSize: '1.5rem', | ||||||
|  |     display: 'flex', | ||||||
|  |     alignItems: 'center', | ||||||
|  |     gap: '5px', | ||||||
|  |     justifyContent: 'center', | ||||||
|  |     padding: '0 1rem', | ||||||
|  |     cursor: 'pointer', | ||||||
|  |     height: '50px', | ||||||
|  |     width: '50px', | ||||||
|  |     position: 'fixed', | ||||||
|  |     bottom: '80px', | ||||||
|  |     right: '20px', | ||||||
|  |     zIndex: '9999999999999999999999999', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   div.addEventListener('mouseenter', () => { | ||||||
|  |     const hoverStyles = { | ||||||
|  |       //eslint-disable-next-line @nx/workspace-no-hardcoded-colors | ||||||
|  |       backgroundColor: '#5e5e5e', | ||||||
|  |       //eslint-disable-next-line @nx/workspace-no-hardcoded-colors | ||||||
|  |       borderColor: '#5e5e5e', | ||||||
|  |     }; | ||||||
|  |     Object.assign(div.style, hoverStyles); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   div.addEventListener('mouseleave', () => { | ||||||
|  |     Object.assign(div.style, divStyles); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   div.onclick = async () => { | ||||||
|  |     chrome.runtime.sendMessage({ action: 'openSidepanel' }); | ||||||
|  |     chrome.storage.local.set({ navigateSidepanel: 'settings' }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   div.appendChild(img); | ||||||
|  |  | ||||||
|  |   Object.assign(div.style, divStyles); | ||||||
|  |  | ||||||
|  |   document.body.appendChild(div); | ||||||
|  | } | ||||||
| @@ -1,15 +1,12 @@ | |||||||
| import { isDefined } from '~/utils/isDefined'; | import { isDefined } from '~/utils/isDefined'; | ||||||
|  |  | ||||||
| const changeSidePanelUrl = async (url: string) => { | const changeSidePanelUrl = async (url: string) => { | ||||||
|   const { tab: activeTab } = await chrome.runtime.sendMessage({ |   if (isDefined(url)) { | ||||||
|     action: 'getActiveTab', |     chrome.storage.local.set({ navigateSidepanel: 'sidepanel' }); | ||||||
|   }); |     // we first clear the sidepanelUrl to trigger the onchange listener on sidepanel | ||||||
|   if (isDefined(activeTab) && isDefined(url)) { |     // which will pass the post meessage to handle internal navigation of iframe | ||||||
|     chrome.storage.local.set({ [`sidepanelUrl_${activeTab.id}`]: url }); |     chrome.storage.local.set({ sidepanelUrl: '' }); | ||||||
|     chrome.runtime.sendMessage({ |     chrome.storage.local.set({ sidepanelUrl: url }); | ||||||
|       action: 'changeSidepanelUrl', |  | ||||||
|       message: { url }, |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -32,7 +32,10 @@ export default defineManifest({ | |||||||
|   content_scripts: [ |   content_scripts: [ | ||||||
|     { |     { | ||||||
|       matches: ['https://www.linkedin.com/*'], |       matches: ['https://www.linkedin.com/*'], | ||||||
|       js: ['src/contentScript/index.ts'], |       js: [ | ||||||
|  |         'src/contentScript/index.ts', | ||||||
|  |         'src/contentScript/insertSettingsButton.ts', | ||||||
|  |       ], | ||||||
|       run_at: 'document_end', |       run_at: 'document_end', | ||||||
|     }, |     }, | ||||||
|   ], |   ], | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								packages/twenty-chrome-extension/src/options/App.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								packages/twenty-chrome-extension/src/options/App.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | import { useEffect, useState } from 'react'; | ||||||
|  |  | ||||||
|  | import Settings from '~/options/Settings'; | ||||||
|  | import Sidepanel from '~/options/Sidepanel'; | ||||||
|  | import { isDefined } from '~/utils/isDefined'; | ||||||
|  |  | ||||||
|  | const App = () => { | ||||||
|  |   const [currentScreen, setCurrentScreen] = useState(''); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const setCurrentScreenState = async () => { | ||||||
|  |       const store = await chrome.storage.local.get(['navigateSidepanel']); | ||||||
|  |       if (isDefined(store.navigateSidepanel)) { | ||||||
|  |         setCurrentScreen(store.navigateSidepanel); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     setCurrentScreenState(); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     chrome.storage.local.onChanged.addListener((updatedStore) => { | ||||||
|  |       if ( | ||||||
|  |         isDefined(updatedStore.navigateSidepanel) && | ||||||
|  |         isDefined(updatedStore.navigateSidepanel.newValue) | ||||||
|  |       ) { | ||||||
|  |         setCurrentScreen(updatedStore.navigateSidepanel.newValue); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }, [setCurrentScreen]); | ||||||
|  |  | ||||||
|  |   switch (currentScreen) { | ||||||
|  |     case 'sidepanel': | ||||||
|  |       return <Sidepanel />; | ||||||
|  |     case 'settings': | ||||||
|  |       return <Settings />; | ||||||
|  |     default: | ||||||
|  |       return <Settings />; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default App; | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| import { useEffect, useState } from 'react'; | import { useEffect, useState } from 'react'; | ||||||
| import styled from '@emotion/styled'; | import styled from '@emotion/styled'; | ||||||
|  |  | ||||||
|  | import { MainButton } from '@/ui/input/button/MainButton'; | ||||||
| import { TextInput } from '@/ui/input/components/TextInput'; | import { TextInput } from '@/ui/input/components/TextInput'; | ||||||
|  | import { clearStore } from '~/utils/apolloClient'; | ||||||
| import { isDefined } from '~/utils/isDefined'; | import { isDefined } from '~/utils/isDefined'; | ||||||
|  |  | ||||||
| const StyledWrapper = styled.div` | const StyledWrapper = styled.div` | ||||||
| @@ -34,33 +36,47 @@ const StyledActionContainer = styled.div` | |||||||
| const Settings = () => { | const Settings = () => { | ||||||
|   const [serverBaseUrl, setServerBaseUrl] = useState(''); |   const [serverBaseUrl, setServerBaseUrl] = useState(''); | ||||||
|   const [clientUrl, setClientUrl] = useState(''); |   const [clientUrl, setClientUrl] = useState(''); | ||||||
|  |   const [currentClientUrl, setCurrentClientUrl] = useState(''); | ||||||
|  |   const [currentServerUrl, setCurrentServerUrl] = useState(''); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const getState = async () => { |     const getState = async () => { | ||||||
|       const store = await chrome.storage.local.get(); |       const store = await chrome.storage.local.get([ | ||||||
|  |         'serverBaseUrl', | ||||||
|  |         'clientUrl', | ||||||
|  |       ]); | ||||||
|       if (isDefined(store.serverBaseUrl)) { |       if (isDefined(store.serverBaseUrl)) { | ||||||
|         setServerBaseUrl(store.serverBaseUrl); |         setServerBaseUrl(store.serverBaseUrl); | ||||||
|  |         setCurrentServerUrl(store.serverBaseUrl); | ||||||
|       } else { |       } else { | ||||||
|         setServerBaseUrl(import.meta.env.VITE_SERVER_BASE_URL); |         setServerBaseUrl(import.meta.env.VITE_SERVER_BASE_URL); | ||||||
|  |         setCurrentServerUrl(import.meta.env.VITE_SERVER_BASE_URL); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (isDefined(store.clientUrl)) { |       if (isDefined(store.clientUrl)) { | ||||||
|         setClientUrl(store.clientUrl); |         setClientUrl(store.clientUrl); | ||||||
|  |         setCurrentClientUrl(store.clientUrl); | ||||||
|       } else { |       } else { | ||||||
|         setClientUrl(import.meta.env.VITE_FRONT_BASE_URL); |         setClientUrl(import.meta.env.VITE_FRONT_BASE_URL); | ||||||
|  |         setCurrentClientUrl(import.meta.env.VITE_FRONT_BASE_URL); | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|     void getState(); |     void getState(); | ||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|   const handleBaseUrlChange = (value: string) => { |   const handleSettingsChange = () => { | ||||||
|     setServerBaseUrl(value); |     chrome.storage.local.set({ | ||||||
|     chrome.storage.local.set({ serverBaseUrl: value }); |       serverBaseUrl, | ||||||
|  |       clientUrl, | ||||||
|  |       navigateSidepanel: 'sidepanel', | ||||||
|  |     }); | ||||||
|  |     clearStore(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleClientUrlChange = (value: string) => { |   const handleCloseSettings = () => { | ||||||
|     setClientUrl(value); |     chrome.storage.local.set({ | ||||||
|     chrome.storage.local.set({ clientUrl: value }); |       navigateSidepanel: 'sidepanel', | ||||||
|  |     }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
| @@ -71,17 +87,33 @@ const Settings = () => { | |||||||
|           <TextInput |           <TextInput | ||||||
|             label="Client URL" |             label="Client URL" | ||||||
|             value={clientUrl} |             value={clientUrl} | ||||||
|             onChange={handleClientUrlChange} |             onChange={setClientUrl} | ||||||
|             placeholder="My client URL" |             placeholder="My client URL" | ||||||
|             fullWidth |             fullWidth | ||||||
|           /> |           /> | ||||||
|           <TextInput |           <TextInput | ||||||
|             label="Server URL" |             label="Server URL" | ||||||
|             value={serverBaseUrl} |             value={serverBaseUrl} | ||||||
|             onChange={handleBaseUrlChange} |             onChange={setServerBaseUrl} | ||||||
|             placeholder="My server URL" |             placeholder="My server URL" | ||||||
|             fullWidth |             fullWidth | ||||||
|           /> |           /> | ||||||
|  |           <MainButton | ||||||
|  |             title="Done" | ||||||
|  |             disabled={ | ||||||
|  |               currentClientUrl === clientUrl && | ||||||
|  |               currentServerUrl === serverBaseUrl | ||||||
|  |             } | ||||||
|  |             variant="primary" | ||||||
|  |             onClick={handleSettingsChange} | ||||||
|  |             fullWidth | ||||||
|  |           /> | ||||||
|  |           <MainButton | ||||||
|  |             title="Close" | ||||||
|  |             variant="secondary" | ||||||
|  |             onClick={handleCloseSettings} | ||||||
|  |             fullWidth | ||||||
|  |           /> | ||||||
|         </StyledActionContainer> |         </StyledActionContainer> | ||||||
|       </StyledContainer> |       </StyledContainer> | ||||||
|     </StyledWrapper> |     </StyledWrapper> | ||||||
|   | |||||||
| @@ -46,44 +46,103 @@ const Sidepanel = () => { | |||||||
|   const iframeRef = useRef<HTMLIFrameElement>(null); |   const iframeRef = useRef<HTMLIFrameElement>(null); | ||||||
|  |  | ||||||
|   const setIframeState = useCallback(async () => { |   const setIframeState = useCallback(async () => { | ||||||
|     const store = await chrome.storage.local.get(); |     const store = await chrome.storage.local.get([ | ||||||
|     if (isDefined(store.isAuthenticated)) setIsAuthenticated(true); |       'isAuthenticated', | ||||||
|     const { tab: activeTab } = await chrome.runtime.sendMessage({ |       'sidepanelUrl', | ||||||
|       action: 'getActiveTab', |       'clientUrl', | ||||||
|     }); |       'accessToken', | ||||||
|  |       'refreshToken', | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|     if ( |     if ( | ||||||
|       isDefined(activeTab) && |       store.isAuthenticated === true && | ||||||
|       isDefined(store[`sidepanelUrl_${activeTab.id}`]) |       isDefined(store.accessToken) && | ||||||
|  |       isDefined(store.refreshToken) && | ||||||
|  |       new Date(store.accessToken.expiresAt).getTime() >= Date.now() | ||||||
|     ) { |     ) { | ||||||
|       const url = store[`sidepanelUrl_${activeTab.id}`]; |       setIsAuthenticated(true); | ||||||
|       setClientUrl(url); |       if (isDefined(store.sidepanelUrl)) { | ||||||
|     } else if (isDefined(store.clientUrl)) { |         if (isDefined(store.clientUrl)) { | ||||||
|       setClientUrl(store.clientUrl); |           setClientUrl(`${store.clientUrl}${store.sidepanelUrl}`); | ||||||
|  |         } else { | ||||||
|  |           setClientUrl( | ||||||
|  |             `${import.meta.env.VITE_FRONT_BASE_URL}${store.sidepanelUrl}`, | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       chrome.storage.local.set({ isAuthenticated: false }); | ||||||
|  |       if (isDefined(store.clientUrl)) { | ||||||
|  |         setClientUrl(store.clientUrl); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, [setClientUrl]); |   }, [setClientUrl]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const initState = async () => { |     void setIframeState(); | ||||||
|       const store = await chrome.storage.local.get(); |   }, [setIframeState]); | ||||||
|       if (isDefined(store.isAuthenticated)) setIsAuthenticated(true); |  | ||||||
|       void setIframeState(); |   useEffect(() => { | ||||||
|     }; |     window.addEventListener('message', async (event) => { | ||||||
|     void initState(); |       const store = await chrome.storage.local.get([ | ||||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps |         'clientUrl', | ||||||
|  |         'accessToken', | ||||||
|  |         'refreshToken', | ||||||
|  |       ]); | ||||||
|  |       const clientUrl = isDefined(store.clientUrl) | ||||||
|  |         ? store.clientUrl | ||||||
|  |         : import.meta.env.VITE_FRONT_BASE_URL; | ||||||
|  |  | ||||||
|  |       if ( | ||||||
|  |         isDefined(store.accessToken) && | ||||||
|  |         isDefined(store.refreshToken) && | ||||||
|  |         event.origin === clientUrl && | ||||||
|  |         event.data === 'loaded' | ||||||
|  |       ) { | ||||||
|  |         event.source?.postMessage( | ||||||
|  |           { | ||||||
|  |             type: 'tokens', | ||||||
|  |             value: { | ||||||
|  |               accessToken: { | ||||||
|  |                 token: store.accessToken.token, | ||||||
|  |                 expiresAt: store.accessToken.expiresAt, | ||||||
|  |               }, | ||||||
|  |               refreshToken: { | ||||||
|  |                 token: store.refreshToken.token, | ||||||
|  |                 expiresAt: store.refreshToken.expiresAt, | ||||||
|  |               }, | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |           clientUrl, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     void setIframeState(); |     chrome.storage.local.onChanged.addListener(async (updatedStore) => { | ||||||
|   }, [setIframeState, clientUrl]); |       if (isDefined(updatedStore.isAuthenticated)) { | ||||||
|  |         if (updatedStore.isAuthenticated.newValue === true) { | ||||||
|   useEffect(() => { |  | ||||||
|     chrome.storage.local.onChanged.addListener((store) => { |  | ||||||
|       if (isDefined(store.isAuthenticated)) { |  | ||||||
|         if (store.isAuthenticated.newValue === true) { |  | ||||||
|           setIframeState(); |           setIframeState(); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       if (isDefined(updatedStore.sidepanelUrl)) { | ||||||
|  |         if (isDefined(updatedStore.sidepanelUrl.newValue)) { | ||||||
|  |           const store = await chrome.storage.local.get(['clientUrl']); | ||||||
|  |           const clientUrl = isDefined(store.clientUrl) | ||||||
|  |             ? store.clientUrl | ||||||
|  |             : import.meta.env.VITE_FRONT_BASE_URL; | ||||||
|  |  | ||||||
|  |           iframeRef.current?.contentWindow?.postMessage( | ||||||
|  |             { | ||||||
|  |               type: 'navigate', | ||||||
|  |               value: updatedStore.sidepanelUrl.newValue, | ||||||
|  |             }, | ||||||
|  |             clientUrl, | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     }); |     }); | ||||||
|   }, [setIframeState]); |   }, [setIframeState]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,14 +3,14 @@ import ReactDOM from 'react-dom/client'; | |||||||
|  |  | ||||||
| import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; | import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; | ||||||
| import { ThemeType } from '@/ui/theme/constants/ThemeLight'; | import { ThemeType } from '@/ui/theme/constants/ThemeLight'; | ||||||
| import Sidepanel from '~/options/Sidepanel'; | import App from '~/options/App'; | ||||||
|  |  | ||||||
| import '~/index.css'; | import '~/index.css'; | ||||||
|  |  | ||||||
| ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render( | ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render( | ||||||
|   <AppThemeProvider> |   <AppThemeProvider> | ||||||
|     <React.StrictMode> |     <React.StrictMode> | ||||||
|       <Sidepanel /> |       <App /> | ||||||
|     </React.StrictMode> |     </React.StrictMode> | ||||||
|   </AppThemeProvider>, |   </AppThemeProvider>, | ||||||
| ); | ); | ||||||
|   | |||||||
| @@ -1,34 +1,19 @@ | |||||||
| import { | import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client'; | ||||||
|   ApolloClient, |  | ||||||
|   from, |  | ||||||
|   fromPromise, |  | ||||||
|   HttpLink, |  | ||||||
|   InMemoryCache, |  | ||||||
| } from '@apollo/client'; |  | ||||||
| import { setContext } from '@apollo/client/link/context'; | import { setContext } from '@apollo/client/link/context'; | ||||||
| import { onError } from '@apollo/client/link/error'; | import { onError } from '@apollo/client/link/error'; | ||||||
|  |  | ||||||
| import { renewToken } from '~/db/token.db'; |  | ||||||
| import { Tokens } from '~/db/types/auth.types'; |  | ||||||
| import { isDefined } from '~/utils/isDefined'; | import { isDefined } from '~/utils/isDefined'; | ||||||
|  |  | ||||||
| const clearStore = () => { | export const clearStore = () => { | ||||||
|   chrome.storage.local.remove(['loginToken', 'accessToken', 'refreshToken']); |   chrome.storage.local.remove([ | ||||||
|  |     'loginToken', | ||||||
|  |     'accessToken', | ||||||
|  |     'refreshToken', | ||||||
|  |     'sidepanelUrl', | ||||||
|  |   ]); | ||||||
|   chrome.storage.local.set({ isAuthenticated: false }); |   chrome.storage.local.set({ isAuthenticated: false }); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const setStore = (tokens: Tokens) => { |  | ||||||
|   if (isDefined(tokens.loginToken)) { |  | ||||||
|     chrome.storage.local.set({ |  | ||||||
|       loginToken: tokens.loginToken, |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|   chrome.storage.local.set({ |  | ||||||
|     accessToken: tokens.accessToken, |  | ||||||
|     refreshToken: tokens.refreshToken, |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const getServerUrl = async () => { | export const getServerUrl = async () => { | ||||||
|   const store = await chrome.storage.local.get(); |   const store = await chrome.storage.local.get(); | ||||||
|   const serverUrl = `${ |   const serverUrl = `${ | ||||||
| @@ -46,8 +31,6 @@ const getAuthToken = async () => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| const getApolloClient = async () => { | const getApolloClient = async () => { | ||||||
|   const store = await chrome.storage.local.get(); |  | ||||||
|  |  | ||||||
|   const authLink = setContext(async (_, { headers }) => { |   const authLink = setContext(async (_, { headers }) => { | ||||||
|     const token = await getAuthToken(); |     const token = await getAuthToken(); | ||||||
|     return { |     return { | ||||||
| @@ -57,57 +40,37 @@ const getApolloClient = async () => { | |||||||
|       }, |       }, | ||||||
|     }; |     }; | ||||||
|   }); |   }); | ||||||
|   const errorLink = onError( |   const errorLink = onError(({ graphQLErrors, networkError }) => { | ||||||
|     ({ graphQLErrors, networkError, forward, operation }) => { |     if (isDefined(graphQLErrors)) { | ||||||
|       if (isDefined(graphQLErrors)) { |       for (const graphQLError of graphQLErrors) { | ||||||
|         for (const graphQLError of graphQLErrors) { |         if (graphQLError.message === 'Unauthorized') { | ||||||
|           if (graphQLError.message === 'Unauthorized') { |           clearStore(); | ||||||
|             return fromPromise( |           return; | ||||||
|               renewToken(store.refreshToken.token) |         } | ||||||
|                 .then((response) => { |         switch (graphQLError?.extensions?.code) { | ||||||
|                   if (isDefined(response)) { |           case 'UNAUTHENTICATED': { | ||||||
|                     setStore(response.renewToken.tokens); |             clearStore(); | ||||||
|                   } |             break; | ||||||
|                 }) |  | ||||||
|                 .catch(() => { |  | ||||||
|                   clearStore(); |  | ||||||
|                 }), |  | ||||||
|             ).flatMap(() => forward(operation)); |  | ||||||
|           } |  | ||||||
|           switch (graphQLError?.extensions?.code) { |  | ||||||
|             case 'UNAUTHENTICATED': { |  | ||||||
|               return fromPromise( |  | ||||||
|                 renewToken(store.refreshToken.token) |  | ||||||
|                   .then((response) => { |  | ||||||
|                     if (isDefined(response)) { |  | ||||||
|                       setStore(response.renewToken.tokens); |  | ||||||
|                     } |  | ||||||
|                   }) |  | ||||||
|                   .catch(() => { |  | ||||||
|                     clearStore(); |  | ||||||
|                   }), |  | ||||||
|               ).flatMap(() => forward(operation)); |  | ||||||
|             } |  | ||||||
|             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; |  | ||||||
|           } |           } | ||||||
|  |           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)) { |     if (isDefined(networkError)) { | ||||||
|         // eslint-disable-next-line no-console |       // eslint-disable-next-line no-console | ||||||
|         console.error(`[Network error]: ${networkError}`); |       console.error(`[Network error]: ${networkError}`); | ||||||
|       } |     } | ||||||
|     }, |   }); | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   const httpLink = new HttpLink({ |   const httpLink = new HttpLink({ | ||||||
|     uri: await getServerUrl(), |     uri: await getServerUrl(), | ||||||
|   | |||||||
| @@ -13,6 +13,8 @@ import { useRecoilValue } from 'recoil'; | |||||||
|  |  | ||||||
| import { ApolloProvider } from '@/apollo/components/ApolloProvider'; | import { ApolloProvider } from '@/apollo/components/ApolloProvider'; | ||||||
| import { VerifyEffect } from '@/auth/components/VerifyEffect'; | import { VerifyEffect } from '@/auth/components/VerifyEffect'; | ||||||
|  | import { ChromeExtensionSidecarEffect } from '@/chrome-extension-sidecar/components/ChromeExtensionSidecarEffect'; | ||||||
|  | import { ChromeExtensionSidecarProvider } from '@/chrome-extension-sidecar/components/ChromeExtensionSidecarProvider'; | ||||||
| import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider'; | import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider'; | ||||||
| import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect'; | import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect'; | ||||||
| import { billingState } from '@/client-config/states/billingState'; | import { billingState } from '@/client-config/states/billingState'; | ||||||
| @@ -85,36 +87,41 @@ const ProvidersThatNeedRouterContext = () => { | |||||||
|   const pageTitle = getPageTitleFromPath(pathname); |   const pageTitle = getPageTitleFromPath(pathname); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <ApolloProvider> |     <> | ||||||
|       <ClientConfigProviderEffect /> |       <ApolloProvider> | ||||||
|       <ClientConfigProvider> |         <ClientConfigProviderEffect /> | ||||||
|         <UserProviderEffect /> |         <ClientConfigProvider> | ||||||
|         <UserProvider> |           <ChromeExtensionSidecarEffect /> | ||||||
|           <ApolloMetadataClientProvider> |           <ChromeExtensionSidecarProvider> | ||||||
|             <ObjectMetadataItemsProvider> |             <UserProviderEffect /> | ||||||
|               <PrefetchDataProvider> |             <UserProvider> | ||||||
|                 <AppThemeProvider> |               <ApolloMetadataClientProvider> | ||||||
|                   <SnackBarProvider> |                 <ObjectMetadataItemsProvider> | ||||||
|                     <DialogManagerScope dialogManagerScopeId="dialog-manager"> |                   <PrefetchDataProvider> | ||||||
|                       <DialogManager> |                     <AppThemeProvider> | ||||||
|                         <StrictMode> |                       <SnackBarProvider> | ||||||
|                           <PromiseRejectionEffect /> |                         <DialogManagerScope dialogManagerScopeId="dialog-manager"> | ||||||
|                           <CommandMenuEffect /> |                           <DialogManager> | ||||||
|                           <GotoHotkeysEffect /> |                             <StrictMode> | ||||||
|                           <PageTitle title={pageTitle} /> |                               <PromiseRejectionEffect /> | ||||||
|                           <Outlet /> |                               <CommandMenuEffect /> | ||||||
|                         </StrictMode> |                               <GotoHotkeysEffect /> | ||||||
|                       </DialogManager> |                               <PageTitle title={pageTitle} /> | ||||||
|                     </DialogManagerScope> |                               <Outlet /> | ||||||
|                   </SnackBarProvider> |                             </StrictMode> | ||||||
|                 </AppThemeProvider> |                           </DialogManager> | ||||||
|               </PrefetchDataProvider> |                         </DialogManagerScope> | ||||||
|               <PageChangeEffect /> |                       </SnackBarProvider> | ||||||
|             </ObjectMetadataItemsProvider> |                     </AppThemeProvider> | ||||||
|           </ApolloMetadataClientProvider> |                   </PrefetchDataProvider> | ||||||
|         </UserProvider> |                   <PageChangeEffect /> | ||||||
|       </ClientConfigProvider> |                 </ObjectMetadataItemsProvider> | ||||||
|     </ApolloProvider> |               </ApolloMetadataClientProvider> | ||||||
|  |             </UserProvider> | ||||||
|  |           </ChromeExtensionSidecarProvider> | ||||||
|  |         </ClientConfigProvider> | ||||||
|  |       </ApolloProvider> | ||||||
|  |     </> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -133,6 +133,7 @@ export type ClientConfig = { | |||||||
|   authProviders: AuthProviders; |   authProviders: AuthProviders; | ||||||
|   billing: Billing; |   billing: Billing; | ||||||
|   captcha: Captcha; |   captcha: Captcha; | ||||||
|  |   chromeExtensionId?: Maybe<Scalars['String']>; | ||||||
|   debugMode: Scalars['Boolean']; |   debugMode: Scalars['Boolean']; | ||||||
|   sentry: Sentry; |   sentry: Sentry; | ||||||
|   signInPrefilled: Scalars['Boolean']; |   signInPrefilled: Scalars['Boolean']; | ||||||
| @@ -1186,7 +1187,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat | |||||||
| export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; | export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; | ||||||
|  |  | ||||||
|  |  | ||||||
| export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null } } }; | export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null } } }; | ||||||
|  |  | ||||||
| export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; | export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; | ||||||
|  |  | ||||||
| @@ -2307,6 +2308,7 @@ export const GetClientConfigDocument = gql` | |||||||
|       provider |       provider | ||||||
|       siteKey |       siteKey | ||||||
|     } |     } | ||||||
|  |     chromeExtensionId | ||||||
|   } |   } | ||||||
| } | } | ||||||
|     `; |     `; | ||||||
|   | |||||||
| @@ -0,0 +1,58 @@ | |||||||
|  | import { useEffect } from 'react'; | ||||||
|  | import { useNavigate } from 'react-router-dom'; | ||||||
|  | import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||||
|  |  | ||||||
|  | import { tokenPairState } from '@/auth/states/tokenPairState'; | ||||||
|  | import { isLoadingTokensFromExtensionState } from '@/chrome-extension-sidecar/states/isLoadingTokensFromExtensionState'; | ||||||
|  | import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; | ||||||
|  | import { isDefined } from '~/utils/isDefined'; | ||||||
|  | import { isInFrame } from '~/utils/isInIframe'; | ||||||
|  |  | ||||||
|  | export const ChromeExtensionSidecarEffect = () => { | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const setTokenPair = useSetRecoilState(tokenPairState); | ||||||
|  |   const chromeExtensionId = useRecoilValue(chromeExtensionIdState); | ||||||
|  |   const setIsLoadingTokensFromExtension = useSetRecoilState( | ||||||
|  |     isLoadingTokensFromExtensionState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isInFrame() && isDefined(chromeExtensionId)) { | ||||||
|  |       window.parent.postMessage( | ||||||
|  |         'loaded', | ||||||
|  |         `chrome-extension://${chromeExtensionId}`, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       const handleWindowEvents = (event: MessageEvent<any>) => { | ||||||
|  |         if (event.origin === `chrome-extension://${chromeExtensionId}`) { | ||||||
|  |           switch (event.data.type) { | ||||||
|  |             case 'tokens': { | ||||||
|  |               setTokenPair(event.data.value); | ||||||
|  |               setIsLoadingTokensFromExtension(true); | ||||||
|  |               break; | ||||||
|  |             } | ||||||
|  |             case 'navigate': | ||||||
|  |               navigate(event.data.value); | ||||||
|  |               break; | ||||||
|  |             default: | ||||||
|  |               break; | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           setIsLoadingTokensFromExtension(false); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |       window.addEventListener('message', handleWindowEvents); | ||||||
|  |       return () => { | ||||||
|  |         window.removeEventListener('message', handleWindowEvents); | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   }, [ | ||||||
|  |     chromeExtensionId, | ||||||
|  |     setIsLoadingTokensFromExtension, | ||||||
|  |     setTokenPair, | ||||||
|  |     navigate, | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|  |   return <></>; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | import styled from '@emotion/styled'; | ||||||
|  | import { useRecoilValue } from 'recoil'; | ||||||
|  |  | ||||||
|  | import { isLoadingTokensFromExtensionState } from '@/chrome-extension-sidecar/states/isLoadingTokensFromExtensionState'; | ||||||
|  | import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; | ||||||
|  | import { isDefined } from '~/utils/isDefined'; | ||||||
|  | import { isInFrame } from '~/utils/isInIframe'; | ||||||
|  |  | ||||||
|  | const StyledContainer = styled.div` | ||||||
|  |   align-items: center; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   height: 100vh; | ||||||
|  |   justify-content: center; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const AppInaccessible = ({ message }: { message: string }) => { | ||||||
|  |   return ( | ||||||
|  |     <StyledContainer> | ||||||
|  |       <img | ||||||
|  |         src="/images/integrations/twenty-logo.svg" | ||||||
|  |         alt="twenty-icon" | ||||||
|  |         height={40} | ||||||
|  |         width={40} | ||||||
|  |       /> | ||||||
|  |       <h3>{message}</h3> | ||||||
|  |     </StyledContainer> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ChromeExtensionSidecarProvider: React.FC< | ||||||
|  |   React.PropsWithChildren | ||||||
|  | > = ({ children }) => { | ||||||
|  |   const isLoadingTokensFromExtension = useRecoilValue( | ||||||
|  |     isLoadingTokensFromExtensionState, | ||||||
|  |   ); | ||||||
|  |   const chromeExtensionId = useRecoilValue(chromeExtensionIdState); | ||||||
|  |  | ||||||
|  |   if (!isInFrame()) return <>{children}</>; | ||||||
|  |  | ||||||
|  |   if (!isDefined(chromeExtensionId)) | ||||||
|  |     return ( | ||||||
|  |       <AppInaccessible message={`Twenty is not accessible inside an iframe.`} /> | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |   if (isDefined(isLoadingTokensFromExtension) && !isLoadingTokensFromExtension) | ||||||
|  |     return ( | ||||||
|  |       <AppInaccessible | ||||||
|  |         message={`Unauthorized access from iframe origin. If you're trying to access from chrome extension, | ||||||
|  |       please check your chrome extension ID on your server. | ||||||
|  |     `} | ||||||
|  |       /> | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |   return isLoadingTokensFromExtension && <>{children}</>; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | import { createState } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const isLoadingTokensFromExtensionState = createState<boolean | null>({ | ||||||
|  |   key: 'isLoadingTokensFromExtensionState', | ||||||
|  |   defaultValue: null, | ||||||
|  | }); | ||||||
| @@ -4,6 +4,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil'; | |||||||
| import { authProvidersState } from '@/client-config/states/authProvidersState'; | import { authProvidersState } from '@/client-config/states/authProvidersState'; | ||||||
| import { billingState } from '@/client-config/states/billingState'; | import { billingState } from '@/client-config/states/billingState'; | ||||||
| import { captchaProviderState } from '@/client-config/states/captchaProviderState'; | import { captchaProviderState } from '@/client-config/states/captchaProviderState'; | ||||||
|  | import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; | ||||||
| import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; | import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; | ||||||
| import { isDebugModeState } from '@/client-config/states/isDebugModeState'; | import { isDebugModeState } from '@/client-config/states/isDebugModeState'; | ||||||
| import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; | import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; | ||||||
| @@ -32,6 +33,8 @@ export const ClientConfigProviderEffect = () => { | |||||||
|  |  | ||||||
|   const setCaptchaProvider = useSetRecoilState(captchaProviderState); |   const setCaptchaProvider = useSetRecoilState(captchaProviderState); | ||||||
|  |  | ||||||
|  |   const setChromeExtensionId = useSetRecoilState(chromeExtensionIdState); | ||||||
|  |  | ||||||
|   const { data, loading } = useGetClientConfigQuery({ |   const { data, loading } = useGetClientConfigQuery({ | ||||||
|     skip: isClientConfigLoaded, |     skip: isClientConfigLoaded, | ||||||
|   }); |   }); | ||||||
| @@ -63,6 +66,8 @@ export const ClientConfigProviderEffect = () => { | |||||||
|         provider: data?.clientConfig?.captcha?.provider, |         provider: data?.clientConfig?.captcha?.provider, | ||||||
|         siteKey: data?.clientConfig?.captcha?.siteKey, |         siteKey: data?.clientConfig?.captcha?.siteKey, | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|  |       setChromeExtensionId(data?.clientConfig?.chromeExtensionId); | ||||||
|     } |     } | ||||||
|   }, [ |   }, [ | ||||||
|     data, |     data, | ||||||
| @@ -77,6 +82,7 @@ export const ClientConfigProviderEffect = () => { | |||||||
|     loading, |     loading, | ||||||
|     setIsClientConfigLoaded, |     setIsClientConfigLoaded, | ||||||
|     setCaptchaProvider, |     setCaptchaProvider, | ||||||
|  |     setChromeExtensionId, | ||||||
|   ]); |   ]); | ||||||
|  |  | ||||||
|   return <></>; |   return <></>; | ||||||
|   | |||||||
| @@ -33,6 +33,7 @@ export const GET_CLIENT_CONFIG = gql` | |||||||
|         provider |         provider | ||||||
|         siteKey |         siteKey | ||||||
|       } |       } | ||||||
|  |       chromeExtensionId | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| `; | `; | ||||||
|   | |||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | import { createState } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const chromeExtensionIdState = createState<string | null | undefined>({ | ||||||
|  |   key: 'chromeExtensionIdState', | ||||||
|  |   defaultValue: null, | ||||||
|  | }); | ||||||
							
								
								
									
										7
									
								
								packages/twenty-front/src/utils/isInIframe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/twenty-front/src/utils/isInIframe.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | export const isInFrame = () => { | ||||||
|  |   try { | ||||||
|  |     return window.self !== window.top; | ||||||
|  |   } catch (e) { | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | }; | ||||||
| @@ -71,5 +71,5 @@ 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.chromiumapp.org | # CHROME_EXTENSION_ID=bggmipldbceihilonnbpgoeclgbkblkp | ||||||
| # PG_SSL_ALLOW_SELF_SIGNED=true | # PG_SSL_ALLOW_SELF_SIGNED=true | ||||||
|   | |||||||
| @@ -203,7 +203,9 @@ export class AuthService { | |||||||
|           this.environmentService.get('NODE_ENV') === |           this.environmentService.get('NODE_ENV') === | ||||||
|           NodeEnvironment.development |           NodeEnvironment.development | ||||||
|             ? authorizeAppInput.redirectUrl |             ? authorizeAppInput.redirectUrl | ||||||
|             : `${this.environmentService.get('CHROME_EXTENSION_REDIRECT_URL')}`, |             : `https://${this.environmentService.get( | ||||||
|  |                 'CHROME_EXTENSION_ID', | ||||||
|  |               )}.chromiumapp.org/`, | ||||||
|       }, |       }, | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -96,4 +96,7 @@ export class ClientConfig { | |||||||
|  |  | ||||||
|   @Field(() => Captcha) |   @Field(() => Captcha) | ||||||
|   captcha: Captcha; |   captcha: Captcha; | ||||||
|  |  | ||||||
|  |   @Field(() => String, { nullable: true }) | ||||||
|  |   chromeExtensionId: string | undefined; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -48,6 +48,7 @@ export class ClientConfigResolver { | |||||||
|         provider: this.environmentService.get('CAPTCHA_DRIVER'), |         provider: this.environmentService.get('CAPTCHA_DRIVER'), | ||||||
|         siteKey: this.environmentService.get('CAPTCHA_SITE_KEY'), |         siteKey: this.environmentService.get('CAPTCHA_SITE_KEY'), | ||||||
|       }, |       }, | ||||||
|  |       chromeExtensionId: this.environmentService.get('CHROME_EXTENSION_ID'), | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     return Promise.resolve(clientConfig); |     return Promise.resolve(clientConfig); | ||||||
|   | |||||||
| @@ -380,7 +380,7 @@ export class EnvironmentVariables { | |||||||
|  |  | ||||||
|   AUTH_GOOGLE_APIS_CALLBACK_URL: string; |   AUTH_GOOGLE_APIS_CALLBACK_URL: string; | ||||||
|  |  | ||||||
|   CHROME_EXTENSION_REDIRECT_URL: string; |   CHROME_EXTENSION_ID: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| export const validate = ( | export const validate = ( | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								yarn.lock
									
									
									
									
									
								
							| @@ -16113,13 +16113,6 @@ __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-array@npm:*": | "@types/d3-array@npm:*": | ||||||
|   version: 3.2.1 |   version: 3.2.1 | ||||||
|   resolution: "@types/d3-array@npm:3.2.1" |   resolution: "@types/d3-array@npm:3.2.1" | ||||||
| @@ -16452,13 +16445,6 @@ __metadata: | |||||||
|   languageName: node |   languageName: node | ||||||
|   linkType: hard |   linkType: hard | ||||||
| 
 | 
 | ||||||
| "@types/dagre@npm:^0.7.52": |  | ||||||
|   version: 0.7.52 |  | ||||||
|   resolution: "@types/dagre@npm:0.7.52" |  | ||||||
|   checksum: 0e196a8c17a92765d6e28b10d78d5c1cb1ee540598428cbb61ce3b90e0fedaac2b11f6dbeebf0d2f69d5332d492b12091be5f1e575f538194e20d8887979d006 |  | ||||||
|   languageName: node |  | ||||||
|   linkType: hard |  | ||||||
| 
 |  | ||||||
| "@types/debug@npm:^4.0.0": | "@types/debug@npm:^4.0.0": | ||||||
|   version: 4.1.12 |   version: 4.1.12 | ||||||
|   resolution: "@types/debug@npm:4.1.12" |   resolution: "@types/debug@npm:4.1.12" | ||||||
| @@ -23960,13 +23946,6 @@ __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" | ||||||
| @@ -46918,8 +46897,6 @@ __metadata: | |||||||
|     "@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/chrome": "npm:^0.0.267" |     "@types/chrome": "npm:^0.0.267" | ||||||
|     "@types/crypto-js": "npm:^4.2.2" |  | ||||||
|     "@types/dagre": "npm:^0.7.52" |  | ||||||
|     "@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" | ||||||
| @@ -46980,7 +46957,6 @@ __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
	 Aditya Pimpalkar
					Aditya Pimpalkar