@yackey-labs/yauth-ui-solidjs 0.12.3 → 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.
- package/dist/index.js +1990 -404
- package/package.json +3 -3
- package/src/components/audit-destination-create.tsx +410 -0
- package/src/components/audit-destination-list.test.tsx +87 -0
- package/src/components/audit-destination-list.tsx +134 -0
- package/src/components/domain-claim.tsx +121 -0
- package/src/components/domain-list.tsx +94 -0
- package/src/components/domain-verify-step.tsx +81 -0
- package/src/components/invitation-accept.tsx +82 -0
- package/src/components/invite-form.tsx +108 -0
- package/src/components/member-list.tsx +214 -0
- package/src/components/organization-card.tsx +45 -0
- package/src/components/organization-create.tsx +147 -0
- package/src/components/organization-detail.tsx +68 -0
- package/src/components/organization-list.tsx +54 -0
- package/src/components/organization-switcher.tsx +139 -0
- package/src/components/role-selector.tsx +32 -0
- package/src/components/saml-connection-form.tsx +348 -0
- package/src/components/saml-login-button.tsx +53 -0
- package/src/components/scim-settings-panel.tsx +191 -0
- package/src/components/sso-connection-form.tsx +265 -0
- package/src/components/sso-connection-list.tsx +158 -0
- package/src/components/sso-login-button.tsx +46 -0
- package/src/components/transfer-ownership.tsx +122 -0
- package/src/hooks/create-active-org.ts +98 -0
- package/src/hooks/create-audit-destinations.ts +179 -0
- package/src/hooks/create-organizations.ts +495 -0
- package/src/hooks/create-sso-connections.ts +132 -0
- package/src/index.ts +43 -0
|
@@ -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:<uuid></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 <key></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><your-scim-key></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
|
+
}
|