Unified · Split
| @@ -15,6 +15,12 @@ import { · 14 unmodified lines | |||
| 15 | } from "../api/atoms"; | 15 | } from "../api/atoms"; |
| 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; | 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 17 | import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; | 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 | import { useConnectionHealth } from "../lib/use-connection-health"; | 24 | import { useConnectionHealth } from "../lib/use-connection-health"; |
| 19 | import { messageFromExit } from "../api/error-reporting"; | 25 | import { messageFromExit } from "../api/error-reporting"; |
| 20 | import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; | 26 | import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; |
| @@ -114,8 +120,19 @@ function AccountRow(props: { · 93 unmodified lines | |||
| 114 | : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null); | 120 | : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null); |
| 115 | const displayLabel = identity ?? String(connection.name); | 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 | const expired = status === "expired"; | 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 | const needsHealthAttention = status === "expired" || status === "degraded"; | 132 | const needsHealthAttention = status === "expired" || status === "degraded"; |
| 133 | const statusLabel = policy.blockedByAdmin | ||
| 134 | ? MANAGED_CONNECTION_BLOCKED_LABEL | ||
| 135 | : HEALTH_STATUS_LABEL[status]; | ||
| 119 | const healthDetail = needsHealthAttention ? probe?.detail : undefined; | 136 | const healthDetail = needsHealthAttention ? probe?.detail : undefined; |
| 120 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; | 137 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| 121 | 138 | ||
| @@ -152,9 +169,26 @@ function AccountRow(props: { · 30 unmodified lines | |||
| 152 | className={`size-2 shrink-0 rounded-full ${indicator.dot}`} | 169 | className={`size-2 shrink-0 rounded-full ${indicator.dot}`} |
| 153 | /> | 170 | /> |
| 154 | <span className="truncate">{displayLabel}</span> | 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 | {needsHealthAttention ? ( | 186 | {needsHealthAttention ? ( |
| 156 | <Badge variant={expired ? "destructive" : "outline"} className="shrink-0"> | 187 | <Badge |
| 157 | {HEALTH_STATUS_LABEL[status]} | 188 | variant={expired && !policy.blockedByAdmin ? "destructive" : "outline"} |
| 189 | className="shrink-0" | ||
| 190 | > | ||
| 191 | {statusLabel} | ||
| 158 | </Badge> | 192 | </Badge> |
| 159 | ) : null} | 193 | ) : null} |
| 160 | {needsReconsent ? ( | 194 | {needsReconsent ? ( |
| @@ -183,6 +217,16 @@ function AccountRow(props: { · 22 unmodified lines | |||
| 183 | Missing scopes: {missingOAuthScopes.join(", ")} | 217 | Missing scopes: {missingOAuthScopes.join(", ")} |
| 184 | </CardStackEntryDescription> | 218 | </CardStackEntryDescription> |
| 185 | ) : null} | 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 | </CardStackEntryContent> | 230 | </CardStackEntryContent> |
| 187 | <CardStackEntryActions className="self-start pt-0.5"> | 231 | <CardStackEntryActions className="self-start pt-0.5"> |
| 188 | {props.showOwnerLabel ? ( | 232 | {props.showOwnerLabel ? ( |
| @@ -213,12 +257,22 @@ function AccountRow(props: { · 24 unmodified lines | |||
| 213 | <DropdownMenuItem className="text-sm" onClick={props.onEdit}> | 257 | <DropdownMenuItem className="text-sm" onClick={props.onEdit}> |
| 214 | Edit | 258 | Edit |
| 215 | </DropdownMenuItem> | 259 | </DropdownMenuItem> |
| 216 | <DropdownMenuItem className="text-sm" onClick={props.onReconnect}> | 260 | {/* Reconnect and Remove are WITHHELD, not disabled, for an |
| 217 | Reconnect | 261 | enterprise-managed connection. Reconnect re-runs the interactive |
| 218 | </DropdownMenuItem> | 262 | consent this profile has no step for, and Remove would claim the |
| 219 | <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}> | 263 | member revoked access they cannot revoke here — the identity |
| 220 | Remove | 264 | provider still authorizes them, and the next call would hand it |
| 221 | </DropdownMenuItem> | 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 | </DropdownMenuContent> | 276 | </DropdownMenuContent> |
| 223 | </DropdownMenu> | 277 | </DropdownMenu> |
| 224 | </CardStackEntryActions> | 278 | </CardStackEntryActions> |
| @@ -44,6 +44,7 @@ import { · 43 unmodified lines | |||
| 44 | oauthClientWriteKeys, | 44 | oauthClientWriteKeys, |
| 45 | } from "../api/reactivity-keys"; | 45 | } from "../api/reactivity-keys"; |
| 46 | import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; | 46 | import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; |
| 47 | import { AdminBlockNotice } from "./admin-block-notice"; | ||
| 47 | import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; | 48 | import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; |
| 48 | import { messageFromExit } from "../api/error-reporting"; | 49 | import { messageFromExit } from "../api/error-reporting"; |
| 49 | import { trackEvent } from "../api/analytics"; | 50 | import { trackEvent } from "../api/analytics"; |
| @@ -1618,6 +1619,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1568 unmodified lines | |||
| 1618 | const isBuiltInGoogleClient = | 1619 | const isBuiltInGoogleClient = |
| 1619 | chosenClient?.origin.kind === "first_party" && | 1620 | chosenClient?.origin.kind === "first_party" && |
| 1620 | String(chosenClient.slug) === "first-party:google"; | 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 | const oauthBusy = ccBusy || oauthPopup.busy; | 1626 | const oauthBusy = ccBusy || oauthPopup.busy; |
| 1622 | const cimdConnecting = cimdBusy || oauthPopup.busy; | 1627 | const cimdConnecting = cimdBusy || oauthPopup.busy; |
| 1623 | const dcrConnecting = dcrBusy || oauthPopup.busy; | 1628 | const dcrConnecting = dcrBusy || oauthPopup.busy; |
| @@ -2952,11 +2957,33 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1328 unmodified lines | |||
| 2952 | {continueError} | 2957 | {continueError} |
| 2953 | </p> | 2958 | </p> |
| 2954 | ) : null} | 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 | {/* Above the footer, not inside the method tab: the automatic | 2977 | {/* Above the footer, not inside the method tab: the automatic |
| 2956 | (CIMD/DCR) flows render no tab panel at all, and putting the | 2978 | (CIMD/DCR) flows render no tab panel at all, and putting the |
| 2957 | sign-in error in there left a blocked popup with nothing on | 2979 | sign-in error in there left a blocked popup with nothing on |
| 2958 | screen but the button returning to "Connect". */} | 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 | <p role="alert" className="px-1 text-xs text-destructive"> | 2987 | <p role="alert" className="px-1 text-xs text-destructive"> |
| 2961 | {oauthPopup.error} | 2988 | {oauthPopup.error} |
| 2962 | </p> | 2989 | </p> |
| @@ -2978,7 +3005,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 15 unmodified lines | |||
| 2978 | - registering a BYO app: the form owns its own submit, no footer; | 3005 | - registering a BYO app: the form owns its own submit, no footer; |
| 2979 | - picked BYO OAuth app: Connect with OAuth / Connect (client creds); | 3006 | - picked BYO OAuth app: Connect with OAuth / Connect (client creds); |
| 2980 | - credential/no-auth method: Add connection. */} | 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 | <Button | 3014 | <Button |
| 2983 | type="button" | 3015 | type="button" |
| 2984 | onClick={() => void handleCimdConnect()} | 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 | } from "../api/oauth-popup"; | 16 | } from "../api/oauth-popup"; |
| 17 | import { connectionWriteKeys } from "../api/reactivity-keys"; | 17 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 18 | import { getActiveOrgSlug } from "../api/server-connection"; | 18 | import { getActiveOrgSlug } from "../api/server-connection"; |
| 19 | import { adminBlockFromExit, type OAuthAdminBlock } from "./oauth-admin-block"; | ||
| 19 | 20 | ||
| 20 | export type DesktopBridge = { | 21 | export type DesktopBridge = { |
| 21 | readonly openExternal: (url: string) => Promise<void>; | 22 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -193,6 +194,10 @@ export function useOAuthPopupFlow< · 171 unmodified lines | |||
| 193 | const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE; | 194 | const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE; |
| 194 | const [busy, setBusy] = useState(false); | 195 | const [busy, setBusy] = useState(false); |
| 195 | const [error, setError] = useState<string | null>(null); | 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 | const cleanupRef = useRef<(() => void) | null>(null); | 201 | const cleanupRef = useRef<(() => void) | null>(null); |
| 197 | const sessionRef = useRef<{ readonly state: string } | null>(null); | 202 | const sessionRef = useRef<{ readonly state: string } | null>(null); |
| 198 | // A window reserved on the click but not yet handed to a flow owns nothing | 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 | setBusy(true); | 280 | setBusy(true); |
| 276 | setError(null); | 281 | setError(null); |
| 282 | setAdminBlock(null); | ||
| 277 | // Desktop hosts open the auth URL in the user's real browser, so they | 283 | // Desktop hosts open the auth URL in the user's real browser, so they |
| 278 | // reserve no in-page window and rely on the polling channel for the | 284 | // reserve no in-page window and rely on the polling channel for the |
| 279 | // result. | 285 | // result. |
| @@ -294,6 +300,12 @@ export function useOAuthPopupFlow< · 14 unmodified lines | |||
| 294 | ); | 300 | ); |
| 295 | if (Exit.isFailure(startExit)) { | 301 | if (Exit.isFailure(startExit)) { |
| 296 | const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in"); | 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 | reportHandledError(startExit.cause, { | 309 | reportHandledError(startExit.cause, { |
| 298 | surface: "oauth", | 310 | surface: "oauth", |
| 299 | action: "start", | 311 | action: "start", |
| @@ -302,6 +314,7 @@ export function useOAuthPopupFlow< · 2 unmodified lines | |||
| 302 | }); | 314 | }); |
| 303 | reservedPopup?.popup.close(); | 315 | reservedPopup?.popup.close(); |
| 304 | setBusy(false); | 316 | setBusy(false); |
| 317 | setAdminBlock(blocked); | ||
| 305 | setError(message); | 318 | setError(message); |
| 306 | input.onError?.(message); | 319 | input.onError?.(message); |
| 307 | return; | 320 | return; |
| @@ -455,29 +468,37 @@ export function useOAuthPopupFlow< · 147 unmodified lines | |||
| 455 | newConnection: input.payload.newConnection, | 468 | newConnection: input.payload.newConnection, |
| 456 | redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath), | 469 | redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath), |
| 457 | }, | 470 | }, |
| 458 | }).then((exit) => | 471 | }).then((exit) => { |
| 459 | Exit.isSuccess(exit) | 472 | if (Exit.isSuccess(exit)) { |
| 460 | ? // The redirect branch carries `authorizationUrl` + `state`; the | 473 | // The redirect branch carries `authorizationUrl` + `state`; the |
| 461 | // inline "connected" (client_credentials) branch has no URL to | 474 | // inline "connected" (client_credentials / enterprise-managed) |
| 462 | // open and no redirect, so `state` is intentionally empty — it | 475 | // branch has no URL to open and no redirect, so `state` is |
| 463 | // is never read for an already-minted connection. | 476 | // intentionally empty — it is never read for an already-minted |
| 464 | exit.value.status === "redirect" | 477 | // connection. |
| 478 | return exit.value.status === "redirect" | ||
| 465 | ? { state: exit.value.state, authorizationUrl: exit.value.authorizationUrl } | 479 | ? { state: exit.value.state, authorizationUrl: exit.value.authorizationUrl } |
| 466 | : { state: "", authorizationUrl: null } | 480 | : { state: "", authorizationUrl: null }; |
| 467 | : Effect.runPromise( | 481 | } |
| 468 | Effect.fail({ | 482 | // Reject with the server's OWN typed failure, cause and all — not |
| 469 | message: messageFromExit(exit, startErrorMessage ?? "Failed to start sign-in"), | 483 | // with a message extracted from it. `openAuthorization` wraps this |
| 470 | }), | 484 | // as the `cause` of its start error, and that is where the |
| 471 | ), | 485 | // enterprise verdict (`blockedByAdmin`) is read from; flattening it |
| 472 | ), | 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 | return { | 495 | return { |
| 479 | busy, | 496 | busy, |
| 480 | error, | 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 | setError, | 502 | setError, |
| 482 | start, | 503 | start, |
| 483 | openAuthorization, | 504 | openAuthorization, |