@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.
- 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,265 @@
|
|
|
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
|
+
* SSO OIDC connection creation form (issue #93, Phase B).
|
|
10
|
+
*
|
|
11
|
+
* Mirror of the Vue component. Drafts a new connection; admin
|
|
12
|
+
* flips it to `active` after testing.
|
|
13
|
+
*/
|
|
14
|
+
export interface SsoConnectionFormProps {
|
|
15
|
+
organizationId: string;
|
|
16
|
+
onSuccess?: (created: SsoConnectionResponse) => void;
|
|
17
|
+
onError?: (error: Error) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SsoConnectionForm(props: SsoConnectionFormProps) {
|
|
21
|
+
const { create, error } = createSsoConnections(() => props.organizationId);
|
|
22
|
+
|
|
23
|
+
const [name, setName] = createSignal("");
|
|
24
|
+
const [discoveryUrl, setDiscoveryUrl] = createSignal("");
|
|
25
|
+
const [clientId, setClientId] = createSignal("");
|
|
26
|
+
const [clientSecret, setClientSecret] = createSignal("");
|
|
27
|
+
const [scopes, setScopes] = createSignal("openid, email, profile");
|
|
28
|
+
const [externalIdClaim, setExternalIdClaim] = createSignal("sub");
|
|
29
|
+
const [emailClaim, setEmailClaim] = createSignal("email");
|
|
30
|
+
const [displayNameClaim, setDisplayNameClaim] = createSignal("name");
|
|
31
|
+
const [groupsClaim, setGroupsClaim] = createSignal("groups");
|
|
32
|
+
const [groupRoles, setGroupRoles] = createSignal<
|
|
33
|
+
Array<{ group: string; role: string }>
|
|
34
|
+
>([{ group: "", role: "member" }]);
|
|
35
|
+
const [jitEnabled, setJitEnabled] = createSignal(true);
|
|
36
|
+
const [defaultRole, setDefaultRole] = createSignal("member");
|
|
37
|
+
const [submitting, setSubmitting] = createSignal(false);
|
|
38
|
+
|
|
39
|
+
const isValid = createMemo(
|
|
40
|
+
() =>
|
|
41
|
+
name().trim() !== "" &&
|
|
42
|
+
discoveryUrl().includes(".well-known/openid-configuration") &&
|
|
43
|
+
clientId().trim() !== "" &&
|
|
44
|
+
clientSecret().trim() !== "",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const updateGroupRow = (i: number, patch: Partial<{ group: string; role: string }>) => {
|
|
48
|
+
setGroupRoles((rows) =>
|
|
49
|
+
rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)),
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleSubmit = async (e: Event) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
if (!isValid() || submitting()) return;
|
|
56
|
+
setSubmitting(true);
|
|
57
|
+
const groupMap: Record<string, string> = {};
|
|
58
|
+
for (const { group, role } of groupRoles()) {
|
|
59
|
+
const g = group.trim();
|
|
60
|
+
if (g) groupMap[g] = role;
|
|
61
|
+
}
|
|
62
|
+
const req: CreateSsoConnectionRequest = {
|
|
63
|
+
name: name().trim(),
|
|
64
|
+
kind: "oidc_client",
|
|
65
|
+
oidc: {
|
|
66
|
+
discovery_url: discoveryUrl().trim(),
|
|
67
|
+
client_id: clientId().trim(),
|
|
68
|
+
client_secret: clientSecret(),
|
|
69
|
+
scopes: scopes()
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean),
|
|
73
|
+
claim_mappings: {
|
|
74
|
+
external_id: externalIdClaim().trim() || "sub",
|
|
75
|
+
email: emailClaim().trim() || "email",
|
|
76
|
+
display_name: displayNameClaim().trim() || null,
|
|
77
|
+
groups: groupsClaim().trim() || null,
|
|
78
|
+
group_to_role: groupMap,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
jit_provisioning_enabled: jitEnabled(),
|
|
82
|
+
default_role_on_jit: defaultRole(),
|
|
83
|
+
};
|
|
84
|
+
const result = await create(req);
|
|
85
|
+
setSubmitting(false);
|
|
86
|
+
if (result) {
|
|
87
|
+
props.onSuccess?.(result);
|
|
88
|
+
} else if (error()) {
|
|
89
|
+
props.onError?.(new Error(error() ?? "create failed"));
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<form class="space-y-4" onSubmit={handleSubmit}>
|
|
95
|
+
<Show when={error()}>
|
|
96
|
+
<div
|
|
97
|
+
class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
98
|
+
role="alert"
|
|
99
|
+
>
|
|
100
|
+
{error()}
|
|
101
|
+
</div>
|
|
102
|
+
</Show>
|
|
103
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
104
|
+
<label class="block">
|
|
105
|
+
<span class="text-xs font-medium">Name</span>
|
|
106
|
+
<input
|
|
107
|
+
value={name()}
|
|
108
|
+
onInput={(e) => setName(e.currentTarget.value)}
|
|
109
|
+
required
|
|
110
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
111
|
+
placeholder="Acme Okta"
|
|
112
|
+
/>
|
|
113
|
+
</label>
|
|
114
|
+
<label class="block">
|
|
115
|
+
<span class="text-xs font-medium">Discovery URL</span>
|
|
116
|
+
<input
|
|
117
|
+
value={discoveryUrl()}
|
|
118
|
+
onInput={(e) => setDiscoveryUrl(e.currentTarget.value)}
|
|
119
|
+
required
|
|
120
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
|
121
|
+
placeholder="https://idp.example/.well-known/openid-configuration"
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
<label class="block">
|
|
125
|
+
<span class="text-xs font-medium">Client ID</span>
|
|
126
|
+
<input
|
|
127
|
+
value={clientId()}
|
|
128
|
+
onInput={(e) => setClientId(e.currentTarget.value)}
|
|
129
|
+
required
|
|
130
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
131
|
+
/>
|
|
132
|
+
</label>
|
|
133
|
+
<label class="block">
|
|
134
|
+
<span class="text-xs font-medium">Client Secret</span>
|
|
135
|
+
<input
|
|
136
|
+
value={clientSecret()}
|
|
137
|
+
onInput={(e) => setClientSecret(e.currentTarget.value)}
|
|
138
|
+
type="password"
|
|
139
|
+
required
|
|
140
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
141
|
+
/>
|
|
142
|
+
</label>
|
|
143
|
+
<label class="block md:col-span-2">
|
|
144
|
+
<span class="text-xs font-medium">Scopes (comma-separated)</span>
|
|
145
|
+
<input
|
|
146
|
+
value={scopes()}
|
|
147
|
+
onInput={(e) => setScopes(e.currentTarget.value)}
|
|
148
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
149
|
+
/>
|
|
150
|
+
</label>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<fieldset class="rounded-md border p-3">
|
|
154
|
+
<legend class="px-1 text-xs font-medium">Claim mappings</legend>
|
|
155
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
156
|
+
<label class="block">
|
|
157
|
+
<span class="text-xs">external_id</span>
|
|
158
|
+
<input
|
|
159
|
+
value={externalIdClaim()}
|
|
160
|
+
onInput={(e) => setExternalIdClaim(e.currentTarget.value)}
|
|
161
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
162
|
+
/>
|
|
163
|
+
</label>
|
|
164
|
+
<label class="block">
|
|
165
|
+
<span class="text-xs">email</span>
|
|
166
|
+
<input
|
|
167
|
+
value={emailClaim()}
|
|
168
|
+
onInput={(e) => setEmailClaim(e.currentTarget.value)}
|
|
169
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
170
|
+
/>
|
|
171
|
+
</label>
|
|
172
|
+
<label class="block">
|
|
173
|
+
<span class="text-xs">display_name</span>
|
|
174
|
+
<input
|
|
175
|
+
value={displayNameClaim()}
|
|
176
|
+
onInput={(e) => setDisplayNameClaim(e.currentTarget.value)}
|
|
177
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
178
|
+
/>
|
|
179
|
+
</label>
|
|
180
|
+
<label class="block">
|
|
181
|
+
<span class="text-xs">groups</span>
|
|
182
|
+
<input
|
|
183
|
+
value={groupsClaim()}
|
|
184
|
+
onInput={(e) => setGroupsClaim(e.currentTarget.value)}
|
|
185
|
+
class="mt-1 w-full rounded-md border bg-background px-3 py-2"
|
|
186
|
+
/>
|
|
187
|
+
</label>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="mt-3 space-y-2">
|
|
190
|
+
<div class="text-xs font-medium">group → role</div>
|
|
191
|
+
<For each={groupRoles()}>
|
|
192
|
+
{(row, i) => (
|
|
193
|
+
<div class="flex items-center gap-2">
|
|
194
|
+
<input
|
|
195
|
+
value={row.group}
|
|
196
|
+
onInput={(e) => updateGroupRow(i(), { group: e.currentTarget.value })}
|
|
197
|
+
placeholder="group name"
|
|
198
|
+
class="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
|
199
|
+
/>
|
|
200
|
+
<select
|
|
201
|
+
value={row.role}
|
|
202
|
+
onChange={(e) => updateGroupRow(i(), { role: e.currentTarget.value })}
|
|
203
|
+
class="rounded-md border bg-background px-3 py-2 text-sm"
|
|
204
|
+
>
|
|
205
|
+
<option value="owner">owner</option>
|
|
206
|
+
<option value="admin">admin</option>
|
|
207
|
+
<option value="member">member</option>
|
|
208
|
+
</select>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
class="rounded-md border px-2 py-1 text-xs"
|
|
212
|
+
onClick={() =>
|
|
213
|
+
setGroupRoles((rows) => rows.filter((_, idx) => idx !== i()))
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
✕
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</For>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
class="rounded-md border px-3 py-1 text-xs"
|
|
224
|
+
onClick={() =>
|
|
225
|
+
setGroupRoles((rows) => [...rows, { group: "", role: "member" }])
|
|
226
|
+
}
|
|
227
|
+
>
|
|
228
|
+
+ Add mapping
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</fieldset>
|
|
232
|
+
|
|
233
|
+
<div class="flex items-center gap-4">
|
|
234
|
+
<label class="flex items-center gap-2 text-xs">
|
|
235
|
+
<input
|
|
236
|
+
type="checkbox"
|
|
237
|
+
checked={jitEnabled()}
|
|
238
|
+
onChange={(e) => setJitEnabled(e.currentTarget.checked)}
|
|
239
|
+
/>
|
|
240
|
+
JIT provisioning
|
|
241
|
+
</label>
|
|
242
|
+
<label class="flex items-center gap-2 text-xs">
|
|
243
|
+
Default role:
|
|
244
|
+
<select
|
|
245
|
+
value={defaultRole()}
|
|
246
|
+
onChange={(e) => setDefaultRole(e.currentTarget.value)}
|
|
247
|
+
class="rounded-md border bg-background px-2 py-1"
|
|
248
|
+
>
|
|
249
|
+
<option value="owner">owner</option>
|
|
250
|
+
<option value="admin">admin</option>
|
|
251
|
+
<option value="member">member</option>
|
|
252
|
+
</select>
|
|
253
|
+
</label>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<button
|
|
257
|
+
type="submit"
|
|
258
|
+
disabled={!isValid() || submitting()}
|
|
259
|
+
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
|
260
|
+
>
|
|
261
|
+
{submitting() ? "Saving…" : "Create connection"}
|
|
262
|
+
</button>
|
|
263
|
+
</form>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { SamlConfigResponse } from "@yackey-labs/yauth-client";
|
|
2
|
+
import { For, Show } from "solid-js";
|
|
3
|
+
import { createSsoConnections } from "../hooks/create-sso-connections";
|
|
4
|
+
import { useYAuth } from "../provider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SSO connection list (issue #93 + #94, Phase B).
|
|
8
|
+
*
|
|
9
|
+
* Mirror of the Vue component — admin view of one org's federation
|
|
10
|
+
* connections (OIDC and SAML) with per-row test/enable/disable/delete
|
|
11
|
+
* actions. SAML rows additionally expose a "Download SP metadata"
|
|
12
|
+
* link pointing at `/sso/saml/metadata/{cid}`.
|
|
13
|
+
*/
|
|
14
|
+
export interface SsoConnectionListProps {
|
|
15
|
+
organizationId: string;
|
|
16
|
+
onTest?: (id: string, ok: boolean, detail: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const signingSummary = (saml: SamlConfigResponse) => {
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
if (saml.assertion_signed_required) parts.push("assertion signed");
|
|
22
|
+
if (saml.response_signed_required) parts.push("response signed");
|
|
23
|
+
if (saml.want_encrypted_assertions) parts.push("encrypted");
|
|
24
|
+
if (saml.idp_initiated_sso_allowed) parts.push("IdP-init allowed");
|
|
25
|
+
return parts.length > 0 ? parts.join(" · ") : "no signing required";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function SsoConnectionList(props: SsoConnectionListProps) {
|
|
29
|
+
const { connections, loading, error, disable, enable, remove, test } =
|
|
30
|
+
createSsoConnections(() => props.organizationId);
|
|
31
|
+
const yauth = useYAuth();
|
|
32
|
+
|
|
33
|
+
const handleTest = async (id: string) => {
|
|
34
|
+
const r = await test(id);
|
|
35
|
+
if (r) props.onTest?.(id, r.ok, r.detail);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const samlMetadataUrl = (id: string) => yauth.client.sso.samlMetadataUrl(id);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div class="space-y-4">
|
|
42
|
+
<Show when={loading()}>
|
|
43
|
+
<div class="text-sm text-muted-foreground">Loading…</div>
|
|
44
|
+
</Show>
|
|
45
|
+
<Show when={error()}>
|
|
46
|
+
<div
|
|
47
|
+
class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
48
|
+
role="alert"
|
|
49
|
+
>
|
|
50
|
+
{error()}
|
|
51
|
+
</div>
|
|
52
|
+
</Show>
|
|
53
|
+
<Show
|
|
54
|
+
when={!loading() && connections().length > 0}
|
|
55
|
+
fallback={
|
|
56
|
+
<Show when={!loading()}>
|
|
57
|
+
<div class="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
|
58
|
+
No SSO connections yet. Add one to enable federated sign-in for
|
|
59
|
+
this organization.
|
|
60
|
+
</div>
|
|
61
|
+
</Show>
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<ul class="space-y-3">
|
|
65
|
+
<For each={connections()}>
|
|
66
|
+
{(c) => (
|
|
67
|
+
<li class="rounded-md border bg-card p-4">
|
|
68
|
+
<div class="flex items-start justify-between gap-4">
|
|
69
|
+
<div>
|
|
70
|
+
<div class="font-medium">{c.name}</div>
|
|
71
|
+
<div class="text-xs text-muted-foreground">
|
|
72
|
+
{c.kind} ·{" "}
|
|
73
|
+
<span
|
|
74
|
+
classList={{
|
|
75
|
+
"text-emerald-600": c.status === "active",
|
|
76
|
+
"text-amber-600": c.status === "draft",
|
|
77
|
+
"text-muted-foreground": c.status === "disabled",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{c.status}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
<Show when={c.oidc}>
|
|
84
|
+
<div class="mt-1 text-xs text-muted-foreground">
|
|
85
|
+
client_id: <code>{c.oidc?.client_id}</code>
|
|
86
|
+
</div>
|
|
87
|
+
</Show>
|
|
88
|
+
<Show when={c.saml}>
|
|
89
|
+
{(saml) => (
|
|
90
|
+
<div class="mt-1 space-y-1">
|
|
91
|
+
<div class="text-xs text-muted-foreground">
|
|
92
|
+
IdP entity: <code>{saml().idp_entity_id}</code>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="text-xs text-muted-foreground">
|
|
95
|
+
SP entity: <code>{saml().sp_entity_id}</code>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="text-xs text-muted-foreground">
|
|
98
|
+
{signingSummary(saml())}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</Show>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="flex flex-wrap gap-2">
|
|
105
|
+
<Show when={c.saml}>
|
|
106
|
+
<a
|
|
107
|
+
href={samlMetadataUrl(c.id)}
|
|
108
|
+
download={`sp-metadata-${c.id}.xml`}
|
|
109
|
+
class="rounded-md border px-3 py-1 text-xs hover:bg-accent"
|
|
110
|
+
data-testid="saml-metadata-download"
|
|
111
|
+
>
|
|
112
|
+
SP metadata
|
|
113
|
+
</a>
|
|
114
|
+
</Show>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
class="rounded-md border px-3 py-1 text-xs"
|
|
118
|
+
onClick={() => handleTest(c.id)}
|
|
119
|
+
>
|
|
120
|
+
Test
|
|
121
|
+
</button>
|
|
122
|
+
<Show
|
|
123
|
+
when={c.status === "active"}
|
|
124
|
+
fallback={
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
class="rounded-md border px-3 py-1 text-xs"
|
|
128
|
+
onClick={() => enable(c.id)}
|
|
129
|
+
>
|
|
130
|
+
Enable
|
|
131
|
+
</button>
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
class="rounded-md border px-3 py-1 text-xs"
|
|
137
|
+
onClick={() => disable(c.id)}
|
|
138
|
+
>
|
|
139
|
+
Disable
|
|
140
|
+
</button>
|
|
141
|
+
</Show>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
class="rounded-md border border-destructive px-3 py-1 text-xs text-destructive"
|
|
145
|
+
onClick={() => remove(c.id)}
|
|
146
|
+
>
|
|
147
|
+
Delete
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</li>
|
|
152
|
+
)}
|
|
153
|
+
</For>
|
|
154
|
+
</ul>
|
|
155
|
+
</Show>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Show, createMemo } from "solid-js";
|
|
2
|
+
import { useYAuth } from "../provider";
|
|
3
|
+
|
|
4
|
+
export interface SsoLoginButtonProps {
|
|
5
|
+
/** Org slug for the explicit-org login path. */
|
|
6
|
+
orgSlug?: string;
|
|
7
|
+
/** Email-domain for the HRD path (e.g. `acme.com`). */
|
|
8
|
+
domain?: string;
|
|
9
|
+
/** Display label override. Default: "Sign in with SSO". */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Where to redirect after a successful sign-in. */
|
|
12
|
+
redirectTo?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SsoLoginButton(props: SsoLoginButtonProps) {
|
|
16
|
+
const yauth = useYAuth();
|
|
17
|
+
const url = createMemo(() =>
|
|
18
|
+
yauth.client.sso.loginUrl({
|
|
19
|
+
org: props.orgSlug,
|
|
20
|
+
domain: props.domain,
|
|
21
|
+
redirectTo: props.redirectTo,
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
const canSignIn = createMemo(() => Boolean(props.orgSlug || props.domain));
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Show when={canSignIn()}>
|
|
28
|
+
<a
|
|
29
|
+
href={url()}
|
|
30
|
+
class="inline-flex items-center justify-center rounded-md border bg-background px-4 py-2 text-sm font-medium hover:bg-accent"
|
|
31
|
+
>
|
|
32
|
+
<svg
|
|
33
|
+
class="mr-2 h-4 w-4"
|
|
34
|
+
viewBox="0 0 24 24"
|
|
35
|
+
fill="none"
|
|
36
|
+
stroke="currentColor"
|
|
37
|
+
stroke-width="2"
|
|
38
|
+
>
|
|
39
|
+
<title>SSO</title>
|
|
40
|
+
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
|
41
|
+
</svg>
|
|
42
|
+
{props.label ?? "Sign in with SSO"}
|
|
43
|
+
</a>
|
|
44
|
+
</Show>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type Component, For, Show, createMemo, createSignal } from "solid-js";
|
|
2
|
+
import { ROLES, createMembers, createOrgRoles } from "../hooks/create-organizations";
|
|
3
|
+
|
|
4
|
+
export interface TransferOwnershipProps {
|
|
5
|
+
organizationId: string;
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onSuccess?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Modal-style component for transferring org ownership to another
|
|
13
|
+
* member.
|
|
14
|
+
*/
|
|
15
|
+
export const TransferOwnership: Component<TransferOwnershipProps> = (props) => {
|
|
16
|
+
const { members, refetch: refetchMembers } = createMembers(() => props.organizationId);
|
|
17
|
+
const { submitting, error, transferOwnership } = createOrgRoles(() => props.organizationId);
|
|
18
|
+
const [selectedUserId, setSelectedUserId] = createSignal("");
|
|
19
|
+
const [confirmed, setConfirmed] = createSignal(false);
|
|
20
|
+
|
|
21
|
+
const eligibleMembers = createMemo(() => members().filter((m) => m.role !== ROLES.OWNER));
|
|
22
|
+
|
|
23
|
+
const close = () => {
|
|
24
|
+
setSelectedUserId("");
|
|
25
|
+
setConfirmed(false);
|
|
26
|
+
props.onClose();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const submit = async () => {
|
|
30
|
+
if (!selectedUserId() || !confirmed()) return;
|
|
31
|
+
const ok = await transferOwnership({
|
|
32
|
+
new_owner_user_id: selectedUserId(),
|
|
33
|
+
});
|
|
34
|
+
if (ok) {
|
|
35
|
+
props.onSuccess?.();
|
|
36
|
+
await refetchMembers();
|
|
37
|
+
close();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Show when={props.open}>
|
|
43
|
+
<div
|
|
44
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
45
|
+
role="dialog"
|
|
46
|
+
aria-modal="true"
|
|
47
|
+
aria-labelledby="transfer-ownership-title"
|
|
48
|
+
>
|
|
49
|
+
<div class="w-full max-w-md rounded-lg border border-input bg-background p-6 shadow-lg">
|
|
50
|
+
<h2 id="transfer-ownership-title" class="mb-2 text-lg font-semibold">
|
|
51
|
+
Transfer ownership
|
|
52
|
+
</h2>
|
|
53
|
+
<p class="mb-4 text-sm text-muted-foreground">
|
|
54
|
+
Choose a member to promote to owner. You will be demoted to admin. This cannot be undone
|
|
55
|
+
without another transfer.
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<Show when={error()}>
|
|
59
|
+
<div
|
|
60
|
+
class="mb-3 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
61
|
+
role="alert"
|
|
62
|
+
>
|
|
63
|
+
{error()}
|
|
64
|
+
</div>
|
|
65
|
+
</Show>
|
|
66
|
+
|
|
67
|
+
<label class="mb-2 block text-sm font-medium" for="successor-select">
|
|
68
|
+
New owner
|
|
69
|
+
</label>
|
|
70
|
+
<select
|
|
71
|
+
id="successor-select"
|
|
72
|
+
value={selectedUserId()}
|
|
73
|
+
disabled={submitting()}
|
|
74
|
+
onChange={(e) => setSelectedUserId(e.currentTarget.value)}
|
|
75
|
+
class="mb-4 w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
76
|
+
>
|
|
77
|
+
<option value="" disabled>
|
|
78
|
+
Select a member…
|
|
79
|
+
</option>
|
|
80
|
+
<For each={eligibleMembers()}>
|
|
81
|
+
{(m) => (
|
|
82
|
+
<option value={m.user_id}>
|
|
83
|
+
{m.user_id} ({m.role})
|
|
84
|
+
</option>
|
|
85
|
+
)}
|
|
86
|
+
</For>
|
|
87
|
+
</select>
|
|
88
|
+
|
|
89
|
+
<label class="mb-4 flex items-start gap-2 text-sm">
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
checked={confirmed()}
|
|
93
|
+
disabled={submitting()}
|
|
94
|
+
onChange={(e) => setConfirmed(e.currentTarget.checked)}
|
|
95
|
+
class="mt-0.5"
|
|
96
|
+
/>
|
|
97
|
+
<span>I understand I will lose owner privileges in this organization.</span>
|
|
98
|
+
</label>
|
|
99
|
+
|
|
100
|
+
<div class="flex justify-end gap-2">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
class="rounded-md border border-input px-3 py-1.5 text-sm hover:bg-secondary"
|
|
104
|
+
disabled={submitting()}
|
|
105
|
+
onClick={close}
|
|
106
|
+
>
|
|
107
|
+
Cancel
|
|
108
|
+
</button>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
112
|
+
disabled={!selectedUserId() || !confirmed() || submitting()}
|
|
113
|
+
onClick={submit}
|
|
114
|
+
>
|
|
115
|
+
{submitting() ? "Transferring…" : "Transfer ownership"}
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</Show>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless hook for the active-organization claim (issue #89).
|
|
3
|
+
*
|
|
4
|
+
* Exposes the currently-active org, the full membership list, and an
|
|
5
|
+
* imperative `switchTo(orgId)` action.
|
|
6
|
+
*
|
|
7
|
+
* Carrier semantics (cookie vs bearer) are abstracted from callers:
|
|
8
|
+
* - Cookie sessions update server-side; no token rotation needed.
|
|
9
|
+
* - Bearer JWT clients receive a freshly-issued token via
|
|
10
|
+
* `bearer_access_token` on the response; callers are responsible for
|
|
11
|
+
* adopting it (e.g. via the YAuth client mutator).
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
ActiveOrgEntry,
|
|
15
|
+
ActiveOrgResponse,
|
|
16
|
+
SetActiveOrgRequest,
|
|
17
|
+
} from "@yackey-labs/yauth-client";
|
|
18
|
+
import { createSignal } from "solid-js";
|
|
19
|
+
import { useYAuth } from "../provider";
|
|
20
|
+
|
|
21
|
+
export function createActiveOrg() {
|
|
22
|
+
const { client } = useYAuth();
|
|
23
|
+
const [activeOrgId, setActiveOrgId] = createSignal<string | null>(null);
|
|
24
|
+
const [orgs, setOrgs] = createSignal<ActiveOrgEntry[]>([]);
|
|
25
|
+
const [loading, setLoading] = createSignal(false);
|
|
26
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
27
|
+
|
|
28
|
+
const apply = (resp: ActiveOrgResponse) => {
|
|
29
|
+
setActiveOrgId(resp.active_org_id ?? null);
|
|
30
|
+
setOrgs(resp.orgs);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const refetch = async () => {
|
|
34
|
+
if (!client?.organizations) {
|
|
35
|
+
setError("Organizations feature is not enabled on this server.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setLoading(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
apply(await client.organizations.getActiveOrg());
|
|
42
|
+
} catch (err) {
|
|
43
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Switch into an organization the caller is a member of.
|
|
51
|
+
*
|
|
52
|
+
* Returns the new bearer access token when the caller used JWT auth —
|
|
53
|
+
* the client adopter is responsible for surfacing it onto subsequent
|
|
54
|
+
* requests. Cookie callers receive `null` since no rotation occurs.
|
|
55
|
+
*/
|
|
56
|
+
const switchTo = async (orgId: string): Promise<string | null> => {
|
|
57
|
+
if (!client?.organizations) {
|
|
58
|
+
setError("Organizations feature is not enabled on this server.");
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
setLoading(true);
|
|
62
|
+
setError(null);
|
|
63
|
+
try {
|
|
64
|
+
const body: SetActiveOrgRequest = { organization_id: orgId };
|
|
65
|
+
const resp = await client.organizations.setActiveOrg(body);
|
|
66
|
+
apply(resp);
|
|
67
|
+
return resp.bearer_access_token ?? null;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
70
|
+
return null;
|
|
71
|
+
} finally {
|
|
72
|
+
setLoading(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const clear = async (): Promise<string | null> => {
|
|
77
|
+
if (!client?.organizations) {
|
|
78
|
+
setError("Organizations feature is not enabled on this server.");
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
setLoading(true);
|
|
82
|
+
setError(null);
|
|
83
|
+
try {
|
|
84
|
+
const resp = await client.organizations.clearActiveOrg();
|
|
85
|
+
apply(resp);
|
|
86
|
+
return resp.bearer_access_token ?? null;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
89
|
+
return null;
|
|
90
|
+
} finally {
|
|
91
|
+
setLoading(false);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
void refetch();
|
|
96
|
+
|
|
97
|
+
return { activeOrgId, orgs, loading, error, refetch, switchTo, clear };
|
|
98
|
+
}
|