diff --git a/application/parser/connectors/google_drive/auth.py b/application/parser/connectors/google_drive/auth.py index 7b920815..8e4983db 100644 --- a/application/parser/connectors/google_drive/auth.py +++ b/application/parser/connectors/google_drive/auth.py @@ -18,7 +18,7 @@ class GoogleDriveAuth(BaseConnectorAuth): """ SCOPES = [ - 'https://www.googleapis.com/auth/drive.file' + 'https://www.googleapis.com/auth/drive.readonly' ] def __init__(self): @@ -43,23 +43,27 @@ class GoogleDriveAuth(BaseConnectorAuth): "redirect_uris": [self.redirect_uri] } }, - scopes=self.SCOPES + scopes=self.SCOPES, + # Flow is rebuilt at exchange time, so an auto-generated + # code_verifier wouldn't survive; confidential client doesn't + # need PKCE anyway. + autogenerate_code_verifier=False, ) flow.redirect_uri = self.redirect_uri - + authorization_url, _ = flow.authorization_url( access_type='offline', prompt='consent', include_granted_scopes='false', state=state ) - + return authorization_url - + except Exception as e: logging.error(f"Error generating authorization URL: {e}") raise - + def exchange_code_for_tokens(self, authorization_code: str) -> Dict[str, Any]: try: if not authorization_code: @@ -75,7 +79,8 @@ class GoogleDriveAuth(BaseConnectorAuth): "redirect_uris": [self.redirect_uri] } }, - scopes=self.SCOPES + scopes=self.SCOPES, + autogenerate_code_verifier=False, ) flow.redirect_uri = self.redirect_uri diff --git a/application/parser/connectors/google_drive/loader.py b/application/parser/connectors/google_drive/loader.py index 9605517c..a6ade702 100644 --- a/application/parser/connectors/google_drive/loader.py +++ b/application/parser/connectors/google_drive/loader.py @@ -175,7 +175,8 @@ class GoogleDriveLoader(BaseConnectorLoader): try: file_metadata = self.service.files().get( fileId=file_id, - fields='id,name,mimeType,size,createdTime,modifiedTime,parents' + fields='id,name,mimeType,size,createdTime,modifiedTime,parents', + supportsAllDrives=True ).execute() return self._process_file(file_metadata, load_content=load_content) @@ -228,7 +229,9 @@ class GoogleDriveLoader(BaseConnectorLoader): fields='nextPageToken,files(id,name,mimeType,size,createdTime,modifiedTime,parents)', pageToken=page_token, pageSize=page_size, - orderBy='name' + orderBy='name', + supportsAllDrives=True, + includeItemsFromAllDrives=True ).execute() items = results.get('files', []) @@ -308,7 +311,7 @@ class GoogleDriveLoader(BaseConnectorLoader): mimeType=export_mime_type ) else: - request = self.service.files().get_media(fileId=file_id) + request = self.service.files().get_media(fileId=file_id, supportsAllDrives=True) file_io = io.BytesIO() downloader = MediaIoBaseDownload(file_io, request) @@ -378,7 +381,8 @@ class GoogleDriveLoader(BaseConnectorLoader): def _download_single_file(self, file_id: str, local_dir: str) -> bool: file_metadata = self.service.files().get( fileId=file_id, - fields='name,mimeType' + fields='name,mimeType', + supportsAllDrives=True ).execute() file_name = file_metadata['name'] @@ -400,7 +404,7 @@ class GoogleDriveLoader(BaseConnectorLoader): if not full_path.endswith(extension): full_path += extension else: - request = self.service.files().get_media(fileId=file_id) + request = self.service.files().get_media(fileId=file_id, supportsAllDrives=True) with open(full_path, 'wb') as f: downloader = MediaIoBaseDownload(f, request) @@ -423,7 +427,9 @@ class GoogleDriveLoader(BaseConnectorLoader): q=query, fields='nextPageToken, files(id, name, mimeType)', pageToken=page_token, - pageSize=1000 + pageSize=1000, + supportsAllDrives=True, + includeItemsFromAllDrives=True ).execute() items = results.get('files', []) @@ -516,7 +522,8 @@ class GoogleDriveLoader(BaseConnectorLoader): try: folder_metadata = self.service.files().get( fileId=folder_id, - fields='name' + fields='name', + supportsAllDrives=True ).execute() folder_name = folder_metadata.get('name', '') folder_path = os.path.join(local_dir, folder_name) diff --git a/frontend/.env.development b/frontend/.env.development index f5dc2bb1..45456275 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -2,6 +2,6 @@ VITE_BASE_URL=http://localhost:5173 VITE_API_HOST=http://127.0.0.1:7091 VITE_API_STREAMING=true -VITE_NOTIFICATION_TEXT="What's new in 0.15.0 — Changelog" -VITE_NOTIFICATION_LINK="https://blog.docsgpt.cloud/docsgpt-0-15-masters-long-term-memory-and-tooling/" +VITE_NOTIFICATION_TEXT="What's new in 0.17 — Changelog" +VITE_NOTIFICATION_LINK="https://blog.docsgpt.cloud/docsgpt-0-17-production-grade-from-the-ground-up/" VITE_GOOGLE_CLIENT_ID=896376503572-u46l78n8ctgtdr4dlei4u06jv6rbpqc5.apps.googleusercontent.com diff --git a/frontend/src/agents/schedules/ScheduleFormModal.tsx b/frontend/src/agents/schedules/ScheduleFormModal.tsx index ec73c853..1b7c48c4 100644 --- a/frontend/src/agents/schedules/ScheduleFormModal.tsx +++ b/frontend/src/agents/schedules/ScheduleFormModal.tsx @@ -199,6 +199,7 @@ export default function ScheduleFormModal({ : t('agents.schedules.modal.create') } size="md" + mobileVariant="sheet" className="w-[min(560px,92vw)] sm:p-6" contentClassName="max-h-[80vh]" > diff --git a/frontend/src/components/ConnectorAuth.tsx b/frontend/src/components/ConnectorAuth.tsx index a530d545..c2bb0192 100644 --- a/frontend/src/components/ConnectorAuth.tsx +++ b/frontend/src/components/ConnectorAuth.tsx @@ -78,81 +78,109 @@ const ConnectorAuth: React.FC = ({ } }; - const handleAuth = async () => { - try { - completedRef.current = false; - // Close any popup left over from a previous click before wiping - // the ref — otherwise the old window keeps living with no - // interval watching it and no listener handling its messages. - if (authWindowRef.current && !authWindowRef.current.closed) { - authWindowRef.current.close(); - } - authWindowRef.current = null; - cleanup(); - - const authResponse = await userService.getConnectorAuthUrl( - provider, - token, - ); - if (!mountedRef.current) return; - - if (!authResponse.ok) { - throw new Error( - `${t('modals.uploadDoc.connectors.auth.authUrlFailed')}: ${authResponse.status}`, - ); - } - - const authData = await authResponse.json(); - if (!mountedRef.current) return; - if (!authData.success || !authData.authorization_url) { - throw new Error( - authData.error || t('modals.uploadDoc.connectors.auth.authUrlFailed'), - ); - } - - const authWindow = window.open( - authData.authorization_url, - `${provider}-auth`, - 'width=500,height=600,scrollbars=yes,resizable=yes', - ); - if (!authWindow) { - throw new Error(t('modals.uploadDoc.connectors.auth.popupBlocked')); - } - authWindowRef.current = authWindow; - - messageHandlerRef.current = handleAuthMessage; - window.addEventListener('message', handleAuthMessage as any); - - const checkClosed = window.setInterval(() => { - if (authWindow.closed) { - clearInterval(checkClosed); - intervalRef.current = null; - if (messageHandlerRef.current) { - window.removeEventListener( - 'message', - messageHandlerRef.current as any, - ); - messageHandlerRef.current = null; - } - authWindowRef.current = null; - if (!completedRef.current) { - onError(t('modals.uploadDoc.connectors.auth.authCancelled')); - } - } - }, 1000); - intervalRef.current = checkClosed; - } catch (error) { - if (!mountedRef.current) return; - onError( - error instanceof Error - ? error.message - : t('modals.uploadDoc.connectors.auth.authFailed'), - ); + const handleAuth = () => { + completedRef.current = false; + // Close any popup left over from a previous click before wiping + // the ref — otherwise the old window keeps living with no + // interval watching it and no listener handling its messages. + if (authWindowRef.current && !authWindowRef.current.closed) { + authWindowRef.current.close(); } + authWindowRef.current = null; + cleanup(); + + // Synchronous popup inside the click; navigated below after the + // async URL fetch resolves. Otherwise Safari blocks it as non-gesture. + const authWindow = window.open( + 'about:blank', + `${provider}-auth`, + 'width=500,height=600,scrollbars=yes,resizable=yes', + ); + if (!authWindow) { + onError(t('modals.uploadDoc.connectors.auth.popupBlocked')); + return; + } + authWindowRef.current = authWindow; + + (async () => { + try { + const authResponse = await userService.getConnectorAuthUrl( + provider, + token, + ); + if (!mountedRef.current) { + authWindow.close(); + return; + } + + if (!authResponse.ok) { + authWindow.close(); + throw new Error( + `${t('modals.uploadDoc.connectors.auth.authUrlFailed')}: ${authResponse.status}`, + ); + } + + const authData = await authResponse.json(); + if (!mountedRef.current) { + authWindow.close(); + return; + } + if (!authData.success || !authData.authorization_url) { + authWindow.close(); + throw new Error( + authData.error || + t('modals.uploadDoc.connectors.auth.authUrlFailed'), + ); + } + + if (authWindow.closed) { + // User closed the placeholder before we resolved. + authWindowRef.current = null; + onError(t('modals.uploadDoc.connectors.auth.authCancelled')); + return; + } + authWindow.location.href = authData.authorization_url; + + messageHandlerRef.current = handleAuthMessage; + window.addEventListener('message', handleAuthMessage as any); + + const checkClosed = window.setInterval(() => { + if (authWindow.closed) { + clearInterval(checkClosed); + intervalRef.current = null; + if (messageHandlerRef.current) { + window.removeEventListener( + 'message', + messageHandlerRef.current as any, + ); + messageHandlerRef.current = null; + } + authWindowRef.current = null; + if (!completedRef.current) { + onError(t('modals.uploadDoc.connectors.auth.authCancelled')); + } + } + }, 1000); + intervalRef.current = checkClosed; + } catch (error) { + if (!authWindow.closed) { + authWindow.close(); + } + authWindowRef.current = null; + if (!mountedRef.current) return; + onError( + error instanceof Error + ? error.message + : t('modals.uploadDoc.connectors.auth.authFailed'), + ); + } + })(); }; - // Release interval, message listener, and popup on unmount only. useEffect(() => { + // Re-arm on mount; React 19 strict-mode's double-invoke would otherwise + // leave this stuck at false from the prior cleanup, aborting later awaits. + mountedRef.current = true; return () => { mountedRef.current = false; cleanup(); diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index 0b278915..96dc9142 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -1,6 +1,12 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import useDrivePicker from 'react-google-drive-picker'; +import drivePickerImport from 'react-google-drive-picker'; + +// Vite 8 CJS interop returns the namespace object for this lib instead of +// unwrapping `default`; tolerate both shapes. +const useDrivePicker = (( + drivePickerImport as unknown as { default?: typeof drivePickerImport } +).default ?? drivePickerImport) as typeof drivePickerImport; import userService from '../api/services/userService'; import ConnectorAuth from './ConnectorAuth'; @@ -116,6 +122,8 @@ const GoogleDrivePicker: React.FC = ({ try { const clientId: string = import.meta.env.VITE_GOOGLE_CLIENT_ID; + const developerKey: string = + import.meta.env.VITE_GOOGLE_PICKER_API_KEY ?? ''; // Derive appId from clientId (extract numeric part before first dash) const appId = clientId ? clientId.split('-')[0] : null; @@ -129,7 +137,7 @@ const GoogleDrivePicker: React.FC = ({ openPicker({ clientId: clientId, - developerKey: '', + developerKey, appId: appId, setSelectFolderEnabled: false, viewId: 'DOCS', diff --git a/frontend/src/components/MultiSelectPopover.tsx b/frontend/src/components/MultiSelectPopover.tsx index 68fc7665..9a41fe7f 100644 --- a/frontend/src/components/MultiSelectPopover.tsx +++ b/frontend/src/components/MultiSelectPopover.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import CheckmarkIcon from '../assets/checkmark.svg'; import NoFilesDarkIcon from '../assets/no-files-dark.svg'; import NoFilesIcon from '../assets/no-files.svg'; -import { useDarkTheme } from '../hooks'; +import { useDarkTheme, useMediaQuery } from '../hooks'; import { cn } from '@/lib/utils'; import Spinner from './Spinner'; import { @@ -16,6 +16,7 @@ import { CommandList, } from './ui/command'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from './ui/sheet'; export type MultiSelectPopoverItem = { id: string; @@ -84,6 +85,7 @@ export function MultiSelectPopover({ }: MultiSelectPopoverProps) { const { t } = useTranslation(); const [isDarkTheme] = useDarkTheme(); + const { isMobile } = useMediaQuery(); const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]); const orderedItems = React.useMemo(() => { @@ -175,6 +177,86 @@ export function MultiSelectPopover({ ); + const commandBody = ( + + {title && ( +
+

+ {title} +

+
+ )} + {searchable && ( +
+ +
+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ + {renderEmptyState()} + {hasGroups ? ( + grouped.groupOrder.map((groupKey) => { + const groupItems = grouped.map.get(groupKey) || []; + if (groupItems.length === 0) return null; + return ( + + {groupItems.map(renderItem)} + + ); + }) + ) : ( + + {orderedItems.map(renderItem)} + + )} + +
+ )} + + {footer && ( +
+ {footer} +
+ )} +
+ ); + + if (isMobile) { + return ( + + {trigger} + e.preventDefault()} + className={cn( + 'border-border bg-background dark:border-border dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]', + contentClassName, + )} + > + + {title || effectivePlaceholder} + +