@yackey-labs/yauth-ui-solidjs 0.12.2 → 0.12.4

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.
@@ -0,0 +1,32 @@
1
+ import { type Component } from "solid-js";
2
+ import { ROLES } from "../hooks/create-organizations";
3
+
4
+ export interface RoleSelectorProps {
5
+ value: string;
6
+ disabled?: boolean;
7
+ onChange: (value: string) => void;
8
+ }
9
+
10
+ /**
11
+ * Dropdown for picking a built-in org role.
12
+ *
13
+ * `owner` is intentionally omitted — the documented promotion path is
14
+ * `TransferOwnership`, and the server-side change-role endpoint rejects
15
+ * an `owner` payload with 409.
16
+ */
17
+ export const RoleSelector: Component<RoleSelectorProps> = (props) => {
18
+ return (
19
+ <select
20
+ value={props.value}
21
+ disabled={props.disabled}
22
+ class="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
23
+ aria-label="Member role"
24
+ onChange={(e) => props.onChange(e.currentTarget.value)}
25
+ >
26
+ <option value={ROLES.ADMIN}>Admin</option>
27
+ <option value={ROLES.BILLING_ADMIN}>Billing admin</option>
28
+ <option value={ROLES.MEMBER}>Member</option>
29
+ <option value={ROLES.VIEWER}>Viewer</option>
30
+ </select>
31
+ );
32
+ };
@@ -0,0 +1,348 @@
1
+ import type {
2
+ CreateSsoConnectionRequest,
3
+ SsoConnectionResponse,
4
+ } from "@yackey-labs/yauth-client";
5
+ import { For, Show, createMemo, createSignal } from "solid-js";
6
+ import { createSsoConnections } from "../hooks/create-sso-connections";
7
+
8
+ /**
9
+ * SAML 2.0 SP connection creation form (issue #94, Phase B).
10
+ *
11
+ * Mirror of `SamlConnectionForm.vue` — drafts a new SAML SP
12
+ * connection; the admin flips it to `active` after testing.
13
+ *
14
+ * Field shape mirrors `crates/yauth/src/plugins/organizations.rs`'s
15
+ * `SamlConfigInput`. `sp_entity_id` / `sp_acs_url` are server-derived
16
+ * and surfaced on the returned `SsoConnectionResponse.saml` blob; the
17
+ * admin then pastes those into the IdP's "Audience URI" / "ACS URL"
18
+ * fields (or imports the SP metadata XML via the connection list).
19
+ */
20
+ export interface SamlConnectionFormProps {
21
+ organizationId: string;
22
+ onSuccess?: (created: SsoConnectionResponse) => void;
23
+ onError?: (error: Error) => void;
24
+ }
25
+
26
+ export function SamlConnectionForm(props: SamlConnectionFormProps) {
27
+ const { create, error } = createSsoConnections(() => props.organizationId);
28
+
29
+ const [name, setName] = createSignal("");
30
+ const [idpEntityId, setIdpEntityId] = createSignal("");
31
+ const [idpSsoUrl, setIdpSsoUrl] = createSignal("");
32
+ const [idpSloUrl, setIdpSloUrl] = createSignal("");
33
+ const [idpX509Cert, setIdpX509Cert] = createSignal("");
34
+ const [spPrivateKey, setSpPrivateKey] = createSignal("");
35
+ const [emailAttr, setEmailAttr] = createSignal(
36
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
37
+ );
38
+ const [displayNameAttr, setDisplayNameAttr] = createSignal(
39
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
40
+ );
41
+ const [externalIdAttr, setExternalIdAttr] = createSignal("NameID");
42
+ const [groupsAttr, setGroupsAttr] = createSignal(
43
+ "http://schemas.xmlsoap.org/claims/Group",
44
+ );
45
+ const [groupRoles, setGroupRoles] = createSignal<
46
+ Array<{ group: string; role: string }>
47
+ >([{ group: "", role: "member" }]);
48
+ const [assertionSignedRequired, setAssertionSignedRequired] = createSignal(true);
49
+ const [responseSignedRequired, setResponseSignedRequired] = createSignal(true);
50
+ const [wantEncryptedAssertions, setWantEncryptedAssertions] = createSignal(false);
51
+ const [idpInitiatedSsoAllowed, setIdpInitiatedSsoAllowed] = createSignal(false);
52
+ const [jitEnabled, setJitEnabled] = createSignal(true);
53
+ const [defaultRole, setDefaultRole] = createSignal("member");
54
+ const [submitting, setSubmitting] = createSignal(false);
55
+
56
+ const isValid = createMemo(
57
+ () =>
58
+ name().trim() !== "" &&
59
+ idpEntityId().trim() !== "" &&
60
+ idpSsoUrl().trim().startsWith("http") &&
61
+ idpX509Cert().includes("BEGIN CERTIFICATE"),
62
+ );
63
+
64
+ const updateGroupRow = (
65
+ i: number,
66
+ patch: Partial<{ group: string; role: string }>,
67
+ ) => {
68
+ setGroupRoles((rows) =>
69
+ rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)),
70
+ );
71
+ };
72
+
73
+ const handleSubmit = async (e: Event) => {
74
+ e.preventDefault();
75
+ if (!isValid() || submitting()) return;
76
+ setSubmitting(true);
77
+ const groupMap: Record<string, string> = {};
78
+ for (const { group, role } of groupRoles()) {
79
+ const g = group.trim();
80
+ if (g) groupMap[g] = role;
81
+ }
82
+ const req: CreateSsoConnectionRequest = {
83
+ name: name().trim(),
84
+ kind: "saml_sp",
85
+ saml: {
86
+ idp_entity_id: idpEntityId().trim(),
87
+ idp_sso_url: idpSsoUrl().trim(),
88
+ idp_slo_url: idpSloUrl().trim() || null,
89
+ idp_x509_cert: idpX509Cert().trim(),
90
+ sp_private_key: spPrivateKey().trim() || null,
91
+ idp_initiated_sso_allowed: idpInitiatedSsoAllowed(),
92
+ assertion_signed_required: assertionSignedRequired(),
93
+ response_signed_required: responseSignedRequired(),
94
+ want_encrypted_assertions: wantEncryptedAssertions(),
95
+ attribute_mappings: {
96
+ email: emailAttr().trim(),
97
+ display_name: displayNameAttr().trim() || null,
98
+ external_id: externalIdAttr().trim() || "NameID",
99
+ groups: groupsAttr().trim() || null,
100
+ group_to_role: groupMap,
101
+ },
102
+ },
103
+ jit_provisioning_enabled: jitEnabled(),
104
+ default_role_on_jit: defaultRole(),
105
+ };
106
+ const result = await create(req);
107
+ setSubmitting(false);
108
+ if (result) {
109
+ props.onSuccess?.(result);
110
+ } else if (error()) {
111
+ props.onError?.(new Error(error() ?? "create failed"));
112
+ }
113
+ };
114
+
115
+ return (
116
+ <form class="space-y-4" onSubmit={handleSubmit}>
117
+ <Show when={error()}>
118
+ <div
119
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
120
+ role="alert"
121
+ >
122
+ {error()}
123
+ </div>
124
+ </Show>
125
+ <div class="grid gap-3 md:grid-cols-2">
126
+ <label class="block">
127
+ <span class="text-xs font-medium">Name</span>
128
+ <input
129
+ value={name()}
130
+ onInput={(e) => setName(e.currentTarget.value)}
131
+ required
132
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2"
133
+ placeholder="Acme Okta SAML"
134
+ />
135
+ </label>
136
+ <label class="block">
137
+ <span class="text-xs font-medium">IdP Entity ID</span>
138
+ <input
139
+ value={idpEntityId()}
140
+ onInput={(e) => setIdpEntityId(e.currentTarget.value)}
141
+ required
142
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
143
+ placeholder="urn:idp:acme:saml or https://acme.okta.com/..."
144
+ />
145
+ </label>
146
+ <label class="block md:col-span-2">
147
+ <span class="text-xs font-medium">IdP Single Sign-On URL</span>
148
+ <input
149
+ value={idpSsoUrl()}
150
+ onInput={(e) => setIdpSsoUrl(e.currentTarget.value)}
151
+ required
152
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
153
+ placeholder="https://acme.okta.com/app/.../sso/saml"
154
+ />
155
+ </label>
156
+ <label class="block md:col-span-2">
157
+ <span class="text-xs font-medium">
158
+ IdP Single Logout URL (optional)
159
+ </span>
160
+ <input
161
+ value={idpSloUrl()}
162
+ onInput={(e) => setIdpSloUrl(e.currentTarget.value)}
163
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
164
+ placeholder="https://acme.okta.com/app/.../slo/saml"
165
+ />
166
+ </label>
167
+ <label class="block md:col-span-2">
168
+ <span class="text-xs font-medium">
169
+ IdP X.509 Signing Certificate (PEM)
170
+ </span>
171
+ <textarea
172
+ value={idpX509Cert()}
173
+ onInput={(e) => setIdpX509Cert(e.currentTarget.value)}
174
+ required
175
+ rows="6"
176
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
177
+ placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
178
+ />
179
+ </label>
180
+ <label class="block md:col-span-2">
181
+ <span class="text-xs font-medium">
182
+ SP Private Key (PEM) — optional, only for SP signing or encrypted
183
+ assertions
184
+ </span>
185
+ <textarea
186
+ value={spPrivateKey()}
187
+ onInput={(e) => setSpPrivateKey(e.currentTarget.value)}
188
+ rows="4"
189
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
190
+ placeholder="-----BEGIN PRIVATE KEY-----"
191
+ />
192
+ </label>
193
+ </div>
194
+
195
+ <fieldset class="rounded-md border p-3">
196
+ <legend class="px-1 text-xs font-medium">Attribute mappings</legend>
197
+ <div class="grid gap-3 md:grid-cols-2">
198
+ <label class="block">
199
+ <span class="text-xs">email attribute</span>
200
+ <input
201
+ value={emailAttr()}
202
+ onInput={(e) => setEmailAttr(e.currentTarget.value)}
203
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
204
+ />
205
+ </label>
206
+ <label class="block">
207
+ <span class="text-xs">display_name attribute</span>
208
+ <input
209
+ value={displayNameAttr()}
210
+ onInput={(e) => setDisplayNameAttr(e.currentTarget.value)}
211
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
212
+ />
213
+ </label>
214
+ <label class="block">
215
+ <span class="text-xs">external_id (default: NameID)</span>
216
+ <input
217
+ value={externalIdAttr()}
218
+ onInput={(e) => setExternalIdAttr(e.currentTarget.value)}
219
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
220
+ />
221
+ </label>
222
+ <label class="block">
223
+ <span class="text-xs">groups attribute</span>
224
+ <input
225
+ value={groupsAttr()}
226
+ onInput={(e) => setGroupsAttr(e.currentTarget.value)}
227
+ class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
228
+ />
229
+ </label>
230
+ </div>
231
+ <div class="mt-3 space-y-2">
232
+ <div class="text-xs font-medium">group → role</div>
233
+ <For each={groupRoles()}>
234
+ {(row, i) => (
235
+ <div class="flex items-center gap-2">
236
+ <input
237
+ value={row.group}
238
+ onInput={(e) =>
239
+ updateGroupRow(i(), { group: e.currentTarget.value })
240
+ }
241
+ placeholder="group name"
242
+ class="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
243
+ />
244
+ <select
245
+ value={row.role}
246
+ onChange={(e) => updateGroupRow(i(), { role: e.currentTarget.value })}
247
+ class="rounded-md border bg-background px-3 py-2 text-sm"
248
+ >
249
+ <option value="owner">owner</option>
250
+ <option value="admin">admin</option>
251
+ <option value="member">member</option>
252
+ </select>
253
+ <button
254
+ type="button"
255
+ class="rounded-md border px-2 py-1 text-xs"
256
+ onClick={() =>
257
+ setGroupRoles((rows) => rows.filter((_, idx) => idx !== i()))
258
+ }
259
+ >
260
+ x
261
+ </button>
262
+ </div>
263
+ )}
264
+ </For>
265
+ <button
266
+ type="button"
267
+ class="rounded-md border px-3 py-1 text-xs"
268
+ onClick={() =>
269
+ setGroupRoles((rows) => [...rows, { group: "", role: "member" }])
270
+ }
271
+ >
272
+ + Add mapping
273
+ </button>
274
+ </div>
275
+ </fieldset>
276
+
277
+ <fieldset class="rounded-md border p-3">
278
+ <legend class="px-1 text-xs font-medium">Signing requirements</legend>
279
+ <div class="space-y-2">
280
+ <label class="flex items-center gap-2 text-xs">
281
+ <input
282
+ type="checkbox"
283
+ checked={assertionSignedRequired()}
284
+ onChange={(e) => setAssertionSignedRequired(e.currentTarget.checked)}
285
+ />
286
+ Require assertion signature (recommended)
287
+ </label>
288
+ <label class="flex items-center gap-2 text-xs">
289
+ <input
290
+ type="checkbox"
291
+ checked={responseSignedRequired()}
292
+ onChange={(e) => setResponseSignedRequired(e.currentTarget.checked)}
293
+ />
294
+ Require response signature (recommended)
295
+ </label>
296
+ <label class="flex items-center gap-2 text-xs">
297
+ <input
298
+ type="checkbox"
299
+ checked={wantEncryptedAssertions()}
300
+ onChange={(e) => setWantEncryptedAssertions(e.currentTarget.checked)}
301
+ />
302
+ Require encrypted assertions (requires SP private key)
303
+ </label>
304
+ <label class="flex items-center gap-2 text-xs">
305
+ <input
306
+ type="checkbox"
307
+ checked={idpInitiatedSsoAllowed()}
308
+ onChange={(e) => setIdpInitiatedSsoAllowed(e.currentTarget.checked)}
309
+ />
310
+ Allow IdP-initiated SSO (cross-tenant footgun — leave OFF unless
311
+ the IdP sets <code>RelayState=cid:&lt;uuid&gt;</code>)
312
+ </label>
313
+ </div>
314
+ </fieldset>
315
+
316
+ <div class="flex items-center gap-4">
317
+ <label class="flex items-center gap-2 text-xs">
318
+ <input
319
+ type="checkbox"
320
+ checked={jitEnabled()}
321
+ onChange={(e) => setJitEnabled(e.currentTarget.checked)}
322
+ />
323
+ JIT provisioning
324
+ </label>
325
+ <label class="flex items-center gap-2 text-xs">
326
+ Default role:
327
+ <select
328
+ value={defaultRole()}
329
+ onChange={(e) => setDefaultRole(e.currentTarget.value)}
330
+ class="rounded-md border bg-background px-2 py-1"
331
+ >
332
+ <option value="owner">owner</option>
333
+ <option value="admin">admin</option>
334
+ <option value="member">member</option>
335
+ </select>
336
+ </label>
337
+ </div>
338
+
339
+ <button
340
+ type="submit"
341
+ disabled={!isValid() || submitting()}
342
+ class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
343
+ >
344
+ {submitting() ? "Saving..." : "Create SAML connection"}
345
+ </button>
346
+ </form>
347
+ );
348
+ }
@@ -0,0 +1,53 @@
1
+ import { Show, createMemo } from "solid-js";
2
+ import { useYAuth } from "../provider";
3
+
4
+ /**
5
+ * "Sign in via SAML" button (issue #94, Phase B).
6
+ *
7
+ * Drives the user-facing SAML federated login flow by redirecting to
8
+ * `/sso/saml/login?org=<slug>&redirect_to=<path>`. Renders nothing if
9
+ * neither `orgSlug` nor `domain` is provided.
10
+ */
11
+ export interface SamlLoginButtonProps {
12
+ /** Org slug for the explicit-org login path. */
13
+ orgSlug?: string;
14
+ /** Email-domain for the HRD path (e.g. `acme.com`). */
15
+ domain?: string;
16
+ /** Display label override. Default: "Sign in via SAML". */
17
+ label?: string;
18
+ /** Where to redirect after a successful sign-in. */
19
+ redirectTo?: string;
20
+ }
21
+
22
+ export function SamlLoginButton(props: SamlLoginButtonProps) {
23
+ const yauth = useYAuth();
24
+ const url = createMemo(() =>
25
+ yauth.client.sso.samlLoginUrl({
26
+ org: props.orgSlug,
27
+ domain: props.domain,
28
+ redirectTo: props.redirectTo,
29
+ }),
30
+ );
31
+ const canSignIn = createMemo(() => Boolean(props.orgSlug || props.domain));
32
+
33
+ return (
34
+ <Show when={canSignIn()}>
35
+ <a
36
+ href={url()}
37
+ class="inline-flex items-center justify-center rounded-md border bg-background px-4 py-2 text-sm font-medium hover:bg-accent"
38
+ >
39
+ <svg
40
+ class="mr-2 h-4 w-4"
41
+ viewBox="0 0 24 24"
42
+ fill="none"
43
+ stroke="currentColor"
44
+ stroke-width="2"
45
+ >
46
+ <title>SAML</title>
47
+ <path d="M21 2H3a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zM7 17l5-5-5-5M13 17h4" />
48
+ </svg>
49
+ {props.label ?? "Sign in via SAML"}
50
+ </a>
51
+ </Show>
52
+ );
53
+ }
@@ -0,0 +1,191 @@
1
+ import { createMemo, createSignal } from "solid-js";
2
+
3
+ /**
4
+ * SCIM settings panel (issue #95).
5
+ *
6
+ * SolidJS mirror of the Vue ScimSettingsPanel. Surfaces the per-org
7
+ * SCIM Base URL and the bound API key requirement to org admins, with
8
+ * copy-pasteable curl snippets an IdP admin can run to verify the
9
+ * connector before wiring it into Okta / Entra / OneLogin.
10
+ *
11
+ * Authentication: SCIM endpoints use Authorization: Bearer <key> where
12
+ * <key> is an org-scoped API key (issue #91) with the `scim:read` and
13
+ * `scim:write` scopes. This component does NOT mint the key — it links
14
+ * to the API Keys panel. We never display the plaintext here.
15
+ */
16
+ export interface ScimSettingsPanelProps {
17
+ organizationId: string;
18
+ /** yauth base URL without trailing slash. */
19
+ baseUrl: string;
20
+ /** Route to the API Keys panel; defaults to a sensible path. */
21
+ apiKeysRoute?: string;
22
+ }
23
+
24
+ function stripTrailingSlashes(url: string): string {
25
+ let i = url.length;
26
+ while (i > 0 && url[i - 1] === "/") i--;
27
+ return url.slice(0, i);
28
+ }
29
+
30
+ export function ScimSettingsPanel(props: ScimSettingsPanelProps) {
31
+ const apiKeysRoute = createMemo(
32
+ () =>
33
+ props.apiKeysRoute ??
34
+ `/organizations/${props.organizationId}/settings/api-keys`,
35
+ );
36
+ const scimBaseUrl = createMemo(
37
+ () =>
38
+ `${stripTrailingSlashes(props.baseUrl)}/api/scim/v2/organizations/${props.organizationId}`,
39
+ );
40
+ const curlList = createMemo(
41
+ () => `curl -H "Authorization: Bearer <your-scim-key>" \\
42
+ -H "Accept: application/scim+json" \\
43
+ "${scimBaseUrl()}/Users?count=10"`,
44
+ );
45
+ const curlConfig = createMemo(
46
+ () => `curl -H "Authorization: Bearer <your-scim-key>" \\
47
+ -H "Accept: application/scim+json" \\
48
+ "${scimBaseUrl()}/ServiceProviderConfig"`,
49
+ );
50
+
51
+ type CopyKey = "url" | "curl-list" | "curl-config";
52
+ const [copied, setCopied] = createSignal<CopyKey | null>(null);
53
+ const copy = async (value: string, label: CopyKey) => {
54
+ try {
55
+ await navigator.clipboard.writeText(value);
56
+ setCopied(label);
57
+ setTimeout(() => {
58
+ if (copied() === label) setCopied(null);
59
+ }, 1500);
60
+ } catch {
61
+ // Silent no-op outside HTTPS.
62
+ }
63
+ };
64
+
65
+ return (
66
+ <section class="space-y-6">
67
+ <header>
68
+ <h2 class="text-base font-semibold">SCIM provisioning</h2>
69
+ <p class="mt-1 text-sm text-muted-foreground">
70
+ Paste the SCIM Base URL and an org-scoped API key into your IdP
71
+ (Okta, Entra ID, OneLogin) so it can provision users into this
72
+ organization.
73
+ </p>
74
+ </header>
75
+
76
+ <div class="space-y-2">
77
+ <label class="block text-xs font-medium uppercase tracking-wider text-muted-foreground">
78
+ SCIM Base URL
79
+ </label>
80
+ <div class="flex gap-2">
81
+ <code class="flex-1 truncate rounded-md border bg-muted px-3 py-2 text-xs">
82
+ {scimBaseUrl()}
83
+ </code>
84
+ <button
85
+ type="button"
86
+ class="rounded-md border px-3 py-1 text-xs"
87
+ onClick={() => copy(scimBaseUrl(), "url")}
88
+ >
89
+ {copied() === "url" ? "Copied" : "Copy"}
90
+ </button>
91
+ </div>
92
+ </div>
93
+
94
+ <div
95
+ class="rounded-md border bg-muted/30 p-4 text-sm"
96
+ role="region"
97
+ aria-label="API key requirement"
98
+ >
99
+ <div class="mb-2 font-medium">Authentication</div>
100
+ <p class="text-muted-foreground">
101
+ SCIM uses an <strong>org-scoped API key</strong> with the
102
+ {" "}
103
+ <code>scim:read</code> and <code>scim:write</code> scopes. The IdP
104
+ sends it as <code>Authorization: Bearer &lt;key&gt;</code> on every
105
+ request.
106
+ </p>
107
+ <div class="mt-3">
108
+ <a
109
+ href={apiKeysRoute()}
110
+ class="inline-flex items-center rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground"
111
+ >
112
+ Manage API keys →
113
+ </a>
114
+ </div>
115
+ <p class="mt-3 text-xs text-muted-foreground">
116
+ The plaintext key is shown{" "}
117
+ <strong>only at the moment of creation</strong>. We never display it
118
+ here or in the IdP-side view — only the prefix and the {" "}
119
+ <code>scim:*</code> scopes.
120
+ </p>
121
+ </div>
122
+
123
+ <div class="space-y-3">
124
+ <h3 class="text-sm font-medium">Verify with curl</h3>
125
+ <p class="text-xs text-muted-foreground">
126
+ Replace <code>&lt;your-scim-key&gt;</code> with the plaintext API key
127
+ you copied during key creation.
128
+ </p>
129
+
130
+ <div class="space-y-2">
131
+ <label class="block text-xs font-medium uppercase tracking-wider text-muted-foreground">
132
+ List users
133
+ </label>
134
+ <div class="flex gap-2">
135
+ <pre class="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2 text-xs whitespace-pre">
136
+ {curlList()}
137
+ </pre>
138
+ <button
139
+ type="button"
140
+ class="rounded-md border px-3 py-1 text-xs self-start"
141
+ onClick={() => copy(curlList(), "curl-list")}
142
+ >
143
+ {copied() === "curl-list" ? "Copied" : "Copy"}
144
+ </button>
145
+ </div>
146
+ </div>
147
+
148
+ <div class="space-y-2">
149
+ <label class="block text-xs font-medium uppercase tracking-wider text-muted-foreground">
150
+ Discover capabilities
151
+ </label>
152
+ <div class="flex gap-2">
153
+ <pre class="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2 text-xs whitespace-pre">
154
+ {curlConfig()}
155
+ </pre>
156
+ <button
157
+ type="button"
158
+ class="rounded-md border px-3 py-1 text-xs self-start"
159
+ onClick={() => copy(curlConfig(), "curl-config")}
160
+ >
161
+ {copied() === "curl-config" ? "Copied" : "Copy"}
162
+ </button>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ <div class="rounded-md border bg-muted/30 p-4 text-xs text-muted-foreground">
168
+ <div class="mb-1 font-medium text-foreground">
169
+ Per-IdP setup guides
170
+ </div>
171
+ <ul class="ml-4 list-disc space-y-1">
172
+ <li>
173
+ <a class="underline" href="/docs/scim/okta.md" target="_blank">
174
+ Okta
175
+ </a>
176
+ </li>
177
+ <li>
178
+ <a class="underline" href="/docs/scim/entra.md" target="_blank">
179
+ Microsoft Entra ID
180
+ </a>
181
+ </li>
182
+ <li>
183
+ <a class="underline" href="/docs/scim/onelogin.md" target="_blank">
184
+ OneLogin
185
+ </a>
186
+ </li>
187
+ </ul>
188
+ </div>
189
+ </section>
190
+ );
191
+ }