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 | 3 | // Production case (2026-07-10): a user's "Personal Google" connection showed |
| 4 | 4 | // the red Expired badge although its OAuth token was alive and refreshing. |
| 5 | 5 | // The org's Google OAuth client lives in a GCP project without the Gmail / |
| 6 | 6 | // Drive / Calendar APIs enabled, so the health probe gets Google's |
| 7 | 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 | |
| 9 | −// perfectly healthy credential renders as Expired and the UI tells the user | |
| 10 | −// to reconnect - which cannot fix a disabled upstream API. | |
| 8 | +// disabled"). The old status-only classifier mapped every 401/403 to | |
| 9 | +// "expired", so a healthy credential rendered as Expired and the UI told the | |
| 10 | +// user to reconnect - advice that cannot fix a disabled upstream API. | |
| 11 | 11 | // |
| 12 | 12 | // The scenario replays that exact journey against the Google emulator: a real |
| 13 | 13 | // OAuth grant probes healthy; then the upstream starts answering the probe |
| 14 | 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 | |
| 16 | −// screen with the reconnect toast. The assertions pin today's (wrong) | |
| 17 | −// classification on purpose: when the classifier learns to tell "API disabled | |
| 18 | −// in the project" from "credential expired", this scenario is the one that | |
| 19 | −// must change. | |
| 15 | +// token stays valid, exactly like prod). The fixed classifier reads the error | |
| 16 | +// body and reports "misconfigured": on screen that is the amber "API | |
| 17 | +// disabled" badge with Google's own remediation text (including the console | |
| 18 | +// link that enables the API), and no reconnect prompt. | |
| 20 | 19 | import { randomBytes } from "node:crypto"; |
| 21 | 20 | |
| 22 | 21 | import { expect } from "@effect/vitest"; |
| @@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines | ||
| 205 | 204 | }); |
| 206 | 205 | |
| 207 | 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 | 208 | { timeout: 300_000 }, |
| 210 | 209 | Effect.gen(function* () { |
| 211 | 210 | const target = yield* Target; |
| @@ -259,16 +258,16 @@ scenario( · 47 unmodified lines | ||
| 259 | 258 | ); |
| 260 | 259 | yield* Effect.promise(() => emulator.client.ledger.clear()); |
| 261 | 260 | |
| 262 | − // THE BUG, pinned: a 403 that means "enable this API in your GCP | |
| 263 | − // project" classifies as an expired credential. | |
| 261 | + // The fix under test: a 403 that means "enable this API in your GCP | |
| 262 | + // project" classifies as misconfigured, not as a dead credential. | |
| 264 | 263 | const verdict = yield* client.connections.checkHealth({ |
| 265 | 264 | params: { owner: "org", integration: slug, name: CONNECTION }, |
| 266 | 265 | query: { ifStaleMs: 0 }, |
| 267 | 266 | }); |
| 268 | 267 | expect( |
| 269 | 268 | verdict.status, |
| 270 | − "BUG: the SERVICE_DISABLED 403 classifies as an expired credential", | |
| 271 | − ).toBe("expired"); | |
| 269 | + `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`, | |
| 270 | + ).toBe("misconfigured"); | |
| 272 | 271 | expect(verdict.httpStatus, "the probe observed the 403").toBe(403); |
| 273 | 272 | expect( |
| 274 | 273 | verdict.detail, |
| @@ -287,24 +286,49 @@ scenario( · 12 unmodified lines | ||
| 287 | 286 | }); |
| 288 | 287 | |
| 289 | 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 | 290 | async () => { |
| 292 | 291 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); |
| 293 | − await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 }); | |
| 294 | − await connections.getByLabel("Status: Expired").waitFor(); | |
| 292 | + await connections | |
| 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 | 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 | 321 | async () => { |
| 301 | 322 | await connections.locator('button[aria-haspopup="menu"]').click(); |
| 302 | 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 | 324 | await page |
| 306 | − .getByText("Connection expired, reconnect to restore access") | |
| 325 | + .getByText(/has not been used in project/) | |
| 326 | + .first() | |
| 307 | 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 | 6 | import { |
| 7 | 7 | candidateIdentityTier, |
| 8 | + classifyHttpStatus, | |
| 9 | + classifyProbeResponse, | |
| 8 | 10 | rankResponseSample, |
| 9 | 11 | sortHealthCheckCandidatesByIdentity, |
| 10 | 12 | } from "./health-check"; |
| @@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => { · 55 unmodified lines | ||
| 66 | 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 | 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 | 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 | |
| 24 | −// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is | |
| 25 | −// "never checked / no health check configured". | |
| 23 | +// authenticated fine yesterday and now returns 401/403. `misconfigured` is the | |
| 24 | +// 403 that is NOT a credential problem: the token authenticated, but the | |
| 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 | 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 | 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 | 155 | export const classifyHttpStatus = (status: number): HealthStatus => { |
| 144 | 156 | if (status >= 200 && status < 300) return "healthy"; |
| 145 | 157 | if (status === 401 || status === 403) return "expired"; |
| 146 | 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 | 205 | /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a |
| 150 | 206 | * display string. Numeric segments index arrays. Returns undefined when the |
| 151 | 207 | * path is empty, missing, or lands on a non-scalar. */ |
| @@ -114,6 +114,7 @@ export { · 113 unmodified lines | ||
| 114 | 114 | HealthCheckCandidateParameter, |
| 115 | 115 | HealthCheckResponseField, |
| 116 | 116 | classifyHttpStatus, |
| 117 | + classifyProbeResponse, | |
| 117 | 118 | extractIdentity, |
| 118 | 119 | compareHealthCheckCandidates, |
| 119 | 120 | candidateIdentityTier, |
| @@ -118,6 +118,7 @@ export { · 117 unmodified lines | ||
| 118 | 118 | HealthCheckCandidate, |
| 119 | 119 | HealthCheckCandidateParameter, |
| 120 | 120 | classifyHttpStatus, |
| 121 | + classifyProbeResponse, | |
| 121 | 122 | extractIdentity, |
| 122 | 123 | compareHealthCheckCandidates, |
| 123 | 124 | candidateIdentityTier, |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | ToolName, |
| 8 | 8 | ToolResult, |
| 9 | 9 | authToolFailure, |
| 10 | − classifyHttpStatus, | |
| 10 | + classifyProbeResponse, | |
| 11 | 11 | detectInsufficientScope, |
| 12 | 12 | sortHealthCheckCandidatesByIdentity, |
| 13 | 13 | extractIdentity, |
| @@ -962,7 +962,9 @@ export const checkHealthOpenApi = (input: { · 948 unmodified lines | ||
| 962 | 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 | 968 | const identity = |
| 967 | 969 | status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; |
| 968 | 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 | 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 | 92 | function AccountRow(props: { |
| 64 | 93 | readonly connection: Connection; |
| 65 | 94 | /** The integration declares scopes this connection was not granted — it must |
| @@ -95,6 +124,7 @@ function AccountRow(props: { · 29 unmodified lines | ||
| 95 | 124 | const displayLabel = identity ?? String(connection.name); |
| 96 | 125 | |
| 97 | 126 | const expired = status === "expired"; |
| 127 | + const misconfigured = status === "misconfigured"; | |
| 98 | 128 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| 99 | 129 | |
| 100 | 130 | const handleCheck = async () => { |
| @@ -113,6 +143,13 @@ function AccountRow(props: { · 12 unmodified lines | ||
| 113 | 143 | ); |
| 114 | 144 | } else if (exit.value.status === "expired") { |
| 115 | 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 | 153 | } else if (exit.value.status === "degraded") { |
| 117 | 154 | toast.warning(exit.value.detail ?? "Connection check returned an error"); |
| 118 | 155 | } else { |
| @@ -135,6 +172,14 @@ function AccountRow(props: { · 16 unmodified lines | ||
| 135 | 172 | Expired |
| 136 | 173 | </Badge> |
| 137 | 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 | 183 | {needsReconsent ? ( |
| 139 | 184 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> |
| 140 | 185 | Reconnect to grant access |
| @@ -146,6 +191,11 @@ function AccountRow(props: { · 5 unmodified lines | ||
| 146 | 191 | {connection.description} |
| 147 | 192 | </CardStackEntryDescription> |
| 148 | 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 | 199 | {needsReconsent ? ( |
| 150 | 200 | <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> |
| 151 | 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 | 929 | const { status, identity, detail, httpStatus } = props.result; |
| 930 | 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 | 938 | const font = props.variant === "response" ? "font-mono" : ""; |
| 933 | 939 | return ( |
| 934 | 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 | 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 | 13 | export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = { |
| 11 | 14 | healthy: "Healthy", |
| 12 | 15 | expired: "Expired", |
| 16 | + misconfigured: "API disabled", | |
| 13 | 17 | degraded: "Degraded", |
| 14 | 18 | unknown: "Unchecked", |
| 15 | 19 | }; |
| 16 | 20 | |
| 17 | 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 | 25 | export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = { |
| 20 | 26 | healthy: "text-emerald-600 dark:text-emerald-400", |
| 21 | 27 | expired: "text-destructive", |
| 28 | + misconfigured: "text-amber-600 dark:text-amber-500", | |
| 22 | 29 | degraded: "text-amber-600 dark:text-amber-500", |
| 23 | 30 | unknown: "text-muted-foreground", |
| 24 | 31 | }; |
| @@ -32,22 +39,27 @@ export const HEALTH_INDICATOR_COLOR: Record< · 7 unmodified lines | ||
| 32 | 39 | > = { |
| 33 | 40 | healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, |
| 34 | 41 | expired: { dot: "bg-destructive", ring: "ring-destructive/70" }, |
| 42 | + misconfigured: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, | |
| 35 | 43 | degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, |
| 36 | 44 | unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" }, |
| 37 | 45 | }; |
| 38 | 46 | |
| 39 | 47 | /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a |
| 40 | 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 | 52 | const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = { |
| 43 | 53 | healthy: 1, |
| 44 | 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 | |
| 49 | − * > healthy. `unknown` entries are ignored; when nothing else remains (empty | |
| 50 | − * input or all unknown) there is no verdict and the caller renders nothing. */ | |
| 59 | +/** Collapse many connection statuses to the group's worst: expired > | |
| 60 | + * misconfigured > degraded > healthy. `unknown` entries are ignored; when | |
| 61 | + * nothing else remains (empty input or all unknown) there is no verdict and | |
| 62 | + * the caller renders nothing. */ | |
| 51 | 63 | export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => { |
| 52 | 64 | let worst: Exclude<HealthStatus, "unknown"> | null = null; |
| 53 | 65 | for (const status of statuses) { |
| @@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record< · 10 unmodified lines | ||
| 64 | 76 | > = { |
| 65 | 77 | healthy: "secondary", |
| 66 | 78 | expired: "destructive", |
| 79 | + misconfigured: "outline", | |
| 67 | 80 | degraded: "outline", |
| 68 | 81 | unknown: "outline", |
| 69 | 82 | }; |