History

Present enterprise-managed connections and administrator denials

Rhys Sullivan committed 2 months ago

Commit a4528881 parent febbce98 files · +526 −25Browse files

Unified · Split

8 files
  1. packages/react/src/components/accounts-section.tsx +62 −8
  2. packages/react/src/components/add-account-modal.tsx +34 −2
  3. packages/react/src/components/admin-block-notice.tsx +40 −0
  4. packages/react/src/lib/managed-connection.test.ts +99 −0
  5. packages/react/src/lib/managed-connection.ts +78 −0
  6. packages/react/src/plugins/oauth-admin-block.test.ts +102 −0
  7. packages/react/src/plugins/oauth-admin-block.ts +75 −0
  8. packages/react/src/plugins/oauth-sign-in.tsx +36 −15
packages/react/src/components/accounts-section.tsx+62−8
@@ -15,6 +15,12 @@ import { · 14 unmodified lines
1515 } from "../api/atoms";
1616 import { connectionWriteKeys } from "../api/reactivity-keys";
1717 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";
1824 import { useConnectionHealth } from "../lib/use-connection-health";
1925 import { messageFromExit } from "../api/error-reporting";
2026 import { ownerLabel, useOwnerDisplay } from "../api/owner-display";
@@ -114,8 +120,19 @@ function AccountRow(props: { · 93 unmodified lines
114120 : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null);
115121 const displayLabel = identity ?? String(connection.name);
116122
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+
117128 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.
118132 const needsHealthAttention = status === "expired" || status === "degraded";
133+ const statusLabel = policy.blockedByAdmin
134+ ? MANAGED_CONNECTION_BLOCKED_LABEL
135+ : HEALTH_STATUS_LABEL[status];
119136 const healthDetail = needsHealthAttention ? probe?.detail : undefined;
120137 const missingOAuthScopes = connection.missingOAuthScopes ?? [];
121138
@@ -152,9 +169,26 @@ function AccountRow(props: { · 30 unmodified lines
152169 className={`size-2 shrink-0 rounded-full ${indicator.dot}`}
153170 />
154171 <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}
155186 {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}
158192 </Badge>
159193 ) : null}
160194 {needsReconsent ? (
@@ -183,6 +217,16 @@ function AccountRow(props: { · 22 unmodified lines
183217 Missing scopes: {missingOAuthScopes.join(", ")}
184218 </CardStackEntryDescription>
185219 ) : 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+ )}
186230 </CardStackEntryContent>
187231 <CardStackEntryActions className="self-start pt-0.5">
188232 {props.showOwnerLabel ? (
@@ -213,12 +257,22 @@ function AccountRow(props: { · 24 unmodified lines
213257 <DropdownMenuItem className="text-sm" onClick={props.onEdit}>
214258 Edit
215259 </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}
222276 </DropdownMenuContent>
223277 </DropdownMenu>
224278 </CardStackEntryActions>
packages/react/src/components/add-account-modal.tsx+34−2
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
4444 oauthClientWriteKeys,
4545 } from "../api/reactivity-keys";
4646 import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";
47+import { AdminBlockNotice } from "./admin-block-notice";
4748 import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";
4849 import { messageFromExit } from "../api/error-reporting";
4950 import { trackEvent } from "../api/analytics";
@@ -1618,6 +1619,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1568 unmodified lines
16181619 const isBuiltInGoogleClient =
16191620 chosenClient?.origin.kind === "first_party" &&
16201621 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;
16211626 const oauthBusy = ccBusy || oauthPopup.busy;
16221627 const cimdConnecting = cimdBusy || oauthPopup.busy;
16231628 const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -2952,11 +2957,33 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1328 unmodified lines
29522957 {continueError}
29532958 </p>
29542959 ) : 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}
29552977 {/* Above the footer, not inside the method tab: the automatic
29562978 (CIMD/DCR) flows render no tab panel at all, and putting the
29572979 sign-in error in there left a blocked popup with nothing on
29582980 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 ? (
29602987 <p role="alert" className="px-1 text-xs text-destructive">
29612988 {oauthPopup.error}
29622989 </p>
@@ -2978,7 +3005,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 15 unmodified lines
29783005 - registering a BYO app: the form owns its own submit, no footer;
29793006 - picked BYO OAuth app: Connect with OAuth / Connect (client creds);
29803007 - 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 ? (
29823014 <Button
29833015 type="button"
29843016 onClick={() => void handleCimdConnect()}
packages/react/src/components/admin-block-notice.tsx+40−0
@@ -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+}
packages/react/src/lib/managed-connection.test.ts+99−0
@@ -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+});
packages/react/src/lib/managed-connection.ts+78−0
@@ -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+};
packages/react/src/plugins/oauth-admin-block.test.ts+102−0
@@ -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+});
packages/react/src/plugins/oauth-admin-block.ts+75−0
@@ -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}`;
packages/react/src/plugins/oauth-sign-in.tsx+36−15
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 } from "../api/oauth-popup";
1717 import { connectionWriteKeys } from "../api/reactivity-keys";
1818 import { getActiveOrgSlug } from "../api/server-connection";
19+import { adminBlockFromExit, type OAuthAdminBlock } from "./oauth-admin-block";
1920
2021 export type DesktopBridge = {
2122 readonly openExternal: (url: string) => Promise<void>;
@@ -193,6 +194,10 @@ export function useOAuthPopupFlow< · 171 unmodified lines
193194 const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE;
194195 const [busy, setBusy] = useState(false);
195196 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);
196201 const cleanupRef = useRef<(() => void) | null>(null);
197202 const sessionRef = useRef<{ readonly state: string } | null>(null);
198203 // 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
274279 }
275280 setBusy(true);
276281 setError(null);
282+ setAdminBlock(null);
277283 // Desktop hosts open the auth URL in the user's real browser, so they
278284 // reserve no in-page window and rely on the polling channel for the
279285 // result.
@@ -294,6 +300,12 @@ export function useOAuthPopupFlow< · 14 unmodified lines
294300 );
295301 if (Exit.isFailure(startExit)) {
296302 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);
297309 reportHandledError(startExit.cause, {
298310 surface: "oauth",
299311 action: "start",
@@ -302,6 +314,7 @@ export function useOAuthPopupFlow< · 2 unmodified lines
302314 });
303315 reservedPopup?.popup.close();
304316 setBusy(false);
317+ setAdminBlock(blocked);
305318 setError(message);
306319 input.onError?.(message);
307320 return;
@@ -455,29 +468,37 @@ export function useOAuthPopupFlow< · 147 unmodified lines
455468 newConnection: input.payload.newConnection,
456469 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
457470 },
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"
465479 ? { 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+ }),
473490 });
474491 },
475− [callbackPath, doStartOAuth, openAuthorization, startErrorMessage],
492+ [callbackPath, doStartOAuth, openAuthorization],
476493 );
477494
478495 return {
479496 busy,
480497 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,
481502 setError,
482503 start,
483504 openAuthorization,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t