From 316c59700ecefa5529a0539079d42ff616cde81a Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 30 Mar 2026 20:53:58 -0600 Subject: [PATCH] Update version to 1.4.0 and refactor Dropbox OAuth integration --- package-lock.json | 4 +- package.json | 2 +- src/components/DropboxOauthPanel.tsx | 318 +++++++++++++++++++++++++++ src/index.ts | 29 +-- 4 files changed, 324 insertions(+), 29 deletions(-) create mode 100644 src/components/DropboxOauthPanel.tsx diff --git a/package-lock.json b/package-lock.json index 000fd1c..b3e17e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dropbox", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dropbox", - "version": "1.3.0", + "version": "1.4.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0a604d2..9cc11d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dropbox", - "version": "1.3.0", + "version": "1.4.0", "description": "Send file uploads in Roam directly to your Dropbox!", "main": "./build/main.js", "scripts": { diff --git a/src/components/DropboxOauthPanel.tsx b/src/components/DropboxOauthPanel.tsx new file mode 100644 index 0000000..6a51246 --- /dev/null +++ b/src/components/DropboxOauthPanel.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useMemo, useState } from "react"; +import apiPost from "roamjs-components/util/apiPost"; +import localStorageGet from "roamjs-components/util/localStorageGet"; +import localStorageSet from "roamjs-components/util/localStorageSet"; +import DropboxLogo from "./DropboxLogo"; + +type OauthAccount = { + uid: string; + text: string; + data: string; + time: number; +}; + +const OAUTH_KEY = "oauth-dropbox"; +const ROAMJS_ORIGIN = "https://roamjs.com"; +const REDIRECT_URI = `${ROAMJS_ORIGIN}/oauth?auth=true`; +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const DESKTOP_POLL_INTERVAL_MS = 1500; +const DROPBOX_CLIENT_ID = "ghagecp4sgm6v99"; + +const getAccounts = (): OauthAccount[] => { + try { + return JSON.parse(localStorageGet(OAUTH_KEY) || "[]"); + } catch { + return []; + } +}; + +const setAccounts = (accounts: OauthAccount[]) => + localStorageSet(OAUTH_KEY, JSON.stringify(accounts)); + +const createNonce = () => + `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + +const createSessionId = () => + `sess_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + +const encodeState = (value: unknown) => { + const json = JSON.stringify(value); + return window + .btoa(json) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +}; + +const createState = (session?: string) => { + const nonce = createNonce(); + try { + const payload: { nonce: string; origin: string; session?: string } = { + nonce, + origin: window.location.origin, + }; + if (session) { + payload.session = session; + } + return encodeState(payload); + } catch { + return nonce; + } +}; + +const wait = (ms: number) => + new Promise((resolve) => window.setTimeout(resolve, ms)); + +const createUid = () => + window.roamAlphaAPI?.util?.generateUID?.() || + Math.random().toString(36).slice(2, 11); + +const DropboxOauthPanel = () => { + const [accounts, setLocalAccounts] = useState(() => + getAccounts() + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const nextLabel = useMemo( + () => `Dropbox Account ${accounts.length + 1}`, + [accounts.length] + ); + + const removeAccount = useCallback((uid: string) => { + setLocalAccounts((previous) => { + const next = previous.filter((a) => a.uid !== uid); + setAccounts(next); + return next; + }); + }, []); + + const login = useCallback(() => { + const isDesktop = !!window.roamAlphaAPI?.platform?.isDesktop; + const session = isDesktop ? createSessionId() : undefined; + const state = createState(session); + setError(""); + setLoading(true); + + const url = + "https://www.dropbox.com/oauth2/authorize?" + + `client_id=${DROPBOX_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + + "&response_type=code&token_access_type=offline" + + `&state=${encodeURIComponent(state)}`; + + const width = 600; + const height = 525; + const left = window.screenX + (window.innerWidth - width) / 2; + const top = window.screenY + (window.innerHeight - height) / 2; + const popup = window.open( + url, + "roamjs_dropbox_login", + `left=${left},top=${top},width=${width},height=${height},status=1` + ); + + if (!popup) { + if (!isDesktop) { + setLoading(false); + setError("Popup blocked. Please allow popups and try again."); + return; + } + } + popup?.focus?.(); + + const exchangeCode = (payload: Record) => + apiPost({ + anonymous: true, + domain: ROAMJS_ORIGIN, + path: "dropbox-auth", + data: { + ...payload, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + }, + }).then((tokenData) => { + const label = + typeof tokenData?.label === "string" && tokenData.label + ? tokenData.label + : nextLabel; + const account: OauthAccount = { + uid: createUid(), + text: label, + data: JSON.stringify(tokenData), + time: Date.now(), + }; + setLocalAccounts((previous) => { + const next = [...previous, account]; + setAccounts(next); + return next; + }); + }); + + if (isDesktop && session) { + void (async () => { + try { + const deadline = Date.now() + OAUTH_TIMEOUT_MS; + while (Date.now() < deadline) { + const pollUrl = `${ROAMJS_ORIGIN}/oauth/session?session=${encodeURIComponent( + session + )}`; + const response = await fetch(pollUrl, { cache: "no-store" }); + if (response.ok) { + const pollData = (await response.json()) as { + status?: string; + code?: string; + state?: string; + error?: string; + }; + if (pollData.status === "completed") { + if (pollData.state !== state) { + throw new Error("OAuth state mismatch. Please try again."); + } + if (pollData.error) { + throw new Error(pollData.error); + } + if (!pollData.code) { + throw new Error( + "Did not receive an authorization code from Dropbox." + ); + } + await exchangeCode({ + code: pollData.code, + state: pollData.state, + }); + return; + } + } + await wait(DESKTOP_POLL_INTERVAL_MS); + } + throw new Error( + "Dropbox login timed out or was closed before completing. Please try again." + ); + } catch (e) { + setError( + e instanceof Error + ? e.message + : "Failed to exchange OAuth code. Please try again in a moment." + ); + } finally { + setLoading(false); + } + })(); + return; + } + + let timeoutId = 0; + const cleanup = () => { + window.removeEventListener("message", onMessage); + window.clearTimeout(timeoutId); + }; + + const onMessage = (event: MessageEvent) => { + if (event.origin !== ROAMJS_ORIGIN) { + return; + } + cleanup(); + const raw = event.data; + let payload: Record = {}; + if (typeof raw === "string") { + try { + payload = JSON.parse(raw || "{}") as Record; + } catch { + setLoading(false); + setError("Invalid OAuth response from callback page."); + return; + } + } else if (raw && typeof raw === "object") { + payload = raw as Record; + } + + if (payload.state !== state) { + setLoading(false); + setError("OAuth state mismatch. Please try again."); + return; + } + if (payload.error) { + setLoading(false); + setError(payload.error); + return; + } + if (!payload.code) { + setLoading(false); + setError("Did not receive an authorization code from Dropbox."); + return; + } + + exchangeCode(payload) + .catch((e) => { + setError( + e?.message || + "Failed to exchange OAuth code. Please try again in a moment." + ); + }) + .finally(() => { + setLoading(false); + }); + }; + + window.addEventListener("message", onMessage); + timeoutId = window.setTimeout(() => { + cleanup(); + setLoading(false); + setError( + "Dropbox login timed out or was closed before completing. Please try again." + ); + }, OAUTH_TIMEOUT_MS); + }, [nextLabel]); + + return ( +
+ + {!!accounts.length && ( + <> +
Accounts
+
    + {accounts.map((a) => ( +
  • + {a.text} + +
  • + ))} +
+ + )} + {!!error && ( +
{error}
+ )} +
+ ); +}; + +export default DropboxOauthPanel; diff --git a/src/index.ts b/src/index.ts index f878b30..ba72274 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ import runExtension from "roamjs-components/util/runExtension"; import getOauth from "roamjs-components/util/getOauth"; import getDropUidOffset from "roamjs-components/dom/getDropUidOffset"; -import DropboxLogo from "./components/DropboxLogo"; import differenceInSeconds from "date-fns/differenceInSeconds"; import createBlock from "roamjs-components/writes/createBlock"; import updateBlock from "roamjs-components/writes/updateBlock"; @@ -9,10 +8,10 @@ import getUids from "roamjs-components/dom/getUids"; import createHTMLObserver from "roamjs-components/dom/createHTMLObserver"; import localStorageGet from "roamjs-components/util/localStorageGet"; import localStorageSet from "roamjs-components/util/localStorageSet"; -import OauthPanel from "roamjs-components/components/OauthPanel"; import React from "react"; import apiPost from "roamjs-components/util/apiPost"; import mimeTypes from "./mimeTypes"; +import DropboxOauthPanel from "./components/DropboxOauthPanel"; const mimeLookup = (path: string) => { if (!path || typeof path !== "string") { @@ -48,28 +47,7 @@ export default runExtension(async (args) => { action: { type: "reactComponent", component: () => - React.createElement(OauthPanel, { - service: "dropbox", - ServiceIcon: DropboxLogo, - getPopoutUrl: () => - Promise.resolve( - `https://www.dropbox.com/oauth2/authorize?client_id=ghagecp4sgm6v99&redirect_uri=${encodeURIComponent( - "https://roamjs.com/oauth?auth=true" - )}&response_type=code&token_access_type=offline` - ), - getAuthData: (data) => - apiPost({ - domain: `https://lambda.roamjs.com`, - path: `dropbox-auth`, - anonymous: true, - data: { - ...JSON.parse(data), - grant_type: "authorization_code", - redirect_uri: "https://roamjs.com/oauth?auth=true", - dev: undefined, - }, - }), - }), + React.createElement(DropboxOauthPanel), }, }, ], @@ -87,12 +65,11 @@ export default runExtension(async (args) => { ); return tokenAge > expires_in ? apiPost<{ access_token: string }>({ - domain: `https://lambda.roamjs.com`, + domain: "https://roamjs.com", path: `dropbox-auth`, data: { refresh_token, grant_type: "refresh_token", - dev: undefined, }, anonymous: true, })