import { useCallback } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { WorkIdentityStatusSchema, type OAuthClientSlug, type Owner, type WorkIdentityStatus, } from "@executor-js/sdk/shared"; import { startWorkIdentityLink, workIdentityLinkCompleted } from "../api/atoms"; import { workIdentityWriteKeys } from "../api/reactivity-keys"; import { oauthCallbackUrl, useOAuthPopupFlow, type OAuthPopupReservation } from "./oauth-sign-in"; // --------------------------------------------------------------------------- // Linking a work identity from the browser. // // This is the leg the console could not previously walk. An enterprise-managed // connect presents an identity assertion, and no browser can obtain one — the // identity provider's client secret is server-side. So the browser does the one // thing only it can: it carries the member through a real OIDC sign-in at the // organization's identity provider. Executor builds the authorization URL, // redeems the code with the IdP app's own credentials, and takes custody of the // durable subject. // // It reuses `useOAuthPopupFlow` rather than growing a second popup stack. That // hook already owns the three things that are easy to get wrong and invisible // when they are: the window reserved on the click (user activation expires // while `start` is in flight), settle-exactly-once across postMessage / // BroadcastChannel / localStorage, and teardown on unmount — which is what makes // closing the modal mid-link genuinely abandon it. // // The link's redirect lands on the SHARED `/api/oauth/callback`, which decides // from the stored session which flow it is completing and posts // `{ workIdentity }` back. That key is the discriminator, and it is PARSED here: // a connection payload is spread flat and shares field names with nothing that // could be mistaken for a status. // --------------------------------------------------------------------------- /** The popup payload a completed link posts back to the opener. */ export interface WorkIdentityPopupPayload { readonly workIdentity: WorkIdentityStatus; } const decodeLinkPayload = Schema.decodeUnknownOption( Schema.Struct({ workIdentity: WorkIdentityStatusSchema }), ); /** The linked identity carried by a popup completion, or null when the message * was some other flow's. Parsed, never cast: this value crosses a postMessage * boundary and decides what the console says the member is signed in as. */ export const workIdentityFromPopupPayload = (payload: unknown): WorkIdentityStatus | null => Option.match(decodeLinkPayload(payload), { onNone: () => null, onSome: (decoded) => decoded.workIdentity, }); /** Which held identity a link is about — the IdP registration, and the owner * the connections backed by it are made under. */ export interface WorkIdentityRefInput { readonly owner: Owner; readonly idpClient: OAuthClientSlug; readonly idpClientOwner: Owner; } export interface StartWorkIdentityLinkInput { readonly ref: WorkIdentityRefInput; /** Window reserved on the click that began this flow. Required in practice: * a link is always reached after at least one round trip (the connect that * reported `workIdentityLinkRequired`, or a health verdict), by which time * the browser has forgotten the click. */ readonly reservation?: OAuthPopupReservation; /** The account that was linked. Runs after the reactivity bump, so anything * it triggers already reads the linked state. */ readonly onLinked: (identity: WorkIdentityStatus) => void | Promise; readonly onError?: (message: string) => void; } /** The "who am I signed in as" line, or null when nothing is held. * * `needs_relink` deliberately does NOT read as linked: an identity the provider * has rejected still names an account, and showing it plainly would tell the * member everything is fine while every connection behind it is stalled. */ export const workIdentityLinkedLabel = (identity: WorkIdentityStatus): string | null => { if (identity.status === "unlinked") return null; const who = identity.label ?? identity.subject ?? "your work identity"; return identity.status === "linked" ? `linked as ${who}` : `${who} · sign in again`; }; /** * Drive a work-identity link through the shared OAuth popup machinery. * * Every piece of in-flight state lives in the returned handle, which lives in * the component that called this hook — so a modal that unmounts takes the * dangling session, the popup window, and the busy flag with it. */ export function useWorkIdentityLink(options: { readonly popupName: string }) { const doStartLink = useAtomSet(startWorkIdentityLink, { mode: "promiseExit", }); const doLinkCompleted = useAtomSet(workIdentityLinkCompleted, { mode: "promiseExit", }); const popup = useOAuthPopupFlow({ popupName: options.popupName, // A work identity link crosses the identity provider's own consent and MFA // screens, where COOP can make a live popup read as closed. Cancelling is // the explicit path; a false "you closed it" mid-MFA is not. detectPopupClosed: false, startErrorMessage: "Couldn't start the work identity sign-in", popupBlockedMessage: "Your browser blocked the work identity sign-in window", }); const link = useCallback( async (input: StartWorkIdentityLinkInput): Promise => { await popup.openAuthorization({ owner: input.ref.owner, ...(input.reservation === undefined ? {} : { reservation: input.reservation }), reportMetadata: { idp_client: String(input.ref.idpClient), owner: input.ref.owner, }, run: () => doStartLink({ payload: { owner: input.ref.owner, idpClient: input.ref.idpClient, idpClientOwner: input.ref.idpClientOwner, // The same callback every interactive connect uses, so an // enterprise registers ONE redirect URI and not two. redirectUri: oauthCallbackUrl(), }, // Starting a link changes nothing until the callback completes it. reactivityKeys: [], }).then((exit) => { if (Exit.isSuccess(exit)) { return { state: String(exit.value.state), authorizationUrl: exit.value.authorizationUrl, }; } // Reject with the server's own typed failure, cause and all, so // `WorkIdentityLinkError.message` reaches the user verbatim rather // than as a generic "sign-in failed". return Effect.runPromise(Effect.failCause(exit.cause)); }), onSuccess: async (payload: WorkIdentityPopupPayload) => { const identity = workIdentityFromPopupPayload(payload); if (identity === null) { // The callback answered, but not with a work identity. Refusing to // guess is the point: retrying the connect on a link that did not // happen would fail again with the same unhelpful message. input.onError?.("The sign-in did not return a work identity. Try linking again."); return; } await doLinkCompleted({ reactivityKeys: workIdentityWriteKeys }); await input.onLinked(identity); }, ...(input.onError === undefined ? {} : { onError: (message: string) => input.onError?.(message) }), }); }, [doLinkCompleted, doStartLink, popup], ); return { /** True while the sign-in window is open or the start is in flight. */ busy: popup.busy, error: popup.error, link, /** Claim the sign-in window on the click, before any await. */ reserve: popup.reserve, /** Close a claimed window this flow turned out not to need. */ releaseReservation: popup.releaseReservation, cancel: popup.cancel, setError: popup.setError, }; }