Unified · Split
| @@ -15,6 +15,12 @@ import { · 14 unmodified lines | ||
| 15 | 15 | } from "../api/atoms"; |
| 16 | 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 17 | 17 | import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; |
| 18 | +import { | |
| 19 | + MANAGED_CONNECTION_BADGE, | |
| 20 | + MANAGED_CONNECTION_BLOCKED_LABEL, | |
| 21 | + MANAGED_CONNECTION_REVOCATION_HINT, | |
| 22 | + connectionRowPolicy, | |
| 23 | +} from "../lib/managed-connection"; | |
| 18 | 24 | import { useConnectionHealth } from "../lib/use-connection-health"; |
| 19 | 25 | import { messageFromExit } from "../api/error-reporting"; |
| 20 | 26 | import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; |
| @@ -114,8 +120,19 @@ function AccountRow(props: { · 93 unmodified lines | ||
| 114 | 120 | : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null); |
| 115 | 121 | const displayLabel = identity ?? String(connection.name); |
| 116 | 122 | |
| 123 | + // What this row may OFFER, decided from the connection's persisted | |
| 124 | + // enterprise-managed state and the structured administrator verdict on its | |
| 125 | + // freshest health result — never from a status word or a message. | |
| 126 | + const policy = connectionRowPolicy(connection, probe); | |
| 127 | + | |
| 117 | 128 | const expired = status === "expired"; |
| 129 | + // An administrator decision outranks the health word. "Expired" would invite | |
| 130 | + // a reconnect that cannot succeed, because the route it re-runs is exactly | |
| 131 | + // the one the identity provider just closed. | |
| 118 | 132 | const needsHealthAttention = status === "expired" || status === "degraded"; |
| 133 | + const statusLabel = policy.blockedByAdmin | |
| 134 | + ? MANAGED_CONNECTION_BLOCKED_LABEL | |
| 135 | + : HEALTH_STATUS_LABEL[status]; | |
| 119 | 136 | const healthDetail = needsHealthAttention ? probe?.detail : undefined; |
| 120 | 137 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| 121 | 138 | |
| @@ -152,9 +169,26 @@ function AccountRow(props: { · 30 unmodified lines | ||
| 152 | 169 | className={`size-2 shrink-0 rounded-full ${indicator.dot}`} |
| 153 | 170 | /> |
| 154 | 171 | <span className="truncate">{displayLabel}</span> |
| 172 | + {policy.managed ? ( | |
| 173 | + // Grayscale, a word, no hue — this is a fact about the connection, | |
| 174 | + // not a fault (design.md, "Status and semantics"). It reads | |
| 175 | + // distinctly from the personal rows beside it, which is the point: | |
| 176 | + // an additional personal account on the same integration stays | |
| 177 | + // possible and must stay visibly different. | |
| 178 | + <Badge | |
| 179 | + variant="outline" | |
| 180 | + className="shrink-0 border-border text-muted-foreground" | |
| 181 | + data-slot="managed-connection-badge" | |
| 182 | + > | |
| 183 | + {MANAGED_CONNECTION_BADGE} | |
| 184 | + </Badge> | |
| 185 | + ) : null} | |
| 155 | 186 | {needsHealthAttention ? ( |
| 156 | − <Badge variant={expired ? "destructive" : "outline"} className="shrink-0"> | |
| 157 | − {HEALTH_STATUS_LABEL[status]} | |
| 187 | + <Badge | |
| 188 | + variant={expired && !policy.blockedByAdmin ? "destructive" : "outline"} | |
| 189 | + className="shrink-0" | |
| 190 | + > | |
| 191 | + {statusLabel} | |
| 158 | 192 | </Badge> |
| 159 | 193 | ) : null} |
| 160 | 194 | {needsReconsent ? ( |
| @@ -183,6 +217,16 @@ function AccountRow(props: { · 22 unmodified lines | ||
| 183 | 217 | Missing scopes: {missingOAuthScopes.join(", ")} |
| 184 | 218 | </CardStackEntryDescription> |
| 185 | 219 | ) : null} |
| 220 | + {policy.managed ? ( | |
| 221 | + <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground"> | |
| 222 | + {MANAGED_CONNECTION_REVOCATION_HINT} | |
| 223 | + </CardStackEntryDescription> | |
| 224 | + ) : null} | |
| 225 | + {policy.oauthErrorCode === null ? null : ( | |
| 226 | + <CardStackEntryDescription className="mt-1 font-mono text-[11px] text-muted-foreground"> | |
| 227 | + Reference: {policy.oauthErrorCode} | |
| 228 | + </CardStackEntryDescription> | |
| 229 | + )} | |
| 186 | 230 | </CardStackEntryContent> |
| 187 | 231 | <CardStackEntryActions className="self-start pt-0.5"> |
| 188 | 232 | {props.showOwnerLabel ? ( |
| @@ -213,12 +257,22 @@ function AccountRow(props: { · 24 unmodified lines | ||
| 213 | 257 | <DropdownMenuItem className="text-sm" onClick={props.onEdit}> |
| 214 | 258 | Edit |
| 215 | 259 | </DropdownMenuItem> |
| 216 | − <DropdownMenuItem className="text-sm" onClick={props.onReconnect}> | |
| 217 | − Reconnect | |
| 218 | − </DropdownMenuItem> | |
| 219 | − <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}> | |
| 220 | − Remove | |
| 221 | − </DropdownMenuItem> | |
| 260 | + {/* Reconnect and Remove are WITHHELD, not disabled, for an | |
| 261 | + enterprise-managed connection. Reconnect re-runs the interactive | |
| 262 | + consent this profile has no step for, and Remove would claim the | |
| 263 | + member revoked access they cannot revoke here — the identity | |
| 264 | + provider still authorizes them, and the next call would hand it | |
| 265 | + straight back. Both live at the provider. */} | |
| 266 | + {policy.canReconnect ? ( | |
| 267 | + <DropdownMenuItem className="text-sm" onClick={props.onReconnect}> | |
| 268 | + Reconnect | |
| 269 | + </DropdownMenuItem> | |
| 270 | + ) : null} | |
| 271 | + {policy.canRemove ? ( | |
| 272 | + <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}> | |
| 273 | + Remove | |
| 274 | + </DropdownMenuItem> | |
| 275 | + ) : null} | |
| 222 | 276 | </DropdownMenuContent> |
| 223 | 277 | </DropdownMenu> |
| 224 | 278 | </CardStackEntryActions> |
| @@ -44,6 +44,7 @@ import { · 43 unmodified lines | ||
| 44 | 44 | oauthClientWriteKeys, |
| 45 | 45 | } from "../api/reactivity-keys"; |
| 46 | 46 | import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; |
| 47 | +import { AdminBlockNotice } from "./admin-block-notice"; | |
| 47 | 48 | import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; |
| 48 | 49 | import { messageFromExit } from "../api/error-reporting"; |
| 49 | 50 | import { trackEvent } from "../api/analytics"; |
| @@ -1618,6 +1619,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1568 unmodified lines | ||
| 1618 | 1619 | const isBuiltInGoogleClient = |
| 1619 | 1620 | chosenClient?.origin.kind === "first_party" && |
| 1620 | 1621 | String(chosenClient.slug) === "first-party:google"; |
| 1622 | + // The server names an enterprise identity provider (MCP Enterprise-Managed | |
| 1623 | + // Authorization). Read STRUCTURALLY off the declared method — never from the | |
| 1624 | + // grant of a picked app, which an ordinary connection can also carry. | |
| 1625 | + const managedByOrganization = isOAuth && method?.oauth?.enterpriseIdentityProvider !== undefined; | |
| 1621 | 1626 | const oauthBusy = ccBusy || oauthPopup.busy; |
| 1622 | 1627 | const cimdConnecting = cimdBusy || oauthPopup.busy; |
| 1623 | 1628 | const dcrConnecting = dcrBusy || oauthPopup.busy; |
| @@ -2952,11 +2957,33 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1328 unmodified lines | ||
| 2952 | 2957 | {continueError} |
| 2953 | 2958 | </p> |
| 2954 | 2959 | ) : null} |
| 2960 | + {/* The server is declared enterprise-managed. Copy only, and | |
| 2961 | + deliberately: whether this connect actually takes the | |
| 2962 | + enterprise branch is decided at discovery — the server has to | |
| 2963 | + advertise the ID-JAG grant profile — so the notice describes the | |
| 2964 | + arrangement rather than promising an outcome the console cannot | |
| 2965 | + guarantee. It is withdrawn while an administrator denial stands, | |
| 2966 | + where the denial is the more specific truth. */} | |
| 2967 | + {managedByOrganization && oauthPopup.adminBlock === null ? ( | |
| 2968 | + <p | |
| 2969 | + data-slot="enterprise-managed-notice" | |
| 2970 | + className="mx-1 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground" | |
| 2971 | + > | |
| 2972 | + Your organization manages this server. Where it supports work identities, you sign | |
| 2973 | + in with yours and your identity provider decides the access — no consent screen, and | |
| 2974 | + nothing to revoke here. | |
| 2975 | + </p> | |
| 2976 | + ) : null} | |
| 2955 | 2977 | {/* Above the footer, not inside the method tab: the automatic |
| 2956 | 2978 | (CIMD/DCR) flows render no tab panel at all, and putting the |
| 2957 | 2979 | sign-in error in there left a blocked popup with nothing on |
| 2958 | 2980 | screen but the button returning to "Connect". */} |
| 2959 | − {isOAuth && oauthPopup.error ? ( | |
| 2981 | + {/* An enterprise policy denial replaces the ordinary error line — | |
| 2982 | + it is not a transient fault, and the footer below withdraws | |
| 2983 | + every connect action while it stands. */} | |
| 2984 | + {isOAuth && oauthPopup.adminBlock !== null ? ( | |
| 2985 | + <AdminBlockNotice block={oauthPopup.adminBlock} className="mx-1" /> | |
| 2986 | + ) : isOAuth && oauthPopup.error ? ( | |
| 2960 | 2987 | <p role="alert" className="px-1 text-xs text-destructive"> |
| 2961 | 2988 | {oauthPopup.error} |
| 2962 | 2989 | </p> |
| @@ -2978,7 +3005,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 15 unmodified lines | ||
| 2978 | 3005 | - registering a BYO app: the form owns its own submit, no footer; |
| 2979 | 3006 | - picked BYO OAuth app: Connect with OAuth / Connect (client creds); |
| 2980 | 3007 | - credential/no-auth method: Add connection. */} |
| 2981 | − {cimdActive ? ( | |
| 3008 | + {/* Blocked by administrator: offer NOTHING that reconnects. | |
| 3009 | + Every branch below is a route to the same authorization the | |
| 3010 | + enterprise just refused, and the interactive one would route | |
| 3011 | + the user around the control outright. Close is the only | |
| 3012 | + action left. */} | |
| 3013 | + {isOAuth && oauthPopup.adminBlock !== null ? null : cimdActive ? ( | |
| 2982 | 3014 | <Button |
| 2983 | 3015 | type="button" |
| 2984 | 3016 | onClick={() => void handleCimdConnect()} |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import { | |
| 2 | + ADMIN_BLOCK_NEXT_STEP, | |
| 3 | + ADMIN_BLOCK_TITLE, | |
| 4 | + adminBlockReference, | |
| 5 | + type OAuthAdminBlock, | |
| 6 | +} from "../plugins/oauth-admin-block"; | |
| 7 | + | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | +// The blocked-by-administrator notice. | |
| 10 | +// | |
| 11 | +// Presented as an ORGANIZATION DECISION, not an error the user can work | |
| 12 | +// around: no retry, no alternative sign-in, no "try again" — every one of those | |
| 13 | +// would offer the route the identity provider just closed. What it does give is | |
| 14 | +// the provider's own code, so a member can quote it to whoever administers the | |
| 15 | +// policy. | |
| 16 | +// | |
| 17 | +// Grayscale, per design.md: destructive red is for irreversible actions and | |
| 18 | +// faults, and this is neither. It is a policy outcome. | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +export function AdminBlockNotice(props: { | |
| 22 | + readonly block: OAuthAdminBlock; | |
| 23 | + readonly className?: string; | |
| 24 | +}) { | |
| 25 | + const reference = adminBlockReference(props.block); | |
| 26 | + return ( | |
| 27 | + <div | |
| 28 | + role="alert" | |
| 29 | + data-slot="admin-block-notice" | |
| 30 | + className={`space-y-1 rounded-lg border border-border bg-muted/40 p-3 ${props.className ?? ""}`} | |
| 31 | + > | |
| 32 | + <p className="text-sm font-medium text-foreground">{ADMIN_BLOCK_TITLE}</p> | |
| 33 | + <p className="text-xs text-muted-foreground">{props.block.message}</p> | |
| 34 | + <p className="text-xs text-muted-foreground">{ADMIN_BLOCK_NEXT_STEP}</p> | |
| 35 | + {reference === null ? null : ( | |
| 36 | + <p className="font-mono text-[11px] text-muted-foreground">{reference}</p> | |
| 37 | + )} | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | +} | |
| @@ -0,0 +1,99 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + AuthTemplateSlug, | |
| 4 | + ConnectionAddress, | |
| 5 | + ConnectionName, | |
| 6 | + IntegrationSlug, | |
| 7 | + ProviderKey, | |
| 8 | + type Connection, | |
| 9 | + type HealthCheckResult, | |
| 10 | +} from "@executor-js/sdk/shared"; | |
| 11 | + | |
| 12 | +import { connectionRowPolicy } from "./managed-connection"; | |
| 13 | + | |
| 14 | +const connection = (overrides: Partial<Connection> = {}): Connection => ({ | |
| 15 | + owner: "org", | |
| 16 | + integration: IntegrationSlug.make("linear_mcp"), | |
| 17 | + name: ConnectionName.make("main"), | |
| 18 | + template: AuthTemplateSlug.make("oauth2"), | |
| 19 | + provider: ProviderKey.make("default"), | |
| 20 | + address: ConnectionAddress.make("linear_mcp.org.main"), | |
| 21 | + ...overrides, | |
| 22 | +}); | |
| 23 | + | |
| 24 | +const health = (overrides: Partial<HealthCheckResult> = {}): HealthCheckResult => ({ | |
| 25 | + status: "healthy", | |
| 26 | + checkedAt: 0, | |
| 27 | + ...overrides, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +describe("connectionRowPolicy", () => { | |
| 31 | + it("offers Remove and Reconnect on an ordinary connection", () => { | |
| 32 | + const policy = connectionRowPolicy(connection(), health()); | |
| 33 | + expect(policy).toMatchObject({ managed: false, canRemove: true, canReconnect: true }); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("withholds Remove on an enterprise-managed connection", () => { | |
| 37 | + // There is nothing local to delete: renewal re-runs the ID-JAG chain from | |
| 38 | + // the stored assertion, so a row that vanished would claim a revocation | |
| 39 | + // that did not happen — the provider still authorizes the member. | |
| 40 | + expect(connectionRowPolicy(connection({ enterpriseManaged: true }), health()).canRemove).toBe( | |
| 41 | + false, | |
| 42 | + ); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("withholds Reconnect on an enterprise-managed connection", () => { | |
| 46 | + // Reconnect exists to re-consent through the browser. This profile has no | |
| 47 | + // consent step, and the console holds no identity assertion to present. | |
| 48 | + expect( | |
| 49 | + connectionRowPolicy(connection({ enterpriseManaged: true }), health()).canReconnect, | |
| 50 | + ).toBe(false); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it("withholds Reconnect from an ORDINARY connection an administrator blocked", () => { | |
| 54 | + // The interactive flow is exactly the route the identity provider just | |
| 55 | + // closed; offering it would route the user around the decision. | |
| 56 | + const policy = connectionRowPolicy( | |
| 57 | + connection(), | |
| 58 | + health({ status: "expired", blockedByAdmin: true }), | |
| 59 | + ); | |
| 60 | + expect(policy.canReconnect).toBe(false); | |
| 61 | + // Removing an ordinary connection is still the member's own call. | |
| 62 | + expect(policy.canRemove).toBe(true); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("reads the administrator verdict from the field, never the status word", () => { | |
| 66 | + expect(connectionRowPolicy(connection(), health({ status: "expired" })).blockedByAdmin).toBe( | |
| 67 | + false, | |
| 68 | + ); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("surfaces the provider's code only while the block stands", () => { | |
| 72 | + expect( | |
| 73 | + connectionRowPolicy( | |
| 74 | + connection(), | |
| 75 | + health({ blockedByAdmin: true, oauthErrorCode: "invalid_target" }), | |
| 76 | + ).oauthErrorCode, | |
| 77 | + ).toBe("invalid_target"); | |
| 78 | + // A code left over from some other refusal is not an administrator | |
| 79 | + // decision, and must not be presented as one. | |
| 80 | + expect( | |
| 81 | + connectionRowPolicy(connection(), health({ oauthErrorCode: "invalid_grant" })).oauthErrorCode, | |
| 82 | + ).toBeNull(); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("treats a never-checked connection as unblocked", () => { | |
| 86 | + expect(connectionRowPolicy(connection({ enterpriseManaged: true }), null)).toMatchObject({ | |
| 87 | + managed: true, | |
| 88 | + blockedByAdmin: false, | |
| 89 | + oauthErrorCode: null, | |
| 90 | + }); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("does not infer managed state from an absent field", () => { | |
| 94 | + // `enterpriseManaged` is projected server-side from the connection's | |
| 95 | + // persisted state. An older row that carries nothing is ordinary, not | |
| 96 | + // ambiguous. | |
| 97 | + expect(connectionRowPolicy(connection(), undefined).managed).toBe(false); | |
| 98 | + }); | |
| 99 | +}); | |
| @@ -0,0 +1,78 @@ | ||
| 1 | +import type { Connection, HealthCheckResult } from "@executor-js/sdk/shared"; | |
| 2 | + | |
| 3 | +// --------------------------------------------------------------------------- | |
| 4 | +// Enterprise-managed connections — what a row may offer. | |
| 5 | +// | |
| 6 | +// An enterprise-managed connection mirrors organization policy. It holds no | |
| 7 | +// durable grant of its own: renewal re-runs the ID-JAG chain from the identity | |
| 8 | +// assertion, and the enterprise identity provider decides every time. Two | |
| 9 | +// consequences drive everything below. | |
| 10 | +// | |
| 11 | +// 1. There is nothing local to delete. A "Remove" that dropped the row would | |
| 12 | +// claim the user had revoked access when they had not — the identity | |
| 13 | +// provider still authorizes them, and the next connect would hand it | |
| 14 | +// straight back. Revocation lives at the provider. | |
| 15 | +// | |
| 16 | +// 2. There is no interactive route to re-run. Reconnect exists to re-consent | |
| 17 | +// through the browser, and this profile has no consent step; the console | |
| 18 | +// also holds no identity assertion to present. Offering it would produce | |
| 19 | +// a failure the user cannot act on. | |
| 20 | +// | |
| 21 | +// Connect stays available for ADDITIONAL personal accounts on the same | |
| 22 | +// integration: enterprise-managed authorization binds exactly one enterprise | |
| 23 | +// identity, and it does not claim the integration. | |
| 24 | +// --------------------------------------------------------------------------- | |
| 25 | + | |
| 26 | +/** Grayscale, a word, no hue — see design.md, "Status and semantics". */ | |
| 27 | +export const MANAGED_CONNECTION_BADGE = "Managed by your organization"; | |
| 28 | + | |
| 29 | +/** Why the row offers no Remove. Shown as helper text, sentence case. */ | |
| 30 | +export const MANAGED_CONNECTION_REVOCATION_HINT = | |
| 31 | + "This connection follows your organization's policy. Revoke access at your identity provider, not here."; | |
| 32 | + | |
| 33 | +/** What an enterprise-managed row shows when the identity provider has since | |
| 34 | + * declined to renew it. The status word for this state — NOT "Expired", | |
| 35 | + * which would invite a reconnect that cannot succeed. */ | |
| 36 | +export const MANAGED_CONNECTION_BLOCKED_LABEL = "Blocked by your organization"; | |
| 37 | + | |
| 38 | +export interface ConnectionRowPolicy { | |
| 39 | + /** The connection was minted through enterprise-managed authorization. */ | |
| 40 | + readonly managed: boolean; | |
| 41 | + /** An administrator decision is the CURRENT state of this connection: the | |
| 42 | + * last credential resolution was refused by the identity provider. */ | |
| 43 | + readonly blockedByAdmin: boolean; | |
| 44 | + /** The provider's RFC 6749 §5.2 code, for support traceability. */ | |
| 45 | + readonly oauthErrorCode: string | null; | |
| 46 | + /** Whether the row may offer to delete this connection. */ | |
| 47 | + readonly canRemove: boolean; | |
| 48 | + /** Whether the row may offer to re-run an interactive OAuth flow. */ | |
| 49 | + readonly canReconnect: boolean; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * What one connection row may offer, from the connection and its freshest | |
| 54 | + * health verdict. | |
| 55 | + * | |
| 56 | + * Both inputs are read STRUCTURALLY: `enterpriseManaged` is projected from the | |
| 57 | + * connection's persisted provider state, and `blockedByAdmin` is a typed field | |
| 58 | + * on the health result. Neither is inferred from a message, a status word, or | |
| 59 | + * the grant of the OAuth app behind the connection — an `id_jag` app still | |
| 60 | + * falls back to the interactive flow against a server that does not advertise | |
| 61 | + * the profile, and such a connection is ordinary in every way. | |
| 62 | + */ | |
| 63 | +export const connectionRowPolicy = ( | |
| 64 | + connection: Connection, | |
| 65 | + health: HealthCheckResult | null | undefined, | |
| 66 | +): ConnectionRowPolicy => { | |
| 67 | + const managed = connection.enterpriseManaged === true; | |
| 68 | + const blockedByAdmin = health?.blockedByAdmin === true; | |
| 69 | + return { | |
| 70 | + managed, | |
| 71 | + blockedByAdmin, | |
| 72 | + oauthErrorCode: blockedByAdmin ? (health?.oauthErrorCode ?? null) : null, | |
| 73 | + canRemove: !managed, | |
| 74 | + // A blocked connection is never reconnectable either, managed or not: the | |
| 75 | + // interactive flow is exactly the route the enterprise just closed. | |
| 76 | + canReconnect: !managed && !blockedByAdmin, | |
| 77 | + }; | |
| 78 | +}; | |
| @@ -0,0 +1,102 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import * as Exit from "effect/Exit"; | |
| 3 | +import { OAuthStartError } from "@executor-js/sdk/shared"; | |
| 4 | + | |
| 5 | +import { adminBlockFrom, adminBlockFromExit, adminBlockReference } from "./oauth-admin-block"; | |
| 6 | + | |
| 7 | +// The claim these tests defend is a product claim, not a parsing one: a console | |
| 8 | +// may only withdraw the interactive route when the identity provider actually | |
| 9 | +// refused under policy. Reading it wrong in one direction strands a user with a | |
| 10 | +// recoverable error and no retry; in the other, it walks them around the exact | |
| 11 | +// control the enterprise just exercised. | |
| 12 | + | |
| 13 | +const denial = new OAuthStartError({ | |
| 14 | + message: "Your organization does not permit this server.", | |
| 15 | + blockedByAdmin: true, | |
| 16 | + oauthErrorCode: "invalid_target", | |
| 17 | +}); | |
| 18 | + | |
| 19 | +describe("adminBlockFrom", () => { | |
| 20 | + it("reads the verdict off the typed field", () => { | |
| 21 | + expect(adminBlockFrom(denial)).toEqual({ | |
| 22 | + message: "Your organization does not permit this server.", | |
| 23 | + oauthErrorCode: "invalid_target", | |
| 24 | + }); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("carries a null reference when the provider returned no code", () => { | |
| 28 | + expect( | |
| 29 | + adminBlockFrom(new OAuthStartError({ message: "Refused.", blockedByAdmin: true })) | |
| 30 | + ?.oauthErrorCode, | |
| 31 | + ).toBeNull(); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("leaves an ordinary start failure alone", () => { | |
| 35 | + // No verdict means the interactive route stays open — an expired app, a | |
| 36 | + // bad endpoint, a network fault are all things a retry can fix. | |
| 37 | + expect( | |
| 38 | + adminBlockFrom(new OAuthStartError({ message: "Failed to reach the token endpoint." })), | |
| 39 | + ).toBeNull(); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + it("does not treat an explicitly-false verdict as a denial", () => { | |
| 43 | + expect( | |
| 44 | + adminBlockFrom(new OAuthStartError({ message: "Refused.", blockedByAdmin: false })), | |
| 45 | + ).toBeNull(); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("never infers a denial from the wording of a message", () => { | |
| 49 | + // The whole reason the field exists. A message that says the words is | |
| 50 | + // still not a verdict, and a console that matched on text would start | |
| 51 | + // withdrawing the retry for failures the user could have recovered from. | |
| 52 | + expect( | |
| 53 | + adminBlockFrom( | |
| 54 | + new OAuthStartError({ message: "blocked by admin: your organization declined" }), | |
| 55 | + ), | |
| 56 | + ).toBeNull(); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("finds the verdict through the popup flow's one level of wrapping", () => { | |
| 60 | + // `openAuthorization` rejects with its own error carrying the server's | |
| 61 | + // failure as `cause`; the verdict has to survive that hop or the modal | |
| 62 | + // sees only a sentence. | |
| 63 | + expect(adminBlockFrom({ message: "Failed to start sign-in", cause: denial })).toEqual({ | |
| 64 | + message: "Your organization does not permit this server.", | |
| 65 | + oauthErrorCode: "invalid_target", | |
| 66 | + }); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("does not go hunting down a cause chain", () => { | |
| 70 | + // One level is the contract. A chain is not a search space: something | |
| 71 | + // deeper is not this connect's verdict. | |
| 72 | + expect(adminBlockFrom({ cause: { cause: denial } })).toBeNull(); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("reads nothing out of values that are not start failures", () => { | |
| 76 | + expect(adminBlockFrom(null)).toBeNull(); | |
| 77 | + expect(adminBlockFrom("blocked")).toBeNull(); | |
| 78 | + expect(adminBlockFrom({ blockedByAdmin: true })).toBeNull(); | |
| 79 | + }); | |
| 80 | +}); | |
| 81 | + | |
| 82 | +describe("adminBlockFromExit", () => { | |
| 83 | + it("reads the verdict out of a failed exit", () => { | |
| 84 | + expect(adminBlockFromExit(Exit.fail(denial))?.oauthErrorCode).toBe("invalid_target"); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("has no verdict for a successful exit", () => { | |
| 88 | + expect(adminBlockFromExit(Exit.succeed({ status: "connected" }))).toBeNull(); | |
| 89 | + }); | |
| 90 | +}); | |
| 91 | + | |
| 92 | +describe("adminBlockReference", () => { | |
| 93 | + it("quotes the provider's own code so a member can trace it", () => { | |
| 94 | + expect(adminBlockReference({ message: "…", oauthErrorCode: "invalid_target" })).toBe( | |
| 95 | + "Reference: invalid_target", | |
| 96 | + ); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + it("shows no reference line when there is no code to show", () => { | |
| 100 | + expect(adminBlockReference({ message: "…", oauthErrorCode: null })).toBeNull(); | |
| 101 | + }); | |
| 102 | +}); | |
| @@ -0,0 +1,75 @@ | ||
| 1 | +import * as Exit from "effect/Exit"; | |
| 2 | +import * as Option from "effect/Option"; | |
| 3 | +import * as Schema from "effect/Schema"; | |
| 4 | +import { OAuthStartError } from "@executor-js/sdk/shared"; | |
| 5 | + | |
| 6 | +// --------------------------------------------------------------------------- | |
| 7 | +// Blocked by administrator — reading an enterprise policy denial as STRUCTURE. | |
| 8 | +// | |
| 9 | +// When an enterprise identity provider refuses to mint an ID-JAG, the failure | |
| 10 | +// is an administrator's decision, not a credential problem. `OAuthStartError` | |
| 11 | +// says so in a typed field (`blockedByAdmin`) precisely so a console never has | |
| 12 | +// to read the sentence: matching on message text would silently start passing | |
| 13 | +// or failing whenever the wording changed, and the consequence of getting it | |
| 14 | +// wrong is offering the interactive per-server flow — which would route the | |
| 15 | +// user straight around the control the enterprise just exercised. | |
| 16 | +// | |
| 17 | +// So: this module decodes the field, and NOTHING here inspects `message` | |
| 18 | +// except to display it. | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +/** An enterprise identity provider declined this connect under administrator | |
| 22 | + * policy. Terminal by construction: there is no retry and no alternative | |
| 23 | + * route to offer, only the decision and a code support can trace. */ | |
| 24 | +export interface OAuthAdminBlock { | |
| 25 | + /** The failure's own message, shown verbatim. */ | |
| 26 | + readonly message: string; | |
| 27 | + /** The identity provider's RFC 6749 §5.2 code (`unauthorized_client`, | |
| 28 | + * `invalid_target`, …), when it returned one. Null otherwise. */ | |
| 29 | + readonly oauthErrorCode: string | null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +const decodeStartError = Schema.decodeUnknownOption(OAuthStartError); | |
| 33 | + | |
| 34 | +/** One level of wrapping: the popup flow wraps a start failure in its own | |
| 35 | + * tagged error before it reaches a renderer, and the verdict must survive | |
| 36 | + * that hop. Exactly one level — a `cause` chain is not a search space. */ | |
| 37 | +const decodeWrapped = Schema.decodeUnknownOption(Schema.Struct({ cause: Schema.Unknown })); | |
| 38 | + | |
| 39 | +const directAdminBlock = (error: unknown): OAuthAdminBlock | null => | |
| 40 | + Option.match(decodeStartError(error), { | |
| 41 | + onNone: () => null, | |
| 42 | + onSome: (start) => | |
| 43 | + start.blockedByAdmin === true | |
| 44 | + ? { message: start.message, oauthErrorCode: start.oauthErrorCode ?? null } | |
| 45 | + : null, | |
| 46 | + }); | |
| 47 | + | |
| 48 | +/** The administrator verdict carried by a failed `oauth.start`, or null when | |
| 49 | + * the failure is anything else — including every other OAuth start failure, | |
| 50 | + * which leaves the interactive route open. */ | |
| 51 | +export const adminBlockFrom = (error: unknown): OAuthAdminBlock | null => | |
| 52 | + directAdminBlock(error) ?? | |
| 53 | + Option.match(decodeWrapped(error), { | |
| 54 | + onNone: () => null, | |
| 55 | + onSome: (wrapper) => directAdminBlock(wrapper.cause), | |
| 56 | + }); | |
| 57 | + | |
| 58 | +/** `adminBlockFrom` over an `Exit`, for the mutation call sites that hold one. */ | |
| 59 | +export const adminBlockFromExit = (exit: Exit.Exit<unknown, unknown>): OAuthAdminBlock | null => | |
| 60 | + Option.match(Exit.findErrorOption(exit), { | |
| 61 | + onNone: () => null, | |
| 62 | + onSome: adminBlockFrom, | |
| 63 | + }); | |
| 64 | + | |
| 65 | +/** What the user is told. Sentence case per the design system's voice rules: | |
| 66 | + * what happened, then what to do next — and the next step is a person, not a | |
| 67 | + * button, because no action in this console can change the verdict. */ | |
| 68 | +export const ADMIN_BLOCK_TITLE = "Blocked by your organization"; | |
| 69 | +export const ADMIN_BLOCK_NEXT_STEP = | |
| 70 | + "Ask an administrator to allow this server at your identity provider."; | |
| 71 | + | |
| 72 | +/** The support-traceable reference line, or null when the provider returned no | |
| 73 | + * code. Mono metadata, per the design system. */ | |
| 74 | +export const adminBlockReference = (block: OAuthAdminBlock): string | null => | |
| 75 | + block.oauthErrorCode === null ? null : `Reference: ${block.oauthErrorCode}`; | |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | } from "../api/oauth-popup"; |
| 17 | 17 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 18 | 18 | import { getActiveOrgSlug } from "../api/server-connection"; |
| 19 | +import { adminBlockFromExit, type OAuthAdminBlock } from "./oauth-admin-block"; | |
| 19 | 20 | |
| 20 | 21 | export type DesktopBridge = { |
| 21 | 22 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -193,6 +194,10 @@ export function useOAuthPopupFlow< · 171 unmodified lines | ||
| 193 | 194 | const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE; |
| 194 | 195 | const [busy, setBusy] = useState(false); |
| 195 | 196 | const [error, setError] = useState<string | null>(null); |
| 197 | + // The enterprise verdict, held apart from `error` on purpose: a renderer must | |
| 198 | + // be able to tell "this failed, try again" from "your organization decided | |
| 199 | + // this" WITHOUT reading either string. | |
| 200 | + const [adminBlock, setAdminBlock] = useState<OAuthAdminBlock | null>(null); | |
| 196 | 201 | const cleanupRef = useRef<(() => void) | null>(null); |
| 197 | 202 | const sessionRef = useRef<{ readonly state: string } | null>(null); |
| 198 | 203 | // A window reserved on the click but not yet handed to a flow owns nothing |
| @@ -274,6 +279,7 @@ export function useOAuthPopupFlow< · 75 unmodified lines | ||
| 274 | 279 | } |
| 275 | 280 | setBusy(true); |
| 276 | 281 | setError(null); |
| 282 | + setAdminBlock(null); | |
| 277 | 283 | // Desktop hosts open the auth URL in the user's real browser, so they |
| 278 | 284 | // reserve no in-page window and rely on the polling channel for the |
| 279 | 285 | // result. |
| @@ -294,6 +300,12 @@ export function useOAuthPopupFlow< · 14 unmodified lines | ||
| 294 | 300 | ); |
| 295 | 301 | if (Exit.isFailure(startExit)) { |
| 296 | 302 | const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in"); |
| 303 | + // Read the enterprise verdict off the failure BEFORE it is reduced to a | |
| 304 | + // string. Present means the identity provider refused under | |
| 305 | + // administrator policy, and this flow ends here: the popup closes and | |
| 306 | + // no retry is offered, because retrying is the route the enterprise | |
| 307 | + // just closed. | |
| 308 | + const blocked = adminBlockFromExit(startExit); | |
| 297 | 309 | reportHandledError(startExit.cause, { |
| 298 | 310 | surface: "oauth", |
| 299 | 311 | action: "start", |
| @@ -302,6 +314,7 @@ export function useOAuthPopupFlow< · 2 unmodified lines | ||
| 302 | 314 | }); |
| 303 | 315 | reservedPopup?.popup.close(); |
| 304 | 316 | setBusy(false); |
| 317 | + setAdminBlock(blocked); | |
| 305 | 318 | setError(message); |
| 306 | 319 | input.onError?.(message); |
| 307 | 320 | return; |
| @@ -455,29 +468,37 @@ export function useOAuthPopupFlow< · 147 unmodified lines | ||
| 455 | 468 | newConnection: input.payload.newConnection, |
| 456 | 469 | redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath), |
| 457 | 470 | }, |
| 458 | − }).then((exit) => | |
| 459 | − Exit.isSuccess(exit) | |
| 460 | − ? // The redirect branch carries `authorizationUrl` + `state`; the | |
| 461 | − // inline "connected" (client_credentials) branch has no URL to | |
| 462 | − // open and no redirect, so `state` is intentionally empty — it | |
| 463 | − // is never read for an already-minted connection. | |
| 464 | − exit.value.status === "redirect" | |
| 471 | + }).then((exit) => { | |
| 472 | + if (Exit.isSuccess(exit)) { | |
| 473 | + // The redirect branch carries `authorizationUrl` + `state`; the | |
| 474 | + // inline "connected" (client_credentials / enterprise-managed) | |
| 475 | + // branch has no URL to open and no redirect, so `state` is | |
| 476 | + // intentionally empty — it is never read for an already-minted | |
| 477 | + // connection. | |
| 478 | + return exit.value.status === "redirect" | |
| 465 | 479 | ? { state: exit.value.state, authorizationUrl: exit.value.authorizationUrl } |
| 466 | − : { state: "", authorizationUrl: null } | |
| 467 | − : Effect.runPromise( | |
| 468 | − Effect.fail({ | |
| 469 | − message: messageFromExit(exit, startErrorMessage ?? "Failed to start sign-in"), | |
| 470 | − }), | |
| 471 | − ), | |
| 472 | − ), | |
| 480 | + : { state: "", authorizationUrl: null }; | |
| 481 | + } | |
| 482 | + // Reject with the server's OWN typed failure, cause and all — not | |
| 483 | + // with a message extracted from it. `openAuthorization` wraps this | |
| 484 | + // as the `cause` of its start error, and that is where the | |
| 485 | + // enterprise verdict (`blockedByAdmin`) is read from; flattening it | |
| 486 | + // to a string here would leave the console with only a sentence to | |
| 487 | + // branch on, which is exactly what it must not decide from. | |
| 488 | + return Effect.runPromise(Effect.failCause(exit.cause)); | |
| 489 | + }), | |
| 473 | 490 | }); |
| 474 | 491 | }, |
| 475 | − [callbackPath, doStartOAuth, openAuthorization, startErrorMessage], | |
| 492 | + [callbackPath, doStartOAuth, openAuthorization], | |
| 476 | 493 | ); |
| 477 | 494 | |
| 478 | 495 | return { |
| 479 | 496 | busy, |
| 480 | 497 | error, |
| 498 | + /** The enterprise policy denial behind `error`, when there was one. A | |
| 499 | + * renderer branches on this — never on `error`'s wording — and must not | |
| 500 | + * offer a retry while it is set. */ | |
| 501 | + adminBlock, | |
| 481 | 502 | setError, |
| 482 | 503 | start, |
| 483 | 504 | openAuthorization, |