/** * Google Drive Picker Service * Handles Google Drive file picker integration */ import { loadScript } from '@app/utils/scriptLoader'; const SCOPES = 'https://www.googleapis.com/auth/drive.readonly'; const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken'; interface GoogleDriveConfig { clientId: string; apiKey: string; appId: string; } interface BackendGoogleDriveConfig { enabled?: boolean; clientId?: string; apiKey?: string; appId?: string; } interface PickerOptions { multiple?: boolean; mimeTypes?: string | null; } // Expandable mime types for Google Picker const expandableMimeTypes: Record = { 'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'], }; /** * Convert file input accept attribute to Google Picker mime types */ function fileInputToGooglePickerMimeTypes(accept?: string): string | null { if (!accept || accept === '' || accept.includes('*/*')) { // Setting null will accept all supported mimetypes return null; } const mimeTypes: string[] = []; accept.split(',').forEach((part) => { const trimmedPart = part.trim(); if (!(trimmedPart in expandableMimeTypes)) { mimeTypes.push(trimmedPart); return; } expandableMimeTypes[trimmedPart].forEach((mimeType) => { mimeTypes.push(mimeType); }); }); return mimeTypes.join(',').replace(/\s+/g, ''); } class GoogleDrivePickerService { private config: GoogleDriveConfig | null = null; private tokenClient: any = null; private accessToken: string | null = null; private gapiLoaded = false; private gisLoaded = false; constructor() { this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID); } /** * Initialize the service with credentials */ async initialize(config: GoogleDriveConfig): Promise { this.config = config; // Load Google APIs await Promise.all([ this.loadGapi(), this.loadGis(), ]); } /** * Load Google API client */ private async loadGapi(): Promise { if (this.gapiLoaded) return; await loadScript({ src: 'https://apis.google.com/js/api.js', id: 'gapi-script', }); return new Promise((resolve) => { window.gapi.load('client:picker', async () => { await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'); this.gapiLoaded = true; resolve(); }); }); } /** * Load Google Identity Services */ private async loadGis(): Promise { if (this.gisLoaded) return; await loadScript({ src: 'https://accounts.google.com/gsi/client', id: 'gis-script', }); if (!this.config) { throw new Error('Google Drive config not initialized'); } this.tokenClient = window.google.accounts.oauth2.initTokenClient({ client_id: this.config.clientId, scope: SCOPES, callback: () => {}, // Will be overridden during picker creation }); this.gisLoaded = true; } /** * Open the Google Drive picker */ async openPicker(options: PickerOptions = {}): Promise { if (!this.config) { throw new Error('Google Drive service not initialized'); } // Request access token await this.requestAccessToken(); // Create and show picker return this.createPicker(options); } /** * Request access token from Google */ private requestAccessToken(): Promise { return new Promise((resolve, reject) => { if (!this.tokenClient) { reject(new Error('Token client not initialized')); return; } this.tokenClient.callback = (response: any) => { if (response.error !== undefined) { reject(new Error(response.error)); return; } if(response.access_token == null){ reject(new Error("No acces token in response")); } this.accessToken = response.access_token; sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? ""); resolve(); }; this.tokenClient.requestAccessToken({ prompt: this.accessToken === null ? 'consent' : '', }); }); } /** * Create and display the Google Picker */ private createPicker(options: PickerOptions): Promise { return new Promise((resolve, reject) => { if (!this.config || !this.accessToken) { reject(new Error('Not initialized or no access token')); return; } const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined); const view1 = new window.google.picker.DocsView().setIncludeFolders(true); if (mimeTypes !== null) { view1.setMimeTypes(mimeTypes); } const view2 = new window.google.picker.DocsView() .setIncludeFolders(true) .setEnableDrives(true); if (mimeTypes !== null) { view2.setMimeTypes(mimeTypes); } const builder = new window.google.picker.PickerBuilder() .setDeveloperKey(this.config.apiKey) .setAppId(this.config.appId) .setOAuthToken(this.accessToken) .addView(view1) .addView(view2) .setCallback((data: any) => this.pickerCallback(data, resolve, reject)); if (options.multiple) { builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED); } const picker = builder.build(); picker.setVisible(true); }); } /** * Handle picker selection callback */ private async pickerCallback( data: any, resolve: (files: File[]) => void, reject: (error: Error) => void ): Promise { if (data.action === window.google.picker.Action.PICKED) { try { const files = await Promise.all( data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => { const fileId = pickedFile[window.google.picker.Document.ID]; const res = await window.gapi.client.drive.files.get({ fileId: fileId, alt: 'media', }); // Convert response body to File object const file = new File( [new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))], pickedFile.name, { type: pickedFile.mimeType, lastModified: pickedFile.lastModified, } ); return file; }) ); resolve(files); } catch (error) { reject(error instanceof Error ? error : new Error('Failed to download files')); } } else if (data.action === window.google.picker.Action.CANCEL) { resolve([]); // User cancelled, return empty array } } /** * Sign out and revoke access token */ signOut(): void { if (this.accessToken) { sessionStorage.removeItem(SESSION_STORAGE_ID); window.google?.accounts.oauth2.revoke(this.accessToken, () => {}); this.accessToken = null; } } } // Singleton instance let serviceInstance: GoogleDrivePickerService | null = null; /** * Get or create the Google Drive picker service instance */ export function getGoogleDrivePickerService(): GoogleDrivePickerService { if (!serviceInstance) { serviceInstance = new GoogleDrivePickerService(); } return serviceInstance; } /** * Check if Google Drive credentials are configured * Supports both backend settings and environment variables */ export function isGoogleDriveConfigured(backendConfig?: BackendGoogleDriveConfig): boolean { // If backend config is provided, use it exclusively (don't fall back to env vars) if (backendConfig) { if (backendConfig.enabled === false) { return false; // Admin explicitly disabled it } if (backendConfig.enabled) { return !!(backendConfig.clientId && backendConfig.apiKey && backendConfig.appId); } return false; // No backend config provided } // Fall back to environment variables only when backend config is not available const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID; const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY; const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID; return !!(clientId && apiKey && appId); } /** * Get Google Drive configuration from backend settings or environment variables * Backend settings take priority over environment variables */ export function getGoogleDriveConfig(backendConfig?: BackendGoogleDriveConfig): GoogleDriveConfig | null { // If backend config is provided, use it exclusively (don't fall back to env vars) if (backendConfig) { if (backendConfig.enabled === false) { return null; // Admin explicitly disabled it } if (backendConfig.enabled && backendConfig.clientId && backendConfig.apiKey && backendConfig.appId) { return { clientId: backendConfig.clientId, apiKey: backendConfig.apiKey, appId: backendConfig.appId, }; } return null; // Backend config provided but incomplete } // Fall back to environment variables only when backend config is not available const envClientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID; const envApiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY; const envAppId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID; if (envClientId && envApiKey && envAppId) { return { clientId: envClientId, apiKey: envApiKey, appId: envAppId, }; } return null; } /** * Extract Google Drive backend config from AppConfig object * Eliminates duplicated config construction pattern */ export function extractGoogleDriveBackendConfig(appConfig: any): BackendGoogleDriveConfig { return { enabled: appConfig?.googleDriveEnabled, clientId: appConfig?.googleDriveClientId, apiKey: appConfig?.googleDriveApiKey, appId: appConfig?.googleDriveAppId, }; }