@stigmer/react 0.0.83 → 0.0.84

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 (58) hide show
  1. package/index.d.ts +3 -3
  2. package/index.d.ts.map +1 -1
  3. package/index.js +1 -1
  4. package/index.js.map +1 -1
  5. package/library/ResourceListView.d.ts +57 -7
  6. package/library/ResourceListView.d.ts.map +1 -1
  7. package/library/ResourceListView.js +147 -37
  8. package/library/ResourceListView.js.map +1 -1
  9. package/library/index.d.ts +1 -1
  10. package/library/index.d.ts.map +1 -1
  11. package/library/index.js.map +1 -1
  12. package/mcp-server/McpServerConfigPanel.d.ts +45 -0
  13. package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
  14. package/mcp-server/McpServerConfigPanel.js +90 -14
  15. package/mcp-server/McpServerConfigPanel.js.map +1 -1
  16. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  17. package/mcp-server/McpServerDetailView.js +168 -23
  18. package/mcp-server/McpServerDetailView.js.map +1 -1
  19. package/mcp-server/McpServerPicker.js +3 -3
  20. package/mcp-server/McpServerPicker.js.map +1 -1
  21. package/mcp-server/OAuthAppForm.d.ts +58 -0
  22. package/mcp-server/OAuthAppForm.d.ts.map +1 -0
  23. package/mcp-server/OAuthAppForm.js +67 -0
  24. package/mcp-server/OAuthAppForm.js.map +1 -0
  25. package/mcp-server/index.d.ts +6 -0
  26. package/mcp-server/index.d.ts.map +1 -1
  27. package/mcp-server/index.js +3 -0
  28. package/mcp-server/index.js.map +1 -1
  29. package/mcp-server/useDisconnectOAuth.d.ts +40 -0
  30. package/mcp-server/useDisconnectOAuth.d.ts.map +1 -0
  31. package/mcp-server/useDisconnectOAuth.js +46 -0
  32. package/mcp-server/useDisconnectOAuth.js.map +1 -0
  33. package/mcp-server/useMcpServerCredentials.d.ts +48 -0
  34. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  35. package/mcp-server/useMcpServerCredentials.js +18 -2
  36. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  37. package/mcp-server/useOAuthGrantStatus.d.ts +9 -0
  38. package/mcp-server/useOAuthGrantStatus.d.ts.map +1 -1
  39. package/mcp-server/useOAuthGrantStatus.js +6 -1
  40. package/mcp-server/useOAuthGrantStatus.js.map +1 -1
  41. package/mcp-server/useOrgOAuthApp.d.ts +82 -0
  42. package/mcp-server/useOrgOAuthApp.d.ts.map +1 -0
  43. package/mcp-server/useOrgOAuthApp.js +160 -0
  44. package/mcp-server/useOrgOAuthApp.js.map +1 -0
  45. package/package.json +4 -4
  46. package/src/index.ts +3 -0
  47. package/src/library/ResourceListView.tsx +303 -46
  48. package/src/library/index.ts +4 -1
  49. package/src/mcp-server/McpServerConfigPanel.tsx +370 -45
  50. package/src/mcp-server/McpServerDetailView.tsx +447 -47
  51. package/src/mcp-server/McpServerPicker.tsx +3 -3
  52. package/src/mcp-server/OAuthAppForm.tsx +304 -0
  53. package/src/mcp-server/index.ts +9 -0
  54. package/src/mcp-server/useDisconnectOAuth.ts +76 -0
  55. package/src/mcp-server/useMcpServerCredentials.ts +70 -2
  56. package/src/mcp-server/useOAuthGrantStatus.ts +19 -1
  57. package/src/mcp-server/useOrgOAuthApp.ts +250 -0
  58. package/styles.css +1 -1
@@ -0,0 +1,304 @@
1
+ "use client";
2
+
3
+ import { useCallback, useId, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage } from "@stigmer/sdk";
6
+
7
+ /** Props for {@link OAuthAppForm}. */
8
+ export interface OAuthAppFormProps {
9
+ /**
10
+ * Vendor / provider display name shown in the instruction text.
11
+ * Example: `"Figma"`, `"Slack"`.
12
+ */
13
+ readonly providerName: string;
14
+ /**
15
+ * URL to the vendor's OAuth app registration page. When provided,
16
+ * a help link is rendered so the user can register their app.
17
+ */
18
+ readonly vendorDocsUrl?: string | null;
19
+ /**
20
+ * Called when the form is submitted with valid credentials.
21
+ * The parent is responsible for calling the `setOrgOAuthApp` mutation
22
+ * and handling errors.
23
+ */
24
+ readonly onSubmit: (clientId: string, clientSecret: string) => Promise<void>;
25
+ /** Called when the user cancels the form. */
26
+ readonly onCancel: () => void;
27
+ /** `true` while the submit mutation is in flight. */
28
+ readonly isSubmitting: boolean;
29
+ /** Error from the last failed submit, or `null`. */
30
+ readonly error: Error | null;
31
+ /** Additional CSS classes for the form root. */
32
+ readonly className?: string;
33
+ }
34
+
35
+ /**
36
+ * Two-field form for registering an org-level OAuth app override (BYOA).
37
+ *
38
+ * Collects only `client_id` and `client_secret` — all other OAuth
39
+ * configuration (endpoint URLs, scopes) is cloned from the platform's
40
+ * OAuthApp template by the backend.
41
+ *
42
+ * This is a pure presentational component with no dialog wrapper
43
+ * (headless-first). The parent is responsible for rendering it inside
44
+ * a `<dialog>`, modal, sheet, or inline context as needed. Platform
45
+ * builders who want a different container can import just the form.
46
+ *
47
+ * All styling flows through `--stgm-*` design tokens via `cn()`.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <OAuthAppForm
52
+ * providerName="Figma"
53
+ * vendorDocsUrl="https://www.figma.com/developers/api#oauth2"
54
+ * onSubmit={async (clientId, clientSecret) => {
55
+ * await orgOAuthApp.setOrgOAuthApp(clientId, clientSecret);
56
+ * orgOAuthApp.refetch();
57
+ * }}
58
+ * onCancel={() => setShowForm(false)}
59
+ * isSubmitting={orgOAuthApp.isSetting}
60
+ * error={orgOAuthApp.setError}
61
+ * />
62
+ * ```
63
+ */
64
+ export function OAuthAppForm({
65
+ providerName,
66
+ vendorDocsUrl,
67
+ onSubmit,
68
+ onCancel,
69
+ isSubmitting,
70
+ error,
71
+ className,
72
+ }: OAuthAppFormProps) {
73
+ const [clientId, setClientId] = useState("");
74
+ const [clientSecret, setClientSecret] = useState("");
75
+ const [secretRevealed, setSecretRevealed] = useState(false);
76
+
77
+ const formId = useId();
78
+ const clientIdId = `${formId}-client-id`;
79
+ const clientSecretId = `${formId}-client-secret`;
80
+
81
+ const canSubmit = clientId.trim().length > 0 && clientSecret.trim().length > 0;
82
+ const isDisabled = isSubmitting;
83
+
84
+ const handleSubmit = useCallback(
85
+ async (e: React.FormEvent) => {
86
+ e.preventDefault();
87
+ if (!canSubmit || isDisabled) return;
88
+ await onSubmit(clientId.trim(), clientSecret.trim());
89
+ },
90
+ [canSubmit, isDisabled, onSubmit, clientId, clientSecret],
91
+ );
92
+
93
+ return (
94
+ <form
95
+ onSubmit={handleSubmit}
96
+ className={cn("flex flex-col gap-4", className)}
97
+ >
98
+ {/* Instructions */}
99
+ <div className="space-y-1.5">
100
+ <p className="text-sm text-foreground">
101
+ Register an OAuth app with{" "}
102
+ <span className="font-medium">{providerName}</span> and enter your
103
+ credentials below.
104
+ </p>
105
+ {vendorDocsUrl && (
106
+ <a
107
+ href={vendorDocsUrl}
108
+ target="_blank"
109
+ rel="noopener noreferrer"
110
+ className="inline-flex items-center gap-1 text-xs text-primary underline decoration-primary/40 underline-offset-2 hover:decoration-primary"
111
+ >
112
+ {providerName} OAuth app registration
113
+ <ExternalLinkIcon className="size-3 shrink-0" />
114
+ </a>
115
+ )}
116
+ </div>
117
+
118
+ {/* Fields */}
119
+ <div className="flex flex-col gap-3">
120
+ <div className="flex flex-col gap-1.5">
121
+ <label htmlFor={clientIdId} className="text-xs font-medium text-foreground">
122
+ Client ID
123
+ </label>
124
+ <input
125
+ id={clientIdId}
126
+ type="text"
127
+ value={clientId}
128
+ onChange={(e) => setClientId(e.target.value)}
129
+ disabled={isDisabled}
130
+ required
131
+ aria-required
132
+ autoComplete="off"
133
+ autoFocus
134
+ placeholder="e.g. 1234567890abcdef"
135
+ className={cn(
136
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
137
+ "placeholder:text-muted-foreground",
138
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
139
+ "disabled:pointer-events-none disabled:opacity-50",
140
+ )}
141
+ />
142
+ </div>
143
+
144
+ <div className="flex flex-col gap-1.5">
145
+ <label htmlFor={clientSecretId} className="text-xs font-medium text-foreground">
146
+ Client Secret
147
+ </label>
148
+ <div className="relative">
149
+ <input
150
+ id={clientSecretId}
151
+ type={secretRevealed ? "text" : "password"}
152
+ value={clientSecret}
153
+ onChange={(e) => setClientSecret(e.target.value)}
154
+ disabled={isDisabled}
155
+ required
156
+ aria-required
157
+ autoComplete="off"
158
+ placeholder="Your client secret"
159
+ className={cn(
160
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 pr-8 text-xs text-foreground",
161
+ "placeholder:text-muted-foreground",
162
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
163
+ "disabled:pointer-events-none disabled:opacity-50",
164
+ )}
165
+ />
166
+ <button
167
+ type="button"
168
+ onClick={() => setSecretRevealed((v) => !v)}
169
+ disabled={isDisabled}
170
+ className={cn(
171
+ "absolute right-2 top-1/2 -translate-y-1/2",
172
+ "text-muted-foreground hover:text-foreground",
173
+ "disabled:pointer-events-none disabled:opacity-50",
174
+ )}
175
+ aria-label={secretRevealed ? "Hide client secret" : "Show client secret"}
176
+ tabIndex={-1}
177
+ >
178
+ {secretRevealed ? <EyeOffIcon /> : <EyeIcon />}
179
+ </button>
180
+ </div>
181
+ </div>
182
+ </div>
183
+
184
+ {/* Error */}
185
+ {error && (
186
+ <div
187
+ role="alert"
188
+ className="rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-xs text-destructive"
189
+ >
190
+ {getUserMessage(error)}
191
+ </div>
192
+ )}
193
+
194
+ {/* Actions */}
195
+ <div className="flex items-center justify-end gap-2">
196
+ <button
197
+ type="button"
198
+ onClick={onCancel}
199
+ disabled={isDisabled}
200
+ className={cn(
201
+ "rounded-md px-3 py-1.5 text-xs",
202
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
203
+ "disabled:pointer-events-none disabled:opacity-50",
204
+ )}
205
+ >
206
+ Cancel
207
+ </button>
208
+ <button
209
+ type="submit"
210
+ disabled={!canSubmit || isDisabled}
211
+ className={cn(
212
+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
213
+ "bg-primary text-primary-foreground hover:bg-primary/90",
214
+ "disabled:pointer-events-none disabled:opacity-40",
215
+ )}
216
+ >
217
+ {isSubmitting && <SpinnerIcon />}
218
+ Save
219
+ </button>
220
+ </div>
221
+ </form>
222
+ );
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Icons (internal to this module — avoids cross-file dependencies)
227
+ // ---------------------------------------------------------------------------
228
+
229
+ function ExternalLinkIcon({ className }: { readonly className?: string }) {
230
+ return (
231
+ <svg
232
+ className={className}
233
+ viewBox="0 0 16 16"
234
+ fill="none"
235
+ stroke="currentColor"
236
+ strokeWidth="1.5"
237
+ strokeLinecap="round"
238
+ strokeLinejoin="round"
239
+ aria-hidden="true"
240
+ >
241
+ <path d="M6 3.5H3.5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V10" />
242
+ <path d="M9.5 2.5h4v4" />
243
+ <path d="M13.5 2.5 8 8" />
244
+ </svg>
245
+ );
246
+ }
247
+
248
+ function EyeIcon() {
249
+ return (
250
+ <svg
251
+ width="14"
252
+ height="14"
253
+ viewBox="0 0 16 16"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ strokeWidth="1.5"
257
+ strokeLinecap="round"
258
+ strokeLinejoin="round"
259
+ aria-hidden="true"
260
+ >
261
+ <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
262
+ <circle cx="8" cy="8" r="2" />
263
+ </svg>
264
+ );
265
+ }
266
+
267
+ function EyeOffIcon() {
268
+ return (
269
+ <svg
270
+ width="14"
271
+ height="14"
272
+ viewBox="0 0 16 16"
273
+ fill="none"
274
+ stroke="currentColor"
275
+ strokeWidth="1.5"
276
+ strokeLinecap="round"
277
+ strokeLinejoin="round"
278
+ aria-hidden="true"
279
+ >
280
+ <path d="M6.59 6.59a2 2 0 0 0 2.82 2.82" />
281
+ <path d="M10.73 10.73A6.5 6.5 0 0 1 8 12.5c-4 0-6.5-4.5-6.5-4.5a11.5 11.5 0 0 1 3.77-3.73" />
282
+ <path d="M5.71 3.56A6.3 6.3 0 0 1 8 3.5c4 0 6.5 4.5 6.5 4.5a11.5 11.5 0 0 1-1.28 1.73" />
283
+ <path d="M2 2l12 12" />
284
+ </svg>
285
+ );
286
+ }
287
+
288
+ function SpinnerIcon() {
289
+ return (
290
+ <svg
291
+ width="12"
292
+ height="12"
293
+ viewBox="0 0 16 16"
294
+ fill="none"
295
+ stroke="currentColor"
296
+ strokeWidth="2"
297
+ strokeLinecap="round"
298
+ className="animate-spin"
299
+ aria-hidden="true"
300
+ >
301
+ <path d="M8 2a6 6 0 1 0 6 6" />
302
+ </svg>
303
+ );
304
+ }
@@ -22,6 +22,15 @@ export type { UseMcpServerReturn } from "./useMcpServer";
22
22
  export { useOAuthGrantStatus } from "./useOAuthGrantStatus";
23
23
  export type { UseOAuthGrantStatusReturn } from "./useOAuthGrantStatus";
24
24
 
25
+ export { useDisconnectOAuth } from "./useDisconnectOAuth";
26
+ export type { UseDisconnectOAuthReturn } from "./useDisconnectOAuth";
27
+
28
+ export { useOrgOAuthApp } from "./useOrgOAuthApp";
29
+ export type { UseOrgOAuthAppReturn } from "./useOrgOAuthApp";
30
+
31
+ export { OAuthAppForm } from "./OAuthAppForm";
32
+ export type { OAuthAppFormProps } from "./OAuthAppForm";
33
+
25
34
  export { McpServerPicker } from "./McpServerPicker";
26
35
  export type {
27
36
  McpServerPickerProps,
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { DisconnectOAuthInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
6
+ import { useStigmer } from "../hooks";
7
+ import { toError } from "../internal/toError";
8
+
9
+ /** Return value of {@link useDisconnectOAuth}. */
10
+ export interface UseDisconnectOAuthReturn {
11
+ /**
12
+ * Disconnect the current user's OAuth grant for an MCP server.
13
+ *
14
+ * Deletes the managed environment (secrets first) and then the grant
15
+ * document. The operation is idempotent — disconnecting when no grant
16
+ * exists returns `false` without error.
17
+ *
18
+ * Resolves with `true` when a grant was removed, `false` when no
19
+ * grant existed. Callers should `refetch()` grant status and
20
+ * credentials after a successful disconnect.
21
+ */
22
+ readonly disconnect: (resourceId: string, org: string) => Promise<boolean>;
23
+ /** `true` while the disconnect request is in flight. */
24
+ readonly isDisconnecting: boolean;
25
+ /** Error from the last failed disconnect, or `null` when healthy. */
26
+ readonly error: Error | null;
27
+ /** Reset `error` to `null`. */
28
+ readonly clearError: () => void;
29
+ }
30
+
31
+ /**
32
+ * Behavior hook that wraps `mcpServer.disconnectOAuth()` with loading
33
+ * and error state.
34
+ *
35
+ * Removes the user's OAuth grant and associated managed environment
36
+ * for a given MCP server resource. After a successful disconnect the
37
+ * UI should revert to the "Not connected" state — call `refetch()` on
38
+ * the credentials / grant status hooks to reflect the change.
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * const { disconnect, isDisconnecting, error } = useDisconnectOAuth();
43
+ *
44
+ * await disconnect(mcpServerId, org);
45
+ * credentials.refetch(); // refresh grant status + env
46
+ * ```
47
+ */
48
+ export function useDisconnectOAuth(): UseDisconnectOAuthReturn {
49
+ const stigmer = useStigmer();
50
+ const [isDisconnecting, setIsDisconnecting] = useState(false);
51
+ const [error, setError] = useState<Error | null>(null);
52
+
53
+ const clearError = useCallback(() => setError(null), []);
54
+
55
+ const disconnect = useCallback(
56
+ async (resourceId: string, org: string): Promise<boolean> => {
57
+ setIsDisconnecting(true);
58
+ setError(null);
59
+
60
+ try {
61
+ const result = await stigmer.mcpServer.disconnectOAuth(
62
+ create(DisconnectOAuthInputSchema, { resourceId, org }),
63
+ );
64
+ return result.disconnected;
65
+ } catch (err) {
66
+ setError(toError(err));
67
+ throw err;
68
+ } finally {
69
+ setIsDisconnecting(false);
70
+ }
71
+ },
72
+ [stigmer],
73
+ );
74
+
75
+ return { disconnect, isDisconnecting, error, clearError };
76
+ }
@@ -3,6 +3,8 @@
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import type { EnvVarInput } from "@stigmer/sdk";
5
5
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
6
+ import { OAuthConnectionHealth } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
7
+ import { OAuthAppSource } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
6
8
  import { VendorApprovalStatus } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/spec_pb";
7
9
  import { usePersonalEnvironment } from "../environment/usePersonalEnvironment";
8
10
  import { diffEnv } from "../environment/diffEnv";
@@ -41,6 +43,20 @@ export interface UseMcpServerCredentialsReturn {
41
43
  * environment key presence. Always `false` when `authMode` is `"manual"`.
42
44
  */
43
45
  readonly isOAuthConnected: boolean;
46
+ /**
47
+ * Health of the OAuth connection for this server.
48
+ *
49
+ * Provides a four-state signal beyond the binary `isOAuthConnected`:
50
+ * healthy, expired-but-refreshable, expired (re-auth needed), or no
51
+ * grant. `UNSPECIFIED` when `authMode` is `"manual"` or the status
52
+ * has not been fetched yet.
53
+ */
54
+ readonly connectionHealth: OAuthConnectionHealth;
55
+ /**
56
+ * `true` when the user can disconnect (i.e., an OAuth grant exists).
57
+ * Always `false` when `authMode` is `"manual"` or no grant is present.
58
+ */
59
+ readonly canDisconnect: boolean;
44
60
  /**
45
61
  * When the OAuth access token expires (Unix timestamp seconds).
46
62
  * `BigInt(0)` when no grant exists, `authMode` is `"manual"`, or the token
@@ -105,6 +121,38 @@ export interface UseMcpServerCredentialsReturn {
105
121
  * `null` when no documentation link is available.
106
122
  */
107
123
  readonly vendorApprovalDocsUrl: string | null;
124
+ /**
125
+ * `true` when the platform OAuth app's vendor approval is PENDING or
126
+ * REJECTED — i.e., the platform sign-in flow is blocked. Covers both
127
+ * statuses since the user-facing behavior is the same: sign-in is
128
+ * disabled and BYOA / manual entry are the available alternatives.
129
+ *
130
+ * See also {@link isVendorApprovalPending} which only checks PENDING.
131
+ */
132
+ readonly isVendorApprovalBlocked: boolean;
133
+ /**
134
+ * Where the effective OAuth app was resolved from for the caller's org.
135
+ *
136
+ * Enriched at query time by the backend — no additional RPC needed.
137
+ * `UNSPECIFIED` when `authMode` is `"manual"` or enrichment has not
138
+ * been performed yet.
139
+ */
140
+ readonly effectiveOAuthSource: OAuthAppSource;
141
+ /**
142
+ * `true` when an org-level BYOA override is active for this server.
143
+ * Derived from `effectiveOAuthSource === OAUTH_APP_SOURCE_ORG_OVERRIDE`.
144
+ */
145
+ readonly isOrgOAuthApp: boolean;
146
+ /**
147
+ * `true` when the BYOA option is relevant for this server: the server
148
+ * uses vendor OAuth (`authMode === "oauth"` with an `oauth_app_ref`)
149
+ * and no org override is currently active.
150
+ *
151
+ * When `true`, the UI should offer "Use your own OAuth app" as either
152
+ * a primary action (when vendor approval is blocked) or a secondary
153
+ * link (when vendor approval is granted).
154
+ */
155
+ readonly canBringOwnApp: boolean;
108
156
  /**
109
157
  * When `true`, the user has opted to bypass OAuth and enter the
110
158
  * `target_env_var` token manually. In this state:
@@ -203,10 +251,24 @@ export function useMcpServerCredentials(
203
251
  const oauthTargetEnvVar = auth?.targetEnvVar || null;
204
252
  const tokenLifetimeHint = auth?.tokenLifetimeHint || null;
205
253
 
254
+ const oauthStatus = mcpServer?.status?.oauthStatus;
206
255
  const isVendorApprovalPending =
207
256
  authMode === "oauth" &&
208
- auth?.vendorApprovalStatus === VendorApprovalStatus.PENDING;
209
- const vendorApprovalDocsUrl = auth?.vendorApprovalDocsUrl || null;
257
+ oauthStatus?.vendorApprovalStatus === VendorApprovalStatus.PENDING;
258
+ const isVendorApprovalBlocked =
259
+ authMode === "oauth" &&
260
+ (oauthStatus?.vendorApprovalStatus === VendorApprovalStatus.PENDING ||
261
+ oauthStatus?.vendorApprovalStatus === VendorApprovalStatus.REJECTED);
262
+ const vendorApprovalDocsUrl = oauthStatus?.vendorApprovalDocsUrl || null;
263
+
264
+ const effectiveOAuthSource =
265
+ oauthStatus?.effectiveOauthSource ??
266
+ OAuthAppSource.OAUTH_APP_SOURCE_UNSPECIFIED;
267
+ const isOrgOAuthApp =
268
+ effectiveOAuthSource === OAuthAppSource.OAUTH_APP_SOURCE_ORG_OVERRIDE;
269
+ const hasOAuthAppRef = Boolean(auth?.oauthAppRef?.slug);
270
+ const canBringOwnApp =
271
+ authMode === "oauth" && hasOAuthAppRef && !isOrgOAuthApp;
210
272
 
211
273
  const grantStatus = useOAuthGrantStatus(
212
274
  authMode === "oauth" ? (mcpServer?.metadata?.id ?? null) : null,
@@ -263,10 +325,16 @@ export function useMcpServerCredentials(
263
325
  authMode,
264
326
  oauthTargetEnvVar,
265
327
  isOAuthConnected,
328
+ connectionHealth: grantStatus.connectionHealth,
329
+ canDisconnect: isOAuthConnected,
266
330
  accessTokenExpiresAt: grantStatus.accessTokenExpiresAt,
267
331
  tokenLifetimeHint,
268
332
  isVendorApprovalPending,
333
+ isVendorApprovalBlocked,
269
334
  vendorApprovalDocsUrl,
335
+ effectiveOAuthSource,
336
+ isOrgOAuthApp,
337
+ canBringOwnApp,
270
338
  missingVariables,
271
339
  isReady,
272
340
  isLoading: personalEnv.isLoading || grantStatus.isLoading,
@@ -2,7 +2,10 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
5
- import { GetOAuthGrantStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
5
+ import {
6
+ OAuthConnectionHealth,
7
+ GetOAuthGrantStatusInputSchema,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
6
9
  import { useStigmer } from "../hooks";
7
10
  import { toError } from "../internal/toError";
8
11
 
@@ -19,6 +22,14 @@ export interface UseOAuthGrantStatusReturn {
19
22
  readonly targetEnvVar: string;
20
23
  /** Auth method used (`"mcp_oauth"` or `"vendor_oauth"`), or empty string. */
21
24
  readonly authMethod: string;
25
+ /**
26
+ * Health of the OAuth connection, as evaluated by the backend.
27
+ *
28
+ * Gives the frontend an actionable signal beyond the binary `connected`
29
+ * boolean: healthy, expired-but-refreshable, expired (re-auth needed),
30
+ * or no grant at all. `UNSPECIFIED` when the status has not been fetched.
31
+ */
32
+ readonly connectionHealth: OAuthConnectionHealth;
22
33
  /** `true` while the grant status is being fetched. */
23
34
  readonly isLoading: boolean;
24
35
  /** Error from the last failed request, or `null` when healthy. */
@@ -34,6 +45,7 @@ const IDLE: UseOAuthGrantStatusReturn = {
34
45
  accessTokenExpiresAt: BIGINT_ZERO,
35
46
  targetEnvVar: "",
36
47
  authMethod: "",
48
+ connectionHealth: OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_UNSPECIFIED,
37
49
  isLoading: false,
38
50
  error: null,
39
51
  refetch: () => {},
@@ -67,6 +79,9 @@ export function useOAuthGrantStatus(
67
79
  const [accessTokenExpiresAt, setAccessTokenExpiresAt] = useState(BIGINT_ZERO);
68
80
  const [targetEnvVar, setTargetEnvVar] = useState("");
69
81
  const [authMethod, setAuthMethod] = useState("");
82
+ const [connectionHealth, setConnectionHealth] = useState(
83
+ OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_UNSPECIFIED,
84
+ );
70
85
  const [isLoading, setIsLoading] = useState(false);
71
86
  const [error, setError] = useState<Error | null>(null);
72
87
  const [fetchKey, setFetchKey] = useState(0);
@@ -79,6 +94,7 @@ export function useOAuthGrantStatus(
79
94
  setAccessTokenExpiresAt(BIGINT_ZERO);
80
95
  setTargetEnvVar("");
81
96
  setAuthMethod("");
97
+ setConnectionHealth(OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_UNSPECIFIED);
82
98
  setIsLoading(false);
83
99
  setError(null);
84
100
  return;
@@ -97,6 +113,7 @@ export function useOAuthGrantStatus(
97
113
  setAccessTokenExpiresAt(result.accessTokenExpiresAt);
98
114
  setTargetEnvVar(result.targetEnvVar);
99
115
  setAuthMethod(result.authMethod);
116
+ setConnectionHealth(result.connectionHealth);
100
117
  setIsLoading(false);
101
118
  },
102
119
  (err) => {
@@ -118,6 +135,7 @@ export function useOAuthGrantStatus(
118
135
  accessTokenExpiresAt,
119
136
  targetEnvVar,
120
137
  authMethod,
138
+ connectionHealth,
121
139
  isLoading,
122
140
  error,
123
141
  refetch,