Unified · Split
| @@ -4,6 +4,7 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; · 3 unmodified lines | |||
| 4 | import { AutumnProvider } from "autumn-js/react"; | 4 | import { AutumnProvider } from "autumn-js/react"; |
| 5 | import posthog from "posthog-js"; | 5 | import posthog from "posthog-js"; |
| 6 | import { PostHogProvider } from "posthog-js/react"; | 6 | import { PostHogProvider } from "posthog-js/react"; |
| 7 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; | ||
| 7 | import { ExecutorProvider } from "@executor-js/react/api/provider"; | 8 | import { ExecutorProvider } from "@executor-js/react/api/provider"; |
| 8 | import { Skeleton } from "@executor-js/react/components/skeleton"; | 9 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 9 | import { Toaster } from "@executor-js/react/components/sonner"; | 10 | import { Toaster } from "@executor-js/react/components/sonner"; |
| @@ -40,6 +41,21 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { · 30 unmodified lines | |||
| 40 | }); | 41 | }); |
| 41 | } | 42 | } |
| 42 | 43 | ||
| 44 | const captureFrontendError: FrontendErrorReporter = (error, context) => { | ||
| 45 | Sentry.captureException(error, (scope) => { | ||
| 46 | scope.setTag("executor.ui.surface", context.surface); | ||
| 47 | scope.setTag("executor.ui.action", context.action); | ||
| 48 | scope.setTag("executor.ui.severity", context.severity ?? "error"); | ||
| 49 | scope.setContext("executor.ui", { | ||
| 50 | surface: context.surface, | ||
| 51 | action: context.action, | ||
| 52 | message: context.message, | ||
| 53 | metadata: context.metadata, | ||
| 54 | }); | ||
| 55 | return scope; | ||
| 56 | }); | ||
| 57 | }; | ||
| 58 | |||
| 43 | export const Route = createRootRoute({ | 59 | export const Route = createRootRoute({ |
| 44 | head: () => ({ | 60 | head: () => ({ |
| 45 | meta: [ | 61 | meta: [ |
| @@ -168,12 +184,14 @@ function AuthGate() { · 122 unmodified lines | |||
| 168 | 184 | ||
| 169 | return ( | 185 | return ( |
| 170 | <AutumnProvider pathPrefix="/api/autumn"> | 186 | <AutumnProvider pathPrefix="/api/autumn"> |
| 171 | <ExecutorProvider fallback={<ShellSkeleton />}> | 187 | <Sentry.ErrorBoundary fallback={<ShellSkeleton />} showDialog={false}> |
| 172 | <ExecutorPluginsProvider plugins={clientPlugins}> | 188 | <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}> |
| 173 | <Shell /> | 189 | <ExecutorPluginsProvider plugins={clientPlugins}> |
| 174 | <Toaster /> | 190 | <Shell /> |
| 175 | </ExecutorPluginsProvider> | 191 | <Toaster /> |
| 176 | </ExecutorProvider> | 192 | </ExecutorPluginsProvider> |
| 193 | </ExecutorProvider> | ||
| 194 | </Sentry.ErrorBoundary> | ||
| 177 | </AutumnProvider> | 195 | </AutumnProvider> |
| 178 | ); | 196 | ); |
| 179 | } | 197 | } |
| @@ -1,9 +1,8 @@ | |||
| 1 | import { useCallback, useState } from "react"; | 1 | import { useCallback, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | 4 | ||
| 5 | import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting"; | ||
| 7 | import { useScope } from "@executor-js/react/api/scope-context"; | 6 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 7 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 9 | import { | 8 | import { |
| @@ -45,15 +44,6 @@ import { addGraphqlSourceOptimistic } from "./atoms"; · 35 unmodified lines | |||
| 45 | import { initialGraphqlCredentials } from "./defaults"; | 44 | import { initialGraphqlCredentials } from "./defaults"; |
| 46 | import type { GraphqlCredentialInput } from "../sdk/types"; | 45 | import type { GraphqlCredentialInput } from "../sdk/types"; |
| 47 | 46 | ||
| 48 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 49 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 50 | |||
| 51 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 52 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 53 | onNone: () => fallback, | ||
| 54 | onSome: ({ message }) => message, | ||
| 55 | }); | ||
| 56 | |||
| 57 | type AuthMode = "none" | "oauth2"; | 47 | type AuthMode = "none" | "oauth2"; |
| 58 | 48 | ||
| 59 | export default function AddGraphqlSource(props: { | 49 | export default function AddGraphqlSource(props: { |
| @@ -70,6 +60,7 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines | |||
| 70 | const [addError, setAddError] = useState<string | null>(null); | 60 | const [addError, setAddError] = useState<string | null>(null); |
| 71 | const [authMode, setAuthMode] = useState<AuthMode>("none"); | 61 | const [authMode, setAuthMode] = useState<AuthMode>("none"); |
| 72 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); | 62 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); |
| 63 | const errorMessageFromExit = useErrorMessageFromExit(); | ||
| 73 | 64 | ||
| 74 | const scopeId = useScope(); | 65 | const scopeId = useScope(); |
| 75 | const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); | 66 | const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); |
| @@ -170,7 +161,12 @@ export default function AddGraphqlSource(props: { · 94 unmodified lines | |||
| 170 | reactivityKeys: sourceWriteKeys, | 161 | reactivityKeys: sourceWriteKeys, |
| 171 | }); | 162 | }); |
| 172 | if (Exit.isFailure(exit)) { | 163 | if (Exit.isFailure(exit)) { |
| 173 | setAddError(errorMessageFromExit(exit, "Failed to add source")); | 164 | setAddError( |
| 165 | errorMessageFromExit(exit, "Failed to add source", { | ||
| 166 | surface: "graphql_source", | ||
| 167 | action: "add_source", | ||
| 168 | }), | ||
| 169 | ); | ||
| 174 | setAdding(false); | 170 | setAdding(false); |
| 175 | return; | 171 | return; |
| 176 | } | 172 | } |
| @@ -1,9 +1,8 @@ | |||
| 1 | import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; | 1 | import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | 4 | ||
| 5 | import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting"; | ||
| 7 | import { useScope } from "@executor-js/react/api/scope-context"; | 6 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | import { Button } from "@executor-js/react/components/button"; | 7 | import { Button } from "@executor-js/react/components/button"; |
| 9 | import { | 8 | import { |
| @@ -60,15 +59,6 @@ import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; · 50 unmodified lines | |||
| 60 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 59 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 61 | import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; | 60 | import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; |
| 62 | 61 | ||
| 63 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 64 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 65 | |||
| 66 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 67 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 68 | onNone: () => fallback, | ||
| 69 | onSome: ({ message }) => message, | ||
| 70 | }); | ||
| 71 | |||
| 72 | // --------------------------------------------------------------------------- | 62 | // --------------------------------------------------------------------------- |
| 73 | // Preset lookup | 63 | // Preset lookup |
| 74 | // --------------------------------------------------------------------------- | 64 | // --------------------------------------------------------------------------- |
| @@ -301,6 +291,7 @@ export default function AddMcpSource(props: { · 226 unmodified lines | |||
| 301 | const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { | 291 | const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { |
| 302 | mode: "promiseExit", | 292 | mode: "promiseExit", |
| 303 | }); | 293 | }); |
| 294 | const errorMessageFromExit = useErrorMessageFromExit(); | ||
| 304 | const secretList = useSecretPickerSecrets(); | 295 | const secretList = useSecretPickerSecrets(); |
| 305 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ | 296 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 306 | popupName: "mcp-oauth", | 297 | popupName: "mcp-oauth", |
| @@ -357,13 +348,16 @@ export default function AddMcpSource(props: { · 50 unmodified lines | |||
| 357 | if (Exit.isFailure(exit)) { | 348 | if (Exit.isFailure(exit)) { |
| 358 | dispatch({ | 349 | dispatch({ |
| 359 | type: "probe-fail", | 350 | type: "probe-fail", |
| 360 | error: errorMessageFromExit(exit, "Failed to connect"), | 351 | error: errorMessageFromExit(exit, "Failed to connect", { |
| 352 | surface: "mcp_source", | ||
| 353 | action: "probe_remote_source", | ||
| 354 | }), | ||
| 361 | }); | 355 | }); |
| 362 | return; | 356 | return; |
| 363 | } | 357 | } |
| 364 | setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none"); | 358 | setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none"); |
| 365 | dispatch({ type: "probe-ok", probe: exit.value }); | 359 | dispatch({ type: "probe-ok", probe: exit.value }); |
| 366 | }, [state.url, scopeId, doProbe, remoteCredentials]); | 360 | }, [state.url, scopeId, doProbe, remoteCredentials, errorMessageFromExit]); |
| 367 | 361 | ||
| 368 | // Keep the latest handleProbe in a ref so the debounced effect can call it | 362 | // Keep the latest handleProbe in a ref so the debounced effect can call it |
| 369 | // without depending on its identity (which changes every render). | 363 | // without depending on its identity (which changes every render). |
| @@ -483,7 +477,10 @@ export default function AddMcpSource(props: { · 113 unmodified lines | |||
| 483 | if (Exit.isFailure(exit)) { | 477 | if (Exit.isFailure(exit)) { |
| 484 | dispatch({ | 478 | dispatch({ |
| 485 | type: "add-fail", | 479 | type: "add-fail", |
| 486 | error: errorMessageFromExit(exit, "Failed to add source"), | 480 | error: errorMessageFromExit(exit, "Failed to add source", { |
| 481 | surface: "mcp_source", | ||
| 482 | action: "add_remote_source", | ||
| 483 | }), | ||
| 487 | }); | 484 | }); |
| 488 | return; | 485 | return; |
| 489 | } | 486 | } |
| @@ -499,6 +496,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines | |||
| 499 | doAdd, | 496 | doAdd, |
| 500 | props, | 497 | props, |
| 501 | scopeId, | 498 | scopeId, |
| 499 | errorMessageFromExit, | ||
| 502 | requestCredentialTargetScope, | 500 | requestCredentialTargetScope, |
| 503 | oauthCredentialTargetScope, | 501 | oauthCredentialTargetScope, |
| 504 | ]); | 502 | ]); |
| @@ -549,12 +547,26 @@ export default function AddMcpSource(props: { · 44 unmodified lines | |||
| 549 | reactivityKeys: sourceWriteKeys, | 547 | reactivityKeys: sourceWriteKeys, |
| 550 | }); | 548 | }); |
| 551 | if (Exit.isFailure(exit)) { | 549 | if (Exit.isFailure(exit)) { |
| 552 | setStdioError(errorMessageFromExit(exit, "Failed to add source")); | 550 | setStdioError( |
| 551 | errorMessageFromExit(exit, "Failed to add source", { | ||
| 552 | surface: "mcp_source", | ||
| 553 | action: "add_stdio_source", | ||
| 554 | }), | ||
| 555 | ); | ||
| 553 | setStdioAdding(false); | 556 | setStdioAdding(false); |
| 554 | return; | 557 | return; |
| 555 | } | 558 | } |
| 556 | props.onComplete(); | 559 | props.onComplete(); |
| 557 | }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]); | 560 | }, [ |
| 561 | stdioCommand, | ||
| 562 | stdioArgs, | ||
| 563 | stdioEnv, | ||
| 564 | stdioIdentity, | ||
| 565 | doAdd, | ||
| 566 | scopeId, | ||
| 567 | props, | ||
| 568 | errorMessageFromExit, | ||
| 569 | ]); | ||
| 558 | 570 | ||
| 559 | // ---- Render ---- | 571 | // ---- Render ---- |
| 560 | 572 | ||
| @@ -0,0 +1,48 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import { | ||
| 5 | messageFromExit, | ||
| 6 | messageFromUnknown, | ||
| 7 | reportExitFailure, | ||
| 8 | type FrontendErrorContext, | ||
| 9 | } from "./error-reporting"; | ||
| 10 | |||
| 11 | describe("frontend error reporting", () => { | ||
| 12 | it("extracts stable messages from structured failures", () => { | ||
| 13 | expect(messageFromUnknown({ message: "Saved connection failed" }, "Fallback")).toBe( | ||
| 14 | "Saved connection failed", | ||
| 15 | ); | ||
| 16 | expect(messageFromUnknown("Plain failure", "Fallback")).toBe("Plain failure"); | ||
| 17 | expect(messageFromUnknown({ reason: "unknown" }, "Fallback")).toBe("Fallback"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("extracts stable messages from Effect exits", () => { | ||
| 21 | const exit = Exit.fail({ message: "Could not update source" }); | ||
| 22 | |||
| 23 | expect(messageFromExit(exit, "Fallback")).toBe("Could not update source"); | ||
| 24 | expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("reports failed exits with the provided context", () => { | ||
| 28 | const exit = Exit.fail({ message: "Could not update source" }); | ||
| 29 | const calls: Array<{ error: unknown; context: FrontendErrorContext }> = []; | ||
| 30 | |||
| 31 | reportExitFailure( | ||
| 32 | (error, context) => { | ||
| 33 | calls.push({ error, context }); | ||
| 34 | }, | ||
| 35 | exit, | ||
| 36 | { | ||
| 37 | surface: "sources", | ||
| 38 | action: "update", | ||
| 39 | message: "Could not update source", | ||
| 40 | }, | ||
| 41 | ); | ||
| 42 | |||
| 43 | expect(calls).toHaveLength(1); | ||
| 44 | expect(Cause.isCause(calls[0]!.error)).toBe(true); | ||
| 45 | expect(calls[0]!.context.surface).toBe("sources"); | ||
| 46 | expect(calls[0]!.context.action).toBe("update"); | ||
| 47 | }); | ||
| 48 | }); | ||
| @@ -0,0 +1,89 @@ | |||
| 1 | import * as React from "react"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | |||
| 8 | export type FrontendErrorContext = { | ||
| 9 | readonly surface: string; | ||
| 10 | readonly action: string; | ||
| 11 | readonly message?: string; | ||
| 12 | readonly severity?: "error" | "warning"; | ||
| 13 | readonly metadata?: Record<string, string | number | boolean | null | undefined>; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void; | ||
| 17 | |||
| 18 | class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ | ||
| 19 | readonly cause: unknown; | ||
| 20 | readonly context: FrontendErrorContext; | ||
| 21 | }> {} | ||
| 22 | |||
| 23 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 24 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 25 | |||
| 26 | const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => { | ||
| 27 | if (typeof globalThis.reportError !== "function") return; | ||
| 28 | globalThis.reportError(new FrontendHandledError({ cause: error, context })); | ||
| 29 | }; | ||
| 30 | |||
| 31 | const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( | ||
| 32 | defaultFrontendErrorReporter, | ||
| 33 | ); | ||
| 34 | |||
| 35 | export const FrontendErrorReporterProvider = ( | ||
| 36 | props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>, | ||
| 37 | ) => ( | ||
| 38 | <FrontendErrorReporterContext.Provider value={props.reporter ?? defaultFrontendErrorReporter}> | ||
| 39 | {props.children} | ||
| 40 | </FrontendErrorReporterContext.Provider> | ||
| 41 | ); | ||
| 42 | |||
| 43 | export const useReportHandledError = (): FrontendErrorReporter => | ||
| 44 | React.useContext(FrontendErrorReporterContext); | ||
| 45 | |||
| 46 | export const messageFromUnknown = (error: unknown, fallback: string): string => | ||
| 47 | Option.match(decodeErrorMessage(error), { | ||
| 48 | onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback), | ||
| 49 | onSome: ({ message }) => message, | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 53 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 54 | onNone: () => fallback, | ||
| 55 | onSome: ({ message }) => message, | ||
| 56 | }); | ||
| 57 | |||
| 58 | export const reportExitFailure = ( | ||
| 59 | report: FrontendErrorReporter, | ||
| 60 | exit: Exit.Exit<unknown, unknown>, | ||
| 61 | context: FrontendErrorContext, | ||
| 62 | ): void => { | ||
| 63 | if (!Exit.isFailure(exit)) return; | ||
| 64 | report(exit.cause, context); | ||
| 65 | }; | ||
| 66 | |||
| 67 | export const useErrorMessageFromExit = (): (( | ||
| 68 | exit: Exit.Exit<unknown, unknown>, | ||
| 69 | fallback: string, | ||
| 70 | context: Omit<FrontendErrorContext, "message"> & { readonly message?: string }, | ||
| 71 | ) => string) => { | ||
| 72 | const report = useReportHandledError(); | ||
| 73 | return React.useCallback( | ||
| 74 | (exit, fallback, context) => { | ||
| 75 | const message = messageFromExit(exit, fallback); | ||
| 76 | reportExitFailure(report, exit, { ...context, message: context.message ?? message }); | ||
| 77 | return message; | ||
| 78 | }, | ||
| 79 | [report], | ||
| 80 | ); | ||
| 81 | }; | ||
| 82 | |||
| 83 | export const reportCauseFailure = ( | ||
| 84 | report: FrontendErrorReporter, | ||
| 85 | cause: Cause.Cause<unknown>, | ||
| 86 | context: FrontendErrorContext, | ||
| 87 | ): void => { | ||
| 88 | report(cause, context); | ||
| 89 | }; | ||
| @@ -1,11 +1,17 @@ | |||
| 1 | import { RegistryProvider } from "@effect/atom-react"; | 1 | import { RegistryProvider } from "@effect/atom-react"; |
| 2 | import * as React from "react"; | 2 | import * as React from "react"; |
| 3 | import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; | ||
| 3 | import { ScopeProvider } from "./scope-context"; | 4 | import { ScopeProvider } from "./scope-context"; |
| 4 | 5 | ||
| 5 | export const ExecutorProvider = ( | 6 | export const ExecutorProvider = ( |
| 6 | props: React.PropsWithChildren<{ fallback?: React.ReactNode }>, | 7 | props: React.PropsWithChildren<{ |
| 8 | fallback?: React.ReactNode; | ||
| 9 | onHandledError?: FrontendErrorReporter; | ||
| 10 | }>, | ||
| 7 | ) => ( | 11 | ) => ( |
| 8 | <RegistryProvider> | 12 | <FrontendErrorReporterProvider reporter={props.onHandledError}> |
| 9 | <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider> | 13 | <RegistryProvider> |
| 10 | </RegistryProvider> | 14 | <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider> |
| 15 | </RegistryProvider> | ||
| 16 | </FrontendErrorReporterProvider> | ||
| 11 | ); | 17 | ); |
| @@ -1,11 +1,11 @@ | |||
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Data from "effect/Data"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | 6 | ||
| 8 | import { cancelOAuth, startOAuth } from "../api/atoms"; | 7 | import { cancelOAuth, startOAuth } from "../api/atoms"; |
| 8 | import { messageFromUnknown, useReportHandledError } from "../api/error-reporting"; | ||
| 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; | 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; |
| 10 | import { Button } from "../components/button"; | 10 | import { Button } from "../components/button"; |
| 11 | import { | 11 | import { |
| @@ -46,12 +46,17 @@ export type OAuthAuthorizationStartResult = { · 34 unmodified lines | |||
| 46 | readonly authorizationUrl: string | null; | 46 | readonly authorizationUrl: string | null; |
| 47 | }; | 47 | }; |
| 48 | 48 | ||
| 49 | class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ | ||
| 50 | readonly cause: unknown; | ||
| 51 | }> {} | ||
| 52 | |||
| 49 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { | 53 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { |
| 50 | readonly tokenScope: string; | 54 | readonly tokenScope: string; |
| 51 | readonly run: () => Promise<OAuthAuthorizationStartResult>; | 55 | readonly run: () => Promise<OAuthAuthorizationStartResult>; |
| 52 | readonly onSuccess: (payload: TPayload) => void | Promise<void>; | 56 | readonly onSuccess: (payload: TPayload) => void | Promise<void>; |
| 53 | readonly onError?: (error: string) => void; | 57 | readonly onError?: (error: string) => void; |
| 54 | readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; | 58 | readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; |
| 59 | readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>; | ||
| 55 | }; | 60 | }; |
| 56 | 61 | ||
| 57 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { | 62 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { |
| @@ -73,16 +78,6 @@ const oauthRouteParamsForTokenScope = ( · 15 unmodified lines | |||
| 73 | scopeId: ScopeId.make(String(tokenScope)), | 78 | scopeId: ScopeId.make(String(tokenScope)), |
| 74 | }); | 79 | }); |
| 75 | 80 | ||
| 76 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 77 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 78 | |||
| 79 | const oauthPersistenceErrorMessage = (cause: unknown): string => | ||
| 80 | Option.match(decodeErrorMessage(cause), { | ||
| 81 | onNone: () => | ||
| 82 | typeof cause === "string" && cause.length > 0 ? cause : "Failed to save connection", | ||
| 83 | onSome: ({ message }) => message, | ||
| 84 | }); | ||
| 85 | |||
| 86 | export function useOAuthPopupFlow< | 81 | export function useOAuthPopupFlow< |
| 87 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, | 82 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, |
| 88 | >(options: { | 83 | >(options: { |
| @@ -103,6 +98,7 @@ export function useOAuthPopupFlow< · 14 unmodified lines | |||
| 103 | } = options; | 98 | } = options; |
| 104 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); | 99 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 105 | const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); | 100 | const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); |
| 101 | const reportHandledError = useReportHandledError(); | ||
| 106 | const [busy, setBusy] = useState(false); | 102 | const [busy, setBusy] = useState(false); |
| 107 | const [error, setError] = useState<string | null>(null); | 103 | const [error, setError] = useState<string | null>(null); |
| 108 | const cleanupRef = useRef<(() => void) | null>(null); | 104 | const cleanupRef = useRef<(() => void) | null>(null); |
| @@ -157,11 +153,17 @@ export function useOAuthPopupFlow< · 48 unmodified lines | |||
| 157 | const startExit = await Effect.runPromiseExit( | 153 | const startExit = await Effect.runPromiseExit( |
| 158 | Effect.tryPromise({ | 154 | Effect.tryPromise({ |
| 159 | try: input.run, | 155 | try: input.run, |
| 160 | catch: (cause) => cause, | 156 | catch: (cause) => new OAuthAuthorizationStartError({ cause }), |
| 161 | }), | 157 | }), |
| 162 | ); | 158 | ); |
| 163 | if (Exit.isFailure(startExit)) { | 159 | if (Exit.isFailure(startExit)) { |
| 164 | const message = startErrorMessage ?? "Failed to start sign-in"; | 160 | const message = startErrorMessage ?? "Failed to start sign-in"; |
| 161 | reportHandledError(startExit.cause, { | ||
| 162 | surface: "oauth", | ||
| 163 | action: "start", | ||
| 164 | message, | ||
| 165 | metadata: input.reportMetadata, | ||
| 166 | }); | ||
| 165 | reservedPopup.popup.close(); | 167 | reservedPopup.popup.close(); |
| 166 | setBusy(false); | 168 | setBusy(false); |
| 167 | setError(message); | 169 | setError(message); |
| @@ -206,7 +208,13 @@ export function useOAuthPopupFlow< · 38 unmodified lines | |||
| 206 | (cause: unknown) => cause, | 208 | (cause: unknown) => cause, |
| 207 | ); | 209 | ); |
| 208 | if (persistenceError !== null) { | 210 | if (persistenceError !== null) { |
| 209 | const message = oauthPersistenceErrorMessage(persistenceError); | 211 | const message = messageFromUnknown(persistenceError, "Failed to save connection"); |
| 212 | reportHandledError(persistenceError, { | ||
| 213 | surface: "oauth", | ||
| 214 | action: "persist_connection", | ||
| 215 | message, | ||
| 216 | metadata: input.reportMetadata, | ||
| 217 | }); | ||
| 210 | setBusy(false); | 218 | setBusy(false); |
| 211 | setError(message); | 219 | setError(message); |
| 212 | input.onError?.(message); | 220 | input.onError?.(message); |
| @@ -243,6 +251,7 @@ export function useOAuthPopupFlow< · 30 unmodified lines | |||
| 243 | popupBlockedMessage, | 251 | popupBlockedMessage, |
| 244 | popupClosedMessage, | 252 | popupClosedMessage, |
| 245 | popupName, | 253 | popupName, |
| 254 | reportHandledError, | ||
| 246 | startErrorMessage, | 255 | startErrorMessage, |
| 247 | ], | 256 | ], |
| 248 | ); | 257 | ); |
| @@ -254,6 +263,11 @@ export function useOAuthPopupFlow< · 5 unmodified lines | |||
| 254 | onSuccess: input.onSuccess, | 263 | onSuccess: input.onSuccess, |
| 255 | onError: input.onError, | 264 | onError: input.onError, |
| 256 | onAuthorizationStarted: input.onAuthorizationStarted, | 265 | onAuthorizationStarted: input.onAuthorizationStarted, |
| 266 | reportMetadata: { | ||
| 267 | pluginId: input.payload.pluginId, | ||
| 268 | connectionId: input.payload.connectionId, | ||
| 269 | tokenScope: input.payload.tokenScope, | ||
| 270 | }, | ||
| 257 | run: () => | 271 | run: () => |
| 258 | doStartOAuth({ | 272 | doStartOAuth({ |
| 259 | params: oauthRouteParamsForTokenScope(input.payload.tokenScope), | 273 | params: oauthRouteParamsForTokenScope(input.payload.tokenScope), |