import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {reportError} from 'app/client/models/errors'; import {spinnerModal} from 'app/client/ui2018/modals'; import type { DocPageModel } from 'app/client/models/DocPageModel'; import type { Document } from 'app/common/UserAPI'; import type { Disposable } from 'grainjs'; const G = getBrowserGlobals('window'); /** * Generates Google Auth endpoint (exposed by Grist) url. For example: * https://docs.getgrist.com/auth/google * @param scope Requested access scope for Google Services * https://developers.google.com/identity/protocols/oauth2/scopes */ function getGoogleAuthEndpoint(scope?: string) { return new URL(`auth/google?scope=${scope || ''}`, window.location.origin).href; } /** * Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access token, * then it calls "send-to-drive" api endpoint to upload xlsx file to drive and finally it redirects * to the created spreadsheet. * Code that is received from Google contains encrypted access token, server is able to decrypt it * using GOOGLE_CLIENT_SECRET key. */ export function sendToDrive(doc: Document, pageModel: DocPageModel) { // Get current document - it will be used to remove popup listener. const gristDoc = pageModel.gristDoc.get(); // Sanity check - gristDoc should be always present if (!gristDoc) { throw new Error("Grist document is not present in Page Model"); } // Create send to google drive handler (it will return a spreadsheet url). const send = (code: string) => // Decorate it with a spinner spinnerModal('Sending file to Google Drive', pageModel.appModel.api.getDocAPI(doc.id) .sendToDrive(code, pageModel.currentDocTitle.get()) ); // Compute google auth server endpoint (grist endpoint for server side google authentication). // This endpoint will redirect user to Google Consent screen and after Google sends a response, // it will render a page (/static/message.html) that will post a message containing message // from Google. Message will be an object { code, error }. We will use the code to invoke // "send-to-drive" api endpoint - that will actually send the xlsx file to Google Drive. const authLink = getGoogleAuthEndpoint(); const authWindow = openPopup(authLink); attachListener(gristDoc, authWindow, async (event: MessageEvent) => { // For the first message (we expect only a single message) close the window. authWindow.close(); // Check response from the popup const response = (event.data || {}) as { code?: string, error?: string }; // - when user declined, do nothing, if (response.error === "access_denied") { return; } // - when there is no response code or error code is different from what we expected - report to user. if (!response.code) { reportError(response.error || "Unrecognized or empty error code"); return; } // Send file to Google Drive. try { const { url } = await send(response.code); G.window.location.assign(url); } catch (err) { reportError(err); } }); } // Helper function that attaches a handler to message event from a popup window. function attachListener(owner: Disposable, popup: Window, listener: (e: MessageEvent) => any) { const wrapped = (e: MessageEvent) => { // Listen to events only from our window. if (e.source !== popup) { return; } // In case when Grist was reloaded or user navigated away - do nothing. if (owner.isDisposed()) { return; } listener(e); }; G.window.addEventListener('message', wrapped); owner.onDispose(() => { G.window.removeEventListener('message', wrapped); }); } function openPopup(url: string): Window { // Center window on desktop // https://stackoverflow.com/questions/16363474/window-open-on-a-multi-monitor-dual-monitor-system-where-does-window-pop-up const width = 600; const height = 650; const left = window.screenX + (screen.width - width) / 2; const top = (screen.height - height) / 4; let windowFeatures = `top=${top},left=${left},menubar=no,location=no,` + `resizable=yes,scrollbars=yes,status=yes,height=${height},width=${width}`; // If window will be too large (for example on mobile) - open as a new tab if (screen.width <= width || screen.height <= height) { windowFeatures = ''; } const authWindow = G.window.open(url, "GoogleAuthPopup", windowFeatures); if (!authWindow) { // This method should be invoked by an user action. throw new Error("This method should be invoked synchronously"); } return authWindow; }