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
15} from "../api/atoms";15} from "../api/atoms";
16import { connectionWriteKeys } from "../api/reactivity-keys";16import { connectionWriteKeys } from "../api/reactivity-keys";
17import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";17import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";
18import {
19 MANAGED_CONNECTION_BADGE,
20 MANAGED_CONNECTION_BLOCKED_LABEL,
21 MANAGED_CONNECTION_REVOCATION_HINT,
22 connectionRowPolicy,
23} from "../lib/managed-connection";
18import { useConnectionHealth } from "../lib/use-connection-health";24import { useConnectionHealth } from "../lib/use-connection-health";
19import { messageFromExit } from "../api/error-reporting";25import { messageFromExit } from "../api/error-reporting";
20import { ownerLabel, useOwnerDisplay } from "../api/owner-display";26import { 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 Edit258 Edit
215 </DropdownMenuItem>259 </DropdownMenuItem>
216 <DropdownMenuItem className="text-sm" onClick={props.onReconnect}>260 {/* Reconnect and Remove are WITHHELD, not disabled, for an
217 Reconnect261 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 Remove264 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>
packages/react/src/components/add-account-modal.tsx+34−2
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
44 oauthClientWriteKeys,44 oauthClientWriteKeys,
45} from "../api/reactivity-keys";45} from "../api/reactivity-keys";
46import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";46import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";
47import { AdminBlockNotice } from "./admin-block-notice";
47import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";48import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";
48import { messageFromExit } from "../api/error-reporting";49import { messageFromExit } from "../api/error-reporting";
49import { trackEvent } from "../api/analytics";50import { 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 automatic2977 {/* Above the footer, not inside the method tab: the automatic
2956 (CIMD/DCR) flows render no tab panel at all, and putting the2978 (CIMD/DCR) flows render no tab panel at all, and putting the
2957 sign-in error in there left a blocked popup with nothing on2979 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 <Button3014 <Button
2983 type="button"3015 type="button"
2984 onClick={() => void handleCimdConnect()}3016 onClick={() => void handleCimdConnect()}
packages/react/src/components/admin-block-notice.tsx+40−0
@@ -0,0 +1,40 @@
1import {
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
21export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthTemplateSlug,
4 ConnectionAddress,
5 ConnectionName,
6 IntegrationSlug,
7 ProviderKey,
8 type Connection,
9 type HealthCheckResult,
10} from "@executor-js/sdk/shared";
11
12import { connectionRowPolicy } from "./managed-connection";
13
14const 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
24const health = (overrides: Partial<HealthCheckResult> = {}): HealthCheckResult => ({
25 status: "healthy",
26 checkedAt: 0,
27 ...overrides,
28});
29
30describe("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 @@
1import 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". */
27export const MANAGED_CONNECTION_BADGE = "Managed by your organization";
28
29/** Why the row offers no Remove. Shown as helper text, sentence case. */
30export 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. */
36export const MANAGED_CONNECTION_BLOCKED_LABEL = "Blocked by your organization";
37
38export 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 */
63export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Exit from "effect/Exit";
3import { OAuthStartError } from "@executor-js/sdk/shared";
4
5import { 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
13const denial = new OAuthStartError({
14 message: "Your organization does not permit this server.",
15 blockedByAdmin: true,
16 oauthErrorCode: "invalid_target",
17});
18
19describe("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
82describe("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
92describe("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 @@
1import * as Exit from "effect/Exit";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import { 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. */
24export 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
32const 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. */
37const decodeWrapped = Schema.decodeUnknownOption(Schema.Struct({ cause: Schema.Unknown }));
38
39const 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. */
51export 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. */
59export 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. */
68export const ADMIN_BLOCK_TITLE = "Blocked by your organization";
69export 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. */
74export 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
16} from "../api/oauth-popup";16} from "../api/oauth-popup";
17import { connectionWriteKeys } from "../api/reactivity-keys";17import { connectionWriteKeys } from "../api/reactivity-keys";
18import { getActiveOrgSlug } from "../api/server-connection";18import { getActiveOrgSlug } from "../api/server-connection";
19import { adminBlockFromExit, type OAuthAdminBlock } from "./oauth-admin-block";
19 20
20export type DesktopBridge = {21export 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 nothing203 // 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 they283 // 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 the284 // 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`; the473 // The redirect branch carries `authorizationUrl` + `state`; the
461 // inline "connected" (client_credentials) branch has no URL to474 // inline "connected" (client_credentials / enterprise-managed)
462 // open and no redirect, so `state` is intentionally empty — it475 // 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,

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