Skip to content

Commit d22c5d9

Browse files
committed
feat: implement external google drive picker integration
- Added `useDriveExternalPicker` hook to manage Google Drive file selection via external flows (desktop and browser extension). - Introduced `GoogleFileSelectorExternalButton` and `GoogleFolderSelectorExternalButton` components for selecting files and folders from Google Drive. - Updated existing file download modals to support external Google Drive integration. - Enhanced Google sign-in components to handle external authentication flows. - Modified type definitions to accommodate new Google Drive integration features.
1 parent 0001373 commit d22c5d9

36 files changed

Lines changed: 2248 additions & 73 deletions

File tree

apps/api/src/app/controllers/desktop-app.controller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import { UserProfileUiSchema } from '@jetstream/types';
1313
import { fromUnixTime } from 'date-fns';
1414
import { z } from 'zod';
1515
import * as userSyncDbService from '../db/data-sync.db';
16+
import * as orgGroupsDb from '../db/organization.db';
17+
import * as salesforceOrgsDb from '../db/salesforce-org.db';
1618
import * as userDbService from '../db/user.db';
1719
import { checkUserEntitlement } from '../db/user.db';
1820
import * as webExtDb from '../db/web-extension.db';
1921
import { emitRecordSyncEventsToOtherClients, SyncEvent } from '../services/data-sync-broadcast.service';
2022
import * as externalAuthService from '../services/external-auth.service';
2123
import { decryptJwtTokenOrPlaintext } from '../services/jwt-token-encryption.service';
24+
import { UserFacingError } from '../utils/error-handler';
2225
import { redirect, sendJson } from '../utils/response.handlers';
2326
import { createRoute, RouteValidator } from '../utils/route.utils';
2427
import { routeDefinition as dataSyncController } from './data-sync.controller';
@@ -100,6 +103,13 @@ export const routeDefinition = {
100103
hasSourceOrg: false,
101104
} satisfies RouteValidator,
102105
},
106+
googleConfig: {
107+
controllerFn: () => googleConfig,
108+
responseType: z.object({ appId: z.string(), apiKey: z.string(), clientId: z.string() }),
109+
validators: {
110+
hasSourceOrg: false,
111+
} satisfies RouteValidator,
112+
},
103113
};
104114

105115
/**
@@ -262,6 +272,14 @@ const dataSyncPush = createRoute(routeDefinition.dataSyncPush.validators, async
262272
sendJson(res, response);
263273
});
264274

275+
const googleConfig = createRoute(routeDefinition.googleConfig.validators, async (_params, _req, res) => {
276+
sendJson(res, {
277+
appId: ENV.GOOGLE_APP_ID,
278+
apiKey: ENV.GOOGLE_API_KEY,
279+
clientId: ENV.GOOGLE_CLIENT_ID,
280+
});
281+
});
282+
265283
const notifications = createRoute(routeDefinition.notifications.validators, async ({ query, user }, req, res) => {
266284
// TODO: reserved for future use (e.g. check if there is a critical update required, or auto-update is broken etc..)
267285
const { os, version } = query;

apps/api/src/app/controllers/web-extension.controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ export const routeDefinition = {
8484
...dataSyncController.push.validators,
8585
} satisfies RouteValidator,
8686
},
87+
googleConfig: {
88+
controllerFn: () => googleConfig,
89+
responseType: z.object({ appId: z.string(), apiKey: z.string(), clientId: z.string() }),
90+
validators: {
91+
hasSourceOrg: false,
92+
} satisfies RouteValidator,
93+
},
8794
};
8895

8996
/**
@@ -229,3 +236,11 @@ const dataSyncPush = createRoute(routeDefinition.dataSyncPush.validators, async
229236

230237
sendJson(res, response);
231238
});
239+
240+
const googleConfig = createRoute(routeDefinition.googleConfig.validators, async (_params, _req, res) => {
241+
sendJson(res, {
242+
appId: ENV.GOOGLE_APP_ID,
243+
apiKey: ENV.GOOGLE_API_KEY,
244+
clientId: ENV.GOOGLE_CLIENT_ID,
245+
});
246+
});

apps/api/src/app/routes/desktop-app.routes.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ routes.use(
3636
blockAllMixedContent: [],
3737
fontSrc: ["'self'", 'https:'],
3838
frameAncestors: ["'self'"],
39-
imgSrc: ["'self'", '*.cloudinary.com'],
39+
frameSrc: ["'self'", 'https://accounts.google.com', 'https://docs.google.com', 'https://drive.google.com'],
40+
imgSrc: ["'self'", '*.cloudinary.com', '*.googleusercontent.com'],
4041
objectSrc: ["'none'"],
41-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42-
scriptSrc: ["'self'", (_, res) => `'nonce-${(res as any)?.locals?.nonce}'`],
42+
scriptSrc: ["'self'", 'https://apis.google.com', 'https://accounts.google.com'],
4343
scriptSrcAttr: ["'none'"],
4444
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
45+
connectSrc: ["'self'", 'https://www.googleapis.com', 'https://oauth2.googleapis.com'],
4546
upgradeInsecureRequests: [],
4647
},
4748
},
@@ -83,6 +84,8 @@ routes.get('/orgs', authMiddleware, desktopAppController.routeDefinition.getOrgs
8384
routes.get('/data-sync/pull', authMiddleware, desktopAppController.routeDefinition.dataSyncPull.controllerFn());
8485
routes.post('/data-sync/push', authMiddleware, desktopAppController.routeDefinition.dataSyncPush.controllerFn());
8586

87+
routes.get('/google-config', LAX_AuthRateLimit, authMiddleware, desktopAppController.routeDefinition.googleConfig.controllerFn());
88+
8689
routes.get('/v1/notifications', STRICT_2X_AuthRateLimit, authMiddleware, desktopAppController.routeDefinition.notifications.controllerFn());
8790

8891
routes.post(

apps/api/src/app/routes/web-extension-server.routes.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,19 @@ routes.use(
3636
blockAllMixedContent: [],
3737
fontSrc: ["'self'", 'https:'],
3838
frameAncestors: ["'self'"],
39-
imgSrc: ["'self'", '*.cloudinary.com'],
39+
frameSrc: ["'self'", 'https://accounts.google.com', 'https://docs.google.com', 'https://drive.google.com'],
40+
imgSrc: ["'self'", '*.cloudinary.com', '*.googleusercontent.com'],
4041
objectSrc: ["'none'"],
4142
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42-
scriptSrc: ["'self'", (_, res) => `'nonce-${(res as any)?.locals?.nonce}'`],
43+
scriptSrc: [
44+
"'self'",
45+
'https://apis.google.com',
46+
'https://accounts.google.com',
47+
(_, res) => `'nonce-${(res as any)?.locals?.nonce}'`,
48+
],
4349
scriptSrcAttr: ["'none'"],
4450
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
51+
connectSrc: ["'self'", 'https://www.googleapis.com', 'https://oauth2.googleapis.com'],
4552
upgradeInsecureRequests: [],
4653
},
4754
},
@@ -100,6 +107,8 @@ routes.delete('/auth/logout', STRICT_AuthRateLimit, authMiddleware, webExtension
100107
routes.get('/data-sync/pull', authMiddleware, webExtensionController.routeDefinition.dataSyncPull.controllerFn());
101108
routes.post('/data-sync/push', authMiddleware, webExtensionController.routeDefinition.dataSyncPush.controllerFn());
102109

110+
routes.get('/google-config', LAX_AuthRateLimit, authMiddleware, webExtensionController.routeDefinition.googleConfig.controllerFn());
111+
103112
routes.post(
104113
'/feedback',
105114
feedbackRateLimit,

apps/jetstream-desktop/src/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const API: ElectronAPI = {
3838
ipcRenderer.on(IpcEventChannel.openSettings, handler);
3939
return () => ipcRenderer.removeListener(IpcEventChannel.openSettings, handler);
4040
},
41+
onGooglePickerResult: (callback) => {
42+
const handler = (_event, payload) => callback(payload);
43+
ipcRenderer.on(IpcEventChannel.googlePickerResult, handler);
44+
return () => ipcRenderer.removeListener(IpcEventChannel.googlePickerResult, handler);
45+
},
4146
// One-Way from Client
4247
login: () => ipcRenderer.invoke('login'),
4348
logout: () => ipcRenderer.invoke('logout'),
@@ -55,6 +60,7 @@ const API: ElectronAPI = {
5560
checkForUpdates: (userInitiated) => ipcRenderer.invoke('checkForUpdates', userInitiated),
5661
getUpdateStatus: () => ipcRenderer.invoke('getUpdateStatus'),
5762
installUpdate: () => ipcRenderer.invoke('installUpdate'),
63+
openGooglePicker: (payload) => ipcRenderer.invoke('openGooglePicker', payload),
5864
};
5965

6066
contextBridge.exposeInMainWorld('electronAPI', API);

apps/jetstream-desktop/src/services/api.service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ export async function logout({ accessToken, deviceId }: { deviceId: string; acce
6666
return results.data;
6767
}
6868

69+
export async function fetchGoogleConfig({ accessToken, deviceId }: { deviceId: string; accessToken: string }) {
70+
const response = await net.fetch(`${ENV.SERVER_URL}/desktop-app/google-config`, {
71+
method: 'GET',
72+
headers: {
73+
Accept: 'application/json',
74+
Authorization: `Bearer ${accessToken}`,
75+
[HTTP.HEADERS.X_APP_VERSION]: app.getVersion(),
76+
[HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId,
77+
},
78+
});
79+
80+
if (!response.ok) {
81+
throw new Error('Failed to fetch Google configuration');
82+
}
83+
84+
const { data } = await response.json();
85+
const parsed = z.object({ appId: z.string(), apiKey: z.string(), clientId: z.string() }).safeParse(data);
86+
if (!parsed.success) {
87+
throw new Error('Invalid Google configuration response');
88+
}
89+
return parsed.data;
90+
}
91+
6992
export async function checkNotifications({
7093
accessToken,
7194
deviceId,

apps/jetstream-desktop/src/services/ipc.service.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DownloadFileResult,
77
DownloadZipPayload,
88
ElectronApiRequestResponse,
9+
GooglePickerResult,
910
IcpResponse,
1011
IpcEventChannel,
1112
} from '@jetstream/desktop/types';
@@ -23,7 +24,7 @@ import { checkForUpdates, getCurrentUpdateStatus, installUpdate } from '../confi
2324
import { ENV } from '../config/environment';
2425
import { desktopRoutes } from '../controllers/desktop.routes';
2526
import { getOrgFromHeaderOrQuery, initApiConnection } from '../utils/route.utils';
26-
import { AuthResponseSuccess, logout, verifyAuthToken } from './api.service';
27+
import { AuthResponseSuccess, fetchGoogleConfig, logout, verifyAuthToken } from './api.service';
2728
import { deepLink } from './deep-link.service';
2829
import { downloadAndZipFilesToDisk, downloadBulkApiFileAndSaveToDisk } from './file-download.service';
2930
import * as dataService from './persistence.service';
@@ -83,6 +84,8 @@ export function registerIpc(): void {
8384
registerHandler('checkForUpdates', handleCheckForUpdatesEvent);
8485
registerHandler('getUpdateStatus', handleGetUpdateStatusEvent);
8586
registerHandler('installUpdate', handleInstallUpdateEvent);
87+
// Handle Google Picker
88+
registerHandler('openGooglePicker', handleOpenGooglePickerEvent);
8689
}
8790

8891
const handleSelectFolderEvent: MainIpcHandler<'selectFolder'> = async () => {
@@ -247,6 +250,114 @@ const handleAddOrgEvent: MainIpcHandler<'addOrg'> = async (event, payload) => {
247250
}, 900000); // 15 minutes in milliseconds
248251
};
249252

253+
const handleOpenGooglePickerEvent: MainIpcHandler<'openGooglePicker'> = async (event, payload) => {
254+
const { mode } = payload;
255+
const { deviceId, accessToken } = dataService.getAppData();
256+
257+
if (!accessToken || !deviceId) {
258+
event.sender.send(IpcEventChannel.toastMessage, {
259+
type: 'error',
260+
message: 'You must be logged in to use Google Drive integration',
261+
});
262+
return;
263+
}
264+
265+
let googleConfig: { appId: string; apiKey: string; clientId: string };
266+
try {
267+
googleConfig = await fetchGoogleConfig({ accessToken, deviceId });
268+
} catch (ex) {
269+
logger.error('Error fetching Google config', ex);
270+
event.sender.send(IpcEventChannel.toastMessage, {
271+
type: 'error',
272+
message: 'Failed to load Google Drive configuration. Please try again.',
273+
});
274+
return;
275+
}
276+
277+
const nonce = crypto.randomUUID();
278+
const pickerParams = new URLSearchParams({
279+
mode,
280+
nonce,
281+
appId: googleConfig.appId,
282+
apiKey: googleConfig.apiKey,
283+
clientId: googleConfig.clientId,
284+
});
285+
if (payload.accessToken) {
286+
pickerParams.set('accessToken', payload.accessToken);
287+
}
288+
if (payload.accessTokenExpiresAt) {
289+
pickerParams.set('accessTokenExpiresAt', `${payload.accessTokenExpiresAt}`);
290+
}
291+
const pickerUrl = `${ENV.SERVER_URL}/desktop-app/google-picker?${pickerParams.toString()}`;
292+
293+
await shell.openExternal(pickerUrl);
294+
295+
const handleCallback = async (queryParams: Record<string, string>) => {
296+
try {
297+
const parsed = z
298+
.discriminatedUnion('status', [
299+
z.object({
300+
nonce: z.literal(nonce),
301+
status: z.literal('success'),
302+
mode: z.enum(['file', 'folder', 'auth']).default(mode),
303+
googleAccessToken: z.string(),
304+
googleAccessTokenExpiresAt: z.coerce.number().optional(),
305+
fileId: z.string().optional(),
306+
fileName: z.string().optional(),
307+
mimeType: z.string().optional(),
308+
folderId: z.string().optional(),
309+
folderName: z.string().optional(),
310+
}),
311+
z.object({
312+
nonce: z.literal(nonce),
313+
status: z.literal('cancelled'),
314+
}),
315+
z.object({
316+
nonce: z.literal(nonce),
317+
status: z.literal('error'),
318+
errorMessage: z.string().optional(),
319+
}),
320+
])
321+
.parse(queryParams);
322+
323+
let result: GooglePickerResult;
324+
325+
if (parsed.status === 'cancelled') {
326+
result = { status: 'cancelled' };
327+
} else if (parsed.status === 'error') {
328+
result = { status: 'error', error: parsed.errorMessage || 'Unknown error' };
329+
} else {
330+
result = {
331+
status: 'success',
332+
mode: parsed.mode || mode,
333+
googleAccessToken: parsed.googleAccessToken,
334+
googleAccessTokenExpiresAt: parsed.googleAccessTokenExpiresAt,
335+
fileId: parsed.fileId,
336+
fileName: parsed.fileName,
337+
mimeType: parsed.mimeType,
338+
folderId: parsed.folderId,
339+
folderName: parsed.folderName,
340+
};
341+
}
342+
343+
event.sender.send(IpcEventChannel.googlePickerResult, result);
344+
} catch (ex) {
345+
logger.error('Error handling Google Picker callback', ex);
346+
const result: GooglePickerResult = { status: 'error', error: 'Invalid response from Google Picker' };
347+
event.sender.send(IpcEventChannel.googlePickerResult, result);
348+
} finally {
349+
clearTimeout(timeout);
350+
}
351+
};
352+
353+
deepLink.once('googlePicker', handleCallback);
354+
355+
// Remove the listener if it was not already removed - e.g. picker flow did not complete within 15 minutes
356+
const timeout = setTimeout(() => {
357+
deepLink.remove('googlePicker', handleCallback);
358+
}, 900000); // 15 minutes in milliseconds
359+
};
360+
250361
const handleCheckAuthEvent: MainIpcHandler<'checkAuth'> = async (): Promise<
251362
{ userProfile: UserProfileUi; authInfo: DesktopAuthInfo } | undefined
252363
> => {

apps/jetstream-desktop/src/utils/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const CspPolicy = {
1818
`ws://${SERVER_URL.host}`,
1919
`wss://${SERVER_URL.host}`,
2020
'https://*.salesforce.com',
21+
'https://www.googleapis.com',
2122
],
2223
'font-src': [`'self'`, 'data:'],
2324
'frame-ancestors': ["'self'", 'getjetstream.app', '*.google.com', '*.googleapis.com', '*.gstatic.com'],

apps/jetstream-web-extension/src/core/AppInitializer.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ import { getErrorMessage } from '@jetstream/shared/utils';
77
import { JetstreamEventSaveSoqlQueryFormatOptionsPayload, UserProfileUi } from '@jetstream/types';
88
import { ScopedNotification } from '@jetstream/ui';
99
import { AppLoading, fromJetstreamEvents } from '@jetstream/ui-core';
10+
import { getDefaultAppState } from '@jetstream/shared/utils';
1011
import { fromAppState } from '@jetstream/ui/app-state';
1112
import { initDexieDb } from '@jetstream/ui/db';
1213
import { useObservable } from 'dexie-react-hooks';
14+
import { getBrowserExtensionVersion } from '@jetstream/shared/ui-utils';
1315
import { useAtomValue, useSetAtom } from 'jotai';
1416
import localforage from 'localforage';
1517
import React, { FunctionComponent, useEffect, useState } from 'react';
1618
import { useLocation } from 'react-router-dom';
1719
import { Observable } from 'rxjs';
1820
import browser from 'webextension-polyfill';
21+
import { environment } from '../environments/environment';
1922
import { chromeLocalStorage, chromeSyncStorage, UserProfileState } from '../utils/extension.store';
2023
import { sendMessage } from '../utils/web-extension.utils';
2124
import { GlobalExtensionError } from './GlobalExtensionError';
@@ -41,6 +44,7 @@ export const AppInitializer: FunctionComponent<AppInitializerProps> = ({ allowWi
4144
const { options } = useAtomValue(chromeLocalStorage);
4245
const chromeUserProfile = useAtomValue(UserProfileState);
4346
const { serverUrl } = useAtomValue(fromAppState.applicationCookieState);
47+
const setAppInfo = useSetAtom(fromAppState.appInfoState);
4448
const setUserProfile = useSetAtom(fromAppState.userProfileState);
4549

4650
const setSelectedOrgId = useSetAtom(fromAppState.selectedOrgIdState);
@@ -53,6 +57,19 @@ export const AppInitializer: FunctionComponent<AppInitializerProps> = ({ allowWi
5357

5458
const [fatalError, setFatalError] = useState<string>();
5559

60+
// Ensure the appInfoState has the correct serverUrl from the extension environment
61+
// The default is 'https://getjetstream.app' which is incorrect in dev
62+
useEffect(() => {
63+
setAppInfo({
64+
appInfo: getDefaultAppState({
65+
serverUrl: environment.serverUrl,
66+
environment: environment.production ? 'production' : 'development',
67+
}),
68+
version: getBrowserExtensionVersion(),
69+
announcements: [],
70+
});
71+
}, [setAppInfo]);
72+
5673
useEffect(() => {
5774
// wait until this data has initialized before proceeding
5875
if (!authTokens?.accessToken || !extIdentifier?.id) {

0 commit comments

Comments
 (0)