Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/headless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
28 changes: 28 additions & 0 deletions src/headless/oauth/oauthPopupUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 49 additions & 0 deletions src/headless/oauth/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProviderMetadataInfo>;
}

/** 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;
}
136 changes: 136 additions & 0 deletions src/headless/oauth/useOAuthConnect.ts
Original file line number Diff line number Diff line change
@@ -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<Window | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const callbacksRef = useRef<OAuthCallbacks | undefined>(undefined);

const [isPopupOpen, setIsPopupOpen] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}
121 changes: 121 additions & 0 deletions src/headless/oauth/useOAuthPrimitives.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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<OAuthAuthorizationEvent>) => {
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<string | null> => {
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,
};
}
8 changes: 8 additions & 0 deletions src/headless/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading