@stigmer/react 3.12.4 → 3.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/attachment/AttachmentChipList.js +16 -6
  2. package/attachment/AttachmentChipList.js.map +1 -1
  3. package/attachment/index.d.ts +1 -1
  4. package/attachment/index.d.ts.map +1 -1
  5. package/attachment/index.js.map +1 -1
  6. package/attachment/useAttachments.d.ts +51 -8
  7. package/attachment/useAttachments.d.ts.map +1 -1
  8. package/attachment/useAttachments.js +74 -3
  9. package/attachment/useAttachments.js.map +1 -1
  10. package/composer/SessionComposer.d.ts.map +1 -1
  11. package/composer/SessionComposer.js +17 -10
  12. package/composer/SessionComposer.js.map +1 -1
  13. package/conversation/ConversationComposer.d.ts +19 -3
  14. package/conversation/ConversationComposer.d.ts.map +1 -1
  15. package/conversation/ConversationComposer.js +4 -4
  16. package/conversation/ConversationComposer.js.map +1 -1
  17. package/conversation/ConversationTemplatePickerDialog.d.ts +63 -0
  18. package/conversation/ConversationTemplatePickerDialog.d.ts.map +1 -0
  19. package/conversation/ConversationTemplatePickerDialog.js +151 -0
  20. package/conversation/ConversationTemplatePickerDialog.js.map +1 -0
  21. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  22. package/conversation/ConversationsWorkbench.js +16 -5
  23. package/conversation/ConversationsWorkbench.js.map +1 -1
  24. package/conversation/index.d.ts +3 -1
  25. package/conversation/index.d.ts.map +1 -1
  26. package/conversation/index.js +1 -0
  27. package/conversation/index.js.map +1 -1
  28. package/conversation/useConversationParticipation.d.ts +41 -4
  29. package/conversation/useConversationParticipation.d.ts.map +1 -1
  30. package/conversation/useConversationParticipation.js +13 -2
  31. package/conversation/useConversationParticipation.js.map +1 -1
  32. package/identity-account/AccountPreferencesPanel.d.ts +35 -0
  33. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -0
  34. package/identity-account/AccountPreferencesPanel.js +187 -0
  35. package/identity-account/AccountPreferencesPanel.js.map +1 -0
  36. package/identity-account/index.d.ts +8 -0
  37. package/identity-account/index.d.ts.map +1 -1
  38. package/identity-account/index.js +4 -0
  39. package/identity-account/index.js.map +1 -1
  40. package/identity-account/useAccountExecutionDefaults.d.ts +36 -0
  41. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -0
  42. package/identity-account/useAccountExecutionDefaults.js +46 -0
  43. package/identity-account/useAccountExecutionDefaults.js.map +1 -0
  44. package/identity-account/useMyIdentityAccount.d.ts +55 -0
  45. package/identity-account/useMyIdentityAccount.d.ts.map +1 -0
  46. package/identity-account/useMyIdentityAccount.js +37 -0
  47. package/identity-account/useMyIdentityAccount.js.map +1 -0
  48. package/identity-account/useUpdateIdentityAccount.d.ts +42 -0
  49. package/identity-account/useUpdateIdentityAccount.d.ts.map +1 -0
  50. package/identity-account/useUpdateIdentityAccount.js +53 -0
  51. package/identity-account/useUpdateIdentityAccount.js.map +1 -0
  52. package/index.d.ts +11 -9
  53. package/index.d.ts.map +1 -1
  54. package/index.js +8 -5
  55. package/index.js.map +1 -1
  56. package/internal/StandingContextField.d.ts +37 -0
  57. package/internal/StandingContextField.d.ts.map +1 -0
  58. package/internal/StandingContextField.js +24 -0
  59. package/internal/StandingContextField.js.map +1 -0
  60. package/library/InstanceVisibilitySelector.d.ts.map +1 -1
  61. package/library/InstanceVisibilitySelector.js +9 -2
  62. package/library/InstanceVisibilitySelector.js.map +1 -1
  63. package/library/ResourceVisibilityControl.d.ts +13 -3
  64. package/library/ResourceVisibilityControl.d.ts.map +1 -1
  65. package/library/ResourceVisibilityControl.js +33 -5
  66. package/library/ResourceVisibilityControl.js.map +1 -1
  67. package/library/VisibilityOptionRow.d.ts +6 -0
  68. package/library/VisibilityOptionRow.d.ts.map +1 -1
  69. package/library/VisibilityOptionRow.js +11 -1
  70. package/library/VisibilityOptionRow.js.map +1 -1
  71. package/library/VisibilitySelector.d.ts.map +1 -1
  72. package/library/VisibilitySelector.js +14 -2
  73. package/library/VisibilitySelector.js.map +1 -1
  74. package/library/index.d.ts +4 -2
  75. package/library/index.d.ts.map +1 -1
  76. package/library/index.js +2 -1
  77. package/library/index.js.map +1 -1
  78. package/library/useCanSetPublicVisibility.d.ts +30 -0
  79. package/library/useCanSetPublicVisibility.d.ts.map +1 -0
  80. package/library/useCanSetPublicVisibility.js +41 -0
  81. package/library/useCanSetPublicVisibility.js.map +1 -0
  82. package/library/visibilityLevels.d.ts +32 -1
  83. package/library/visibilityLevels.d.ts.map +1 -1
  84. package/library/visibilityLevels.js +34 -14
  85. package/library/visibilityLevels.js.map +1 -1
  86. package/mcp-server/useMcpServerConnect.d.ts +8 -5
  87. package/mcp-server/useMcpServerConnect.d.ts.map +1 -1
  88. package/mcp-server/useMcpServerConnect.js +2 -1
  89. package/mcp-server/useMcpServerConnect.js.map +1 -1
  90. package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
  91. package/mcp-server/useMcpServerOAuthConnect.js +7 -2
  92. package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
  93. package/oauth-app/CreateOAuthAppForm.d.ts +2 -2
  94. package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -1
  95. package/oauth-app/CreateOAuthAppForm.js +13 -4
  96. package/oauth-app/CreateOAuthAppForm.js.map +1 -1
  97. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -1
  98. package/oauth-app/OAuthAppDetailPanel.js +32 -5
  99. package/oauth-app/OAuthAppDetailPanel.js.map +1 -1
  100. package/organization/OrgPreferencesPanel.d.ts +32 -0
  101. package/organization/OrgPreferencesPanel.d.ts.map +1 -0
  102. package/organization/OrgPreferencesPanel.js +90 -0
  103. package/organization/OrgPreferencesPanel.js.map +1 -0
  104. package/organization/OrgProfilePanel.d.ts.map +1 -1
  105. package/organization/OrgProfilePanel.js +5 -4
  106. package/organization/OrgProfilePanel.js.map +1 -1
  107. package/organization/index.d.ts +2 -0
  108. package/organization/index.d.ts.map +1 -1
  109. package/organization/index.js +1 -0
  110. package/organization/index.js.map +1 -1
  111. package/package.json +4 -4
  112. package/session/NewSessionViewer.d.ts +10 -1
  113. package/session/NewSessionViewer.d.ts.map +1 -1
  114. package/session/NewSessionViewer.js +2 -1
  115. package/session/NewSessionViewer.js.map +1 -1
  116. package/session/useNewSessionFlow.d.ts +27 -1
  117. package/session/useNewSessionFlow.d.ts.map +1 -1
  118. package/session/useNewSessionFlow.js +45 -8
  119. package/session/useNewSessionFlow.js.map +1 -1
  120. package/settings/AccountPreferencesSection.d.ts +3 -0
  121. package/settings/AccountPreferencesSection.d.ts.map +1 -0
  122. package/settings/AccountPreferencesSection.js +10 -0
  123. package/settings/AccountPreferencesSection.js.map +1 -0
  124. package/settings/OrgPreferencesSection.d.ts +3 -0
  125. package/settings/OrgPreferencesSection.d.ts.map +1 -0
  126. package/settings/OrgPreferencesSection.js +13 -0
  127. package/settings/OrgPreferencesSection.js.map +1 -0
  128. package/settings/index.d.ts +2 -0
  129. package/settings/index.d.ts.map +1 -1
  130. package/settings/index.js +2 -0
  131. package/settings/index.js.map +1 -1
  132. package/settings/settings-nav.d.ts.map +1 -1
  133. package/settings/settings-nav.js +17 -1
  134. package/settings/settings-nav.js.map +1 -1
  135. package/src/attachment/AttachmentChipList.tsx +19 -8
  136. package/src/attachment/__tests__/AttachmentChipList.test.tsx +12 -0
  137. package/src/attachment/__tests__/useAttachments.test.ts +293 -0
  138. package/src/attachment/index.ts +1 -0
  139. package/src/attachment/useAttachments.ts +143 -11
  140. package/src/composer/SessionComposer.tsx +23 -14
  141. package/src/conversation/ConversationComposer.tsx +38 -4
  142. package/src/conversation/ConversationTemplatePickerDialog.tsx +441 -0
  143. package/src/conversation/ConversationsWorkbench.tsx +40 -4
  144. package/src/conversation/__tests__/ConversationComposer.test.tsx +56 -1
  145. package/src/conversation/__tests__/ConversationTemplatePickerDialog.test.tsx +224 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +45 -4
  147. package/src/conversation/index.ts +4 -0
  148. package/src/conversation/useConversationParticipation.ts +58 -6
  149. package/src/identity-account/AccountPreferencesPanel.tsx +446 -0
  150. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +233 -0
  151. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +116 -0
  152. package/src/identity-account/__tests__/useMyIdentityAccount.test.tsx +121 -0
  153. package/src/identity-account/index.ts +11 -0
  154. package/src/identity-account/useAccountExecutionDefaults.ts +63 -0
  155. package/src/identity-account/useMyIdentityAccount.ts +75 -0
  156. package/src/identity-account/useUpdateIdentityAccount.ts +73 -0
  157. package/src/index.ts +24 -2
  158. package/src/internal/StandingContextField.tsx +92 -0
  159. package/src/library/InstanceVisibilitySelector.tsx +9 -2
  160. package/src/library/ResourceVisibilityControl.tsx +46 -13
  161. package/src/library/VisibilityOptionRow.tsx +28 -6
  162. package/src/library/VisibilitySelector.tsx +14 -2
  163. package/src/library/__tests__/VisibilitySelector.test.tsx +48 -1
  164. package/src/library/__tests__/visibilityLevels.test.ts +67 -1
  165. package/src/library/index.ts +6 -1
  166. package/src/library/useCanSetPublicVisibility.ts +56 -0
  167. package/src/library/visibilityLevels.ts +65 -14
  168. package/src/mcp-server/__tests__/useMcpServerConnect.asyncLane.test.tsx +117 -0
  169. package/src/mcp-server/useMcpServerConnect.ts +9 -6
  170. package/src/mcp-server/useMcpServerOAuthConnect.ts +7 -2
  171. package/src/oauth-app/CreateOAuthAppForm.tsx +58 -3
  172. package/src/oauth-app/OAuthAppDetailPanel.tsx +84 -3
  173. package/src/organization/OrgPreferencesPanel.tsx +221 -0
  174. package/src/organization/OrgProfilePanel.tsx +5 -4
  175. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +145 -0
  176. package/src/organization/__tests__/OrgProfilePanel.test.tsx +108 -0
  177. package/src/organization/index.ts +2 -0
  178. package/src/session/NewSessionViewer.tsx +12 -0
  179. package/src/session/__tests__/useNewSessionFlow.test.tsx +151 -0
  180. package/src/session/useNewSessionFlow.ts +76 -9
  181. package/src/settings/AccountPreferencesSection.tsx +25 -0
  182. package/src/settings/OrgPreferencesSection.tsx +35 -0
  183. package/src/settings/__tests__/settings-nav.test.ts +27 -0
  184. package/src/settings/index.ts +2 -0
  185. package/src/settings/settings-nav.ts +18 -0
  186. package/src/workflow/ApprovalFormBuilder.tsx +5 -27
  187. package/styles.css +1 -1
  188. package/workflow/ApprovalFormBuilder.d.ts.map +1 -1
  189. package/workflow/ApprovalFormBuilder.js +8 -5
  190. package/workflow/ApprovalFormBuilder.js.map +1 -1
@@ -1,15 +1,16 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback } from "react";
4
- import type { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
4
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
5
5
  import { useDeploymentMode } from "../deployment-mode.js";
6
6
  import { PermissionGate } from "../iam-policy/PermissionGate.js";
7
7
  import { useSsoProvider } from "../identity-provider/useSsoProvider.js";
8
+ import { useCanSetPublicVisibility } from "./useCanSetPublicVisibility.js";
8
9
  import { VisibilityBadge, VisibilitySelector } from "./VisibilitySelector.js";
9
10
  import {
10
11
  blueprintVisibilityLevels,
11
12
  environmentVisibilityLevels,
12
- INSTANCE_VISIBILITY_LEVELS,
13
+ instanceVisibilityLevels,
13
14
  } from "./visibilityLevels.js";
14
15
  import {
15
16
  useUpdateVisibility,
@@ -85,9 +86,19 @@ export interface ResourceVisibilityControlProps {
85
86
  * the org boundary. In `local` mode there are no levels to choose (the
86
87
  * OSS backend is single-user), so the control degrades to a badge.
87
88
  *
89
+ * The Public level is operator-gated in the cloud edition (publishing is a
90
+ * curation decision): for callers without `can_set_public_visibility`
91
+ * ({@link useCanSetPublicVisibility}) the option renders locked with copy
92
+ * naming the path forward. The same permission opens the operator takedown
93
+ * lane — on a currently-public resource, an operator gets the interactive
94
+ * selector even without resource `can_edit`, so noisy public resources can
95
+ * be un-published directly from the console (every offered move off PUBLIC
96
+ * is a downgrade, which the backend allows operators unilaterally).
97
+ *
88
98
  * The backend remains the enforcer (`ValidateVisibilityStep` rejects
89
- * platform without an IdP); the gate here only prevents offering an option
90
- * that is guaranteed to fail.
99
+ * platform without an IdP; `AuthorizeVisibilityTransitionStep` gates the
100
+ * PUBLIC level); the gates here only prevent offering an option that is
101
+ * guaranteed to fail.
91
102
  */
92
103
  export function ResourceVisibilityControl({
93
104
  kind,
@@ -99,6 +110,7 @@ export function ResourceVisibilityControl({
99
110
  }: ResourceVisibilityControlProps) {
100
111
  const { updateVisibility, isPending } = useUpdateVisibility(kind, resourceId);
101
112
  const deploymentMode = useDeploymentMode();
113
+ const canSetPublic = useCanSetPublicVisibility();
102
114
 
103
115
  const isInstance = INSTANCE_KINDS.has(kind);
104
116
  const isEnvironment = kind === "environment";
@@ -113,10 +125,13 @@ export function ResourceVisibilityControl({
113
125
  const options = isEnvironment
114
126
  ? environmentVisibilityLevels(deploymentMode)
115
127
  : isInstance
116
- ? INSTANCE_VISIBILITY_LEVELS
128
+ ? instanceVisibilityLevels({
129
+ canSetPublicVisibility: canSetPublic.allowed,
130
+ })
117
131
  : blueprintVisibilityLevels({
118
132
  deploymentMode,
119
133
  hasIdentityProvider: ssoProvider !== null,
134
+ canSetPublicVisibility: canSetPublic.allowed,
120
135
  });
121
136
 
122
137
  const handleChange = useCallback(
@@ -141,6 +156,31 @@ export function ResourceVisibilityControl({
141
156
  return badge;
142
157
  }
143
158
 
159
+ const selector = (
160
+ <VisibilitySelector
161
+ visibility={visibility}
162
+ options={options}
163
+ onVisibilityChange={handleChange}
164
+ isPending={isPending}
165
+ ariaLabel={isInstance ? "Instance visibility" : "Resource visibility"}
166
+ className={className}
167
+ />
168
+ );
169
+
170
+ // Operator takedown lane: on a currently-public resource, a caller holding
171
+ // can_set_public_visibility gets the control without resource can_edit —
172
+ // every offered move is a downgrade off PUBLIC, which the backend allows
173
+ // operators unilaterally (AuthorizeVisibilityTransitionStep), so no
174
+ // offered action can fail authorization. Scoped to the public state on
175
+ // purpose: on non-public foreign resources most transitions would be
176
+ // denied, and publishing those stays a request-to-operator flow.
177
+ if (
178
+ canSetPublic.allowed &&
179
+ visibility === ApiResourceVisibility.visibility_public
180
+ ) {
181
+ return selector;
182
+ }
183
+
144
184
  return (
145
185
  <PermissionGate
146
186
  resource={{ kind: FGA_KIND[kind], id: resourceId }}
@@ -148,14 +188,7 @@ export function ResourceVisibilityControl({
148
188
  fallback={badge}
149
189
  loading={badge}
150
190
  >
151
- <VisibilitySelector
152
- visibility={visibility}
153
- options={options}
154
- onVisibilityChange={handleChange}
155
- isPending={isPending}
156
- ariaLabel={isInstance ? "Instance visibility" : "Resource visibility"}
157
- className={className}
158
- />
191
+ {selector}
159
192
  </PermissionGate>
160
193
  );
161
194
  }
@@ -53,6 +53,12 @@ export interface VisibilityOptionRowProps {
53
53
  * label, one-line description, and a check when current. Shared by the
54
54
  * {@link VisibilitySelector} popover and its create-mode inline list so
55
55
  * option presentation has exactly one source.
56
+ *
57
+ * A level carrying {@link VisibilityLevelOption.lockedReason} renders
58
+ * non-interactive with a lock affordance and the reason as its detail line
59
+ * — the level stays discoverable (the reason names the path forward)
60
+ * without being selectable. The locked row keeps its normal opacity: it is
61
+ * informative, not in a transient disabled state.
56
62
  */
57
63
  export const VisibilityOptionRow = forwardRef<
58
64
  HTMLButtonElement,
@@ -71,6 +77,7 @@ export const VisibilityOptionRow = forwardRef<
71
77
  },
72
78
  ref,
73
79
  ) {
80
+ const locked = option.lockedReason !== undefined;
74
81
  const selectionAttr =
75
82
  role === "radio"
76
83
  ? { "aria-checked": isSelected }
@@ -82,16 +89,20 @@ export const VisibilityOptionRow = forwardRef<
82
89
  type="button"
83
90
  role={role}
84
91
  {...selectionAttr}
92
+ aria-disabled={locked || undefined}
85
93
  tabIndex={tabIndex}
86
- disabled={disabled}
94
+ disabled={disabled || locked}
87
95
  onClick={onSelect}
88
96
  onMouseEnter={onMouseEnter}
89
97
  onKeyDown={onKeyDown}
90
98
  className={cn(
91
99
  "stg:flex stg:w-full stg:items-start stg:gap-2 stg:rounded-md stg:px-2.5 stg:py-2 stg:text-left stg:transition-colors",
92
100
  "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
93
- "stg:disabled:pointer-events-none stg:disabled:opacity-50",
94
- isHighlighted ? "stg:bg-accent stg:text-foreground" : "stg:hover:bg-accent-hover",
101
+ locked
102
+ ? "stg:disabled:pointer-events-none stg:cursor-default"
103
+ : "stg:disabled:pointer-events-none stg:disabled:opacity-50",
104
+ !locked &&
105
+ (isHighlighted ? "stg:bg-accent stg:text-foreground" : "stg:hover:bg-accent-hover"),
95
106
  )}
96
107
  >
97
108
  <VisibilityIcon
@@ -99,14 +110,25 @@ export const VisibilityOptionRow = forwardRef<
99
110
  className="stg:mt-0.5 stg:size-3.5 stg:shrink-0 stg:text-muted-foreground"
100
111
  />
101
112
  <span className="stg:min-w-0 stg:flex-1">
102
- <span className="stg:block stg:text-xs stg:font-medium stg:text-foreground">
113
+ <span
114
+ className={cn(
115
+ "stg:block stg:text-xs stg:font-medium",
116
+ locked ? "stg:text-muted-foreground" : "stg:text-foreground",
117
+ )}
118
+ >
103
119
  {option.label}
104
120
  </span>
105
121
  <span className="stg:block stg:text-[0.65rem] stg:leading-snug stg:text-muted-foreground">
106
- {option.description}
122
+ {locked ? option.lockedReason : option.description}
107
123
  </span>
108
124
  </span>
109
- {isSelected && <CheckIcon className="stg:mt-0.5 stg:size-3.5 stg:shrink-0 stg:text-primary" />}
125
+ {locked ? (
126
+ <LockIcon className="stg:mt-0.5 stg:size-3.5 stg:shrink-0 stg:text-muted-foreground" />
127
+ ) : (
128
+ isSelected && (
129
+ <CheckIcon className="stg:mt-0.5 stg:size-3.5 stg:shrink-0 stg:text-primary" />
130
+ )
131
+ )}
110
132
  </button>
111
133
  );
112
134
  });
@@ -150,6 +150,12 @@ export function VisibilitySelector({
150
150
 
151
151
  const handleSelect = useCallback(
152
152
  (option: VisibilityLevelOption) => {
153
+ // Locked levels are not selectable; the row is disabled, this guard
154
+ // is defense in depth for programmatic activation.
155
+ if (option.lockedReason !== undefined) {
156
+ return;
157
+ }
158
+
153
159
  const value = option.value;
154
160
  if (value === visibility) {
155
161
  setPendingInline(null);
@@ -193,11 +199,17 @@ export function VisibilitySelector({
193
199
  const moveFocus = useCallback(
194
200
  (from: number, delta: number) => {
195
201
  const count = effectiveOptions.length;
196
- const next = (from + delta + count) % count;
202
+ // Skip locked rows: their buttons are disabled and cannot take focus,
203
+ // so landing on one would strand the roving tabindex.
204
+ let next = from;
205
+ for (let step = 0; step < count; step++) {
206
+ next = (next + delta + count) % count;
207
+ if (effectiveOptions[next]?.lockedReason === undefined) break;
208
+ }
197
209
  setHighlightIdx(next);
198
210
  optionRefs.current[next]?.focus();
199
211
  },
200
- [effectiveOptions.length],
212
+ [effectiveOptions],
201
213
  );
202
214
 
203
215
  const handleRowKeyDown = useCallback(
@@ -13,10 +13,14 @@ import {
13
13
  VisibilityBadge,
14
14
  } from "../VisibilitySelector";
15
15
  import {
16
- INSTANCE_VISIBILITY_LEVELS,
16
+ instanceVisibilityLevels,
17
17
  blueprintVisibilityLevels,
18
18
  } from "../visibilityLevels";
19
19
 
20
+ const INSTANCE_VISIBILITY_LEVELS = instanceVisibilityLevels({
21
+ canSetPublicVisibility: true,
22
+ });
23
+
20
24
  // Base UI's Popover positioner observes its anchor; happy-dom lacks
21
25
  // ResizeObserver, so provide a no-op shim.
22
26
  beforeAll(() => {
@@ -35,6 +39,7 @@ afterEach(cleanup);
35
39
  const BLUEPRINT_LEVELS = blueprintVisibilityLevels({
36
40
  deploymentMode: "cloud",
37
41
  hasIdentityProvider: true,
42
+ canSetPublicVisibility: true,
38
43
  });
39
44
 
40
45
  // Option accessible names are "<label> <description>"; anchor on the label so
@@ -118,6 +123,48 @@ describe("VisibilitySelector — create mode (inline list)", () => {
118
123
  });
119
124
  });
120
125
 
126
+ describe("VisibilitySelector — operator-gated Public (locked row)", () => {
127
+ // The caller may not publish: the level builders lock the Public row.
128
+ const GATED_LEVELS = instanceVisibilityLevels({
129
+ canSetPublicVisibility: false,
130
+ });
131
+
132
+ it("renders the locked Public row non-interactive with the platform-team copy", () => {
133
+ const onChange = vi.fn();
134
+ render(
135
+ <VisibilitySelector
136
+ mode="create"
137
+ visibility={ApiResourceVisibility.visibility_private}
138
+ options={GATED_LEVELS}
139
+ onVisibilityChange={onChange}
140
+ />,
141
+ );
142
+
143
+ const publicRow = screen.getByRole("radio", { name: /Public/i });
144
+ expect((publicRow as HTMLButtonElement).disabled).toBe(true);
145
+ expect(publicRow.getAttribute("aria-disabled")).toBe("true");
146
+ expect(publicRow.textContent).toContain("granted by the platform team");
147
+
148
+ fireEvent.click(publicRow);
149
+ expect(onChange).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it("keeps every other level selectable — only publishing is operator-granted", () => {
153
+ const onChange = vi.fn();
154
+ render(
155
+ <VisibilitySelector
156
+ mode="create"
157
+ visibility={ApiResourceVisibility.visibility_private}
158
+ options={GATED_LEVELS}
159
+ onVisibilityChange={onChange}
160
+ />,
161
+ );
162
+
163
+ fireEvent.click(screen.getByRole("radio", { name: /^Organization/i }));
164
+ expect(onChange).toHaveBeenCalledWith(ApiResourceVisibility.visibility_org);
165
+ });
166
+ });
167
+
121
168
  describe("VisibilitySelector — manage mode (popover + confirmation)", () => {
122
169
  it("shows the current level on the trigger and lists levels on open", async () => {
123
170
  render(
@@ -1,6 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
3
- import { environmentVisibilityLevels } from "../visibilityLevels";
3
+ import {
4
+ blueprintVisibilityLevels,
5
+ environmentVisibilityLevels,
6
+ instanceVisibilityLevels,
7
+ PUBLIC_LOCKED_REASON,
8
+ type VisibilityLevelOption,
9
+ } from "../visibilityLevels";
4
10
 
5
11
  describe("environmentVisibilityLevels", () => {
6
12
  it("offers exactly Private and Organization in cloud mode", () => {
@@ -31,3 +37,63 @@ describe("environmentVisibilityLevels", () => {
31
37
  expect(environmentVisibilityLevels("local")).toHaveLength(1);
32
38
  });
33
39
  });
40
+
41
+ describe("operator-gated PUBLIC level", () => {
42
+ const publicOf = (levels: readonly VisibilityLevelOption[]) =>
43
+ levels.find((l) => l.value === ApiResourceVisibility.visibility_public);
44
+
45
+ it("locks Public with the platform-team copy when the caller may not publish", () => {
46
+ const blueprint = publicOf(
47
+ blueprintVisibilityLevels({
48
+ deploymentMode: "cloud",
49
+ hasIdentityProvider: false,
50
+ canSetPublicVisibility: false,
51
+ }),
52
+ );
53
+ const instance = publicOf(
54
+ instanceVisibilityLevels({ canSetPublicVisibility: false }),
55
+ );
56
+
57
+ expect(blueprint?.lockedReason).toBe(PUBLIC_LOCKED_REASON);
58
+ expect(instance?.lockedReason).toBe(PUBLIC_LOCKED_REASON);
59
+ });
60
+
61
+ it("offers Public unlocked for callers with the publish grant", () => {
62
+ const blueprint = publicOf(
63
+ blueprintVisibilityLevels({
64
+ deploymentMode: "cloud",
65
+ hasIdentityProvider: true,
66
+ canSetPublicVisibility: true,
67
+ }),
68
+ );
69
+ const instance = publicOf(
70
+ instanceVisibilityLevels({ canSetPublicVisibility: true }),
71
+ );
72
+
73
+ expect(blueprint?.lockedReason).toBeUndefined();
74
+ expect(instance?.lockedReason).toBeUndefined();
75
+ });
76
+
77
+ it("never locks any level below Public — org/platform sharing stays self-service", () => {
78
+ const levels = blueprintVisibilityLevels({
79
+ deploymentMode: "cloud",
80
+ hasIdentityProvider: true,
81
+ canSetPublicVisibility: false,
82
+ });
83
+ for (const level of levels) {
84
+ if (level.value === ApiResourceVisibility.visibility_public) continue;
85
+ expect(level.lockedReason, `${level.label} must stay self-service`).toBeUndefined();
86
+ }
87
+ });
88
+
89
+ it("keeps the instance Public row's execution-oriented description when locked", () => {
90
+ // The lock replaces the rendered detail line, but the option data keeps
91
+ // its identity (label, tone, confirmation) so an unlock is a pure
92
+ // context change.
93
+ const instance = publicOf(
94
+ instanceVisibilityLevels({ canSetPublicVisibility: false }),
95
+ );
96
+ expect(instance?.lockedReason).toBe(PUBLIC_LOCKED_REASON);
97
+ expect(instance?.confirmDialog).toBeDefined();
98
+ });
99
+ });
@@ -63,12 +63,14 @@ export type {
63
63
 
64
64
  export {
65
65
  blueprintVisibilityLevels,
66
- INSTANCE_VISIBILITY_LEVELS,
66
+ instanceVisibilityLevels,
67
+ PUBLIC_LOCKED_REASON,
67
68
  visibilityLabel,
68
69
  visibilityOption,
69
70
  } from "./visibilityLevels.js";
70
71
  export type {
71
72
  BlueprintVisibilityLevelsContext,
73
+ InstanceVisibilityLevelsContext,
72
74
  VisibilityLevelOption,
73
75
  } from "./visibilityLevels.js";
74
76
 
@@ -81,5 +83,8 @@ export type {
81
83
  UseUpdateVisibilityReturn,
82
84
  } from "./useUpdateVisibility.js";
83
85
 
86
+ export { useCanSetPublicVisibility } from "./useCanSetPublicVisibility.js";
87
+ export type { UseCanSetPublicVisibilityReturn } from "./useCanSetPublicVisibility.js";
88
+
84
89
  export { InstanceVisibilitySelector } from "./InstanceVisibilitySelector.js";
85
90
  export type { InstanceVisibilitySelectorProps } from "./InstanceVisibilitySelector.js";
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useDeploymentMode } from "../deployment-mode.js";
4
+ import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
5
+
6
+ /**
7
+ * The `platform` singleton — platform-level permissions are always checked
8
+ * against this one resource (mirrors the server-side `resource_id = "stigmer"`
9
+ * RPC config).
10
+ */
11
+ const PLATFORM_RESOURCE = { kind: "platform", id: "stigmer" } as const;
12
+
13
+ /** Return value of {@link useCanSetPublicVisibility}. */
14
+ export interface UseCanSetPublicVisibilityReturn {
15
+ /** Whether the current user may set resources to PUBLIC visibility. */
16
+ readonly allowed: boolean;
17
+ /** `true` while the permission check is in flight (cloud only). */
18
+ readonly isLoading: boolean;
19
+ }
20
+
21
+ /**
22
+ * Whether the current user may set a resource's visibility to PUBLIC — the
23
+ * only level that crosses every org boundary (the cross-org "explore"
24
+ * catalog).
25
+ *
26
+ * In the cloud edition, publishing is a curation decision: the backend
27
+ * gates both write doors (updateVisibility escalation and
28
+ * create-with-public) on `can_set_public_visibility` on `platform:stigmer`,
29
+ * an explicit platform-operator capability. This hook runs the same check
30
+ * so selectors can present the Public option as locked instead of letting
31
+ * the request fail.
32
+ *
33
+ * Fail-closed, deliberately the opposite of the `useCheckPermission`
34
+ * default: an escalation gate must not open while the check is loading or
35
+ * when it errors — a locked option that unlocks a moment later is a far
36
+ * better failure than an unlocked option the server then rejects.
37
+ *
38
+ * In `local` deployment mode (OSS Go backend) the answer is always `true`:
39
+ * the self-hosted operator owns the store, the same scoping cloud#320
40
+ * applied to reserved labels.
41
+ */
42
+ export function useCanSetPublicVisibility(): UseCanSetPublicVisibilityReturn {
43
+ const deploymentMode = useDeploymentMode();
44
+ // Hooks run unconditionally; in local mode the null resource skips the
45
+ // RPC entirely and the fail mode's answer is discarded below.
46
+ const { allowed, isLoading } = useCheckPermission(
47
+ deploymentMode === "cloud" ? PLATFORM_RESOURCE : null,
48
+ "can_set_public_visibility",
49
+ { fail: "closed" },
50
+ );
51
+
52
+ if (deploymentMode === "local") {
53
+ return { allowed: true, isLoading: false };
54
+ }
55
+ return { allowed, isLoading };
56
+ }
@@ -43,8 +43,28 @@ export interface VisibilityLevelOption {
43
43
  };
44
44
  /** Color treatment for the selected segment and the confirmation prompt. */
45
45
  readonly tone: "private" | "org" | "platform" | "public";
46
+ /**
47
+ * When present, the level cannot be selected by the current caller and the
48
+ * row renders locked (disabled, lock affordance) with this copy explaining
49
+ * why and what to do instead. Computed by the level builders from caller
50
+ * context — the component stays free of per-level branching, exactly like
51
+ * the confirmation ladder.
52
+ *
53
+ * Today's only locked level is Public in the cloud edition for callers
54
+ * without `can_set_public_visibility` (publishing is operator-granted);
55
+ * the backend independently enforces the same gate.
56
+ */
57
+ readonly lockedReason?: string;
46
58
  }
47
59
 
60
+ /**
61
+ * The locked-row copy for a caller who may not set PUBLIC visibility. Names
62
+ * the path forward (ask the platform team) rather than a bare "disabled" —
63
+ * the option stays discoverable even though it is not self-service.
64
+ */
65
+ export const PUBLIC_LOCKED_REASON =
66
+ "Public listing is granted by the platform team — contact us to publish this resource.";
67
+
48
68
  const PRIVATE_OPTION: VisibilityLevelOption = {
49
69
  value: ApiResourceVisibility.visibility_private,
50
70
  label: "Private",
@@ -97,10 +117,27 @@ const PUBLIC_OPTION: VisibilityLevelOption = {
97
117
  * operate an IdentityProvider — the backend rejects it otherwise
98
118
  * (`ValidateVisibilityStep`), so the option only renders when the signal
99
119
  * is present (use `useSsoProvider`, the permission-free lookup).
120
+ * - `canSetPublicVisibility`: entering PUBLIC is operator-gated in the
121
+ * cloud edition; when the caller lacks the grant the Public option
122
+ * renders locked instead of disappearing (use
123
+ * `useCanSetPublicVisibility`, which is always `true` in local mode).
100
124
  */
101
125
  export interface BlueprintVisibilityLevelsContext {
102
126
  readonly deploymentMode: "cloud" | "local";
103
127
  readonly hasIdentityProvider: boolean;
128
+ readonly canSetPublicVisibility: boolean;
129
+ }
130
+
131
+ /**
132
+ * The PUBLIC option, locked with {@link PUBLIC_LOCKED_REASON} when the
133
+ * caller may not publish. Locked rather than hidden: the level (and the
134
+ * path to it) stays discoverable, matching how the option keeps rendering
135
+ * as the current state on already-public resources.
136
+ */
137
+ function publicOption(canSetPublicVisibility: boolean): VisibilityLevelOption {
138
+ return canSetPublicVisibility
139
+ ? PUBLIC_OPTION
140
+ : { ...PUBLIC_OPTION, lockedReason: PUBLIC_LOCKED_REASON };
104
141
  }
105
142
 
106
143
  /**
@@ -114,12 +151,22 @@ export interface BlueprintVisibilityLevelsContext {
114
151
  export function blueprintVisibilityLevels(
115
152
  context: BlueprintVisibilityLevelsContext,
116
153
  ): readonly VisibilityLevelOption[] {
154
+ const publicLevel = publicOption(context.canSetPublicVisibility);
117
155
  if (context.deploymentMode === "local") {
118
- return [PRIVATE_OPTION, PUBLIC_OPTION];
156
+ return [PRIVATE_OPTION, publicLevel];
119
157
  }
120
158
  return context.hasIdentityProvider
121
- ? [PRIVATE_OPTION, ORG_OPTION, PLATFORM_OPTION, PUBLIC_OPTION]
122
- : [PRIVATE_OPTION, ORG_OPTION, PUBLIC_OPTION];
159
+ ? [PRIVATE_OPTION, ORG_OPTION, PLATFORM_OPTION, publicLevel]
160
+ : [PRIVATE_OPTION, ORG_OPTION, publicLevel];
161
+ }
162
+
163
+ /**
164
+ * Inputs that gate the instance level set — only the publish gate; the set
165
+ * itself is fixed (instances have no platform level and no deployment-mode
166
+ * collapse).
167
+ */
168
+ export interface InstanceVisibilityLevelsContext {
169
+ readonly canSetPublicVisibility: boolean;
123
170
  }
124
171
 
125
172
  /**
@@ -131,17 +178,21 @@ export function blueprintVisibilityLevels(
131
178
  * boundary). Descriptions are execution-oriented because org visibility on
132
179
  * an instance is about who can run it and see its executions.
133
180
  */
134
- export const INSTANCE_VISIBILITY_LEVELS: readonly VisibilityLevelOption[] = [
135
- PRIVATE_OPTION,
136
- {
137
- ...ORG_OPTION,
138
- description: "All org members can view executions",
139
- },
140
- {
141
- ...PUBLIC_OPTION,
142
- description: "All authenticated users can view",
143
- },
144
- ];
181
+ export function instanceVisibilityLevels(
182
+ context: InstanceVisibilityLevelsContext,
183
+ ): readonly VisibilityLevelOption[] {
184
+ return [
185
+ PRIVATE_OPTION,
186
+ {
187
+ ...ORG_OPTION,
188
+ description: "All org members can view executions",
189
+ },
190
+ {
191
+ ...publicOption(context.canSetPublicVisibility),
192
+ description: "All authenticated users can view",
193
+ },
194
+ ];
195
+ }
145
196
 
146
197
  /**
147
198
  * The levels an environment selector offers, in escalation order:
@@ -0,0 +1,117 @@
1
+ // Pins the connect hook's ride on the async connect lane (stigmer#425):
2
+ // startConnect + poll on a backend that serves it, and the blocking-RPC
3
+ // fallback on one that does not (the byoaEditionDegradation idiom — every
4
+ // OTHER mcp-server test in this suite exercises the fallback implicitly, this
5
+ // file pins BOTH lanes explicitly so neither can regress silently). The
6
+ // protocol's own edge cases (deadlines, rehydration codes) are pinned in
7
+ // @stigmer/sdk's mcpserver-connect.test.ts — this is the hook-level contract.
8
+
9
+ import { describe, it, expect, afterEach } from "vitest";
10
+ import { renderHook, act, cleanup } from "@testing-library/react";
11
+ import type { ReactNode } from "react";
12
+ import { create } from "@bufbuild/protobuf";
13
+ import { createRouterTransport } from "@connectrpc/connect";
14
+ import { Stigmer } from "@stigmer/sdk";
15
+ import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
16
+ import { McpServerQueryController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/query_pb";
17
+ import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
18
+ import { ConnectPhase } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
19
+ import { StigmerContext } from "../../context";
20
+ import { useMcpServerConnect } from "../useMcpServerConnect";
21
+
22
+ afterEach(cleanup);
23
+
24
+ const SERVER_ID = "mcps_01test";
25
+ const ORG = "acme";
26
+
27
+ function mcpServer(phase: ConnectPhase, extras?: { failureMessage?: string; toolName?: string }) {
28
+ return create(McpServerSchema, {
29
+ metadata: { id: SERVER_ID, name: "Test Server" },
30
+ status: {
31
+ ...(extras?.toolName !== undefined
32
+ ? { discoveredCapabilities: { tools: [{ name: extras.toolName }] } }
33
+ : {}),
34
+ connectStatus: {
35
+ phase,
36
+ workflowId: `stigmer/mcp-server/connect/${SERVER_ID}`,
37
+ failureCode: extras?.failureMessage !== undefined ? "FailedPrecondition" : "",
38
+ failureMessage: extras?.failureMessage ?? "",
39
+ },
40
+ },
41
+ });
42
+ }
43
+
44
+ function renderConnectHook(register: Parameters<typeof createRouterTransport>[0]) {
45
+ const client = new Stigmer({
46
+ baseUrl: "/",
47
+ getAccessToken: () => "test-token",
48
+ customTransport: createRouterTransport(register),
49
+ });
50
+ const wrapper = ({ children }: { children: ReactNode }) => (
51
+ <StigmerContext.Provider value={client}>{children}</StigmerContext.Provider>
52
+ );
53
+ return renderHook(() => useMcpServerConnect(), { wrapper });
54
+ }
55
+
56
+ describe("useMcpServerConnect — async lane", () => {
57
+ it("resolves through startConnect + polling when the backend serves the lane", async () => {
58
+ let blockingConnectCalled = false;
59
+ const { result } = renderConnectHook((router) => {
60
+ router.service(McpServerCommandController, {
61
+ startConnect: () => mcpServer(ConnectPhase.connecting),
62
+ connect: () => {
63
+ blockingConnectCalled = true;
64
+ return mcpServer(ConnectPhase.succeeded);
65
+ },
66
+ });
67
+ router.service(McpServerQueryController, {
68
+ get: () => mcpServer(ConnectPhase.succeeded, { toolName: "search_code" }),
69
+ });
70
+ });
71
+
72
+ await act(async () => {
73
+ const settled = await result.current.connect(SERVER_ID, ORG);
74
+ expect(settled.status?.discoveredCapabilities?.tools[0]?.name).toBe("search_code");
75
+ });
76
+ expect(blockingConnectCalled).toBe(false);
77
+ expect(result.current.error).toBeNull();
78
+ expect(result.current.isConnecting).toBe(false);
79
+ }, 15_000);
80
+
81
+ it("surfaces a failed operation with the persisted user-facing message", async () => {
82
+ const { result } = renderConnectHook((router) => {
83
+ router.service(McpServerCommandController, {
84
+ startConnect: () => mcpServer(ConnectPhase.connecting),
85
+ });
86
+ router.service(McpServerQueryController, {
87
+ get: () =>
88
+ mcpServer(ConnectPhase.failed, {
89
+ failureMessage: "connect failed for MCP server 'Test Server': missing credentials",
90
+ }),
91
+ });
92
+ });
93
+
94
+ await act(async () => {
95
+ await expect(result.current.connect(SERVER_ID, ORG)).rejects.toThrow("missing credentials");
96
+ });
97
+ expect(result.current.error?.message).toContain("missing credentials");
98
+ expect(result.current.isConnecting).toBe(false);
99
+ }, 15_000);
100
+
101
+ it("falls back to the blocking connect on a backend without the lane", async () => {
102
+ // startConnect deliberately unregistered: the router answers
103
+ // UNIMPLEMENTED, exactly what a pre-#425 backend (e.g. the hosted
104
+ // edition until its parity lands) returns.
105
+ const { result } = renderConnectHook((router) => {
106
+ router.service(McpServerCommandController, {
107
+ connect: () => mcpServer(ConnectPhase.succeeded, { toolName: "search_code" }),
108
+ });
109
+ });
110
+
111
+ await act(async () => {
112
+ const settled = await result.current.connect(SERVER_ID, ORG);
113
+ expect(settled.status?.discoveredCapabilities?.tools[0]?.name).toBe("search_code");
114
+ });
115
+ expect(result.current.error).toBeNull();
116
+ });
117
+ });