From 672679c6882a03382aa4e7dd9c8c3fdb68ddc686 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sun, 26 Jul 2026 23:00:36 +0900 Subject: [PATCH] fix(integrations): surface why the S3 config request failed Both integration pages threw a fixed string on any non-200: if (response.status !== 200) throw new Error("Failed to fetch S3 config"); The route already returns `{ error, details }` and distinguishes 403 (`forbidden_org`) from 500 (decrypt failure, DB error), but all of that was discarded. Neither page has a local ErrorBoundary, so the thrown string reached CapErrorBoundary and became the entire failure UI -- which is what #1840 reports: a full-page "An Error Occured" whose copyable text is Error: Failed to fetch S3 config @tauri://localhost/_build/assets/s3-config-*.js with no indication of the cause, so neither the user nor a maintainer can tell a permissions problem from a corrupt stored bucket row. - Model the 403 and 500 bodies in the contract; only 200 was declared, so the error shape was untyped at the call site. - Add `describeS3ConfigError`, shared by both pages: 403 becomes a plain sentence about organization access, otherwise `error: details` from the server, falling back to the status code. - google-drive-config.tsx made the identical call and had the identical fixed string; both now go through the helper. Server-side messages were already safe to show -- `details` is the caught error's message, and the route does not put credentials in it. Closes #1840 --- .../integrations/google-drive-config.tsx | 5 +++- .../settings/integrations/s3-config-error.ts | 29 +++++++++++++++++++ .../settings/integrations/s3-config.tsx | 9 +++++- packages/web-api-contract/src/desktop.ts | 2 ++ 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config-error.ts diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx index a69ffc316b6..f6d0c522180 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx @@ -6,6 +6,7 @@ import { commands } from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; import { Section, SectionCard, SettingsPageContent } from "../Setting"; import { IntegrationConfigHeader } from "./config-header"; +import { describeS3ConfigError } from "./s3-config-error"; const byteUnits = ["B", "KB", "MB", "GB", "TB", "PB"] as const; const googleDriveConnectionPollIntervalMs = 1500; @@ -71,7 +72,9 @@ const fetchS3Config = async (orgId: string | null) => { headers: await protectedHeaders(), }); - if (response.status !== 200) throw new Error("Failed to fetch S3 config"); + if (response.status !== 200) { + throw new Error(describeS3ConfigError(response)); + } return response.body; }; diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config-error.ts b/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config-error.ts new file mode 100644 index 00000000000..f8a8a0fd234 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config-error.ts @@ -0,0 +1,29 @@ +/** + * Turn a non-200 `getS3Config` response into something a user can act on or + * paste into an issue. + * + * The route already returns `{ error, details }` (see + * `apps/web/app/api/desktop/[...route]/s3Config.ts`), but both callers threw a + * fixed "Failed to fetch S3 config" and dropped it. Since there is no local + * ErrorBoundary on these pages, that string reached `CapErrorBoundary` and was + * all the user could see or copy — which is exactly what was reported in #1840. + */ +export function describeS3ConfigError(response: { + status: number; + body: unknown; +}): string { + const body = response.body as + | { error?: unknown; details?: unknown } + | undefined; + const error = typeof body?.error === "string" ? body.error : undefined; + const details = typeof body?.details === "string" ? body.details : undefined; + + if (response.status === 403 || error === "forbidden_org") { + return "You don't have access to this organization's storage settings."; + } + + if (error && details) return `${error}: ${details}`; + if (error) return error; + + return `Failed to fetch S3 config (HTTP ${response.status})`; +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx index cba26750e95..1cfb4552987 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx @@ -8,6 +8,7 @@ import { commands } from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; import { Section, SectionCard, SettingsPageContent } from "../Setting"; import { IntegrationConfigHeader } from "./config-header"; +import { describeS3ConfigError } from "./s3-config-error"; interface S3Config { provider: string; @@ -37,7 +38,13 @@ export default function S3ConfigPage() { headers: await protectedHeaders(), }); - if (response.status !== 200) throw new Error("Failed to fetch S3 config"); + if (response.status !== 200) { + // The server already explains what went wrong (a stored bucket row + // that fails to decrypt, an org the user is no longer in, ...). + // Throwing a fixed string discarded that and left "Failed to fetch + // S3 config" as the only thing the user could see or report. + throw new Error(describeS3ConfigError(response)); + } return response.body; }, diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index 2a0b96b529b..c3522dcfedd 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -175,6 +175,8 @@ const protectedContract = c.router( source: z.enum(["default", "user", "organization"]), managedByOrganization: ManagedOrganizationStorage, }), + 403: z.object({ error: z.string() }), + 500: z.object({ error: z.string(), details: z.string().optional() }), }, }, setS3Config: {