History

Merge pull request #727 from RhysSullivan/hotfix/oauth-popup-open-result

Report handled frontend errors

Rhys Sullivan committed 6 months ago

Commit 1cc59952 parents 0061e64 2256b0c7 files · +441 −24Browse files

Unified · Split

7 files
  1. apps/cloud/src/frontend-atom-error-capture.node.test.ts +74 −0
  2. apps/cloud/src/routes/__root.tsx +160 −6
  3. packages/react/src/api/client.tsx +22 −0
  4. packages/react/src/api/error-reporting.test.ts +48 −0
  5. packages/react/src/api/error-reporting.tsx +99 −0
  6. packages/react/src/api/provider.tsx +10 −4
  7. packages/react/src/plugins/oauth-sign-in.tsx +28 −14
apps/cloud/src/frontend-atom-error-capture.node.test.ts+74−0
@@ -0,0 +1,74 @@
1import React from "react";
2import { renderToString } from "react-dom/server";
3import { RegistryProvider, useAtomSet } from "@effect/atom-react";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
8import { FetchHttpClient } from "effect/unstable/http";
9import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
10import * as Schema from "effect/Schema";
11
12const Widget = Schema.Struct({ name: Schema.String });
13
14const WidgetGroup = HttpApiGroup.make("widgets").add(
15 HttpApiEndpoint.get("load", "/widgets", {
16 success: Widget,
17 }),
18);
19
20const WidgetApi = HttpApi.make("widget-api").add(WidgetGroup);
21
22const malformedWidgetFetch = () =>
23 Promise.resolve(
24 new Response(JSON.stringify({ name: 123 }), {
25 status: 200,
26 headers: { "content-type": "application/json" },
27 }),
28 );
29
30const runAtomMutation = async (
31 transformResponse?: (
32 effect: Effect.Effect<unknown, unknown, unknown>,
33 ) => Effect.Effect<unknown, unknown, unknown>,
34) => {
35 const originalFetch = globalThis.fetch;
36 globalThis.fetch = malformedWidgetFetch as typeof fetch;
37 const WidgetClient = AtomHttpApi.Service<"WidgetClient">()("WidgetClient", {
38 api: WidgetApi,
39 baseUrl: "https://executor.test",
40 httpClient: FetchHttpClient.layer,
41 ...(transformResponse ? { transformResponse } : {}),
42 });
43 const loadWidget = WidgetClient.mutation("widgets", "load");
44 let runLoad: ((input: {}) => Promise<Exit.Exit<unknown, unknown>>) | undefined;
45
46 function CaptureMutation() {
47 runLoad = useAtomSet(loadWidget, { mode: "promiseExit" });
48 return null;
49 }
50
51 renderToString(React.createElement(RegistryProvider, null, React.createElement(CaptureMutation)));
52
53 const exit = await runLoad!({});
54 globalThis.fetch = originalFetch;
55 return exit;
56};
57
58describe("AtomHttpApi frontend decode failures", () => {
59 it("returns malformed success responses as failed exits in promiseExit mode", async () => {
60 const exit = await runAtomMutation();
61
62 expect(Exit.isFailure(exit)).toBe(true);
63 });
64
65 it("can report malformed success responses once through transformResponse", async () => {
66 const captured: unknown[] = [];
67 const exit = await runAtomMutation((effect) =>
68 Effect.tapCause(effect, (cause) => Effect.sync(() => captured.push(cause))),
69 );
70
71 expect(Exit.isFailure(exit)).toBe(true);
72 expect(captured).toHaveLength(1);
73 });
74});
apps/cloud/src/routes/__root.tsx+160−6
@@ -4,7 +4,19 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; · 3 unmodified lines
4import { AutumnProvider } from "autumn-js/react";4import { AutumnProvider } from "autumn-js/react";
5import posthog from "posthog-js";5import posthog from "posthog-js";
6import { PostHogProvider } from "posthog-js/react";6import { PostHogProvider } from "posthog-js/react";
7import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
7import { ExecutorProvider } from "@executor-js/react/api/provider";8import { ExecutorProvider } from "@executor-js/react/api/provider";
9import { Button } from "@executor-js/react/components/button";
10import {
11 Dialog,
12 DialogClose,
13 DialogContent,
14 DialogDescription,
15 DialogFooter,
16 DialogHeader,
17 DialogTitle,
18 DialogTrigger,
19} from "@executor-js/react/components/dialog";
8import { Skeleton } from "@executor-js/react/components/skeleton";20import { Skeleton } from "@executor-js/react/components/skeleton";
9import { Toaster } from "@executor-js/react/components/sonner";21import { Toaster } from "@executor-js/react/components/sonner";
10import { ExecutorPluginsProvider } from "@executor-js/sdk/client";22import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
@@ -40,6 +52,21 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { · 29 unmodified lines
40 });52 });
41}53}
42 54
55const captureFrontendError: FrontendErrorReporter = (error, context) => {
56 Sentry.captureException(error, (scope) => {
57 scope.setTag("executor.ui.surface", context.surface);
58 scope.setTag("executor.ui.action", context.action);
59 scope.setTag("executor.ui.severity", context.severity ?? "error");
60 scope.setContext("executor.ui", {
61 surface: context.surface,
62 action: context.action,
63 message: context.message,
64 metadata: context.metadata,
65 });
66 return scope;
67 });
68};
69
43export const Route = createRootRoute({70export const Route = createRootRoute({
44 head: () => ({71 head: () => ({
45 meta: [72 meta: [
@@ -151,6 +178,131 @@ function ShellSkeleton() { · 105 unmodified lines
151 );178 );
152}179}
153 180
181function ShellErrorFallback() {
182 const [slackOpen, setSlackOpen] = React.useState(false);
183 const supportLinks = [
184 { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark },
185 {
186 label: "GitHub Issues",
187 href: "https://github.com/RhysSullivan/executor/issues",
188 icon: GitHubMark,
189 },
190 { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark },
191 ] as const;
192
193 return (
194 <main className="flex min-h-screen items-center justify-center bg-background px-6 py-10">
195 <section className="w-full max-w-md text-center">
196 <div className="mx-auto mb-5 flex size-11 items-center justify-center rounded-full border border-border bg-muted">
197 <span className="text-lg font-semibold text-muted-foreground">!</span>
198 </div>
199 <h1 className="text-xl font-semibold text-foreground">Something went wrong</h1>
200 <p className="mt-2 text-sm leading-6 text-muted-foreground">
201 We&apos;ve tracked it. Give refreshing a try, and get in touch if support is needed.
202 </p>
203 <p className="mt-6 text-xs font-medium uppercase tracking-wide text-muted-foreground">
204 Get support
205 </p>
206 <div className="mt-3 flex flex-wrap items-center justify-center gap-2 text-sm">
207 <Dialog open={slackOpen} onOpenChange={setSlackOpen}>
208 <DialogTrigger asChild>
209 <Button type="button" variant="outline" size="sm" className="gap-2">
210 <SlackMark className="size-4" />
211 Slack
212 </Button>
213 </DialogTrigger>
214 <DialogContent>
215 <DialogHeader>
216 <DialogTitle>Slack Connect</DialogTitle>
217 <DialogDescription>
218 Invite <span className="font-medium text-foreground">rhys@executor.sh</span> to
219 Slack Connect.
220 </DialogDescription>
221 </DialogHeader>
222 <DialogFooter>
223 <DialogClose asChild>
224 <Button type="button" variant="outline">
225 Done
226 </Button>
227 </DialogClose>
228 </DialogFooter>
229 </DialogContent>
230 </Dialog>
231 {supportLinks.map((link) => (
232 // oxlint-disable-next-line react/jsx-no-new-function-as-prop -- static support link component choice
233 <a
234 key={link.label}
235 href={link.href}
236 className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 font-medium text-foreground transition-colors hover:bg-muted"
237 >
238 <link.icon className="size-4" />
239 {link.label}
240 </a>
241 ))}
242 </div>
243 </section>
244 </main>
245 );
246}
247
248function DiscordMark({ className }: { className?: string }) {
249 return (
250 <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
251 <path d="M20.32 4.37A19.8 19.8 0 0 0 15.36 2.8a13.8 13.8 0 0 0-.64 1.32 18.4 18.4 0 0 0-5.44 0 13.8 13.8 0 0 0-.64-1.32 19.7 19.7 0 0 0-4.97 1.57C.53 9.09-.32 13.69.1 18.22a19.9 19.9 0 0 0 6.08 3.03 14.7 14.7 0 0 0 1.3-2.09 12.8 12.8 0 0 1-2.04-.97l.5-.38a14.2 14.2 0 0 0 12.12 0l.5.38c-.65.38-1.33.7-2.04.97.37.74.8 1.44 1.3 2.09a19.9 19.9 0 0 0 6.08-3.03c.5-5.25-.84-9.8-3.58-13.85ZM8.02 15.43c-1.18 0-2.15-1.08-2.15-2.4 0-1.33.95-2.41 2.15-2.41 1.2 0 2.17 1.09 2.15 2.4 0 1.33-.95 2.41-2.15 2.41Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.4 0-1.33.95-2.41 2.15-2.41 1.2 0 2.17 1.09 2.15 2.4 0 1.33-.95 2.41-2.15 2.41Z" />
252 </svg>
253 );
254}
255
256function GitHubMark({ className }: { className?: string }) {
257 return (
258 <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
259 <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2.15c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.71 1.26 3.37.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.19-3.08-.12-.29-.52-1.46.11-3.04 0 0 .98-.31 3.17 1.18a10.9 10.9 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.8 1.19 1.83 1.19 3.08 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.16c0 .31.21.67.79.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
260 </svg>
261 );
262}
263
264function MailMark({ className }: { className?: string }) {
265 return (
266 <svg
267 viewBox="0 0 24 24"
268 fill="none"
269 stroke="currentColor"
270 strokeWidth="1.8"
271 className={className}
272 aria-hidden
273 >
274 <path
275 strokeLinecap="round"
276 strokeLinejoin="round"
277 d="M3 7l9 6 9-6M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2Z"
278 />
279 </svg>
280 );
281}
282
283function SlackMark({ className }: { className?: string }) {
284 return (
285 <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
286 <path
287 d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
288 fill="#E01E5A"
289 />
290 <path
291 d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.527 2.527 0 0 1 2.521 2.521 2.527 2.527 0 0 1-2.521 2.521H2.522A2.527 2.527 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"
292 fill="#36C5F0"
293 />
294 <path
295 d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.272 0a2.528 2.528 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.163 0a2.528 2.528 0 0 1 2.521 2.522v6.312z"
296 fill="#2EB67D"
297 />
298 <path
299 d="M15.163 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.163 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.272a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.315A2.527 2.527 0 0 1 24 15.163a2.528 2.528 0 0 1-2.522 2.521h-6.315z"
300 fill="#ECB22E"
301 />
302 </svg>
303 );
304}
305
154function AuthGate() {306function AuthGate() {
155 const auth = useAuth();307 const auth = useAuth();
156 308
@@ -168,12 +320,14 @@ function AuthGate() { · 11 unmodified lines
168 320
169 return (321 return (
170 <AutumnProvider pathPrefix="/api/autumn">322 <AutumnProvider pathPrefix="/api/autumn">
171 <ExecutorProvider fallback={<ShellSkeleton />}>323 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
172 <ExecutorPluginsProvider plugins={clientPlugins}>324 <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>
173 <Shell />325 <ExecutorPluginsProvider plugins={clientPlugins}>
174 <Toaster />326 <Shell />
175 </ExecutorPluginsProvider>327 <Toaster />
176 </ExecutorProvider>328 </ExecutorPluginsProvider>
329 </ExecutorProvider>
330 </Sentry.ErrorBoundary>
177 </AutumnProvider>331 </AutumnProvider>
178 );332 );
179}333}
packages/react/src/api/client.tsx+22−0
@@ -1,8 +1,29 @@
1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3import * as HttpClientError from "effect/unstable/http/HttpClientError";
3import { ExecutorApi } from "@executor-js/api";4import { ExecutorApi } from "@executor-js/api";
5import * as Cause from "effect/Cause";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
4 9
5import { getBaseUrl } from "./base-url";10import { getBaseUrl } from "./base-url";
11import { reportHandledFrontendError } from "./error-reporting";
12
13const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean =>
14 Option.match(Cause.findErrorOption(cause), {
15 onNone: () => false,
16 onSome: (error) => Schema.isSchemaError(error) || HttpClientError.isHttpClientError(error),
17 });
18
19const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>) =>
20 Effect.sync(() => {
21 if (!isApiClientInfrastructureCause(cause)) return;
22 reportHandledFrontendError(cause, {
23 surface: "api_client",
24 action: "decode_or_transport",
25 });
26 });
6 27
7// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
8// Core API client — tools + secrets29// Core API client — tools + secrets
@@ -14,6 +35,7 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 5 unmodified lines
14 transformClient: HttpClient.mapRequest((request) =>35 transformClient: HttpClient.mapRequest((request) =>
15 HttpClientRequest.prependUrl(request, getBaseUrl()),36 HttpClientRequest.prependUrl(request, getBaseUrl()),
16 ),37 ),
38 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
17});39});
18 40
19export { ExecutorApiClient };41export { ExecutorApiClient };
packages/react/src/api/error-reporting.test.ts+48−0
@@ -0,0 +1,48 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Exit from "effect/Exit";
4import {
5 messageFromExit,
6 messageFromUnknown,
7 reportExitFailure,
8 type FrontendErrorContext,
9} from "./error-reporting";
10
11describe("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});
packages/react/src/api/error-reporting.tsx+99−0
@@ -0,0 +1,99 @@
1import * as React from "react";
2import * as Cause from "effect/Cause";
3import * as Data from "effect/Data";
4import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8export 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
16export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void;
17
18class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{
19 readonly cause: unknown;
20 readonly context: FrontendErrorContext;
21}> {}
22
23const ErrorMessage = Schema.Struct({ message: Schema.String });
24const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
25
26const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => {
27 if (typeof globalThis.reportError !== "function") return;
28 globalThis.reportError(new FrontendHandledError({ cause: error, context }));
29};
30
31const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>(
32 defaultFrontendErrorReporter,
33);
34
35let currentFrontendErrorReporter = defaultFrontendErrorReporter;
36
37export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => {
38 currentFrontendErrorReporter(error, context);
39};
40
41export const FrontendErrorReporterProvider = (
42 props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,
43) => {
44 const reporter = props.reporter ?? defaultFrontendErrorReporter;
45 currentFrontendErrorReporter = reporter;
46 return (
47 <FrontendErrorReporterContext.Provider value={reporter}>
48 {props.children}
49 </FrontendErrorReporterContext.Provider>
50 );
51};
52
53export const useReportHandledError = (): FrontendErrorReporter =>
54 React.useContext(FrontendErrorReporterContext);
55
56export const messageFromUnknown = (error: unknown, fallback: string): string =>
57 Option.match(decodeErrorMessage(error), {
58 onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback),
59 onSome: ({ message }) => message,
60 });
61
62export const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
63 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
64 onNone: () => fallback,
65 onSome: ({ message }) => message,
66 });
67
68export const reportExitFailure = (
69 report: FrontendErrorReporter,
70 exit: Exit.Exit<unknown, unknown>,
71 context: FrontendErrorContext,
72): void => {
73 if (!Exit.isFailure(exit)) return;
74 report(exit.cause, context);
75};
76
77export const useErrorMessageFromExit = (): ((
78 exit: Exit.Exit<unknown, unknown>,
79 fallback: string,
80 context: Omit<FrontendErrorContext, "message"> & { readonly message?: string },
81) => string) => {
82 const report = useReportHandledError();
83 return React.useCallback(
84 (exit, fallback, context) => {
85 const message = messageFromExit(exit, fallback);
86 reportExitFailure(report, exit, { ...context, message: context.message ?? message });
87 return message;
88 },
89 [report],
90 );
91};
92
93export const reportCauseFailure = (
94 report: FrontendErrorReporter,
95 cause: Cause.Cause<unknown>,
96 context: FrontendErrorContext,
97): void => {
98 report(cause, context);
99};
packages/react/src/api/provider.tsx+10−4
@@ -1,11 +1,17 @@
1import { RegistryProvider } from "@effect/atom-react";1import { RegistryProvider } from "@effect/atom-react";
2import * as React from "react";2import * as React from "react";
3import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting";
3import { ScopeProvider } from "./scope-context";4import { ScopeProvider } from "./scope-context";
4 5
5export const ExecutorProvider = (6export 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);
packages/react/src/plugins/oauth-sign-in.tsx+28−14
@@ -1,11 +1,11 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7 6
8import { cancelOAuth, startOAuth } from "../api/atoms";7import { cancelOAuth, startOAuth } from "../api/atoms";
8import { messageFromUnknown, useReportHandledError } from "../api/error-reporting";
9import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";9import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
10import { Button } from "../components/button";10import { Button } from "../components/button";
11import {11import {
@@ -46,12 +46,17 @@ export type OAuthAuthorizationStartResult = { · 34 unmodified lines
46 readonly authorizationUrl: string | null;46 readonly authorizationUrl: string | null;
47};47};
48 48
49class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{
50 readonly cause: unknown;
51}> {}
52
49export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = {53export 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
57export function oauthCallbackUrl(path = "/api/oauth/callback"): string {62export 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
76const ErrorMessage = Schema.Struct({ message: Schema.String });
77const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
78
79const 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
86export function useOAuthPopupFlow<81export 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),