@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,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
+ }