A SERVICE_DISABLED / accessNotConfigured 403 means the API is disabled in the OAuth client's project; the credential is fine. Probes now classify it as a new 'misconfigured' status: amber 'API disabled' badge, the provider's own remediation text (console link clickable) on the row, and no reconnect prompt.
Unified · Split
| @@ -1,22 +1,21 @@ | |||
| 1 | // Repro: a Google 403 for a DISABLED API reads as an expired credential. | 1 | // A Google 403 for a DISABLED API must NOT read as an expired credential. |
| 2 | // | 2 | // |
| 3 | // Production case (2026-07-10): a user's "Personal Google" connection showed | 3 | // Production case (2026-07-10): a user's "Personal Google" connection showed |
| 4 | // the red Expired badge although its OAuth token was alive and refreshing. | 4 | // the red Expired badge although its OAuth token was alive and refreshing. |
| 5 | // The org's Google OAuth client lives in a GCP project without the Gmail / | 5 | // The org's Google OAuth client lives in a GCP project without the Gmail / |
| 6 | // Drive / Calendar APIs enabled, so the health probe gets Google's | 6 | // Drive / Calendar APIs enabled, so the health probe gets Google's |
| 7 | // SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is | 7 | // SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is |
| 8 | // disabled"). `classifyHttpStatus` maps every 401/403 to "expired", so a | 8 | // disabled"). The old status-only classifier mapped every 401/403 to |
| 9 | // perfectly healthy credential renders as Expired and the UI tells the user | 9 | // "expired", so a healthy credential rendered as Expired and the UI told the |
| 10 | // to reconnect - which cannot fix a disabled upstream API. | 10 | // user to reconnect - advice that cannot fix a disabled upstream API. |
| 11 | // | 11 | // |
| 12 | // The scenario replays that exact journey against the Google emulator: a real | 12 | // The scenario replays that exact journey against the Google emulator: a real |
| 13 | // OAuth grant probes healthy; then the upstream starts answering the probe | 13 | // OAuth grant probes healthy; then the upstream starts answering the probe |
| 14 | // operation with Google's real SERVICE_DISABLED body (fault injection - the | 14 | // operation with Google's real SERVICE_DISABLED body (fault injection - the |
| 15 | // token stays valid, exactly like prod); the connection now reads Expired on | 15 | // token stays valid, exactly like prod). The fixed classifier reads the error |
| 16 | // screen with the reconnect toast. The assertions pin today's (wrong) | 16 | // body and reports "misconfigured": on screen that is the amber "API |
| 17 | // classification on purpose: when the classifier learns to tell "API disabled | 17 | // disabled" badge with Google's own remediation text (including the console |
| 18 | // in the project" from "credential expired", this scenario is the one that | 18 | // link that enables the API), and no reconnect prompt. |
| 19 | // must change. | ||
| 20 | import { randomBytes } from "node:crypto"; | 19 | import { randomBytes } from "node:crypto"; |
| 21 | 20 | ||
| 22 | import { expect } from "@effect/vitest"; | 21 | import { expect } from "@effect/vitest"; |
| @@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines | |||
| 205 | }); | 204 | }); |
| 206 | 205 | ||
| 207 | scenario( | 206 | scenario( |
| 208 | "Google · repro: a disabled upstream API reads as an expired connection (SERVICE_DISABLED 403)", | 207 | "Google · a disabled upstream API reads as API disabled with the enable link, not Expired", |
| 209 | { timeout: 300_000 }, | 208 | { timeout: 300_000 }, |
| 210 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 211 | const target = yield* Target; | 210 | const target = yield* Target; |
| @@ -259,16 +258,16 @@ scenario( · 47 unmodified lines | |||
| 259 | ); | 258 | ); |
| 260 | yield* Effect.promise(() => emulator.client.ledger.clear()); | 259 | yield* Effect.promise(() => emulator.client.ledger.clear()); |
| 261 | 260 | ||
| 262 | // THE BUG, pinned: a 403 that means "enable this API in your GCP | 261 | // The fix under test: a 403 that means "enable this API in your GCP |
| 263 | // project" classifies as an expired credential. | 262 | // project" classifies as misconfigured, not as a dead credential. |
| 264 | const verdict = yield* client.connections.checkHealth({ | 263 | const verdict = yield* client.connections.checkHealth({ |
| 265 | params: { owner: "org", integration: slug, name: CONNECTION }, | 264 | params: { owner: "org", integration: slug, name: CONNECTION }, |
| 266 | query: { ifStaleMs: 0 }, | 265 | query: { ifStaleMs: 0 }, |
| 267 | }); | 266 | }); |
| 268 | expect( | 267 | expect( |
| 269 | verdict.status, | 268 | verdict.status, |
| 270 | "BUG: the SERVICE_DISABLED 403 classifies as an expired credential", | 269 | `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`, |
| 271 | ).toBe("expired"); | 270 | ).toBe("misconfigured"); |
| 272 | expect(verdict.httpStatus, "the probe observed the 403").toBe(403); | 271 | expect(verdict.httpStatus, "the probe observed the 403").toBe(403); |
| 273 | expect( | 272 | expect( |
| 274 | verdict.detail, | 273 | verdict.detail, |
| @@ -287,24 +286,49 @@ scenario( · 12 unmodified lines | |||
| 287 | }); | 286 | }); |
| 288 | 287 | ||
| 289 | await step( | 288 | await step( |
| 290 | "The healthy-yesterday connection now shows Expired, with no clicks", | 289 | "The connection shows the amber API disabled badge - not Expired - with no clicks", |
| 291 | async () => { | 290 | async () => { |
| 292 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 291 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); |
| 293 | await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 }); | 292 | await connections |
| 294 | await connections.getByLabel("Status: Expired").waitFor(); | 293 | .getByText("API disabled", { exact: true }) |
| 294 | .waitFor({ timeout: 30_000 }); | ||
| 295 | await connections.getByLabel("Status: API disabled").waitFor(); | ||
| 296 | // The dead-credential story must be gone entirely. | ||
| 297 | expect( | ||
| 298 | await connections.getByText("Expired", { exact: true }).count(), | ||
| 299 | "no Expired badge on a misconfigured connection", | ||
| 300 | ).toBe(0); | ||
| 301 | }, | ||
| 302 | ); | ||
| 303 | |||
| 304 | await step( | ||
| 305 | "The row carries Google's own instruction, console link included", | ||
| 306 | async () => { | ||
| 307 | await connections.getByText(/has not been used in project/).waitFor(); | ||
| 308 | const consoleLink = connections.getByRole("link", { | ||
| 309 | name: /console\.developers\.google\.com/, | ||
| 310 | }); | ||
| 311 | await consoleLink.waitFor(); | ||
| 312 | expect( | ||
| 313 | await consoleLink.getAttribute("href"), | ||
| 314 | "the enable-API console link is clickable", | ||
| 315 | ).toContain("console.developers.google.com"); | ||
| 295 | }, | 316 | }, |
| 296 | ); | 317 | ); |
| 297 | 318 | ||
| 298 | await step( | 319 | await step( |
| 299 | "Check now tells the user to reconnect - advice that cannot fix a disabled API", | 320 | "Check now explains the disabled API instead of prescribing reconnect", |
| 300 | async () => { | 321 | async () => { |
| 301 | await connections.locator('button[aria-haspopup="menu"]').click(); | 322 | await connections.locator('button[aria-haspopup="menu"]').click(); |
| 302 | await page.getByRole("menuitem", { name: "Check now" }).click(); | 323 | await page.getByRole("menuitem", { name: "Check now" }).click(); |
| 303 | // The misleading UX in one line: the toast prescribes reconnecting | ||
| 304 | // while the upstream error says "enable the API in your project". | ||
| 305 | await page | 324 | await page |
| 306 | .getByText("Connection expired, reconnect to restore access") | 325 | .getByText(/has not been used in project/) |
| 326 | .first() | ||
| 307 | .waitFor({ timeout: 30_000 }); | 327 | .waitFor({ timeout: 30_000 }); |
| 328 | expect( | ||
| 329 | await page.getByText("Connection expired, reconnect to restore access").count(), | ||
| 330 | "the reconnect toast never fires for a configuration 403", | ||
| 331 | ).toBe(0); | ||
| 308 | }, | 332 | }, |
| 309 | ); | 333 | ); |
| 310 | }); | 334 | }); |
| @@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | candidateIdentityTier, | 7 | candidateIdentityTier, |
| 8 | classifyHttpStatus, | ||
| 9 | classifyProbeResponse, | ||
| 8 | rankResponseSample, | 10 | rankResponseSample, |
| 9 | sortHealthCheckCandidatesByIdentity, | 11 | sortHealthCheckCandidatesByIdentity, |
| 10 | } from "./health-check"; | 12 | } from "./health-check"; |
| @@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => { · 55 unmodified lines | |||
| 66 | ).toBe("user.getAuthUser"); | 68 | ).toBe("user.getAuthUser"); |
| 67 | }); | 69 | }); |
| 68 | }); | 70 | }); |
| 71 | |||
| 72 | // Probe classification: 401/403 mean "credential dead" EXCEPT the | ||
| 73 | // configuration 403 (Google accessNotConfigured / SERVICE_DISABLED), which | ||
| 74 | // must read misconfigured: the token authenticated, the API is disabled in | ||
| 75 | // the OAuth client's project, and reconnecting cannot fix it. This is the | ||
| 76 | // production case behind the "Expired when it is not" report (2026-07-10). | ||
| 77 | describe("classifyProbeResponse", () => { | ||
| 78 | const disabledMessage = | ||
| 79 | "Gmail API has not been used in project 000000000000 before or it is disabled. " + | ||
| 80 | "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry."; | ||
| 81 | |||
| 82 | it("classifies Google's classic accessNotConfigured 403 as misconfigured", () => { | ||
| 83 | const body = { | ||
| 84 | error: { | ||
| 85 | code: 403, | ||
| 86 | message: disabledMessage, | ||
| 87 | errors: [ | ||
| 88 | { message: disabledMessage, domain: "usageLimits", reason: "accessNotConfigured" }, | ||
| 89 | ], | ||
| 90 | status: "PERMISSION_DENIED", | ||
| 91 | }, | ||
| 92 | }; | ||
| 93 | expect(classifyProbeResponse(403, body)).toBe("misconfigured"); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("classifies the newer SERVICE_DISABLED ErrorInfo detail as misconfigured", () => { | ||
| 97 | const body = { | ||
| 98 | error: { | ||
| 99 | code: 403, | ||
| 100 | message: disabledMessage, | ||
| 101 | status: "PERMISSION_DENIED", | ||
| 102 | details: [ | ||
| 103 | { | ||
| 104 | "@type": "type.googleapis.com/google.rpc.ErrorInfo", | ||
| 105 | reason: "SERVICE_DISABLED", | ||
| 106 | domain: "googleapis.com", | ||
| 107 | }, | ||
| 108 | ], | ||
| 109 | }, | ||
| 110 | }; | ||
| 111 | expect(classifyProbeResponse(403, body)).toBe("misconfigured"); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("keeps a plain 403 (no recognized reason) as expired: false-expired is the safe default", () => { | ||
| 115 | expect(classifyProbeResponse(403, { error: { code: 403, message: "Forbidden" } })).toBe( | ||
| 116 | "expired", | ||
| 117 | ); | ||
| 118 | expect(classifyProbeResponse(403, undefined)).toBe("expired"); | ||
| 119 | expect(classifyProbeResponse(403, "Forbidden")).toBe("expired"); | ||
| 120 | // A permission reason that is NOT a configuration marker stays expired. | ||
| 121 | expect( | ||
| 122 | classifyProbeResponse(403, { | ||
| 123 | error: { errors: [{ reason: "insufficientPermissions" }] }, | ||
| 124 | }), | ||
| 125 | ).toBe("expired"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("never rewrites a 401: an authentication failure is a credential problem regardless of body", () => { | ||
| 129 | const body = { error: { errors: [{ reason: "accessNotConfigured" }] } }; | ||
| 130 | expect(classifyProbeResponse(401, body)).toBe("expired"); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("matches the status-only classifier everywhere else", () => { | ||
| 134 | for (const status of [200, 204, 404, 429, 500, 503]) { | ||
| 135 | expect(classifyProbeResponse(status, { error: {} }), `status ${status}`).toBe( | ||
| 136 | classifyHttpStatus(status), | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | }); | ||
| 140 | }); | ||
| @@ -18,14 +18,24 @@ · 17 unmodified lines | |||
| 18 | import { Schema } from "effect"; | 18 | import { Schema } from "effect"; |
| 19 | 19 | ||
| 20 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 21 | // Status: the four states a connection can be in. `expired` is the one this | 21 | // Status: the five states a connection can be in. `expired` is the one this |
| 22 | // whole feature exists for (Google's 7-day dev-token revocation): the credential | 22 | // whole feature exists for (Google's 7-day dev-token revocation): the credential |
| 23 | // authenticated fine yesterday and now returns 401/403. `degraded` covers any | 23 | // authenticated fine yesterday and now returns 401/403. `misconfigured` is the |
| 24 | // other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is | 24 | // 403 that is NOT a credential problem: the token authenticated, but the |
| 25 | // "never checked / no health check configured". | 25 | // upstream rejects on configuration (a Google API not enabled in the OAuth |
| 26 | // client's GCP project): the fix is in the provider console, so telling the | ||
| 27 | // user to reconnect would mislead. `degraded` covers any other non-2xx | ||
| 28 | // (upstream 5xx, a 404 on a mis-picked operation); `unknown` is "never checked | ||
| 29 | // / no health check configured". | ||
| 26 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 27 | 31 | ||
| 28 | export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]); | 32 | export const HealthStatus = Schema.Literals([ |
| 33 | "healthy", | ||
| 34 | "expired", | ||
| 35 | "misconfigured", | ||
| 36 | "degraded", | ||
| 37 | "unknown", | ||
| 38 | ]); | ||
| 29 | export type HealthStatus = typeof HealthStatus.Type; | 39 | export type HealthStatus = typeof HealthStatus.Type; |
| 30 | 40 | ||
| 31 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| @@ -139,13 +149,59 @@ export type HealthCheckCandidate = typeof HealthCheckCandidate.Type; · 107 unmodified lines | |||
| 139 | // --------------------------------------------------------------------------- | 149 | // --------------------------------------------------------------------------- |
| 140 | 150 | ||
| 141 | /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth | 151 | /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth |
| 142 | * wall), everything else degraded. */ | 152 | * wall), everything else degraded. Status-only fallback: when the response |
| 153 | * BODY is available, use `classifyProbeResponse` instead, which tells a | ||
| 154 | * credential 403 apart from a configuration 403. */ | ||
| 143 | export const classifyHttpStatus = (status: number): HealthStatus => { | 155 | export const classifyHttpStatus = (status: number): HealthStatus => { |
| 144 | if (status >= 200 && status < 300) return "healthy"; | 156 | if (status >= 200 && status < 300) return "healthy"; |
| 145 | if (status === 401 || status === 403) return "expired"; | 157 | if (status === 401 || status === 403) return "expired"; |
| 146 | return "degraded"; | 158 | return "degraded"; |
| 147 | }; | 159 | }; |
| 148 | 160 | ||
| 161 | // Error `reason` / `status` markers that make a 403 a CONFIGURATION rejection | ||
| 162 | // rather than a credential one. Deliberately narrow and provider-shaped: | ||
| 163 | // Google's error envelope carries `errors[].reason: "accessNotConfigured"` | ||
| 164 | // (message "<API> has not been used in project N before or it is disabled") | ||
| 165 | // and newer surfaces use `status: "PERMISSION_DENIED"` with | ||
| 166 | // `details[].reason: "SERVICE_DISABLED"`. Unrecognized 403s keep the expired | ||
| 167 | // classification: a false "expired" prompts a harmless reconnect, but a false | ||
| 168 | // "misconfigured" would hide a genuinely dead credential. | ||
| 169 | const CONFIGURATION_403_REASONS = new Set(["accessnotconfigured", "service_disabled"]); | ||
| 170 | |||
| 171 | /** Collect candidate `reason` markers from a Google-shaped error envelope: | ||
| 172 | * `error.errors[].reason` (classic) and `error.details[].reason` (newer | ||
| 173 | * google.rpc.ErrorInfo). Tolerant of partial shapes; returns []. */ | ||
| 174 | const errorReasonMarkers = (body: unknown): string[] => { | ||
| 175 | if (body == null || typeof body !== "object") return []; | ||
| 176 | const error = (body as Record<string, unknown>)["error"]; | ||
| 177 | if (error == null || typeof error !== "object") return []; | ||
| 178 | const markers: string[] = []; | ||
| 179 | for (const key of ["errors", "details"] as const) { | ||
| 180 | const entries = (error as Record<string, unknown>)[key]; | ||
| 181 | if (!Array.isArray(entries)) continue; | ||
| 182 | for (const entry of entries) { | ||
| 183 | if (entry == null || typeof entry !== "object") continue; | ||
| 184 | const reason = (entry as Record<string, unknown>)["reason"]; | ||
| 185 | if (typeof reason === "string") markers.push(reason.toLowerCase()); | ||
| 186 | } | ||
| 187 | } | ||
| 188 | return markers; | ||
| 189 | }; | ||
| 190 | |||
| 191 | /** Classify a probe response from its status AND body. Everything is | ||
| 192 | * `classifyHttpStatus` except one carve-out: a 403 whose error body carries a | ||
| 193 | * known configuration reason (Google `accessNotConfigured` / | ||
| 194 | * `SERVICE_DISABLED`) is `misconfigured`, not `expired`: the credential | ||
| 195 | * authenticated; the upstream API is disabled in the OAuth client's project, | ||
| 196 | * and only enabling it there (not reconnecting) fixes it. */ | ||
| 197 | export const classifyProbeResponse = (status: number, body: unknown): HealthStatus => { | ||
| 198 | const byStatus = classifyHttpStatus(status); | ||
| 199 | if (status !== 403 || byStatus !== "expired") return byStatus; | ||
| 200 | return errorReasonMarkers(body).some((reason) => CONFIGURATION_403_REASONS.has(reason)) | ||
| 201 | ? "misconfigured" | ||
| 202 | : "expired"; | ||
| 203 | }; | ||
| 204 | |||
| 149 | /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a | 205 | /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a |
| 150 | * display string. Numeric segments index arrays. Returns undefined when the | 206 | * display string. Numeric segments index arrays. Returns undefined when the |
| 151 | * path is empty, missing, or lands on a non-scalar. */ | 207 | * path is empty, missing, or lands on a non-scalar. */ |
| @@ -114,6 +114,7 @@ export { · 113 unmodified lines | |||
| 114 | HealthCheckCandidateParameter, | 114 | HealthCheckCandidateParameter, |
| 115 | HealthCheckResponseField, | 115 | HealthCheckResponseField, |
| 116 | classifyHttpStatus, | 116 | classifyHttpStatus, |
| 117 | classifyProbeResponse, | ||
| 117 | extractIdentity, | 118 | extractIdentity, |
| 118 | compareHealthCheckCandidates, | 119 | compareHealthCheckCandidates, |
| 119 | candidateIdentityTier, | 120 | candidateIdentityTier, |
| @@ -118,6 +118,7 @@ export { · 117 unmodified lines | |||
| 118 | HealthCheckCandidate, | 118 | HealthCheckCandidate, |
| 119 | HealthCheckCandidateParameter, | 119 | HealthCheckCandidateParameter, |
| 120 | classifyHttpStatus, | 120 | classifyHttpStatus, |
| 121 | classifyProbeResponse, | ||
| 121 | extractIdentity, | 122 | extractIdentity, |
| 122 | compareHealthCheckCandidates, | 123 | compareHealthCheckCandidates, |
| 123 | candidateIdentityTier, | 124 | candidateIdentityTier, |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | ToolName, | 7 | ToolName, |
| 8 | ToolResult, | 8 | ToolResult, |
| 9 | authToolFailure, | 9 | authToolFailure, |
| 10 | classifyHttpStatus, | 10 | classifyProbeResponse, |
| 11 | detectInsufficientScope, | 11 | detectInsufficientScope, |
| 12 | sortHealthCheckCandidatesByIdentity, | 12 | sortHealthCheckCandidatesByIdentity, |
| 13 | extractIdentity, | 13 | extractIdentity, |
| @@ -962,7 +962,9 @@ export const checkHealthOpenApi = (input: { · 948 unmodified lines | |||
| 962 | } satisfies HealthCheckResult; | 962 | } satisfies HealthCheckResult; |
| 963 | } | 963 | } |
| 964 | 964 | ||
| 965 | const status = classifyHttpStatus(probe.result.status); | 965 | // Body-aware: a configuration 403 (Google accessNotConfigured / |
| 966 | // SERVICE_DISABLED) reads misconfigured, not expired. | ||
| 967 | const status = classifyProbeResponse(probe.result.status, probe.result.error); | ||
| 966 | const identity = | 968 | const identity = |
| 967 | status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; | 969 | status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; |
| 968 | // Sample the returned body ONLY on a healthy probe: the sample exists to | 970 | // Sample the returned body ONLY on a healthy probe: the sample exists to |
| @@ -60,6 +60,35 @@ import { · 59 unmodified lines | |||
| 60 | 60 | ||
| 61 | const OWNERS: readonly Owner[] = ["org", "user"]; | 61 | const OWNERS: readonly Owner[] = ["org", "user"]; |
| 62 | 62 | ||
| 63 | /** Render a health-check detail with any bare https URL as a clickable link. | ||
| 64 | * Exists for the misconfigured verdict, whose detail is the provider's own | ||
| 65 | * remediation text (Google's includes the console URL that enables the | ||
| 66 | * disabled API); the rest of the string stays plain text. */ | ||
| 67 | function DetailWithLinks(props: { readonly text: string }) { | ||
| 68 | const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g); | ||
| 69 | return ( | ||
| 70 | <> | ||
| 71 | {parts.map((part, index) => | ||
| 72 | part.startsWith("https://") ? ( | ||
| 73 | <a | ||
| 74 | // Stable for a given detail string: parts are positional. | ||
| 75 | // oxlint-disable-next-line react/no-array-index-key | ||
| 76 | key={index} | ||
| 77 | href={part} | ||
| 78 | target="_blank" | ||
| 79 | rel="noreferrer" | ||
| 80 | className="underline underline-offset-2 hover:text-foreground" | ||
| 81 | > | ||
| 82 | {part} | ||
| 83 | </a> | ||
| 84 | ) : ( | ||
| 85 | part | ||
| 86 | ), | ||
| 87 | )} | ||
| 88 | </> | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | |||
| 63 | function AccountRow(props: { | 92 | function AccountRow(props: { |
| 64 | readonly connection: Connection; | 93 | readonly connection: Connection; |
| 65 | /** The integration declares scopes this connection was not granted — it must | 94 | /** The integration declares scopes this connection was not granted — it must |
| @@ -95,6 +124,7 @@ function AccountRow(props: { · 29 unmodified lines | |||
| 95 | const displayLabel = identity ?? String(connection.name); | 124 | const displayLabel = identity ?? String(connection.name); |
| 96 | 125 | ||
| 97 | const expired = status === "expired"; | 126 | const expired = status === "expired"; |
| 127 | const misconfigured = status === "misconfigured"; | ||
| 98 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; | 128 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| 99 | 129 | ||
| 100 | const handleCheck = async () => { | 130 | const handleCheck = async () => { |
| @@ -113,6 +143,13 @@ function AccountRow(props: { · 12 unmodified lines | |||
| 113 | ); | 143 | ); |
| 114 | } else if (exit.value.status === "expired") { | 144 | } else if (exit.value.status === "expired") { |
| 115 | toast.error("Connection expired, reconnect to restore access"); | 145 | toast.error("Connection expired, reconnect to restore access"); |
| 146 | } else if (exit.value.status === "misconfigured") { | ||
| 147 | // NOT a reconnect prompt: the credential is fine; the upstream API is | ||
| 148 | // disabled where the OAuth client lives. The detail carries the | ||
| 149 | // provider's own instruction (with a console link for Google). | ||
| 150 | toast.warning( | ||
| 151 | exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client", | ||
| 152 | ); | ||
| 116 | } else if (exit.value.status === "degraded") { | 153 | } else if (exit.value.status === "degraded") { |
| 117 | toast.warning(exit.value.detail ?? "Connection check returned an error"); | 154 | toast.warning(exit.value.detail ?? "Connection check returned an error"); |
| 118 | } else { | 155 | } else { |
| @@ -135,6 +172,14 @@ function AccountRow(props: { · 16 unmodified lines | |||
| 135 | Expired | 172 | Expired |
| 136 | </Badge> | 173 | </Badge> |
| 137 | ) : null} | 174 | ) : null} |
| 175 | {misconfigured ? ( | ||
| 176 | <Badge | ||
| 177 | variant="outline" | ||
| 178 | className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500" | ||
| 179 | > | ||
| 180 | API disabled | ||
| 181 | </Badge> | ||
| 182 | ) : null} | ||
| 138 | {needsReconsent ? ( | 183 | {needsReconsent ? ( |
| 139 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> | 184 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> |
| 140 | Reconnect to grant access | 185 | Reconnect to grant access |
| @@ -146,6 +191,11 @@ function AccountRow(props: { · 5 unmodified lines | |||
| 146 | {connection.description} | 191 | {connection.description} |
| 147 | </CardStackEntryDescription> | 192 | </CardStackEntryDescription> |
| 148 | ) : null} | 193 | ) : null} |
| 194 | {misconfigured && probe?.detail ? ( | ||
| 195 | <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> | ||
| 196 | <DetailWithLinks text={probe.detail} /> | ||
| 197 | </CardStackEntryDescription> | ||
| 198 | ) : null} | ||
| 149 | {needsReconsent ? ( | 199 | {needsReconsent ? ( |
| 150 | <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> | 200 | <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> |
| 151 | This connection wasn't granted all the access this integration now needs. | 201 | This connection wasn't granted all the access this integration now needs. |
| @@ -928,7 +928,13 @@ function HealthStatusLine(props: { · 927 unmodified lines | |||
| 928 | }) { | 928 | }) { |
| 929 | const { status, identity, detail, httpStatus } = props.result; | 929 | const { status, identity, detail, httpStatus } = props.result; |
| 930 | const healthy = status === "healthy"; | 930 | const healthy = status === "healthy"; |
| 931 | const tone = healthy ? "text-muted-foreground" : "text-destructive"; | 931 | // Misconfigured is amber everywhere (see health-display.ts): the credential |
| 932 | // being validated is fine, so the verdict must not read as a key failure. | ||
| 933 | const tone = healthy | ||
| 934 | ? "text-muted-foreground" | ||
| 935 | : status === "misconfigured" | ||
| 936 | ? "text-amber-600 dark:text-amber-500" | ||
| 937 | : "text-destructive"; | ||
| 932 | const font = props.variant === "response" ? "font-mono" : ""; | 938 | const font = props.variant === "response" ? "font-mono" : ""; |
| 933 | return ( | 939 | return ( |
| 934 | <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}> | 940 | <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}> |
| @@ -6,19 +6,26 @@ · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import type { HealthStatus } from "@executor-js/sdk/shared"; | 7 | import type { HealthStatus } from "@executor-js/sdk/shared"; |
| 8 | 8 | ||
| 9 | /** State form: badge text, indicator tooltips, "what is the current state". */ | 9 | /** State form: badge text, indicator tooltips, "what is the current state". |
| 10 | * `misconfigured` reads "API disabled": the one concrete cause it detects | ||
| 11 | * today (an upstream API not enabled in the OAuth client's project), named | ||
| 12 | * plainly so the user looks at the provider console, not the credential. */ | ||
| 10 | export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = { | 13 | export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = { |
| 11 | healthy: "Healthy", | 14 | healthy: "Healthy", |
| 12 | expired: "Expired", | 15 | expired: "Expired", |
| 16 | misconfigured: "API disabled", | ||
| 13 | degraded: "Degraded", | 17 | degraded: "Degraded", |
| 14 | unknown: "Unchecked", | 18 | unknown: "Unchecked", |
| 15 | }; | 19 | }; |
| 16 | 20 | ||
| 17 | /** Text tone per status, for verdict lines and previews. Same per-status color | 21 | /** Text tone per status, for verdict lines and previews. Same per-status color |
| 18 | * decision as the indicator dots below; change them together. */ | 22 | * decision as the indicator dots below; change them together. `misconfigured` |
| 23 | * shares degraded's amber: it needs attention, but the credential is fine, so | ||
| 24 | * it must not carry expired's destructive red. */ | ||
| 19 | export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = { | 25 | export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = { |
| 20 | healthy: "text-emerald-600 dark:text-emerald-400", | 26 | healthy: "text-emerald-600 dark:text-emerald-400", |
| 21 | expired: "text-destructive", | 27 | expired: "text-destructive", |
| 28 | misconfigured: "text-amber-600 dark:text-amber-500", | ||
| 22 | degraded: "text-amber-600 dark:text-amber-500", | 29 | degraded: "text-amber-600 dark:text-amber-500", |
| 23 | unknown: "text-muted-foreground", | 30 | unknown: "text-muted-foreground", |
| 24 | }; | 31 | }; |
| @@ -32,22 +39,27 @@ export const HEALTH_INDICATOR_COLOR: Record< · 7 unmodified lines | |||
| 32 | > = { | 39 | > = { |
| 33 | healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, | 40 | healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, |
| 34 | expired: { dot: "bg-destructive", ring: "ring-destructive/70" }, | 41 | expired: { dot: "bg-destructive", ring: "ring-destructive/70" }, |
| 42 | misconfigured: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, | ||
| 35 | degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, | 43 | degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, |
| 36 | unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" }, | 44 | unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" }, |
| 37 | }; | 45 | }; |
| 38 | 46 | ||
| 39 | /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a | 47 | /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a |
| 40 | * never-probed connection carries no signal, so it must not drag a group | 48 | * never-probed connection carries no signal, so it must not drag a group |
| 41 | * verdict in either direction. */ | 49 | * verdict in either direction. `misconfigured` outranks `degraded` (a named |
| 50 | * configuration break beats a generic error) but not `expired` (a dead | ||
| 51 | * credential blocks everything). */ | ||
| 42 | const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = { | 52 | const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = { |
| 43 | healthy: 1, | 53 | healthy: 1, |
| 44 | degraded: 2, | 54 | degraded: 2, |
| 45 | expired: 3, | 55 | misconfigured: 3, |
| 56 | expired: 4, | ||
| 46 | }; | 57 | }; |
| 47 | 58 | ||
| 48 | /** Collapse many connection statuses to the group's worst: expired > degraded | 59 | /** Collapse many connection statuses to the group's worst: expired > |
| 49 | * > healthy. `unknown` entries are ignored; when nothing else remains (empty | 60 | * misconfigured > degraded > healthy. `unknown` entries are ignored; when |
| 50 | * input or all unknown) there is no verdict and the caller renders nothing. */ | 61 | * nothing else remains (empty input or all unknown) there is no verdict and |
| 62 | * the caller renders nothing. */ | ||
| 51 | export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => { | 63 | export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => { |
| 52 | let worst: Exclude<HealthStatus, "unknown"> | null = null; | 64 | let worst: Exclude<HealthStatus, "unknown"> | null = null; |
| 53 | for (const status of statuses) { | 65 | for (const status of statuses) { |
| @@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record< · 10 unmodified lines | |||
| 64 | > = { | 76 | > = { |
| 65 | healthy: "secondary", | 77 | healthy: "secondary", |
| 66 | expired: "destructive", | 78 | expired: "destructive", |
| 79 | misconfigured: "outline", | ||
| 67 | degraded: "outline", | 80 | degraded: "outline", |
| 68 | unknown: "outline", | 81 | unknown: "outline", |
| 69 | }; | 82 | }; |