@stigmer/react 0.0.83 → 0.0.85
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/demo/fixtures.d.ts +4 -0
- package/demo/fixtures.d.ts.map +1 -1
- package/demo/fixtures.js +4 -0
- package/demo/fixtures.js.map +1 -1
- package/index.d.ts +5 -3
- package/index.d.ts.map +1 -1
- package/index.js +3 -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/McpServerConnectDialog.d.ts +51 -0
- package/mcp-server/McpServerConnectDialog.d.ts.map +1 -0
- package/mcp-server/McpServerConnectDialog.js +164 -0
- package/mcp-server/McpServerConnectDialog.js.map +1 -0
- 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.d.ts.map +1 -1
- package/mcp-server/McpServerPicker.js +9 -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 +8 -0
- package/mcp-server/index.d.ts.map +1 -1
- package/mcp-server/index.js +4 -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/oauth-app/CreateOAuthAppForm.d.ts +41 -0
- package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -0
- package/oauth-app/CreateOAuthAppForm.js +140 -0
- package/oauth-app/CreateOAuthAppForm.js.map +1 -0
- package/oauth-app/OAuthAppDetailPanel.d.ts +43 -0
- package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -0
- package/oauth-app/OAuthAppDetailPanel.js +202 -0
- package/oauth-app/OAuthAppDetailPanel.js.map +1 -0
- package/oauth-app/OAuthAppListPanel.d.ts +43 -0
- package/oauth-app/OAuthAppListPanel.d.ts.map +1 -0
- package/oauth-app/OAuthAppListPanel.js +79 -0
- package/oauth-app/OAuthAppListPanel.js.map +1 -0
- package/oauth-app/index.d.ts +15 -0
- package/oauth-app/index.d.ts.map +1 -0
- package/oauth-app/index.js +8 -0
- package/oauth-app/index.js.map +1 -0
- package/oauth-app/useCreateOAuthApp.d.ts +39 -0
- package/oauth-app/useCreateOAuthApp.d.ts.map +1 -0
- package/oauth-app/useCreateOAuthApp.js +50 -0
- package/oauth-app/useCreateOAuthApp.js.map +1 -0
- package/oauth-app/useDeleteOAuthApp.d.ts +31 -0
- package/oauth-app/useDeleteOAuthApp.d.ts.map +1 -0
- package/oauth-app/useDeleteOAuthApp.js +43 -0
- package/oauth-app/useDeleteOAuthApp.js.map +1 -0
- package/oauth-app/useOAuthAppList.d.ts +32 -0
- package/oauth-app/useOAuthAppList.d.ts.map +1 -0
- package/oauth-app/useOAuthAppList.js +61 -0
- package/oauth-app/useOAuthAppList.js.map +1 -0
- package/oauth-app/useUpdateOAuthApp.d.ts +38 -0
- package/oauth-app/useUpdateOAuthApp.d.ts.map +1 -0
- package/oauth-app/useUpdateOAuthApp.js +49 -0
- package/oauth-app/useUpdateOAuthApp.js.map +1 -0
- package/package.json +4 -4
- package/src/demo/fixtures.ts +8 -0
- package/src/index.ts +25 -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/McpServerConnectDialog.tsx +527 -0
- package/src/mcp-server/McpServerDetailView.tsx +448 -47
- package/src/mcp-server/McpServerPicker.tsx +10 -3
- package/src/mcp-server/OAuthAppForm.tsx +304 -0
- package/src/mcp-server/index.ts +12 -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/src/oauth-app/CreateOAuthAppForm.tsx +449 -0
- package/src/oauth-app/OAuthAppDetailPanel.tsx +671 -0
- package/src/oauth-app/OAuthAppListPanel.tsx +237 -0
- package/src/oauth-app/index.ts +14 -0
- package/src/oauth-app/useCreateOAuthApp.ts +70 -0
- package/src/oauth-app/useDeleteOAuthApp.ts +62 -0
- package/src/oauth-app/useOAuthAppList.ts +84 -0
- package/src/oauth-app/useUpdateOAuthApp.ts +69 -0
- package/styles.css +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import {
|
|
6
|
+
GetOrgOAuthAppInputSchema,
|
|
7
|
+
SetOrgOAuthAppInputSchema,
|
|
8
|
+
DeleteOrgOAuthAppInputSchema,
|
|
9
|
+
} from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
10
|
+
import { useStigmer } from "../hooks";
|
|
11
|
+
import { toError } from "../internal/toError";
|
|
12
|
+
|
|
13
|
+
/** Return value of {@link useOrgOAuthApp}. */
|
|
14
|
+
export interface UseOrgOAuthAppReturn {
|
|
15
|
+
/** Whether an org-level BYOA override exists for this resource + org. */
|
|
16
|
+
readonly hasOverride: boolean;
|
|
17
|
+
/** System-generated ID of the override's OAuthApp, or `null` when absent. */
|
|
18
|
+
readonly oauthAppId: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Client ID from the override's OAuthApp (non-secret, safe to display).
|
|
21
|
+
* `null` when no override exists. Useful for admin verification of
|
|
22
|
+
* which app registration is active.
|
|
23
|
+
*/
|
|
24
|
+
readonly clientId: string | null;
|
|
25
|
+
/** `true` while the override status is being fetched. */
|
|
26
|
+
readonly isLoading: boolean;
|
|
27
|
+
/** Error from the last failed fetch, or `null` when healthy. */
|
|
28
|
+
readonly error: Error | null;
|
|
29
|
+
/** Discard cached data and re-fetch the override status. */
|
|
30
|
+
readonly refetch: () => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create or update the org's BYOA OAuth app override.
|
|
34
|
+
*
|
|
35
|
+
* The backend clones endpoint URLs from the platform's OAuthApp template,
|
|
36
|
+
* creates an OAuthApp with the org's credentials, and binds it as the
|
|
37
|
+
* override for this resource. On success, call {@link refetch} to
|
|
38
|
+
* reflect the new state.
|
|
39
|
+
*
|
|
40
|
+
* @returns The system-generated ID of the created/updated OAuthApp.
|
|
41
|
+
*/
|
|
42
|
+
readonly setOrgOAuthApp: (
|
|
43
|
+
clientId: string,
|
|
44
|
+
clientSecret: string,
|
|
45
|
+
) => Promise<string>;
|
|
46
|
+
/** `true` while a set operation is in flight. */
|
|
47
|
+
readonly isSetting: boolean;
|
|
48
|
+
/** Error from the last failed set, or `null`. */
|
|
49
|
+
readonly setError: Error | null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove the org's BYOA override for this resource.
|
|
53
|
+
*
|
|
54
|
+
* Deletes both the override binding and the OAuthApp resource created
|
|
55
|
+
* for it. After deletion, the resolution chain falls back to the
|
|
56
|
+
* platform default. Existing user grants that were issued with the
|
|
57
|
+
* org's OAuthApp will fail on next refresh.
|
|
58
|
+
*
|
|
59
|
+
* @returns `true` when the override was removed.
|
|
60
|
+
*/
|
|
61
|
+
readonly deleteOrgOAuthApp: () => Promise<boolean>;
|
|
62
|
+
/** `true` while a delete operation is in flight. */
|
|
63
|
+
readonly isDeleting: boolean;
|
|
64
|
+
/** Error from the last failed delete, or `null`. */
|
|
65
|
+
readonly deleteError: Error | null;
|
|
66
|
+
|
|
67
|
+
/** Reset all mutation error states. */
|
|
68
|
+
readonly clearErrors: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const IDLE: UseOrgOAuthAppReturn = {
|
|
72
|
+
hasOverride: false,
|
|
73
|
+
oauthAppId: null,
|
|
74
|
+
clientId: null,
|
|
75
|
+
isLoading: false,
|
|
76
|
+
error: null,
|
|
77
|
+
refetch: () => {},
|
|
78
|
+
setOrgOAuthApp: () => Promise.resolve(""),
|
|
79
|
+
isSetting: false,
|
|
80
|
+
setError: null,
|
|
81
|
+
deleteOrgOAuthApp: () => Promise.resolve(false),
|
|
82
|
+
isDeleting: false,
|
|
83
|
+
deleteError: null,
|
|
84
|
+
clearErrors: () => {},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hybrid data + behavior hook for managing org-level BYOA OAuth app overrides.
|
|
89
|
+
*
|
|
90
|
+
* **Data side**: Auto-fetches `getOrgOAuthApp` when both parameters are
|
|
91
|
+
* non-null. Returns override existence, OAuthApp ID, and client ID for
|
|
92
|
+
* display. Follows the same fetch-on-mount pattern as
|
|
93
|
+
* {@link useOAuthGrantStatus}.
|
|
94
|
+
*
|
|
95
|
+
* **Behavior side**: Exposes `setOrgOAuthApp` and `deleteOrgOAuthApp`
|
|
96
|
+
* mutations bound to the hook's resource + org context, eliminating
|
|
97
|
+
* parameter repetition at call sites.
|
|
98
|
+
*
|
|
99
|
+
* Pass `null` for either parameter to skip fetching (stable no-op).
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```tsx
|
|
103
|
+
* const orgApp = useOrgOAuthApp(mcpServer?.metadata?.id ?? null, org);
|
|
104
|
+
*
|
|
105
|
+
* if (orgApp.hasOverride) {
|
|
106
|
+
* return <span>Using your OAuth app (client: {orgApp.clientId})</span>;
|
|
107
|
+
* }
|
|
108
|
+
*
|
|
109
|
+
* const handleSubmit = async (clientId: string, clientSecret: string) => {
|
|
110
|
+
* await orgApp.setOrgOAuthApp(clientId, clientSecret);
|
|
111
|
+
* orgApp.refetch();
|
|
112
|
+
* credentials.refetch();
|
|
113
|
+
* };
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export function useOrgOAuthApp(
|
|
117
|
+
resourceId: string | null,
|
|
118
|
+
org: string | null,
|
|
119
|
+
): UseOrgOAuthAppReturn {
|
|
120
|
+
const stigmer = useStigmer();
|
|
121
|
+
|
|
122
|
+
const [hasOverride, setHasOverride] = useState(false);
|
|
123
|
+
const [oauthAppId, setOauthAppId] = useState<string | null>(null);
|
|
124
|
+
const [clientId, setClientId] = useState<string | null>(null);
|
|
125
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
126
|
+
const [error, setError] = useState<Error | null>(null);
|
|
127
|
+
const [fetchKey, setFetchKey] = useState(0);
|
|
128
|
+
|
|
129
|
+
const [isSetting, setIsSetting] = useState(false);
|
|
130
|
+
const [setError_, setSetError] = useState<Error | null>(null);
|
|
131
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
132
|
+
const [deleteError, setDeleteError] = useState<Error | null>(null);
|
|
133
|
+
|
|
134
|
+
const refetch = useCallback(() => setFetchKey((k) => k + 1), []);
|
|
135
|
+
|
|
136
|
+
const clearErrors = useCallback(() => {
|
|
137
|
+
setSetError(null);
|
|
138
|
+
setDeleteError(null);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!resourceId || !org) {
|
|
143
|
+
setHasOverride(false);
|
|
144
|
+
setOauthAppId(null);
|
|
145
|
+
setClientId(null);
|
|
146
|
+
setIsLoading(false);
|
|
147
|
+
setError(null);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const cancelled = { current: false };
|
|
152
|
+
setIsLoading(true);
|
|
153
|
+
setError(null);
|
|
154
|
+
|
|
155
|
+
stigmer.mcpServer
|
|
156
|
+
.getOrgOAuthApp(
|
|
157
|
+
create(GetOrgOAuthAppInputSchema, { resourceId, org }),
|
|
158
|
+
)
|
|
159
|
+
.then(
|
|
160
|
+
(result) => {
|
|
161
|
+
if (cancelled.current) return;
|
|
162
|
+
setHasOverride(result.hasOverride);
|
|
163
|
+
setOauthAppId(result.oauthAppId || null);
|
|
164
|
+
setClientId(result.clientId || null);
|
|
165
|
+
setIsLoading(false);
|
|
166
|
+
},
|
|
167
|
+
(err) => {
|
|
168
|
+
if (cancelled.current) return;
|
|
169
|
+
setError(toError(err));
|
|
170
|
+
setIsLoading(false);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return () => {
|
|
175
|
+
cancelled.current = true;
|
|
176
|
+
};
|
|
177
|
+
}, [resourceId, org, stigmer, fetchKey]);
|
|
178
|
+
|
|
179
|
+
const setOrgOAuthApp = useCallback(
|
|
180
|
+
async (newClientId: string, clientSecret: string): Promise<string> => {
|
|
181
|
+
if (!resourceId || !org) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"useOrgOAuthApp: resourceId and org are required for setOrgOAuthApp",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
setIsSetting(true);
|
|
187
|
+
setSetError(null);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const result = await stigmer.mcpServer.setOrgOAuthApp(
|
|
191
|
+
create(SetOrgOAuthAppInputSchema, {
|
|
192
|
+
resourceId,
|
|
193
|
+
org,
|
|
194
|
+
clientId: newClientId,
|
|
195
|
+
clientSecret,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
return result.oauthAppId;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const wrapped = toError(err);
|
|
201
|
+
setSetError(wrapped);
|
|
202
|
+
throw wrapped;
|
|
203
|
+
} finally {
|
|
204
|
+
setIsSetting(false);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[stigmer, resourceId, org],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const deleteOrgOAuthApp = useCallback(async (): Promise<boolean> => {
|
|
211
|
+
if (!resourceId || !org) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
"useOrgOAuthApp: resourceId and org are required for deleteOrgOAuthApp",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
setIsDeleting(true);
|
|
217
|
+
setDeleteError(null);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const result = await stigmer.mcpServer.deleteOrgOAuthApp(
|
|
221
|
+
create(DeleteOrgOAuthAppInputSchema, { resourceId, org }),
|
|
222
|
+
);
|
|
223
|
+
return result.deleted;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const wrapped = toError(err);
|
|
226
|
+
setDeleteError(wrapped);
|
|
227
|
+
throw wrapped;
|
|
228
|
+
} finally {
|
|
229
|
+
setIsDeleting(false);
|
|
230
|
+
}
|
|
231
|
+
}, [stigmer, resourceId, org]);
|
|
232
|
+
|
|
233
|
+
if (!resourceId || !org) return { ...IDLE, refetch, clearErrors };
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
hasOverride,
|
|
237
|
+
oauthAppId,
|
|
238
|
+
clientId,
|
|
239
|
+
isLoading,
|
|
240
|
+
error,
|
|
241
|
+
refetch,
|
|
242
|
+
setOrgOAuthApp,
|
|
243
|
+
isSetting,
|
|
244
|
+
setError: setError_,
|
|
245
|
+
deleteOrgOAuthApp,
|
|
246
|
+
isDeleting,
|
|
247
|
+
deleteError,
|
|
248
|
+
clearErrors,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState, type FormEvent } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { getUserMessage } from "@stigmer/sdk";
|
|
6
|
+
import type { OAuthApp } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/api_pb";
|
|
7
|
+
import { VendorApprovalStatus } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/spec_pb";
|
|
8
|
+
import { useCreateOAuthApp } from "./useCreateOAuthApp";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Public API
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Props for {@link CreateOAuthAppForm}. */
|
|
15
|
+
export interface CreateOAuthAppFormProps {
|
|
16
|
+
/** Organization slug — the OAuth app will be created in this org. */
|
|
17
|
+
readonly org: string;
|
|
18
|
+
/** Fired with the newly created OAuth app on success. */
|
|
19
|
+
readonly onCreated?: (app: OAuthApp) => void;
|
|
20
|
+
/** Fired when the user cancels creation. */
|
|
21
|
+
readonly onCancel?: () => void;
|
|
22
|
+
/** Additional CSS class names for the root container. */
|
|
23
|
+
readonly className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Form for creating a new OAuth app within an organization.
|
|
28
|
+
*
|
|
29
|
+
* Collects the required OAuth configuration: **name**, **provider**,
|
|
30
|
+
* **client ID**, **client secret**, **authorization URL**, and
|
|
31
|
+
* **token URL**. An expandable "Advanced" section provides optional
|
|
32
|
+
* fields for scopes, userinfo URL, scope parameter name, and vendor
|
|
33
|
+
* approval settings.
|
|
34
|
+
*
|
|
35
|
+
* This is a pure presentational component with no dialog wrapper
|
|
36
|
+
* (headless-first). The parent is responsible for rendering it inside
|
|
37
|
+
* a card, dialog, or inline context as needed.
|
|
38
|
+
*
|
|
39
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <CreateOAuthAppForm
|
|
44
|
+
* org="acme"
|
|
45
|
+
* onCreated={(app) => {
|
|
46
|
+
* refetch();
|
|
47
|
+
* setShowForm(false);
|
|
48
|
+
* }}
|
|
49
|
+
* onCancel={() => setShowForm(false)}
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function CreateOAuthAppForm({
|
|
54
|
+
org,
|
|
55
|
+
onCreated,
|
|
56
|
+
onCancel,
|
|
57
|
+
className,
|
|
58
|
+
}: CreateOAuthAppFormProps) {
|
|
59
|
+
const { create, isCreating, error, clearError } = useCreateOAuthApp();
|
|
60
|
+
|
|
61
|
+
const [name, setName] = useState("");
|
|
62
|
+
const [provider, setProvider] = useState("");
|
|
63
|
+
const [clientId, setClientId] = useState("");
|
|
64
|
+
const [clientSecret, setClientSecret] = useState("");
|
|
65
|
+
const [authorizationUrl, setAuthorizationUrl] = useState("");
|
|
66
|
+
const [tokenUrl, setTokenUrl] = useState("");
|
|
67
|
+
|
|
68
|
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
69
|
+
const [scopes, setScopes] = useState("");
|
|
70
|
+
const [userinfoUrl, setUserinfoUrl] = useState("");
|
|
71
|
+
const [scopeParameterName, setScopeParameterName] = useState("");
|
|
72
|
+
const [vendorApprovalStatus, setVendorApprovalStatus] = useState<
|
|
73
|
+
"unspecified" | "pending" | "approved" | "rejected"
|
|
74
|
+
>("unspecified");
|
|
75
|
+
const [vendorApprovalDocsUrl, setVendorApprovalDocsUrl] = useState("");
|
|
76
|
+
|
|
77
|
+
const trimmedName = name.trim();
|
|
78
|
+
const trimmedProvider = provider.trim();
|
|
79
|
+
const trimmedClientId = clientId.trim();
|
|
80
|
+
const trimmedClientSecret = clientSecret.trim();
|
|
81
|
+
const trimmedAuthUrl = authorizationUrl.trim();
|
|
82
|
+
const trimmedTokenUrl = tokenUrl.trim();
|
|
83
|
+
|
|
84
|
+
const canSubmit =
|
|
85
|
+
trimmedName !== "" &&
|
|
86
|
+
trimmedProvider !== "" &&
|
|
87
|
+
trimmedClientId !== "" &&
|
|
88
|
+
trimmedClientSecret !== "" &&
|
|
89
|
+
trimmedAuthUrl !== "" &&
|
|
90
|
+
trimmedTokenUrl !== "" &&
|
|
91
|
+
!isCreating;
|
|
92
|
+
|
|
93
|
+
const handleSubmit = useCallback(
|
|
94
|
+
async (e: FormEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
if (!canSubmit) return;
|
|
97
|
+
|
|
98
|
+
clearError();
|
|
99
|
+
try {
|
|
100
|
+
const parsedScopes = scopes
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((s) => s.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
|
|
105
|
+
const app = await create({
|
|
106
|
+
name: trimmedName,
|
|
107
|
+
org,
|
|
108
|
+
provider: trimmedProvider,
|
|
109
|
+
clientId: trimmedClientId,
|
|
110
|
+
clientSecret: trimmedClientSecret,
|
|
111
|
+
authorizationUrl: trimmedAuthUrl,
|
|
112
|
+
tokenUrl: trimmedTokenUrl,
|
|
113
|
+
...(parsedScopes.length > 0 && { scopes: parsedScopes }),
|
|
114
|
+
...(userinfoUrl.trim() && { userinfoUrl: userinfoUrl.trim() }),
|
|
115
|
+
...(scopeParameterName.trim() && {
|
|
116
|
+
scopeParameterName: scopeParameterName.trim(),
|
|
117
|
+
}),
|
|
118
|
+
...(vendorApprovalStatus !== "unspecified" && {
|
|
119
|
+
vendorApprovalStatus: APPROVAL_STATUS_MAP[vendorApprovalStatus],
|
|
120
|
+
}),
|
|
121
|
+
...(vendorApprovalDocsUrl.trim() && {
|
|
122
|
+
vendorApprovalDocsUrl: vendorApprovalDocsUrl.trim(),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
onCreated?.(app);
|
|
126
|
+
} catch {
|
|
127
|
+
// error state is managed by useCreateOAuthApp
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[
|
|
131
|
+
canSubmit,
|
|
132
|
+
trimmedName,
|
|
133
|
+
org,
|
|
134
|
+
trimmedProvider,
|
|
135
|
+
trimmedClientId,
|
|
136
|
+
trimmedClientSecret,
|
|
137
|
+
trimmedAuthUrl,
|
|
138
|
+
trimmedTokenUrl,
|
|
139
|
+
scopes,
|
|
140
|
+
userinfoUrl,
|
|
141
|
+
scopeParameterName,
|
|
142
|
+
vendorApprovalStatus,
|
|
143
|
+
vendorApprovalDocsUrl,
|
|
144
|
+
create,
|
|
145
|
+
clearError,
|
|
146
|
+
onCreated,
|
|
147
|
+
],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<form onSubmit={handleSubmit} className={cn("space-y-3", className)}>
|
|
152
|
+
<div className="space-y-3">
|
|
153
|
+
<FormField
|
|
154
|
+
id="stgm-oauth-name"
|
|
155
|
+
label="Name"
|
|
156
|
+
value={name}
|
|
157
|
+
onChange={setName}
|
|
158
|
+
placeholder="e.g. My Slack App"
|
|
159
|
+
disabled={isCreating}
|
|
160
|
+
required
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<FormField
|
|
164
|
+
id="stgm-oauth-provider"
|
|
165
|
+
label="Provider"
|
|
166
|
+
value={provider}
|
|
167
|
+
onChange={setProvider}
|
|
168
|
+
placeholder="e.g. Slack, GitHub, Salesforce"
|
|
169
|
+
hint="Human-readable vendor name for display"
|
|
170
|
+
disabled={isCreating}
|
|
171
|
+
required
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<FormField
|
|
175
|
+
id="stgm-oauth-client-id"
|
|
176
|
+
label="Client ID"
|
|
177
|
+
value={clientId}
|
|
178
|
+
onChange={setClientId}
|
|
179
|
+
placeholder="OAuth client identifier"
|
|
180
|
+
disabled={isCreating}
|
|
181
|
+
required
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
<FormField
|
|
185
|
+
id="stgm-oauth-client-secret"
|
|
186
|
+
label="Client secret"
|
|
187
|
+
value={clientSecret}
|
|
188
|
+
onChange={setClientSecret}
|
|
189
|
+
placeholder="OAuth client secret"
|
|
190
|
+
type="password"
|
|
191
|
+
disabled={isCreating}
|
|
192
|
+
required
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
<FormField
|
|
196
|
+
id="stgm-oauth-auth-url"
|
|
197
|
+
label="Authorization URL"
|
|
198
|
+
value={authorizationUrl}
|
|
199
|
+
onChange={setAuthorizationUrl}
|
|
200
|
+
placeholder="https://vendor.com/oauth/authorize"
|
|
201
|
+
hint="Vendor's OAuth authorization endpoint"
|
|
202
|
+
disabled={isCreating}
|
|
203
|
+
required
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
<FormField
|
|
207
|
+
id="stgm-oauth-token-url"
|
|
208
|
+
label="Token URL"
|
|
209
|
+
value={tokenUrl}
|
|
210
|
+
onChange={setTokenUrl}
|
|
211
|
+
placeholder="https://vendor.com/oauth/token"
|
|
212
|
+
hint="Vendor's OAuth token exchange endpoint"
|
|
213
|
+
disabled={isCreating}
|
|
214
|
+
required
|
|
215
|
+
/>
|
|
216
|
+
|
|
217
|
+
{/* Advanced section — collapsed by default */}
|
|
218
|
+
<div>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => setShowAdvanced((v) => !v)}
|
|
222
|
+
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-[0.65rem] font-medium transition-colors"
|
|
223
|
+
>
|
|
224
|
+
<ChevronIcon expanded={showAdvanced} />
|
|
225
|
+
Advanced settings
|
|
226
|
+
</button>
|
|
227
|
+
|
|
228
|
+
{showAdvanced && (
|
|
229
|
+
<div className="mt-2 space-y-3 border-l-2 border-border/60 pl-3">
|
|
230
|
+
<FormField
|
|
231
|
+
id="stgm-oauth-scopes"
|
|
232
|
+
label="Scopes"
|
|
233
|
+
value={scopes}
|
|
234
|
+
onChange={setScopes}
|
|
235
|
+
placeholder="read, write, admin"
|
|
236
|
+
hint="Comma-separated OAuth scopes to request"
|
|
237
|
+
disabled={isCreating}
|
|
238
|
+
/>
|
|
239
|
+
|
|
240
|
+
<FormField
|
|
241
|
+
id="stgm-oauth-userinfo-url"
|
|
242
|
+
label="Userinfo URL"
|
|
243
|
+
value={userinfoUrl}
|
|
244
|
+
onChange={setUserinfoUrl}
|
|
245
|
+
placeholder="https://vendor.com/userinfo"
|
|
246
|
+
hint="OIDC endpoint for fetching user profile data (optional)"
|
|
247
|
+
disabled={isCreating}
|
|
248
|
+
/>
|
|
249
|
+
|
|
250
|
+
<FormField
|
|
251
|
+
id="stgm-oauth-scope-param"
|
|
252
|
+
label="Scope parameter name"
|
|
253
|
+
value={scopeParameterName}
|
|
254
|
+
onChange={setScopeParameterName}
|
|
255
|
+
placeholder="scope"
|
|
256
|
+
hint='Defaults to "scope". Some vendors use a non-standard name (e.g. "user_scope" for Slack).'
|
|
257
|
+
disabled={isCreating}
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
<div className="space-y-1">
|
|
261
|
+
<label
|
|
262
|
+
htmlFor="stgm-oauth-approval-status"
|
|
263
|
+
className="text-xs font-medium text-foreground"
|
|
264
|
+
>
|
|
265
|
+
Vendor approval status
|
|
266
|
+
</label>
|
|
267
|
+
<select
|
|
268
|
+
id="stgm-oauth-approval-status"
|
|
269
|
+
value={vendorApprovalStatus}
|
|
270
|
+
onChange={(e) =>
|
|
271
|
+
setVendorApprovalStatus(
|
|
272
|
+
e.target.value as typeof vendorApprovalStatus,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
disabled={isCreating}
|
|
276
|
+
className={cn(
|
|
277
|
+
"w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
|
|
278
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
279
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
280
|
+
)}
|
|
281
|
+
>
|
|
282
|
+
<option value="unspecified">Unspecified (treated as approved)</option>
|
|
283
|
+
<option value="pending">Pending</option>
|
|
284
|
+
<option value="approved">Approved</option>
|
|
285
|
+
<option value="rejected">Rejected</option>
|
|
286
|
+
</select>
|
|
287
|
+
<p className="text-[0.65rem] text-muted-foreground">
|
|
288
|
+
Vendor marketplace approval lifecycle status
|
|
289
|
+
</p>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<FormField
|
|
293
|
+
id="stgm-oauth-approval-docs"
|
|
294
|
+
label="Vendor approval docs URL"
|
|
295
|
+
value={vendorApprovalDocsUrl}
|
|
296
|
+
onChange={setVendorApprovalDocsUrl}
|
|
297
|
+
placeholder="https://docs.example.com/byoa"
|
|
298
|
+
hint="Help link shown when vendor approval is pending"
|
|
299
|
+
disabled={isCreating}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{error && (
|
|
307
|
+
<p className="text-destructive text-[0.65rem]" role="alert">
|
|
308
|
+
{getUserMessage(error)}
|
|
309
|
+
</p>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
<div className="flex items-center gap-2">
|
|
313
|
+
<button
|
|
314
|
+
type="submit"
|
|
315
|
+
disabled={!canSubmit}
|
|
316
|
+
className={cn(
|
|
317
|
+
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
|
|
318
|
+
"bg-primary text-primary-foreground hover:bg-primary/90",
|
|
319
|
+
"disabled:pointer-events-none disabled:opacity-40",
|
|
320
|
+
)}
|
|
321
|
+
>
|
|
322
|
+
{isCreating && <SpinnerIcon />}
|
|
323
|
+
Create OAuth app
|
|
324
|
+
</button>
|
|
325
|
+
|
|
326
|
+
{onCancel && (
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
onClick={onCancel}
|
|
330
|
+
disabled={isCreating}
|
|
331
|
+
className={cn(
|
|
332
|
+
"rounded-md px-3 py-1.5 text-xs",
|
|
333
|
+
"text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
334
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
335
|
+
)}
|
|
336
|
+
>
|
|
337
|
+
Cancel
|
|
338
|
+
</button>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
</form>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Constants
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
const APPROVAL_STATUS_MAP = {
|
|
350
|
+
pending: VendorApprovalStatus.PENDING,
|
|
351
|
+
approved: VendorApprovalStatus.APPROVED,
|
|
352
|
+
rejected: VendorApprovalStatus.REJECTED,
|
|
353
|
+
} as const;
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// FormField (internal)
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
function FormField({
|
|
360
|
+
id,
|
|
361
|
+
label,
|
|
362
|
+
value,
|
|
363
|
+
onChange,
|
|
364
|
+
placeholder,
|
|
365
|
+
hint,
|
|
366
|
+
type = "text",
|
|
367
|
+
disabled,
|
|
368
|
+
required,
|
|
369
|
+
}: {
|
|
370
|
+
id: string;
|
|
371
|
+
label: string;
|
|
372
|
+
value: string;
|
|
373
|
+
onChange: (v: string) => void;
|
|
374
|
+
placeholder: string;
|
|
375
|
+
hint?: string;
|
|
376
|
+
type?: "text" | "password";
|
|
377
|
+
disabled: boolean;
|
|
378
|
+
required?: boolean;
|
|
379
|
+
}) {
|
|
380
|
+
return (
|
|
381
|
+
<div className="space-y-1">
|
|
382
|
+
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
|
383
|
+
{label}
|
|
384
|
+
</label>
|
|
385
|
+
<input
|
|
386
|
+
id={id}
|
|
387
|
+
type={type}
|
|
388
|
+
value={value}
|
|
389
|
+
onChange={(e) => onChange(e.target.value)}
|
|
390
|
+
placeholder={placeholder}
|
|
391
|
+
disabled={disabled}
|
|
392
|
+
required={required}
|
|
393
|
+
className={cn(
|
|
394
|
+
"w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
|
|
395
|
+
"placeholder:text-muted-foreground",
|
|
396
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
397
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
398
|
+
)}
|
|
399
|
+
/>
|
|
400
|
+
{hint && (
|
|
401
|
+
<p className="text-[0.65rem] text-muted-foreground">{hint}</p>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Icons
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
|
412
|
+
return (
|
|
413
|
+
<svg
|
|
414
|
+
width="10"
|
|
415
|
+
height="10"
|
|
416
|
+
viewBox="0 0 16 16"
|
|
417
|
+
fill="none"
|
|
418
|
+
stroke="currentColor"
|
|
419
|
+
strokeWidth="2"
|
|
420
|
+
strokeLinecap="round"
|
|
421
|
+
strokeLinejoin="round"
|
|
422
|
+
aria-hidden="true"
|
|
423
|
+
className={cn(
|
|
424
|
+
"shrink-0 transition-transform",
|
|
425
|
+
expanded && "rotate-90",
|
|
426
|
+
)}
|
|
427
|
+
>
|
|
428
|
+
<path d="M6 4l4 4-4 4" />
|
|
429
|
+
</svg>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function SpinnerIcon() {
|
|
434
|
+
return (
|
|
435
|
+
<svg
|
|
436
|
+
width="12"
|
|
437
|
+
height="12"
|
|
438
|
+
viewBox="0 0 16 16"
|
|
439
|
+
fill="none"
|
|
440
|
+
stroke="currentColor"
|
|
441
|
+
strokeWidth="2"
|
|
442
|
+
strokeLinecap="round"
|
|
443
|
+
className="animate-spin"
|
|
444
|
+
aria-hidden="true"
|
|
445
|
+
>
|
|
446
|
+
<path d="M8 2a6 6 0 1 0 6 6" />
|
|
447
|
+
</svg>
|
|
448
|
+
);
|
|
449
|
+
}
|