diff --git a/package-lock.json b/package-lock.json index 0da3011..75e9820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "google", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "google", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "react-resizable": "^3.0.4", diff --git a/package.json b/package.json index 90dea43..22c1df9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google", - "version": "1.5.0", + "version": "1.6.0", "description": "Connect to various Google services to your Roam graph!", "main": "./build/main.js", "scripts": { diff --git a/src/components/GoogleOauthPanel.tsx b/src/components/GoogleOauthPanel.tsx new file mode 100644 index 0000000..87a3972 --- /dev/null +++ b/src/components/GoogleOauthPanel.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 GoogleLogo from "./GoogleLogo"; + +type OauthAccount = { + uid: string; + text: string; + data: string; + time: number; +}; + +const OAUTH_KEY = "oauth-google"; +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 GOOGLE_CLIENT_ID = + "950860433572-rvt5aborg8raln483ogada67n201quvh.apps.googleusercontent.com"; + +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 GoogleOauthPanel = ({ scopes }: { scopes: string }) => { + const [accounts, setLocalAccounts] = useState(() => + getAccounts(), + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const nextLabel = useMemo( + () => `Google 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://accounts.google.com/o/oauth2/v2/auth?" + + `prompt=consent&access_type=offline&client_id=${GOOGLE_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + + `&response_type=code&scope=${scopes}&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_google_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: "google-auth", + data: { + ...payload, + grant_type: "authorization_code", + }, + }).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 Google.", + ); + } + await exchangeCode({ + code: pollData.code, + state: pollData.state, + }); + return; + } + } + await wait(DESKTOP_POLL_INTERVAL_MS); + } + throw new Error( + "Google 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 Google."); + 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( + "Google login timed out or was closed before completing. Please try again.", + ); + }, OAUTH_TIMEOUT_MS); + }, [nextLabel, scopes]); + + return ( +
+ + {!!accounts.length && ( + <> +
Accounts
+
    + {accounts.map((a) => ( +
  • + {a.text} + +
  • + ))} +
+ + )} + {!!error && ( +
{error}
+ )} +
+ ); +}; + +export default GoogleOauthPanel; + diff --git a/src/index.ts b/src/index.ts index de508c5..3468ba9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,9 @@ import React from "react"; import runExtension from "roamjs-components/util/runExtension"; -import OauthPanel from "roamjs-components/components/OauthPanel"; -import apiPost from "roamjs-components/util/apiPost"; import CalendarConfig from "./components/CalendarConfig"; import loadGoogleCalendar, { DEFAULT_FORMAT } from "./services/calendar"; import loadGoogleDrive from "./services/drive"; -import GoogleLogo from "./components/GoogleLogo"; +import GoogleOauthPanel from "./components/GoogleOauthPanel"; const scopes = [ @@ -29,24 +27,7 @@ export default runExtension(async (args) => { description: "Log into Google to connect your account to Roam!", action: { type: "reactComponent", - component: () => - React.createElement(OauthPanel, { - service: "google", - getPopoutUrl: () => - Promise.resolve( - `https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline&client_id=950860433572-rvt5aborg8raln483ogada67n201quvh.apps.googleusercontent.com&redirect_uri=https://roamjs.com/oauth?auth=true&response_type=code&scope=${scopes}` - ), - getAuthData: (data: string) => - apiPost({ - anonymous: true, - path: "google-auth", - data: { - ...JSON.parse(data), - grant_type: "authorization_code", - }, - }), - ServiceIcon: GoogleLogo, - }), + component: () => React.createElement(GoogleOauthPanel, { scopes }), }, }, { diff --git a/src/utils/getAccessToken.ts b/src/utils/getAccessToken.ts index 3616662..b4c338a 100644 --- a/src/utils/getAccessToken.ts +++ b/src/utils/getAccessToken.ts @@ -15,6 +15,7 @@ const getAccessToken = (label?: string) => { ); return tokenAge > expires_in ? apiPost({ + domain: "https://roamjs.com", path: `google-auth`, data: { refresh_token,