From 15f9ff0327aa16312dac522c6731c76c30265a49 Mon Sep 17 00:00:00 2001 From: Dion Low Date: Thu, 19 Mar 2026 14:59:23 -0700 Subject: [PATCH] feat(headless): add OAuth connection management hooks Extract OAuth popup/postMessage logic from OauthFlow2 into reusable headless hooks for both internal and external consumers. - useOAuthPrimitives: low-level hook for URL generation and message listener control (for custom popup management) - useOAuthConnect: high-level hook managing the full popup lifecycle with callbacks, polling for manual close, and reset support Co-Authored-By: Claude Opus 4.6 (1M context) --- src/headless/index.ts | 4 + src/headless/oauth/oauthPopupUtils.ts | 28 +++++ src/headless/oauth/types.ts | 49 ++++++++ src/headless/oauth/useOAuthConnect.ts | 136 +++++++++++++++++++++++ src/headless/oauth/useOAuthPrimitives.ts | 121 ++++++++++++++++++++ src/headless/types.ts | 8 ++ 6 files changed, 346 insertions(+) create mode 100644 src/headless/oauth/oauthPopupUtils.ts create mode 100644 src/headless/oauth/types.ts create mode 100644 src/headless/oauth/useOAuthConnect.ts create mode 100644 src/headless/oauth/useOAuthPrimitives.ts diff --git a/src/headless/index.ts b/src/headless/index.ts index 57df8d2b..55dc5712 100644 --- a/src/headless/index.ts +++ b/src/headless/index.ts @@ -21,5 +21,9 @@ export { useConfig, useLocalConfig } from "./config/ConfigContext"; // Config Bridge Types export type { InstallationConfigContent } from "./config/types"; +// OAuth Hooks +export { useOAuthPrimitives } from "./oauth/useOAuthPrimitives"; +export { useOAuthConnect } from "./oauth/useOAuthConnect"; + // Export all necessary types export * from "./types"; diff --git a/src/headless/oauth/oauthPopupUtils.ts b/src/headless/oauth/oauthPopupUtils.ts new file mode 100644 index 00000000..81d88a2e --- /dev/null +++ b/src/headless/oauth/oauthPopupUtils.ts @@ -0,0 +1,28 @@ +/** + * Pure utility functions for OAuth popup window management. + */ + +import { AMP_SERVER } from "src/services/api"; + +import type { OAuthPopupOptions } from "./types"; + +export const DEFAULT_POPUP_WIDTH = 600; +export const DEFAULT_POPUP_HEIGHT = 600; + +/** Opens a centered popup window for OAuth authorization. */ +export function openCenteredPopup( + url: string, + options?: OAuthPopupOptions, +): Window | null { + const width = options?.width ?? DEFAULT_POPUP_WIDTH; + const height = options?.height ?? DEFAULT_POPUP_HEIGHT; + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2.5; + const features = `width=${width},height=${height},left=${left},top=${top}`; + return window.open(url, "OAuthPopup", features); +} + +/** Checks whether a postMessage origin matches the Ampersand server. */ +export function isValidOAuthOrigin(origin: string): boolean { + return origin === AMP_SERVER; +} diff --git a/src/headless/oauth/types.ts b/src/headless/oauth/types.ts new file mode 100644 index 00000000..de3ba704 --- /dev/null +++ b/src/headless/oauth/types.ts @@ -0,0 +1,49 @@ +/** + * Types for headless OAuth connection management. + */ + +import type { ProviderMetadataInfo } from "@generated/api/src"; + +/** Payload shape received from the OAuth popup via postMessage. */ +export type OAuthAuthorizationEvent = + | { + eventType: "AUTHORIZATION_SUCCEEDED"; + data: { connection: string }; + } + | { + eventType: "AUTHORIZATION_FAILED"; + data: { error: string }; + }; + +/** Parameters for creating a new OAuth connection. */ +export interface CreateOAuthConnectionParams { + provider: string; + consumerRef: string; + groupRef: string; + consumerName?: string; + groupName?: string; + providerWorkspaceRef?: string; + providerMetadata?: Record; +} + +/** Parameters for updating an existing OAuth connection. */ +export interface UpdateOAuthConnectionParams { + connectionId: string; +} + +/** Discriminated union for create vs update OAuth flows. */ +export type OAuthConnectionMode = + | { type: "create"; params: CreateOAuthConnectionParams } + | { type: "update"; params: UpdateOAuthConnectionParams }; + +/** Configuration for the OAuth popup window. */ +export interface OAuthPopupOptions { + width?: number; + height?: number; +} + +/** Callbacks for OAuth flow completion. */ +export interface OAuthCallbacks { + onSuccess?: (connectionId: string) => void; + onError?: (error: Error) => void; +} diff --git a/src/headless/oauth/useOAuthConnect.ts b/src/headless/oauth/useOAuthConnect.ts new file mode 100644 index 00000000..65fabab3 --- /dev/null +++ b/src/headless/oauth/useOAuthConnect.ts @@ -0,0 +1,136 @@ +/** + * High-level hook that manages the full OAuth popup lifecycle. + * Opens the popup, listens for authorization events, and cleans up automatically. + * + * For lower-level control over the popup, use `useOAuthPrimitives` instead. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { openCenteredPopup } from "./oauthPopupUtils"; +import type { + OAuthCallbacks, + OAuthConnectionMode, + OAuthPopupOptions, +} from "./types"; +import { useOAuthPrimitives } from "./useOAuthPrimitives"; + +const POPUP_POLL_INTERVAL_MS = 500; + +export function useOAuthConnect(mode: OAuthConnectionMode) { + const { + getOAuthUrl, + isGeneratingUrl, + startListening, + stopListening, + isListening, + connectionId, + error: primitivesError, + } = useOAuthPrimitives(mode); + + const popupRef = useRef(null); + const pollRef = useRef | null>(null); + const callbacksRef = useRef(undefined); + + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [error, setError] = useState(null); + + // Sync error from primitives + useEffect(() => { + if (primitivesError) { + setError(primitivesError); + } + }, [primitivesError]); + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + stopListening(); + setIsPopupOpen(false); + }, [stopListening]); + + const closePopup = useCallback(() => { + popupRef.current?.close(); + popupRef.current = null; + cleanup(); + }, [cleanup]); + + // React to authorization results from primitives + useEffect(() => { + if (connectionId) { + callbacksRef.current?.onSuccess?.(connectionId); + closePopup(); + } + }, [connectionId, closePopup]); + + useEffect(() => { + if (primitivesError) { + callbacksRef.current?.onError?.(new Error(primitivesError)); + closePopup(); + } + }, [primitivesError, closePopup]); + + // Start polling for popup closure (user closed the window manually) + const startPopupPolling = useCallback(() => { + if (pollRef.current) return; + pollRef.current = setInterval(() => { + if (popupRef.current?.closed) { + cleanup(); + popupRef.current = null; + } + }, POPUP_POLL_INTERVAL_MS); + }, [cleanup]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + stopListening(); + }; + }, [stopListening]); + + const connect = useCallback( + async (callbacks?: OAuthCallbacks, popupOptions?: OAuthPopupOptions) => { + callbacksRef.current = callbacks; + setError(null); + + const url = await getOAuthUrl(); + if (!url) return; + + const popup = openCenteredPopup(url, popupOptions); + if (!popup) { + const err = + "Failed to open popup window. Please check your popup blocker settings."; + setError(err); + callbacks?.onError?.(new Error(err)); + return; + } + + popupRef.current = popup; + setIsPopupOpen(true); + startListening(); + startPopupPolling(); + }, + [getOAuthUrl, startListening, startPopupPolling], + ); + + const reset = useCallback(() => { + setError(null); + closePopup(); + }, [closePopup]); + + return { + connect, + connectionId, + error, + isConnecting: isGeneratingUrl || isListening, + isPopupOpen, + closePopup, + reset, + }; +} diff --git a/src/headless/oauth/useOAuthPrimitives.ts b/src/headless/oauth/useOAuthPrimitives.ts new file mode 100644 index 00000000..7ac0c8b6 --- /dev/null +++ b/src/headless/oauth/useOAuthPrimitives.ts @@ -0,0 +1,121 @@ +/** + * Low-level hook providing OAuth URL generation and message listener utilities. + * Use this when you need full control over the popup window lifecycle. + * For a managed experience, use `useOAuthConnect` instead. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { enableCSRFProtection } from "src/components/auth/Oauth/AuthorizationCode/enableCSRFprotection"; +import { useAmpersandProviderProps } from "src/context/AmpersandContextProvider"; +import { useCreateOauthConnectionMutation } from "src/hooks/mutation/useCreateOauthConnectionMutation"; +import { useUpdateOauthConnectMutation } from "src/hooks/mutation/useUpdateOauthConnectMutation"; + +import { isValidOAuthOrigin } from "./oauthPopupUtils"; +import type { OAuthAuthorizationEvent, OAuthConnectionMode } from "./types"; + +export function useOAuthPrimitives(mode: OAuthConnectionMode) { + const { projectIdOrName } = useAmpersandProviderProps(); + const queryClient = useQueryClient(); + + const [connectionId, setConnectionId] = useState(null); + const [error, setError] = useState(null); + const [isListening, setIsListening] = useState(false); + const listenerRef = useRef<((ev: MessageEvent) => void) | null>(null); + + const { mutateAsync: createOauthUrl, isPending: isCreatePending } = + useCreateOauthConnectionMutation(); + + const { mutateAsync: updateOauthUrl, isPending: isUpdatePending } = + useUpdateOauthConnectMutation(); + + const isGeneratingUrl = isCreatePending || isUpdatePending; + + const handleMessage = useCallback( + (ev: MessageEvent) => { + if (!isValidOAuthOrigin(ev.origin)) return; + + if (ev.data?.eventType === "AUTHORIZATION_SUCCEEDED") { + setError(null); + setConnectionId(ev.data.data.connection); + queryClient.invalidateQueries({ queryKey: ["amp", "connections"] }); + } else if (ev.data?.eventType === "AUTHORIZATION_FAILED") { + queryClient.invalidateQueries({ queryKey: ["amp", "connections"] }); + setError(ev.data.data.error || "An error occurred. Please try again."); + } + }, + [queryClient], + ); + + const startListening = useCallback(() => { + if (listenerRef.current) return; // already listening + const listener = (ev: MessageEvent) => handleMessage(ev); + listenerRef.current = listener; + window.addEventListener("message", listener); + setIsListening(true); + }, [handleMessage]); + + const stopListening = useCallback(() => { + if (listenerRef.current) { + window.removeEventListener("message", listenerRef.current); + listenerRef.current = null; + } + setIsListening(false); + }, []); + + // Cleanup listener on unmount + useEffect(() => { + return () => { + if (listenerRef.current) { + window.removeEventListener("message", listenerRef.current); + listenerRef.current = null; + } + }; + }, []); + + const getOAuthUrl = useCallback(async (): Promise => { + try { + if (mode.type === "create") { + const { params } = mode; + const url = await createOauthUrl({ + connectOAuthParams: { + provider: params.provider, + consumerRef: params.consumerRef, + groupRef: params.groupRef, + projectId: projectIdOrName, + consumerName: params.consumerName, + groupName: params.groupName, + providerWorkspaceRef: params.providerWorkspaceRef, + providerMetadata: params.providerMetadata, + enableCSRFProtection, + }, + }); + return url ?? null; + } + + const { params } = mode; + const url = await updateOauthUrl({ + projectIdOrName, + connectionId: params.connectionId, + }); + return url ?? null; + } catch (err) { + const message = + err instanceof Error + ? err.message + : String(err) || "An error occurred. Please try again."; + setError(message); + return null; + } + }, [mode, projectIdOrName, createOauthUrl, updateOauthUrl]); + + return { + getOAuthUrl, + isGeneratingUrl, + startListening, + stopListening, + isListening, + connectionId, + error, + }; +} diff --git a/src/headless/types.ts b/src/headless/types.ts index cc7fa1a3..7b3568d3 100644 --- a/src/headless/types.ts +++ b/src/headless/types.ts @@ -34,6 +34,14 @@ export type { WriteObjectHandlers, } from "./config/useConfigHelper"; export type { Manifest } from "./manifest/useManifest"; +export type { + OAuthAuthorizationEvent, + OAuthConnectionMode, + CreateOAuthConnectionParams, + UpdateOAuthConnectionParams, + OAuthPopupOptions, + OAuthCallbacks, +} from "./oauth/types"; // Re-export generated types that are commonly used export type {