mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) (#620)
* feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) Adds the managed (rowboat-mode) Google Docs picker via Google's trigger_onepick flow. The Rowboat backend runs a standalone drive.file OAuth with the company client, renders the Picker inside the browser consent screen, and deep-links the selection back; the desktop downloads the picked doc with the fresh drive.file token the backend returns. No Picker API key, appId, or BYOK credentials on the desktop. - core: importGoogleDocWithToken downloads a picked doc with an explicit token; fetch/metadata helpers take an optional Drive client and share writeDocxAndLink. claimPickedFilesViaBackend claims the parked file ids + token from the api. - main: google-picker-managed.ts opens the backend start URL and resolves on the rowboat://oauth/google/picker/done deep link; deeplink.ts routes that completion. - ipc: google-docs:pickViaManaged. - renderer: the picker dialog gates on Rowboat sign-in (the picker grants drive.file per-file, so no pre-existing connection or scope is required). Backend contract: rowboatlabs/rowboatx-backend#7 (GET /oauth/google/picker/{start,callback}, POST /v1/google-oauth/claim-picked). * chore(google-docs): remove the dead API-key/system-browser Picker The managed picker replaced the only consumer (the picker dialog), so the experimental API-key Picker is now unused. Removes: - main: google-docs:openPicker handler (system-browser loopback Picker) - shared: google-docs:openPicker + google-docs:getAccessToken IPC schemas - core: getGoogleAccessToken (token plumbing for the client-side Picker) - renderer: lib/google-picker.ts (Picker JS SDK loader) Kept GoogleClientIdModal / google-credentials-store — still used by the general BYOK Google connect in onboarding, connectors, and settings.
This commit is contained in:
parent
875b65d279
commit
67b521489c
8 changed files with 314 additions and 447 deletions
|
|
@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise<OAuthTokens>
|
|||
return toOAuthTokens(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim what the user selected in the managed OAuth-redirect Picker, parked
|
||||
* under `session` by the webapp picker callback. Returns the picked file ids
|
||||
* plus a fresh drive.file access token — the picker runs a standalone
|
||||
* drive.file authorization (the main connection doesn't carry drive.file), so
|
||||
* the desktop downloads the picked files with this token, not the main one.
|
||||
*/
|
||||
export async function claimPickedFilesViaBackend(
|
||||
session: string,
|
||||
): Promise<{ fileIds: string[]; accessToken: string }> {
|
||||
const res = await postWithBearer("/v1/google-oauth/claim-picked", { session });
|
||||
if (!res.ok) {
|
||||
const err = await readError(res);
|
||||
throw new Error(`claim picked files failed: ${res.status} ${err.error ?? ""}`.trim());
|
||||
}
|
||||
const body = (await res.json()) as { fileIds?: unknown; tokens?: { access_token?: unknown } };
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
const accessToken = typeof body.tokens?.access_token === "string" ? body.tokens.access_token : "";
|
||||
return { fileIds, accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token via the api. Preserves caller's `refreshToken` and
|
||||
* `existingScopes` when Google omits them on the refresh response.
|
||||
|
|
|
|||
|
|
@ -127,27 +127,43 @@ async function getDriveClient() {
|
|||
return google.drive({ version: 'v3', auth });
|
||||
}
|
||||
|
||||
// Build a Drive client from a raw OAuth access token, bypassing the stored
|
||||
// connection. Used by the OAuth-redirect Picker (trigger_onepick), which runs
|
||||
// its own standalone drive.file authorization and hands back a fresh token —
|
||||
// no stored connection, Picker API key, or appId involved. Read/export only;
|
||||
// the token is short-lived and used immediately, so no refresh is wired up.
|
||||
function driveClientFromToken(accessToken: string) {
|
||||
const auth = new google.auth.OAuth2();
|
||||
auth.setCredentials({ access_token: accessToken });
|
||||
return google.drive({ version: 'v3', auth });
|
||||
}
|
||||
|
||||
// Get the file as .docx bytes: a native Google Doc is exported; an uploaded
|
||||
// Word file is downloaded as-is.
|
||||
async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise<Buffer> {
|
||||
const driveClient = await getDriveClient();
|
||||
// Word file is downloaded as-is. Pass `driveClient` to use a specific token
|
||||
// (e.g. the OAuth-redirect Picker's); omit it to use the stored connection.
|
||||
async function fetchAsDocx(
|
||||
fileId: string,
|
||||
mimeType: string | undefined,
|
||||
driveClient?: drive.Drive,
|
||||
): Promise<Buffer> {
|
||||
const dc = driveClient ?? await getDriveClient();
|
||||
if (!mimeType || mimeType === GOOGLE_DOC_MIME) {
|
||||
const result = await driveClient.files.export(
|
||||
const result = await dc.files.export(
|
||||
{ fileId, mimeType: DOCX_MIME },
|
||||
{ responseType: 'arraybuffer' },
|
||||
);
|
||||
return Buffer.from(result.data as ArrayBuffer);
|
||||
}
|
||||
const result = await driveClient.files.get(
|
||||
const result = await dc.files.get(
|
||||
{ fileId, alt: 'media', supportsAllDrives: true },
|
||||
{ responseType: 'arraybuffer' },
|
||||
);
|
||||
return Buffer.from(result.data as ArrayBuffer);
|
||||
}
|
||||
|
||||
async function getDocMetadata(fileId: string): Promise<GoogleDocListItem> {
|
||||
const driveClient = await getDriveClient();
|
||||
const result = await driveClient.files.get({
|
||||
async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise<GoogleDocListItem> {
|
||||
const dc = driveClient ?? await getDriveClient();
|
||||
const result = await dc.files.get({
|
||||
fileId,
|
||||
fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)',
|
||||
supportsAllDrives: true,
|
||||
|
|
@ -195,21 +211,26 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
|
|||
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// getClient() refreshes an expired token when it can. If it returns a token
|
||||
// that's still past expiry, the refresh failed (e.g. no refresh_token) — hand
|
||||
// back null rather than a dead token, so the UI prompts a reconnect instead
|
||||
// of silently passing an expired token to the Picker (which 403s on it).
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
const token = auth?.credentials?.access_token ?? null;
|
||||
if (!token) return null;
|
||||
const expiry = auth?.credentials?.expiry_date;
|
||||
if (typeof expiry === 'number' && expiry <= Date.now()) return null;
|
||||
return token;
|
||||
// Write the exported .docx bytes into the knowledge folder and record the
|
||||
// Drive link. Shared by both import paths (stored connection / explicit token).
|
||||
async function writeDocxAndLink(
|
||||
doc: GoogleDocListItem,
|
||||
bytes: Buffer,
|
||||
targetFolder: string,
|
||||
): Promise<string> {
|
||||
const relPath = await uniqueDocxPath(targetFolder, doc.name);
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, bytes);
|
||||
await setLink(relPath, {
|
||||
id: doc.id,
|
||||
url: doc.url,
|
||||
title: doc.name,
|
||||
syncedAt: new Date().toISOString(),
|
||||
mimeType: doc.mimeType,
|
||||
remoteModifiedTime: doc.modifiedTime ?? undefined,
|
||||
});
|
||||
return relPath;
|
||||
}
|
||||
|
||||
/** Import a Google Doc as a local .docx and register the link. */
|
||||
|
|
@ -223,18 +244,26 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro
|
|||
|
||||
const doc = await getDocMetadata(fileId);
|
||||
const bytes = await fetchAsDocx(fileId, doc.mimeType);
|
||||
const relPath = await uniqueDocxPath(targetFolder, doc.name);
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, bytes);
|
||||
await setLink(relPath, {
|
||||
id: doc.id,
|
||||
url: doc.url,
|
||||
title: doc.name,
|
||||
syncedAt: new Date().toISOString(),
|
||||
mimeType: doc.mimeType,
|
||||
remoteModifiedTime: doc.modifiedTime ?? undefined,
|
||||
});
|
||||
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
|
||||
return { path: relPath, doc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a Google Doc using an explicit OAuth access token instead of the
|
||||
* stored Google connection. Powers the OAuth-redirect Picker (trigger_onepick):
|
||||
* that flow runs its own standalone drive.file authorization, so the picked
|
||||
* file is granted to a fresh token that has no other scopes and isn't persisted
|
||||
* as the user's main connection. No Picker API key or appId is involved.
|
||||
*/
|
||||
export async function importGoogleDocWithToken(
|
||||
fileId: string,
|
||||
targetFolder: string,
|
||||
accessToken: string,
|
||||
): Promise<{ path: string; doc: GoogleDocListItem }> {
|
||||
const driveClient = driveClientFromToken(accessToken);
|
||||
const doc = await getDocMetadata(fileId, driveClient);
|
||||
const bytes = await fetchAsDocx(fileId, doc.mimeType, driveClient);
|
||||
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
|
||||
return { path: relPath, doc };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -650,27 +650,6 @@ const ipcSchemas = {
|
|||
missingScopes: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
// Live Google OAuth access token for driving the Google Picker in the renderer.
|
||||
'google-docs:getAccessToken': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
accessToken: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Open a Google Picker in a dedicated BrowserWindow (avoids session-cookie
|
||||
// issues when running the Picker widget inside the renderer iframe).
|
||||
'google-docs:openPicker': {
|
||||
req: z.object({
|
||||
accessToken: z.string(),
|
||||
apiKey: z.string().optional(),
|
||||
appId: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
mimeType: z.string(),
|
||||
}).nullable(),
|
||||
},
|
||||
'google-docs:import': {
|
||||
req: z.object({
|
||||
fileId: z.string().min(1),
|
||||
|
|
@ -687,6 +666,24 @@ const ipcSchemas = {
|
|||
}),
|
||||
}),
|
||||
},
|
||||
// Managed OAuth-redirect Picker: the Rowboat backend runs the pick with the
|
||||
// company Google client; the desktop opens the start URL, waits for the deep
|
||||
// link, and imports with the existing managed token. No API key or BYOK creds.
|
||||
'google-docs:pickViaManaged': {
|
||||
req: z.object({
|
||||
targetFolder: RelPath,
|
||||
}),
|
||||
res: z.object({
|
||||
path: RelPath,
|
||||
doc: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
modifiedTime: z.string().nullable(),
|
||||
owner: z.string().nullable(),
|
||||
}),
|
||||
}).nullable(),
|
||||
},
|
||||
'google-docs:refreshSnapshot': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue