@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.
Files changed (109) hide show
  1. package/demo/fixtures.d.ts +4 -0
  2. package/demo/fixtures.d.ts.map +1 -1
  3. package/demo/fixtures.js +4 -0
  4. package/demo/fixtures.js.map +1 -1
  5. package/index.d.ts +5 -3
  6. package/index.d.ts.map +1 -1
  7. package/index.js +3 -1
  8. package/index.js.map +1 -1
  9. package/library/ResourceListView.d.ts +57 -7
  10. package/library/ResourceListView.d.ts.map +1 -1
  11. package/library/ResourceListView.js +147 -37
  12. package/library/ResourceListView.js.map +1 -1
  13. package/library/index.d.ts +1 -1
  14. package/library/index.d.ts.map +1 -1
  15. package/library/index.js.map +1 -1
  16. package/mcp-server/McpServerConfigPanel.d.ts +45 -0
  17. package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
  18. package/mcp-server/McpServerConfigPanel.js +90 -14
  19. package/mcp-server/McpServerConfigPanel.js.map +1 -1
  20. package/mcp-server/McpServerConnectDialog.d.ts +51 -0
  21. package/mcp-server/McpServerConnectDialog.d.ts.map +1 -0
  22. package/mcp-server/McpServerConnectDialog.js +164 -0
  23. package/mcp-server/McpServerConnectDialog.js.map +1 -0
  24. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  25. package/mcp-server/McpServerDetailView.js +168 -23
  26. package/mcp-server/McpServerDetailView.js.map +1 -1
  27. package/mcp-server/McpServerPicker.d.ts.map +1 -1
  28. package/mcp-server/McpServerPicker.js +9 -3
  29. package/mcp-server/McpServerPicker.js.map +1 -1
  30. package/mcp-server/OAuthAppForm.d.ts +58 -0
  31. package/mcp-server/OAuthAppForm.d.ts.map +1 -0
  32. package/mcp-server/OAuthAppForm.js +67 -0
  33. package/mcp-server/OAuthAppForm.js.map +1 -0
  34. package/mcp-server/index.d.ts +8 -0
  35. package/mcp-server/index.d.ts.map +1 -1
  36. package/mcp-server/index.js +4 -0
  37. package/mcp-server/index.js.map +1 -1
  38. package/mcp-server/useDisconnectOAuth.d.ts +40 -0
  39. package/mcp-server/useDisconnectOAuth.d.ts.map +1 -0
  40. package/mcp-server/useDisconnectOAuth.js +46 -0
  41. package/mcp-server/useDisconnectOAuth.js.map +1 -0
  42. package/mcp-server/useMcpServerCredentials.d.ts +48 -0
  43. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  44. package/mcp-server/useMcpServerCredentials.js +18 -2
  45. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  46. package/mcp-server/useOAuthGrantStatus.d.ts +9 -0
  47. package/mcp-server/useOAuthGrantStatus.d.ts.map +1 -1
  48. package/mcp-server/useOAuthGrantStatus.js +6 -1
  49. package/mcp-server/useOAuthGrantStatus.js.map +1 -1
  50. package/mcp-server/useOrgOAuthApp.d.ts +82 -0
  51. package/mcp-server/useOrgOAuthApp.d.ts.map +1 -0
  52. package/mcp-server/useOrgOAuthApp.js +160 -0
  53. package/mcp-server/useOrgOAuthApp.js.map +1 -0
  54. package/oauth-app/CreateOAuthAppForm.d.ts +41 -0
  55. package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -0
  56. package/oauth-app/CreateOAuthAppForm.js +140 -0
  57. package/oauth-app/CreateOAuthAppForm.js.map +1 -0
  58. package/oauth-app/OAuthAppDetailPanel.d.ts +43 -0
  59. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -0
  60. package/oauth-app/OAuthAppDetailPanel.js +202 -0
  61. package/oauth-app/OAuthAppDetailPanel.js.map +1 -0
  62. package/oauth-app/OAuthAppListPanel.d.ts +43 -0
  63. package/oauth-app/OAuthAppListPanel.d.ts.map +1 -0
  64. package/oauth-app/OAuthAppListPanel.js +79 -0
  65. package/oauth-app/OAuthAppListPanel.js.map +1 -0
  66. package/oauth-app/index.d.ts +15 -0
  67. package/oauth-app/index.d.ts.map +1 -0
  68. package/oauth-app/index.js +8 -0
  69. package/oauth-app/index.js.map +1 -0
  70. package/oauth-app/useCreateOAuthApp.d.ts +39 -0
  71. package/oauth-app/useCreateOAuthApp.d.ts.map +1 -0
  72. package/oauth-app/useCreateOAuthApp.js +50 -0
  73. package/oauth-app/useCreateOAuthApp.js.map +1 -0
  74. package/oauth-app/useDeleteOAuthApp.d.ts +31 -0
  75. package/oauth-app/useDeleteOAuthApp.d.ts.map +1 -0
  76. package/oauth-app/useDeleteOAuthApp.js +43 -0
  77. package/oauth-app/useDeleteOAuthApp.js.map +1 -0
  78. package/oauth-app/useOAuthAppList.d.ts +32 -0
  79. package/oauth-app/useOAuthAppList.d.ts.map +1 -0
  80. package/oauth-app/useOAuthAppList.js +61 -0
  81. package/oauth-app/useOAuthAppList.js.map +1 -0
  82. package/oauth-app/useUpdateOAuthApp.d.ts +38 -0
  83. package/oauth-app/useUpdateOAuthApp.d.ts.map +1 -0
  84. package/oauth-app/useUpdateOAuthApp.js +49 -0
  85. package/oauth-app/useUpdateOAuthApp.js.map +1 -0
  86. package/package.json +4 -4
  87. package/src/demo/fixtures.ts +8 -0
  88. package/src/index.ts +25 -0
  89. package/src/library/ResourceListView.tsx +303 -46
  90. package/src/library/index.ts +4 -1
  91. package/src/mcp-server/McpServerConfigPanel.tsx +370 -45
  92. package/src/mcp-server/McpServerConnectDialog.tsx +527 -0
  93. package/src/mcp-server/McpServerDetailView.tsx +448 -47
  94. package/src/mcp-server/McpServerPicker.tsx +10 -3
  95. package/src/mcp-server/OAuthAppForm.tsx +304 -0
  96. package/src/mcp-server/index.ts +12 -0
  97. package/src/mcp-server/useDisconnectOAuth.ts +76 -0
  98. package/src/mcp-server/useMcpServerCredentials.ts +70 -2
  99. package/src/mcp-server/useOAuthGrantStatus.ts +19 -1
  100. package/src/mcp-server/useOrgOAuthApp.ts +250 -0
  101. package/src/oauth-app/CreateOAuthAppForm.tsx +449 -0
  102. package/src/oauth-app/OAuthAppDetailPanel.tsx +671 -0
  103. package/src/oauth-app/OAuthAppListPanel.tsx +237 -0
  104. package/src/oauth-app/index.ts +14 -0
  105. package/src/oauth-app/useCreateOAuthApp.ts +70 -0
  106. package/src/oauth-app/useDeleteOAuthApp.ts +62 -0
  107. package/src/oauth-app/useOAuthAppList.ts +84 -0
  108. package/src/oauth-app/useUpdateOAuthApp.ts +69 -0
  109. 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
+ }