@stigmer/react 0.0.82 → 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.
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/library/ResourceListView.d.ts +57 -7
- package/library/ResourceListView.d.ts.map +1 -1
- package/library/ResourceListView.js +147 -37
- package/library/ResourceListView.js.map +1 -1
- package/library/index.d.ts +1 -1
- package/library/index.d.ts.map +1 -1
- package/library/index.js.map +1 -1
- package/mcp-server/McpServerConfigPanel.d.ts +45 -0
- package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
- package/mcp-server/McpServerConfigPanel.js +90 -14
- package/mcp-server/McpServerConfigPanel.js.map +1 -1
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +168 -23
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/mcp-server/McpServerPicker.js +3 -3
- package/mcp-server/McpServerPicker.js.map +1 -1
- package/mcp-server/OAuthAppForm.d.ts +58 -0
- package/mcp-server/OAuthAppForm.d.ts.map +1 -0
- package/mcp-server/OAuthAppForm.js +67 -0
- package/mcp-server/OAuthAppForm.js.map +1 -0
- package/mcp-server/index.d.ts +6 -0
- package/mcp-server/index.d.ts.map +1 -1
- package/mcp-server/index.js +3 -0
- package/mcp-server/index.js.map +1 -1
- package/mcp-server/useDisconnectOAuth.d.ts +40 -0
- package/mcp-server/useDisconnectOAuth.d.ts.map +1 -0
- package/mcp-server/useDisconnectOAuth.js +46 -0
- package/mcp-server/useDisconnectOAuth.js.map +1 -0
- package/mcp-server/useMcpServerCredentials.d.ts +48 -0
- package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
- package/mcp-server/useMcpServerCredentials.js +18 -2
- package/mcp-server/useMcpServerCredentials.js.map +1 -1
- package/mcp-server/useOAuthGrantStatus.d.ts +9 -0
- package/mcp-server/useOAuthGrantStatus.d.ts.map +1 -1
- package/mcp-server/useOAuthGrantStatus.js +6 -1
- package/mcp-server/useOAuthGrantStatus.js.map +1 -1
- package/mcp-server/useOrgOAuthApp.d.ts +82 -0
- package/mcp-server/useOrgOAuthApp.d.ts.map +1 -0
- package/mcp-server/useOrgOAuthApp.js +160 -0
- package/mcp-server/useOrgOAuthApp.js.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +3 -0
- package/src/library/ResourceListView.tsx +303 -46
- package/src/library/index.ts +4 -1
- package/src/mcp-server/McpServerConfigPanel.tsx +370 -45
- package/src/mcp-server/McpServerDetailView.tsx +447 -47
- package/src/mcp-server/McpServerPicker.tsx +3 -3
- package/src/mcp-server/OAuthAppForm.tsx +304 -0
- package/src/mcp-server/index.ts +9 -0
- package/src/mcp-server/useDisconnectOAuth.ts +76 -0
- package/src/mcp-server/useMcpServerCredentials.ts +70 -2
- package/src/mcp-server/useOAuthGrantStatus.ts +19 -1
- package/src/mcp-server/useOrgOAuthApp.ts +250 -0
- 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
|
+
}
|
package/src/mcp-server/index.ts
CHANGED
|
@@ -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
|
-
|
|
209
|
-
const
|
|
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 {
|
|
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,
|