feat(google-docs): use Google Picker + drive.file scope instead of full-drive listing

This commit is contained in:
Gagancreates 2026-06-01 23:47:27 +05:30
parent 505a9a27e8
commit d08bf49d5a
6 changed files with 229 additions and 174 deletions

View file

@ -52,7 +52,7 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
import { getGoogleDocsConnectionStatus, importGoogleDoc, getGoogleAccessToken, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
@ -815,8 +815,8 @@ export function setupIpcHandlers() {
'google-docs:getStatus': async () => {
return getGoogleDocsConnectionStatus();
},
'google-docs:list': async (_event, args) => {
return listGoogleDocs(args.query);
'google-docs:getAccessToken': async () => {
return { accessToken: await getGoogleAccessToken() };
},
'google-docs:import': async (_event, args) => {
return importGoogleDoc(args.fileId, args.targetFolder);

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FileText, Loader2, RefreshCw, Search } from 'lucide-react'
import { FileText, Loader2, RefreshCw } from 'lucide-react'
import {
Dialog,
@ -12,17 +12,9 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { GoogleClientIdModal } from '@/components/google-client-id-modal'
import { setGoogleCredentials } from '@/lib/google-credentials-store'
import { formatRelativeTime } from '@/lib/relative-time'
import { openGooglePicker, getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker'
import { toast } from '@/lib/toast'
type GoogleDocListItem = {
id: string
name: string
url: string
modifiedTime: string | null
owner: string | null
}
type GoogleDocsStatus = {
connected: boolean
hasRequiredScopes: boolean
@ -36,11 +28,6 @@ type GoogleDocPickerDialogProps = {
onImported: (path: string) => void
}
function formatModified(modifiedTime: string | null): string {
if (!modifiedTime) return ''
return formatRelativeTime(modifiedTime)
}
export function GoogleDocPickerDialog({
open,
targetFolder,
@ -48,15 +35,13 @@ export function GoogleDocPickerDialog({
onImported,
}: GoogleDocPickerDialogProps) {
const [status, setStatus] = useState<GoogleDocsStatus | null>(null)
const [query, setQuery] = useState('')
const [docs, setDocs] = useState<GoogleDocListItem[]>([])
const [loading, setLoading] = useState(false)
const [connecting, setConnecting] = useState(false)
const [importingId, setImportingId] = useState<string | null>(null)
const [opening, setOpening] = useState(false)
const [error, setError] = useState<string | null>(null)
const [byokOpen, setByokOpen] = useState(false)
const [apiKey, setApiKey] = useState('')
const canList = Boolean(status?.connected && status.hasRequiredScopes)
const canPick = Boolean(status?.connected && status.hasRequiredScopes)
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
const loadStatus = useCallback(async () => {
@ -70,36 +55,13 @@ export function GoogleDocPickerDialog({
}
}, [])
const loadDocs = useCallback(async (searchQuery: string) => {
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke('google-docs:list', { query: searchQuery.trim() || undefined })
setDocs(result.files)
} catch (err) {
setDocs([])
setError(err instanceof Error ? err.message : 'Failed to load Google Docs')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
setQuery('')
setDocs([])
setError(null)
setApiKey(getStoredPickerApiKey())
void loadStatus()
}, [loadStatus, open])
useEffect(() => {
if (!open || !canList) return
const timeout = window.setTimeout(() => {
void loadDocs(query)
}, 250)
return () => window.clearTimeout(timeout)
}, [canList, loadDocs, open, query])
const handleConnect = useCallback(async () => {
setConnecting(true)
setError(null)
@ -117,9 +79,9 @@ export function GoogleDocPickerDialog({
}
}, [])
// BYOK: connect Google with the user's own OAuth client. Unlike the managed
// (rowboat) sign-in, this local flow requests the Drive + Docs scopes, so a
// signed-in user can actually grant Docs access without a backend change.
// BYOK: connect Google with the user's own OAuth client, which requests the
// drive.file scope locally (managed sign-in can't grant it without a backend
// change).
const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setByokOpen(false)
@ -144,38 +106,68 @@ export function GoogleDocPickerDialog({
return cleanup
}, [open, loadStatus])
const handleImport = useCallback(async (doc: GoogleDocListItem) => {
setImportingId(doc.id)
const handleChoose = useCallback(async () => {
setError(null)
try {
const result = await window.ipc.invoke('google-docs:import', {
fileId: doc.id,
targetFolder,
})
toast('Google Doc added', 'success')
onImported(result.path)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import Google Doc')
} finally {
setImportingId(null)
const key = apiKey.trim()
if (!key) {
setError('Enter your Google Picker API key first.')
return
}
}, [onImported, onOpenChange, targetFolder])
setStoredPickerApiKey(key)
setOpening(true)
let accessToken: string | null = null
try {
const res = await window.ipc.invoke('google-docs:getAccessToken', null)
accessToken = res.accessToken
} catch (err) {
setOpening(false)
setError(err instanceof Error ? err.message : 'Failed to get a Google access token')
return
}
if (!accessToken) {
setOpening(false)
setError('Google access token unavailable — reconnect Google.')
return
}
// Hand off to Google's Picker; close our modal so it isn't trapped behind it.
onOpenChange(false)
try {
await openGooglePicker({
accessToken,
apiKey: key,
onPicked: async (file) => {
try {
const result = await window.ipc.invoke('google-docs:import', { fileId: file.id, targetFolder })
toast(`Added “${file.name}`, 'success')
onImported(result.path)
} catch (err) {
toast(err instanceof Error ? err.message : 'Failed to import the document', 'error')
}
},
})
} catch (err) {
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
} finally {
setOpening(false)
}
}, [apiKey, targetFolder, onImported, onOpenChange])
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-2xl flex-col gap-0 overflow-hidden p-0">
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
<DialogTitle>Add Google Doc</DialogTitle>
<DialogDescription>
Select a Google Doc to link into {targetLabel}.
Link a Google Doc or Word file from Drive into {targetLabel}.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col">
{!status && error ? (
<div className="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<div className="max-w-sm text-sm text-destructive">{error}</div>
<Button variant="outline" onClick={() => void loadStatus()}>
<RefreshCw className="size-4" />
@ -183,14 +175,14 @@ export function GoogleDocPickerDialog({
</Button>
</div>
) : !status ? (
<div className="flex min-h-[320px] flex-1 items-center justify-center text-sm text-muted-foreground">
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Checking Google connection...
</div>
) : !status.connected || !status.hasRequiredScopes ? (
<div className="flex min-h-[360px] flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-8 py-8 text-center">
) : !canPick ? (
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-8 py-8 text-center">
<div className="max-w-sm text-sm text-muted-foreground">
To choose Google Docs, Rowboat needs Drive&nbsp;+&nbsp;Docs access.
To choose a document, Rowboat needs per-file Drive access (the <code>drive.file</code> scope).
</div>
{status.missingScopes.length > 0 && (
<div className="max-w-md rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs text-muted-foreground">
@ -207,65 +199,42 @@ export function GoogleDocPickerDialog({
</Button>
</div>
<p className="max-w-sm text-xs text-muted-foreground">
Managed sign-in may not grant Docs access yet. If it keeps asking for scopes,
connect a Google OAuth client (Desktop app) with the Drive&nbsp;API and Docs&nbsp;API enabled.
Managed sign-in may not grant Drive access yet. If it keeps asking for scopes,
connect a Google OAuth client (Desktop app) with the Drive&nbsp;API and Picker&nbsp;API enabled.
</p>
</div>
) : (
<>
<div className="shrink-0 border-b border-border p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search Google Docs"
className="pl-9"
autoFocus
/>
</div>
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
<div className="max-w-sm text-sm text-muted-foreground">
Pick a Google Doc or Word file from your Drive. It imports as an editable
<code> .docx</code> and stays linked for two-way sync.
</div>
<div className="flex w-full max-w-sm flex-col gap-1.5 text-left">
<label htmlFor="picker-api-key" className="text-xs font-medium text-muted-foreground">
Google Picker API key
</label>
<Input
id="picker-api-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="AIza…"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
From your Google Cloud project (APIs &amp; Services Credentials API key),
with the Picker&nbsp;API enabled. Stored locally.
</p>
</div>
{error && (
<div className="mx-4 mt-4 shrink-0 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="min-h-[280px] flex-1 overflow-y-auto p-2">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Loading Docs...
</div>
) : docs.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No Google Docs found.
</div>
) : (
<div className="space-y-1">
{docs.map((doc) => (
<button
key={doc.id}
type="button"
onClick={() => void handleImport(doc)}
disabled={importingId !== null}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent disabled:opacity-60"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{doc.name}</div>
<div className="truncate text-xs text-muted-foreground">
{[doc.owner, formatModified(doc.modifiedTime)].filter(Boolean).join(' · ')}
</div>
</div>
{importingId === doc.id && <Loader2 className="size-4 shrink-0 animate-spin" />}
</button>
))}
</div>
)}
</div>
</>
<Button onClick={() => void handleChoose()} disabled={opening}>
{opening ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
Choose from Google Drive
</Button>
</div>
)}
</div>
</DialogContent>
@ -274,7 +243,7 @@ export function GoogleDocPickerDialog({
open={byokOpen}
onOpenChange={setByokOpen}
onSubmit={handleByokSubmit}
description="Enter a Google OAuth client (Desktop app) with the Drive API and Docs API enabled to grant Docs access."
description="Enter a Google OAuth client (Desktop app) with the Drive API and Picker API enabled."
/>
</>
)

View file

@ -0,0 +1,117 @@
// Loader + thin wrapper around the Google Picker JS API. File selection happens
// inside Google's hosted Picker (so the app needs only drive.file, not a broad
// listing scope); the Picker hands back the chosen file id.
export type PickedFile = { id: string; name: string; mimeType: string }
type GapiGlobal = {
load: (lib: string, config: { callback: () => void; onerror?: () => void }) => void
}
type PickerView = {
setIncludeFolders: (b: boolean) => PickerView
setOwnedByMe: (b: boolean) => PickerView
setMimeTypes: (m: string) => PickerView
}
type PickerCallbackData = {
action: string
docs?: Array<{ id: string; name: string; mimeType: string }>
}
type PickerBuilder = {
addView: (v: PickerView) => PickerBuilder
setOAuthToken: (t: string) => PickerBuilder
setDeveloperKey: (k: string) => PickerBuilder
setAppId: (id: string) => PickerBuilder
setTitle: (t: string) => PickerBuilder
setCallback: (cb: (data: PickerCallbackData) => void) => PickerBuilder
build: () => { setVisible: (v: boolean) => void }
}
type GooglePickerNS = {
DocsView: new () => PickerView
PickerBuilder: new () => PickerBuilder
Action: { PICKED: string; CANCEL: string }
}
declare global {
interface Window {
gapi?: GapiGlobal
google?: { picker?: GooglePickerNS }
}
}
const API_JS = 'https://apis.google.com/js/api.js'
const DOC_MIME = 'application/vnd.google-apps.document'
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const API_KEY_STORAGE = 'rowboat:google-picker-api-key'
let pickerLoaded: Promise<void> | null = null
function loadPickerApi(): Promise<void> {
if (pickerLoaded) return pickerLoaded
pickerLoaded = new Promise<void>((resolve, reject) => {
const start = () => {
if (!window.gapi) { reject(new Error('Google API failed to initialize')); return }
window.gapi.load('picker', {
callback: () => resolve(),
onerror: () => reject(new Error('Google Picker failed to load')),
})
}
const existing = document.querySelector<HTMLScriptElement>(`script[src="${API_JS}"]`)
if (existing) {
if (window.gapi) start()
else existing.addEventListener('load', start)
return
}
const script = document.createElement('script')
script.src = API_JS
script.async = true
script.onload = start
script.onerror = () => reject(new Error('Failed to load the Google API script'))
document.head.appendChild(script)
})
return pickerLoaded
}
export function getStoredPickerApiKey(): string {
try { return localStorage.getItem(API_KEY_STORAGE) ?? '' } catch { return '' }
}
export function setStoredPickerApiKey(key: string): void {
try { localStorage.setItem(API_KEY_STORAGE, key.trim()) } catch { /* ignore */ }
}
export async function openGooglePicker(opts: {
accessToken: string
apiKey?: string
appId?: string
onPicked: (file: PickedFile) => void
onCancel?: () => void
}): Promise<void> {
await loadPickerApi()
const picker = window.google?.picker
if (!picker) throw new Error('Google Picker is unavailable')
const view = new picker.DocsView()
.setIncludeFolders(false)
.setOwnedByMe(false)
.setMimeTypes(`${DOC_MIME},${DOCX_MIME}`)
const builder = new picker.PickerBuilder()
.addView(view)
.setOAuthToken(opts.accessToken)
.setTitle('Choose a document to sync')
.setCallback((data) => {
if (data.action === picker.Action.PICKED && data.docs?.[0]) {
const d = data.docs[0]
opts.onPicked({ id: d.id, name: d.name, mimeType: d.mimeType })
} else if (data.action === picker.Action.CANCEL) {
opts.onCancel?.()
}
})
if (opts.apiKey) builder.setDeveloperKey(opts.apiKey)
if (opts.appId) builder.setAppId(opts.appId)
builder.build().setVisible(true)
}

View file

@ -77,10 +77,10 @@ const providerConfigs: ProviderConfig = {
scopes: [
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/calendar.events.readonly',
// Full Drive access: read/export Google Docs to .docx AND write the edited
// .docx back into the original doc (files.update needs write, which
// drive.readonly does not grant). Covers list/get/export/update.
'https://www.googleapis.com/auth/drive',
// Per-file Drive access (non-restricted): the user grants read/write to a
// specific doc by choosing it in the Google Picker. Enough to export/
// download and write back, without the restricted full-drive scope.
'https://www.googleapis.com/auth/drive.file',
],
},
'fireflies-ai': {

View file

@ -5,10 +5,11 @@ import { google, drive_v3 as drive } from 'googleapis';
import { resolveWorkspacePath } from '../workspace/workspace.js';
import { GoogleClientFactory } from './google-client-factory.js';
// Full Drive scope: export Google Docs to .docx (read) and write the edited
// .docx back via files.update (write). drive.readonly can't do the write half.
// Per-file Drive scope (non-restricted). The user picks a doc via the Google
// Picker, which grants this app read/write to that file — enough to export/
// download it and write edits back, without the restricted full-drive scope.
export const GOOGLE_DOC_SCOPES = [
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.file',
] as const;
export type GoogleDocListItem = {
@ -58,10 +59,6 @@ function sanitizeFilename(name: string): string {
return cleaned || 'Google Doc';
}
function escapeDriveQueryValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function normalizeRel(relPath: string): string {
return relPath.replace(/\\/g, '/');
}
@ -198,34 +195,13 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
}
export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> {
const status = await getGoogleDocsConnectionStatus();
if (!status.connected) throw new Error('Google is not connected.');
if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.');
const driveClient = await getDriveClient();
// Native Google Docs (exportable) and uploaded Word files (downloadable).
const typeClause = `(mimeType='${GOOGLE_DOC_MIME}' or mimeType='${DOCX_MIME}')`;
const clauses = [typeClause, 'trashed=false'];
const trimmed = query?.trim();
if (trimmed) {
clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`);
}
const q = clauses.join(' and ');
const result = await driveClient.files.list({
q,
pageSize: 25,
orderBy: 'modifiedTime desc',
fields: 'files(id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress))',
// Also surface docs in shared drives and "Shared with me", not just My Drive.
corpora: 'allDrives',
includeItemsFromAllDrives: true,
supportsAllDrives: true,
});
const files = (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id);
console.log(`[GoogleDocs] list q="${q}" → ${files.length} doc(s)`);
return { files };
/**
* The live Google OAuth access token, for the renderer to drive the Google
* Picker (file selection happens client-side; the app never lists Drive).
*/
export async function getGoogleAccessToken(): Promise<string | null> {
const auth = await GoogleClientFactory.getClient();
return auth?.credentials?.access_token ?? null;
}
/** Import a Google Doc as a local .docx and register the link. */

View file

@ -650,18 +650,11 @@ const ipcSchemas = {
missingScopes: z.array(z.string()),
}),
},
'google-docs:list': {
req: z.object({
query: z.string().optional(),
}),
// Live Google OAuth access token for driving the Google Picker in the renderer.
'google-docs:getAccessToken': {
req: z.null(),
res: z.object({
files: z.array(z.object({
id: z.string(),
name: z.string(),
url: z.string(),
modifiedTime: z.string().nullable(),
owner: z.string().nullable(),
})),
accessToken: z.string().nullable(),
}),
},
'google-docs:import': {