import { useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import { cancelOAuth, oauthConnectionCompleted, startOAuth } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting"; import { openOAuthPopup, openOAuthSystemBrowser, reserveOAuthPopup, type OAuthPopupResult, type ReservedOAuthPopup, } from "../api/oauth-popup"; import { connectionWriteKeys } from "../api/reactivity-keys"; import { getActiveOrgSlug } from "../api/server-connection"; export type DesktopBridge = { readonly openExternal: (url: string) => Promise; }; const getDesktopBridge = (): DesktopBridge | null => { if (typeof window === "undefined") return null; const candidate = (window as { readonly executor?: Partial }).executor; return candidate && typeof candidate.openExternal === "function" ? // oxlint-disable-next-line executor/no-double-cast -- boundary: narrowed by the typeof guard above (candidate as DesktopBridge) : null; }; import { Button } from "../components/button"; import { OAUTH_POPUP_MESSAGE_TYPE, OAuthState, type AuthTemplateSlug, type ConnectionName, type IntegrationSlug, type OAuthClientSlug, type Owner, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // OAuth sign-in (v2). A registered OAuth client (`oauth.createClient`) is run by // `oauth.start` for one integration, minting an owner-scoped Connection. `start` // either returns the connection inline (`status: "connected"`) or a redirect // (`status: "redirect"` with an `authorizationUrl` + `state`); the popup // completes the redirect via the server `/oauth/callback`, then posts the result // back. `state` is the correlation token (the v1 session id). // // NOTE(v2): the server-side flow is stubbed (D18). The shapes below track the // v2 contract so this UI compiles; the plugin `/react` wave wires the buttons. // --------------------------------------------------------------------------- export type OAuthCompletionPayload = { readonly owner: Owner; readonly integration: IntegrationSlug; readonly name: ConnectionName; readonly identityLabel?: string | null; }; export type OAuthStartPayload = { /** Registered OAuth client slug to run. */ readonly client: OAuthClientSlug; /** Owner of `client` (a Personal connection may use a shared Workspace app). */ readonly clientOwner: Owner; readonly owner: Owner; /** Name for the connection the flow mints. */ readonly name: ConnectionName; readonly integration: IntegrationSlug; readonly template: AuthTemplateSlug; readonly identityLabel?: string; /** Mint a NEW connection: the server resolves a taken `name` to the next * free suffixed one. Omitted on reconnect, which targets the existing row. */ readonly newConnection?: boolean; readonly redirectUri?: string; }; export type StartOAuthPopupInput = { readonly payload: OAuthStartPayload; readonly onSuccess: (payload: TPayload) => void | Promise; readonly onError?: (error: string) => void; readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; /** Window reserved on the click that began this flow. See `reserve`. */ readonly reservation?: OAuthPopupReservation; }; /** * A sign-in window claimed synchronously inside a click handler. * * `window.open` requires transient user activation, which Chromium expires * about five seconds after the click. Any flow that awaits the network before * it has an authorization URL (DCR probes then registers, CIMD creates a * client, reconnect starts a session) therefore cannot open the window when the * URL finally arrives: by then the activation is gone and the browser refuses * silently. Those flows reserve a blank window on the click and navigate it * later, however long the round trips take. * * Desktop hosts hand the URL to the real browser, so they reserve nothing and * carry the bridge that will open it instead. */ export type OAuthPopupReservation = | { readonly kind: "desktop"; readonly bridge: DesktopBridge } | { readonly kind: "window"; readonly popup: ReservedOAuthPopup } | { readonly kind: "blocked" }; const POPUP_BLOCKED_MESSAGE = "Sign-in popup was blocked by the browser"; export type OAuthAuthorizationStartResult = { /** OAuth correlation token (was the v1 session id). */ readonly state: string; readonly authorizationUrl: string | null; }; class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ readonly cause: unknown; readonly message: string; }> {} export type StartOAuthAuthorizationInput = { readonly owner: Owner; readonly run: () => Promise; readonly onSuccess: (payload: TPayload) => void | Promise; readonly onError?: (error: string, details?: string) => void; readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; readonly reportMetadata?: Record; /** Window reserved on the click that began this flow. See `reserve`. */ readonly reservation?: OAuthPopupReservation; }; export function oauthCallbackUrl(path = "/api/oauth/callback"): string { if (typeof window === "undefined") return path; return new URL(path, window.location.origin).toString(); } export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH = "/api/oauth/client-id-metadata/default.json"; export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_PATH = "/api/oauth/client-id-metadata/local.json"; const configuredClientIdMetadataBaseUrl = (): string | undefined => { const env = ( import.meta as ImportMeta & { readonly env?: Record; } ).env; const value = env?.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL?.trim(); return value ? value : undefined; }; export function oauthClientIdMetadataDocumentUrl(options?: { readonly hostedBaseUrl?: string | null; readonly path?: string; }): string { const hostedBaseUrl = options?.hostedBaseUrl ?? configuredClientIdMetadataBaseUrl(); if (hostedBaseUrl) { return new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_PATH, hostedBaseUrl).toString(); } const defaultPath = options?.path ?? OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH; if (typeof window === "undefined") return defaultPath; const orgSlug = getActiveOrgSlug(); const metadataPath = orgSlug ? `/api/oauth/client-id-metadata/${encodeURIComponent(orgSlug)}.json` : defaultPath; const url = new URL(metadataPath, window.location.origin); return url.toString(); } export function useOAuthPopupFlow< TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, >(options: { readonly popupName: string; readonly callbackPath?: string; readonly noAuthorizationUrlMessage?: string; readonly popupBlockedMessage?: string; readonly popupClosedMessage?: string; readonly detectPopupClosed?: boolean; readonly startErrorMessage?: string; }) { const { detectPopupClosed = true, callbackPath, noAuthorizationUrlMessage, popupBlockedMessage, popupClosedMessage, popupName, startErrorMessage, } = options; const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" }); const reportHandledError = useReportHandledError(); const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const cleanupRef = useRef<(() => void) | null>(null); const sessionRef = useRef<{ readonly state: string } | null>(null); // A window reserved on the click but not yet handed to a flow owns nothing // that would close it: `cleanupRef` is only set once the authorization URL // exists. Hold it here so cancel and unmount can close it, or abandoning a // connect mid-probe strands a blank popup on screen. const reservationRef = useRef(null); const releaseReservation = useCallback(() => { reservationRef.current?.popup.close(); reservationRef.current = null; }, []); const cancelSession = useCallback( (state: string) => { void doCancelOAuth({ payload: { state: OAuthState.make(state) } }); }, [doCancelOAuth], ); const cancel = useCallback(() => { const session = sessionRef.current; cleanupRef.current?.(); cleanupRef.current = null; sessionRef.current = null; releaseReservation(); if (session) cancelSession(session.state); setBusy(false); }, [cancelSession, releaseReservation]); useEffect( () => () => { const session = sessionRef.current; cleanupRef.current?.(); cleanupRef.current = null; sessionRef.current = null; reservationRef.current?.popup.close(); reservationRef.current = null; if (session) cancelSession(session.state); }, [cancelSession], ); /** * Claim the sign-in window NOW, while the click still carries user * activation, and return what was claimed so the caller can abandon a flow * the browser already refused instead of running a doomed authorization. * * Callers that reach `openAuthorization` synchronously do not need this; it * reserves for itself. */ const reserve = useCallback((): OAuthPopupReservation => { // Tear the previous flow down BEFORE opening: `window.open` reuses a window // already carrying `popupName`, so cancelling afterwards would close the // very window just reserved. cancel(); const bridge = getDesktopBridge(); if (bridge !== null) return { kind: "desktop", bridge }; const popup = reserveOAuthPopup({ popupName }); if (popup === null) { trackEvent("oauth_popup_blocked"); setError(blockedMessage); return { kind: "blocked" }; } reservationRef.current = popup; return { kind: "window", popup }; }, [blockedMessage, cancel, popupName]); const openAuthorization = useCallback( async (input: StartOAuthAuthorizationInput) => { // A caller that reserved on its own click already ran `cancel` inside // `reserve`; cancelling again here would close the window it reserved. const reservation = input.reservation ?? reserve(); if (reservation.kind === "blocked") { setBusy(false); setError(blockedMessage); input.onError?.(blockedMessage); return; } setBusy(true); setError(null); // Desktop hosts open the auth URL in the user's real browser, so they // reserve no in-page window and rely on the polling channel for the // result. const desktopBridge = reservation.kind === "desktop" ? reservation.bridge : null; const reservedPopup = reservation.kind === "window" ? reservation.popup : null; // The window's lifetime now belongs to this flow's teardown, which closes // it on every failure path below. reservationRef.current = null; const startExit = await Effect.runPromiseExit( Effect.tryPromise({ try: input.run, catch: (cause) => new OAuthAuthorizationStartError({ cause, message: messageFromUnknown(cause, startErrorMessage ?? "Failed to start sign-in"), }), }), ); if (Exit.isFailure(startExit)) { const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in"); reportHandledError(startExit.cause, { surface: "oauth", action: "start", message, metadata: input.reportMetadata, }); reservedPopup?.popup.close(); setBusy(false); setError(message); input.onError?.(message); return; } const response = startExit.value; if (response.authorizationUrl === null) { const message = noAuthorizationUrlMessage ?? "OAuth start did not produce an authorization URL"; reservedPopup?.popup.close(); setBusy(false); setError(message); input.onError?.(message); return; } sessionRef.current = { state: response.state }; input.onAuthorizationStarted?.(response); const handleResult = async (result: OAuthPopupResult) => { cleanupRef.current = null; sessionRef.current = null; if (!result.ok) { trackEvent("oauth_completed", { success: false }); setBusy(false); setError(result.error); input.onError?.(result.error, result.errorDetails); return; } const refreshExit = await doOAuthConnectionCompleted({ reactivityKeys: connectionWriteKeys, }); if (Exit.isFailure(refreshExit)) { const message = messageFromExit(refreshExit, "Failed to refresh connection"); reportHandledError(refreshExit.cause, { surface: "oauth", action: "refresh_connection", message, metadata: input.reportMetadata, }); trackEvent("oauth_completed", { success: false }); setBusy(false); setError(message); input.onError?.(message); return; } const persistenceError = await Promise.resolve(input.onSuccess(result)).then( () => null, (cause: unknown) => cause, ); if (persistenceError !== null) { const message = messageFromUnknown(persistenceError, "Failed to save connection"); reportHandledError(persistenceError, { surface: "oauth", action: "persist_connection", message, metadata: input.reportMetadata, }); trackEvent("oauth_completed", { success: false }); setBusy(false); setError(message); input.onError?.(message); return; } trackEvent("oauth_completed", { success: true }); setBusy(false); }; const handleClosed = () => { cleanupRef.current = null; sessionRef.current = null; // `popup.closed` is advisory: COOP redirects can make a live popup // appear closed to the opener. Keep server OAuth state alive for a // callback or TTL cleanup; only explicit cancel deletes the session. const message = popupClosedMessage ?? "Sign-in cancelled - popup was closed before completing the flow."; trackEvent("oauth_completed", { success: false }); setBusy(false); setError(message); input.onError?.(message); }; const handleOpenFailed = () => { cleanupRef.current = null; sessionRef.current = null; cancelSession(response.state); trackEvent("oauth_completed", { success: false }); setBusy(false); setError(blockedMessage); input.onError?.(blockedMessage); }; cleanupRef.current = desktopBridge ? openOAuthSystemBrowser({ url: response.authorizationUrl, sessionId: response.state, openExternal: desktopBridge.openExternal, onResult: (result) => void handleResult(result), onOpenFailed: handleOpenFailed, onTimeout: handleClosed, }) : openOAuthPopup({ url: response.authorizationUrl, popupName, channelName: OAUTH_POPUP_MESSAGE_TYPE, expectedSessionId: response.state, reservedPopup: reservedPopup ?? undefined, closedPollMs: detectPopupClosed ? undefined : null, onResult: (result) => void handleResult(result), onClosed: handleClosed, onOpenFailed: handleOpenFailed, }); }, [ blockedMessage, cancelSession, detectPopupClosed, doOAuthConnectionCompleted, noAuthorizationUrlMessage, popupClosedMessage, popupName, reportHandledError, reserve, startErrorMessage, ], ); const start = useCallback( async (input: StartOAuthPopupInput) => { await openAuthorization({ owner: input.payload.owner, onSuccess: input.onSuccess, onError: input.onError, onAuthorizationStarted: input.onAuthorizationStarted, ...(input.reservation === undefined ? {} : { reservation: input.reservation }), reportMetadata: { client: String(input.payload.client), integration: String(input.payload.integration), name: String(input.payload.name), owner: input.payload.owner, }, run: () => doStartOAuth({ payload: { client: input.payload.client, clientOwner: input.payload.clientOwner, owner: input.payload.owner, name: input.payload.name, integration: input.payload.integration, template: input.payload.template, identityLabel: input.payload.identityLabel, newConnection: input.payload.newConnection, redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath), }, }).then((exit) => Exit.isSuccess(exit) ? // The redirect branch carries `authorizationUrl` + `state`; the // inline "connected" (client_credentials) branch has no URL to // open and no redirect, so `state` is intentionally empty — it // is never read for an already-minted connection. exit.value.status === "redirect" ? { state: exit.value.state, authorizationUrl: exit.value.authorizationUrl } : { state: "", authorizationUrl: null } : Effect.runPromise( Effect.fail({ message: messageFromExit(exit, startErrorMessage ?? "Failed to start sign-in"), }), ), ), }); }, [callbackPath, doStartOAuth, openAuthorization, startErrorMessage], ); return { busy, error, setError, start, openAuthorization, cancel, reserve, releaseReservation, }; } export function OAuthSignInButton(props: { readonly busy: boolean; readonly error: string | null; readonly isConnected: boolean; readonly onSignIn: () => void; readonly reconnectingLabel?: string; readonly signingInLabel?: string; readonly reconnectLabel?: string; readonly signInLabel?: string; }) { return (
{props.error && {props.error}}
); }