From c62427217cc5905eeccec66bf39ad6fd37cd6d4b Mon Sep 17 00:00:00 2001 From: akxue <18199652+akxue@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:47:57 +0000 Subject: [PATCH 01/10] Embed the live view for the human intervention step --- .changeset/human-intervention-step.md | 5 + packages/demo/src/States.tsx | 12 ++ .../src/KernelManagedAuth.tsx | 14 ++ .../src/appearance/types.ts | 3 + .../src/components/HumanInterventionStep.tsx | 111 ++++++++++++++++ packages/managed-auth-react/src/index.ts | 1 + packages/managed-auth-react/src/lib/types.ts | 3 + .../src/localization/defaults.ts | 9 ++ .../src/localization/types.ts | 8 ++ .../managed-auth-react/src/session/state.ts | 4 +- .../src/session/submission.ts | 12 ++ ...nagedAuthSession.humanIntervention.test.ts | 124 ++++++++++++++++++ .../src/session/useManagedAuthSession.ts | 28 +++- .../managed-auth-react/src/styles/styles.css | 73 +++++++++++ 14 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 .changeset/human-intervention-step.md create mode 100644 packages/managed-auth-react/src/components/HumanInterventionStep.tsx create mode 100644 packages/managed-auth-react/src/session/useManagedAuthSession.humanIntervention.test.ts diff --git a/.changeset/human-intervention-step.md b/.changeset/human-intervention-step.md new file mode 100644 index 0000000..c184b0c --- /dev/null +++ b/.changeset/human-intervention-step.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": minor +--- + +Add the `AWAITING_HUMAN_INTERVENTION` step. When a connection has `hitl` enabled and the login hits a CAPTCHA the browser cannot solve, the component embeds the login browser's live view so the person can complete it, then resumes the login when they confirm. Sites that restrict embedded content with a Content-Security-Policy need to allow the live view origin in `frame-src`. diff --git a/packages/demo/src/States.tsx b/packages/demo/src/States.tsx index 39fc0d1..8ba3f14 100644 --- a/packages/demo/src/States.tsx +++ b/packages/demo/src/States.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { AppearanceProvider, ExternalActionWaiting, + HumanInterventionStep, LoadingState, LocalizationProvider, Shell, @@ -79,6 +80,7 @@ type StateName = | "awaiting_input_mfa" | "awaiting_external_action" | "awaiting_external_action_multi" + | "awaiting_human_intervention" | "submitting" | "success" | "expired" @@ -93,6 +95,7 @@ const allStates: StateName[] = [ "awaiting_input_mfa", "awaiting_external_action", "awaiting_external_action_multi", + "awaiting_human_intervention", "submitting", "success", "expired", @@ -197,6 +200,15 @@ export function States() { onMFASelect={() => {}} /> ); + case "awaiting_human_intervention": + return ( + + ); case "submitting": return ( diff --git a/packages/managed-auth-react/src/KernelManagedAuth.tsx b/packages/managed-auth-react/src/KernelManagedAuth.tsx index 7ba9df4..6a6744a 100644 --- a/packages/managed-auth-react/src/KernelManagedAuth.tsx +++ b/packages/managed-auth-react/src/KernelManagedAuth.tsx @@ -10,6 +10,7 @@ import { StepError } from "./components/StepError"; import { StepExpired } from "./components/StepExpired"; import { LoadingState } from "./components/LoadingState"; import { ExternalActionWaiting } from "./components/ExternalActionWaiting"; +import { HumanInterventionStep } from "./components/HumanInterventionStep"; import { useLocalization } from "./localization/context"; import type { Appearance } from "./appearance/types"; import type { Localization } from "./localization/types"; @@ -83,6 +84,7 @@ function KernelManagedAuthInner({ submitSSO, submitMFA, submitSignIn, + completeHumanIntervention, } = session; const targetDomain = useMemo(() => state?.domain ?? "", [state?.domain]); @@ -121,6 +123,18 @@ function KernelManagedAuthInner({ ); } + if (uiState === "awaiting_human_intervention") { + return ( + + ); + } + if (uiState === "success") { return ; } diff --git a/packages/managed-auth-react/src/appearance/types.ts b/packages/managed-auth-react/src/appearance/types.ts index 4db0796..9993605 100644 --- a/packages/managed-auth-react/src/appearance/types.ts +++ b/packages/managed-auth-react/src/appearance/types.ts @@ -123,6 +123,9 @@ export interface AppearanceElements { /** External action waiting screen. */ externalActionIcon?: ElementValue; externalActionMessage?: ElementValue; + /** Human intervention screen. */ + humanInterventionMessage?: ElementValue; + humanInterventionLiveView?: ElementValue; /** Form wrapper. */ form?: ElementValue; formField?: ElementValue; diff --git a/packages/managed-auth-react/src/components/HumanInterventionStep.tsx b/packages/managed-auth-react/src/components/HumanInterventionStep.tsx new file mode 100644 index 0000000..536651a --- /dev/null +++ b/packages/managed-auth-react/src/components/HumanInterventionStep.tsx @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSlot } from "../appearance/context"; +import { useLocalization } from "../localization/context"; +import { Button } from "./primitives/Button"; +import { ErrorBanner } from "./ErrorBanner"; +import { ShieldCheckIcon } from "./icons"; + +// The live view posts one of these when its connection never succeeds. +const LIVE_VIEW_CONNECT_FAILURES = new Set([ + "KERNEL_CONNECTION_TIMEOUT", + "KERNEL_CONNECTION_FAILED", +]); + +function liveViewOrigin(url: string | null | undefined): string | null { + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +interface HumanInterventionStepProps { + targetDomain: string; + liveViewUrl?: string | null; + onDone: () => void; + isLoading?: boolean; + errorMessage?: string; +} + +export function HumanInterventionStep({ + targetDomain, + liveViewUrl, + onDone, + isLoading, + errorMessage, +}: HumanInterventionStepProps) { + const slot = useSlot(); + const l = useLocalization(); + const origin = useMemo(() => liveViewOrigin(liveViewUrl), [liveViewUrl]); + const [attempt, setAttempt] = useState(0); + const [connectFailed, setConnectFailed] = useState(false); + + useEffect(() => { + if (!origin) return; + const onMessage = (event: MessageEvent) => { + if (event.origin !== origin) return; + const type = (event.data as { type?: unknown } | null)?.type; + if (typeof type === "string" && LIVE_VIEW_CONNECT_FAILURES.has(type)) { + setConnectFailed(true); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [origin]); + + const retry = () => { + setConnectFailed(false); + setAttempt((n) => n + 1); + }; + + return ( +
+
+ +
+

{l.humanInterventionTitle}

+

+ {l.humanInterventionMessage(targetDomain)} +

+
+
+ + {errorMessage && } + +
+ {origin ? ( +