@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,214 @@
|
|
|
1
|
+
import { type Component, For, Show, createSignal } from "solid-js";
|
|
2
|
+
import {
|
|
3
|
+
ROLES,
|
|
4
|
+
createMembers,
|
|
5
|
+
createOrgPermissions,
|
|
6
|
+
createOrgRoles,
|
|
7
|
+
} from "../hooks/create-organizations";
|
|
8
|
+
import { RoleSelector } from "./role-selector";
|
|
9
|
+
|
|
10
|
+
export interface MemberListProps {
|
|
11
|
+
organizationId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const formatDate = (iso: string | null | undefined): string => {
|
|
15
|
+
if (!iso) return "—";
|
|
16
|
+
const d = new Date(iso);
|
|
17
|
+
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tailwind badge class for a built-in role. Custom strings render as
|
|
22
|
+
* the default secondary badge.
|
|
23
|
+
*/
|
|
24
|
+
const roleBadgeClass = (role: string): string => {
|
|
25
|
+
switch (role) {
|
|
26
|
+
case ROLES.OWNER:
|
|
27
|
+
return "bg-amber-500/15 text-amber-700 dark:text-amber-300";
|
|
28
|
+
case ROLES.ADMIN:
|
|
29
|
+
return "bg-blue-500/15 text-blue-700 dark:text-blue-300";
|
|
30
|
+
case ROLES.BILLING_ADMIN:
|
|
31
|
+
return "bg-purple-500/15 text-purple-700 dark:text-purple-300";
|
|
32
|
+
case ROLES.MEMBER:
|
|
33
|
+
return "bg-secondary text-secondary-foreground";
|
|
34
|
+
case ROLES.VIEWER:
|
|
35
|
+
return "bg-muted text-muted-foreground";
|
|
36
|
+
default:
|
|
37
|
+
return "bg-secondary text-secondary-foreground";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const MemberList: Component<MemberListProps> = (props) => {
|
|
42
|
+
const { members, loading, error, refetch } = createMembers(
|
|
43
|
+
() => props.organizationId,
|
|
44
|
+
);
|
|
45
|
+
const { hasPermission } = createOrgPermissions(() => props.organizationId);
|
|
46
|
+
const {
|
|
47
|
+
submitting,
|
|
48
|
+
error: actionError,
|
|
49
|
+
changeRole,
|
|
50
|
+
removeMember,
|
|
51
|
+
} = createOrgRoles(() => props.organizationId);
|
|
52
|
+
|
|
53
|
+
const [editingUserId, setEditingUserId] = createSignal<string | null>(null);
|
|
54
|
+
const [editingRole, setEditingRole] = createSignal("");
|
|
55
|
+
|
|
56
|
+
const startEdit = (userId: string, role: string) => {
|
|
57
|
+
setEditingUserId(userId);
|
|
58
|
+
setEditingRole(role);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const cancelEdit = () => {
|
|
62
|
+
setEditingUserId(null);
|
|
63
|
+
setEditingRole("");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const saveRole = async (userId: string) => {
|
|
67
|
+
const updated = await changeRole(userId, { role: editingRole() });
|
|
68
|
+
if (updated) {
|
|
69
|
+
await refetch();
|
|
70
|
+
cancelEdit();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const doRemove = async (userId: string) => {
|
|
75
|
+
if (!confirm("Remove this member from the organization?")) return;
|
|
76
|
+
const ok = await removeMember(userId);
|
|
77
|
+
if (ok) await refetch();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const showActions = () =>
|
|
81
|
+
hasPermission("members:change_role") || hasPermission("members:remove");
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div class="space-y-3">
|
|
85
|
+
<Show when={error() || actionError()}>
|
|
86
|
+
<div
|
|
87
|
+
class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
88
|
+
role="alert"
|
|
89
|
+
aria-live="polite"
|
|
90
|
+
>
|
|
91
|
+
{error() || actionError()}
|
|
92
|
+
</div>
|
|
93
|
+
</Show>
|
|
94
|
+
|
|
95
|
+
<Show when={loading() && members().length === 0}>
|
|
96
|
+
<div class="text-sm text-muted-foreground">Loading members…</div>
|
|
97
|
+
</Show>
|
|
98
|
+
|
|
99
|
+
<Show when={members().length > 0}>
|
|
100
|
+
<table class="w-full text-sm" aria-label="Organization members">
|
|
101
|
+
<thead>
|
|
102
|
+
<tr class="border-b border-input text-left text-xs font-medium text-muted-foreground">
|
|
103
|
+
<th scope="col" class="py-2 pr-4">User</th>
|
|
104
|
+
<th scope="col" class="py-2 pr-4">Role</th>
|
|
105
|
+
<th scope="col" class="py-2 pr-4">Status</th>
|
|
106
|
+
<th scope="col" class="py-2 pr-4">Joined</th>
|
|
107
|
+
<Show when={showActions()}>
|
|
108
|
+
<th scope="col" class="py-2 pr-4 text-right">Actions</th>
|
|
109
|
+
</Show>
|
|
110
|
+
</tr>
|
|
111
|
+
</thead>
|
|
112
|
+
<tbody>
|
|
113
|
+
<For each={members()}>
|
|
114
|
+
{(m) => (
|
|
115
|
+
<tr class="border-b border-input/40">
|
|
116
|
+
<td class="py-2 pr-4 font-mono text-xs">{m.user_id}</td>
|
|
117
|
+
<td class="py-2 pr-4">
|
|
118
|
+
<Show
|
|
119
|
+
when={editingUserId() !== m.user_id}
|
|
120
|
+
fallback={
|
|
121
|
+
<RoleSelector
|
|
122
|
+
value={editingRole()}
|
|
123
|
+
disabled={submitting()}
|
|
124
|
+
onChange={setEditingRole}
|
|
125
|
+
/>
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
<span
|
|
129
|
+
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(m.role)}`}
|
|
130
|
+
>
|
|
131
|
+
{m.role}
|
|
132
|
+
</span>
|
|
133
|
+
</Show>
|
|
134
|
+
</td>
|
|
135
|
+
<td class="py-2 pr-4 text-xs text-muted-foreground">
|
|
136
|
+
{m.status}
|
|
137
|
+
</td>
|
|
138
|
+
<td class="py-2 pr-4 text-xs text-muted-foreground">
|
|
139
|
+
{formatDate(m.joined_at)}
|
|
140
|
+
</td>
|
|
141
|
+
<Show when={showActions()}>
|
|
142
|
+
<td class="py-2 pr-4 text-right">
|
|
143
|
+
<div class="inline-flex gap-2">
|
|
144
|
+
<Show
|
|
145
|
+
when={editingUserId() === m.user_id}
|
|
146
|
+
fallback={
|
|
147
|
+
<>
|
|
148
|
+
<Show
|
|
149
|
+
when={
|
|
150
|
+
hasPermission("members:change_role") &&
|
|
151
|
+
m.role !== ROLES.OWNER
|
|
152
|
+
}
|
|
153
|
+
>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
class="rounded-md border border-input px-2 py-1 text-xs hover:bg-secondary"
|
|
157
|
+
onClick={() => startEdit(m.user_id, m.role)}
|
|
158
|
+
>
|
|
159
|
+
Change role
|
|
160
|
+
</button>
|
|
161
|
+
</Show>
|
|
162
|
+
<Show
|
|
163
|
+
when={
|
|
164
|
+
hasPermission("members:remove") &&
|
|
165
|
+
m.role !== ROLES.OWNER
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
class="rounded-md border border-destructive/40 px-2 py-1 text-xs text-destructive hover:bg-destructive/10"
|
|
171
|
+
disabled={submitting()}
|
|
172
|
+
onClick={() => doRemove(m.user_id)}
|
|
173
|
+
>
|
|
174
|
+
Remove
|
|
175
|
+
</button>
|
|
176
|
+
</Show>
|
|
177
|
+
</>
|
|
178
|
+
}
|
|
179
|
+
>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
183
|
+
disabled={submitting()}
|
|
184
|
+
onClick={() => saveRole(m.user_id)}
|
|
185
|
+
>
|
|
186
|
+
Save
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
class="rounded-md border border-input px-2 py-1 text-xs hover:bg-secondary"
|
|
191
|
+
disabled={submitting()}
|
|
192
|
+
onClick={cancelEdit}
|
|
193
|
+
>
|
|
194
|
+
Cancel
|
|
195
|
+
</button>
|
|
196
|
+
</Show>
|
|
197
|
+
</div>
|
|
198
|
+
</td>
|
|
199
|
+
</Show>
|
|
200
|
+
</tr>
|
|
201
|
+
)}
|
|
202
|
+
</For>
|
|
203
|
+
</tbody>
|
|
204
|
+
</table>
|
|
205
|
+
</Show>
|
|
206
|
+
|
|
207
|
+
<Show when={!loading() && members().length === 0}>
|
|
208
|
+
<div class="rounded-md border border-dashed border-input px-4 py-6 text-center text-sm text-muted-foreground">
|
|
209
|
+
No members yet.
|
|
210
|
+
</div>
|
|
211
|
+
</Show>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { OrganizationResponse } from "@yackey-labs/yauth-client";
|
|
2
|
+
import type { Component } from "solid-js";
|
|
3
|
+
import { Show } from "solid-js/web";
|
|
4
|
+
|
|
5
|
+
export interface OrganizationCardProps {
|
|
6
|
+
organization: OrganizationResponse;
|
|
7
|
+
role?: string | null;
|
|
8
|
+
memberCount?: number | null;
|
|
9
|
+
onSelect?: (org: OrganizationResponse) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const OrganizationCard: Component<OrganizationCardProps> = (props) => {
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
class="flex w-full items-start justify-between gap-4 rounded-md border border-input bg-card px-4 py-3 text-left text-card-foreground shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
16
|
+
type="button"
|
|
17
|
+
on:click={() => props.onSelect?.(props.organization)}
|
|
18
|
+
>
|
|
19
|
+
<div class="min-w-0 flex-1">
|
|
20
|
+
<div class="flex items-center gap-2">
|
|
21
|
+
<span class="truncate text-sm font-medium">
|
|
22
|
+
{props.organization.display_name || props.organization.name}
|
|
23
|
+
</span>
|
|
24
|
+
<Show when={props.role}>
|
|
25
|
+
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
|
|
26
|
+
{props.role}
|
|
27
|
+
</span>
|
|
28
|
+
</Show>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="mt-1 truncate text-xs text-muted-foreground">
|
|
31
|
+
@{props.organization.slug}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
<Show
|
|
35
|
+
when={
|
|
36
|
+
props.memberCount !== null && props.memberCount !== undefined
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
<div class="shrink-0 text-xs text-muted-foreground">
|
|
40
|
+
{props.memberCount} {props.memberCount === 1 ? "member" : "members"}
|
|
41
|
+
</div>
|
|
42
|
+
</Show>
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { OrganizationResponse } from "@yackey-labs/yauth-client";
|
|
2
|
+
import { type Component, createEffect, createSignal } from "solid-js";
|
|
3
|
+
import { Show } from "solid-js/web";
|
|
4
|
+
import {
|
|
5
|
+
createOrganizations,
|
|
6
|
+
slugify,
|
|
7
|
+
} from "../hooks/create-organizations";
|
|
8
|
+
|
|
9
|
+
export interface OrganizationCreateProps {
|
|
10
|
+
onSuccess?: (org: OrganizationResponse) => void;
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const OrganizationCreate: Component<OrganizationCreateProps> = (
|
|
15
|
+
props,
|
|
16
|
+
) => {
|
|
17
|
+
const { create, error } = createOrganizations();
|
|
18
|
+
|
|
19
|
+
const [name, setName] = createSignal("");
|
|
20
|
+
const [displayName, setDisplayName] = createSignal("");
|
|
21
|
+
const [slug, setSlug] = createSignal("");
|
|
22
|
+
const [slugTouched, setSlugTouched] = createSignal(false);
|
|
23
|
+
const [submitting, setSubmitting] = createSignal(false);
|
|
24
|
+
|
|
25
|
+
createEffect(() => {
|
|
26
|
+
const auto = slugify(name());
|
|
27
|
+
if (!slugTouched()) {
|
|
28
|
+
setSlug(auto);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const onSlugInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
|
33
|
+
setSlugTouched(true);
|
|
34
|
+
setSlug(slugify(e.currentTarget.value));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleSubmit = async (e: SubmitEvent) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
if (!name().trim() || !slug().trim()) return;
|
|
40
|
+
setSubmitting(true);
|
|
41
|
+
const org = await create({
|
|
42
|
+
name: name().trim(),
|
|
43
|
+
slug: slug().trim(),
|
|
44
|
+
display_name: displayName().trim() || undefined,
|
|
45
|
+
});
|
|
46
|
+
setSubmitting(false);
|
|
47
|
+
if (org) {
|
|
48
|
+
props.onSuccess?.(org);
|
|
49
|
+
setName("");
|
|
50
|
+
setDisplayName("");
|
|
51
|
+
setSlug("");
|
|
52
|
+
setSlugTouched(false);
|
|
53
|
+
} else if (error()) {
|
|
54
|
+
props.onError?.(new Error(error() as string));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<form class="space-y-6" on:submit={handleSubmit}>
|
|
60
|
+
<Show when={error()}>
|
|
61
|
+
<div
|
|
62
|
+
class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
63
|
+
role="alert"
|
|
64
|
+
aria-live="polite"
|
|
65
|
+
>
|
|
66
|
+
{error()}
|
|
67
|
+
</div>
|
|
68
|
+
</Show>
|
|
69
|
+
|
|
70
|
+
<div class="space-y-2">
|
|
71
|
+
<label
|
|
72
|
+
class="text-sm font-medium leading-none"
|
|
73
|
+
for="yauth-org-create-name"
|
|
74
|
+
>
|
|
75
|
+
Organization name
|
|
76
|
+
</label>
|
|
77
|
+
<input
|
|
78
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
79
|
+
id="yauth-org-create-name"
|
|
80
|
+
name="name"
|
|
81
|
+
type="text"
|
|
82
|
+
value={name()}
|
|
83
|
+
on:input={(e) => setName(e.currentTarget.value)}
|
|
84
|
+
required
|
|
85
|
+
maxlength={120}
|
|
86
|
+
autocomplete="organization"
|
|
87
|
+
disabled={submitting()}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="space-y-2">
|
|
92
|
+
<label
|
|
93
|
+
class="text-sm font-medium leading-none"
|
|
94
|
+
for="yauth-org-create-slug"
|
|
95
|
+
>
|
|
96
|
+
Slug
|
|
97
|
+
</label>
|
|
98
|
+
<input
|
|
99
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 font-mono text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
100
|
+
id="yauth-org-create-slug"
|
|
101
|
+
name="slug"
|
|
102
|
+
type="text"
|
|
103
|
+
value={slug()}
|
|
104
|
+
on:input={onSlugInput}
|
|
105
|
+
required
|
|
106
|
+
pattern="[a-z0-9-]+"
|
|
107
|
+
maxlength={64}
|
|
108
|
+
aria-describedby="yauth-org-create-slug-hint"
|
|
109
|
+
disabled={submitting()}
|
|
110
|
+
/>
|
|
111
|
+
<p
|
|
112
|
+
id="yauth-org-create-slug-hint"
|
|
113
|
+
class="text-xs text-muted-foreground"
|
|
114
|
+
>
|
|
115
|
+
URL-safe identifier. Lowercase letters, numbers, and hyphens only.
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div class="space-y-2">
|
|
120
|
+
<label
|
|
121
|
+
class="text-sm font-medium leading-none"
|
|
122
|
+
for="yauth-org-create-display-name"
|
|
123
|
+
>
|
|
124
|
+
Display name (optional)
|
|
125
|
+
</label>
|
|
126
|
+
<input
|
|
127
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
128
|
+
id="yauth-org-create-display-name"
|
|
129
|
+
name="display_name"
|
|
130
|
+
type="text"
|
|
131
|
+
value={displayName()}
|
|
132
|
+
on:input={(e) => setDisplayName(e.currentTarget.value)}
|
|
133
|
+
maxlength={120}
|
|
134
|
+
disabled={submitting()}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<button
|
|
139
|
+
class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
140
|
+
type="submit"
|
|
141
|
+
disabled={submitting() || !name().trim() || !slug().trim()}
|
|
142
|
+
>
|
|
143
|
+
{submitting() ? "Creating…" : "Create organization"}
|
|
144
|
+
</button>
|
|
145
|
+
</form>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Component } from "solid-js";
|
|
2
|
+
import { Show } from "solid-js/web";
|
|
3
|
+
import { createOrganization } from "../hooks/create-organizations";
|
|
4
|
+
import { InviteForm } from "./invite-form";
|
|
5
|
+
import { MemberList } from "./member-list";
|
|
6
|
+
|
|
7
|
+
export interface OrganizationDetailProps {
|
|
8
|
+
organizationId: string;
|
|
9
|
+
/** When false, hide the invite form section. */
|
|
10
|
+
canInvite?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const OrganizationDetail: Component<OrganizationDetailProps> = (
|
|
14
|
+
props,
|
|
15
|
+
) => {
|
|
16
|
+
const { organization, loading, error } = createOrganization(
|
|
17
|
+
() => props.organizationId,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<section class="space-y-6">
|
|
22
|
+
<Show when={error()}>
|
|
23
|
+
<div
|
|
24
|
+
class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
25
|
+
role="alert"
|
|
26
|
+
aria-live="polite"
|
|
27
|
+
>
|
|
28
|
+
{error()}
|
|
29
|
+
</div>
|
|
30
|
+
</Show>
|
|
31
|
+
|
|
32
|
+
<Show when={loading() && !organization()}>
|
|
33
|
+
<div class="text-sm text-muted-foreground">Loading organization…</div>
|
|
34
|
+
</Show>
|
|
35
|
+
|
|
36
|
+
<Show when={organization()}>
|
|
37
|
+
{(org) => (
|
|
38
|
+
<>
|
|
39
|
+
<div class="space-y-2">
|
|
40
|
+
<h2 class="text-lg font-semibold">
|
|
41
|
+
{org().display_name || org().name}
|
|
42
|
+
</h2>
|
|
43
|
+
<p class="font-mono text-xs text-muted-foreground">
|
|
44
|
+
@{org().slug}
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="space-y-3">
|
|
49
|
+
<h3 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
50
|
+
Members
|
|
51
|
+
</h3>
|
|
52
|
+
<MemberList organizationId={org().id} />
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<Show when={props.canInvite !== false}>
|
|
56
|
+
<div class="space-y-3">
|
|
57
|
+
<h3 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
58
|
+
Invite a member
|
|
59
|
+
</h3>
|
|
60
|
+
<InviteForm organizationId={org().id} />
|
|
61
|
+
</div>
|
|
62
|
+
</Show>
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
</Show>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OrganizationResponse } from "@yackey-labs/yauth-client";
|
|
2
|
+
import { type Component, For } from "solid-js";
|
|
3
|
+
import { Show } from "solid-js/web";
|
|
4
|
+
import { createOrganizations } from "../hooks/create-organizations";
|
|
5
|
+
import { OrganizationCard } from "./organization-card";
|
|
6
|
+
|
|
7
|
+
export interface OrganizationListProps {
|
|
8
|
+
onSelect?: (org: OrganizationResponse) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const OrganizationList: Component<OrganizationListProps> = (props) => {
|
|
12
|
+
const { organizations, loading, error } = createOrganizations();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div class="space-y-3">
|
|
16
|
+
<Show when={error()}>
|
|
17
|
+
<div
|
|
18
|
+
class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
19
|
+
role="alert"
|
|
20
|
+
aria-live="polite"
|
|
21
|
+
>
|
|
22
|
+
{error()}
|
|
23
|
+
</div>
|
|
24
|
+
</Show>
|
|
25
|
+
|
|
26
|
+
<Show when={loading() && organizations().length === 0}>
|
|
27
|
+
<div class="text-sm text-muted-foreground">
|
|
28
|
+
Loading organizations…
|
|
29
|
+
</div>
|
|
30
|
+
</Show>
|
|
31
|
+
|
|
32
|
+
<Show when={!loading() && organizations().length === 0}>
|
|
33
|
+
<div class="rounded-md border border-dashed border-input px-4 py-6 text-center text-sm text-muted-foreground">
|
|
34
|
+
You're not in any organizations yet.
|
|
35
|
+
</div>
|
|
36
|
+
</Show>
|
|
37
|
+
|
|
38
|
+
<Show when={organizations().length > 0}>
|
|
39
|
+
<ul class="space-y-2">
|
|
40
|
+
<For each={organizations()}>
|
|
41
|
+
{(org) => (
|
|
42
|
+
<li>
|
|
43
|
+
<OrganizationCard
|
|
44
|
+
organization={org}
|
|
45
|
+
onSelect={props.onSelect}
|
|
46
|
+
/>
|
|
47
|
+
</li>
|
|
48
|
+
)}
|
|
49
|
+
</For>
|
|
50
|
+
</ul>
|
|
51
|
+
</Show>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ActiveOrgEntry } from "@yackey-labs/yauth-client";
|
|
2
|
+
import { type Component, createSignal, For, onCleanup } from "solid-js";
|
|
3
|
+
import { Show } from "solid-js/web";
|
|
4
|
+
import { createActiveOrg } from "../hooks/create-active-org";
|
|
5
|
+
|
|
6
|
+
export interface OrganizationSwitcherProps {
|
|
7
|
+
/**
|
|
8
|
+
* Override the active org id (e.g. for SSR or test fixtures). When
|
|
9
|
+
* omitted the component reads from `createActiveOrg()` and is fully
|
|
10
|
+
* self-driving.
|
|
11
|
+
*/
|
|
12
|
+
activeId?: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Callback fired after a successful switch — receives the membership
|
|
15
|
+
* entry the caller switched into. Useful for clients that need to
|
|
16
|
+
* adopt a freshly-issued bearer token from `switchTo`.
|
|
17
|
+
*/
|
|
18
|
+
onSwitch?: (org: ActiveOrgEntry, bearerToken: string | null) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Organization-switcher dropdown (issue #89).
|
|
23
|
+
*
|
|
24
|
+
* Self-driving by default: reads the active-org claim and full membership
|
|
25
|
+
* list from the server via `createActiveOrg`, and dispatches switches
|
|
26
|
+
* through the active-org endpoint. Cookie callers update server-side;
|
|
27
|
+
* bearer callers receive a freshly-issued JWT in the response that the
|
|
28
|
+
* `onSwitch` callback can adopt.
|
|
29
|
+
*/
|
|
30
|
+
export const OrganizationSwitcher: Component<OrganizationSwitcherProps> = (props) => {
|
|
31
|
+
const { activeOrgId, orgs, loading, switchTo } = createActiveOrg();
|
|
32
|
+
const [open, setOpen] = createSignal(false);
|
|
33
|
+
let containerEl: HTMLDivElement | undefined;
|
|
34
|
+
|
|
35
|
+
const effectiveActiveId = () => (props.activeId === undefined ? activeOrgId() : props.activeId);
|
|
36
|
+
const activeOrg = () => orgs().find((o) => o.organization_id === effectiveActiveId()) ?? null;
|
|
37
|
+
|
|
38
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
39
|
+
if (!containerEl) return;
|
|
40
|
+
if (!containerEl.contains(e.target as Node)) {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const toggle = () => {
|
|
47
|
+
const next = !open();
|
|
48
|
+
setOpen(next);
|
|
49
|
+
if (next) {
|
|
50
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
51
|
+
} else {
|
|
52
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const choose = async (org: ActiveOrgEntry) => {
|
|
57
|
+
setOpen(false);
|
|
58
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
59
|
+
const bearerToken = await switchTo(org.organization_id);
|
|
60
|
+
props.onSwitch?.(org, bearerToken);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
onCleanup(() => {
|
|
64
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div ref={containerEl} class="relative inline-block w-full">
|
|
69
|
+
<button
|
|
70
|
+
class="inline-flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
71
|
+
type="button"
|
|
72
|
+
aria-expanded={open()}
|
|
73
|
+
aria-haspopup="listbox"
|
|
74
|
+
aria-label="Switch organization"
|
|
75
|
+
disabled={loading() && orgs().length === 0}
|
|
76
|
+
on:click={toggle}
|
|
77
|
+
>
|
|
78
|
+
<span class="truncate">
|
|
79
|
+
{activeOrg()?.display_name ||
|
|
80
|
+
activeOrg()?.slug ||
|
|
81
|
+
(loading() ? "Loading…" : "Select organization")}
|
|
82
|
+
</span>
|
|
83
|
+
<svg
|
|
84
|
+
class="h-4 w-4 shrink-0 opacity-50"
|
|
85
|
+
viewBox="0 0 24 24"
|
|
86
|
+
fill="none"
|
|
87
|
+
stroke="currentColor"
|
|
88
|
+
stroke-width="2"
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
>
|
|
91
|
+
<polyline points="6 9 12 15 18 9" />
|
|
92
|
+
</svg>
|
|
93
|
+
</button>
|
|
94
|
+
|
|
95
|
+
<Show when={open()}>
|
|
96
|
+
<div
|
|
97
|
+
class="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-md border border-input bg-popover text-popover-foreground shadow-md"
|
|
98
|
+
role="listbox"
|
|
99
|
+
>
|
|
100
|
+
<For each={orgs()}>
|
|
101
|
+
{(org) => (
|
|
102
|
+
<button
|
|
103
|
+
class="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
|
|
104
|
+
type="button"
|
|
105
|
+
role="option"
|
|
106
|
+
aria-selected={org.organization_id === effectiveActiveId()}
|
|
107
|
+
on:click={() => void choose(org)}
|
|
108
|
+
>
|
|
109
|
+
<div class="min-w-0 flex-1">
|
|
110
|
+
<div class="truncate font-medium">
|
|
111
|
+
{org.display_name || org.slug || org.organization_id}
|
|
112
|
+
</div>
|
|
113
|
+
<Show when={org.slug}>
|
|
114
|
+
<div class="truncate font-mono text-xs text-muted-foreground">@{org.slug}</div>
|
|
115
|
+
</Show>
|
|
116
|
+
</div>
|
|
117
|
+
<Show when={org.organization_id === effectiveActiveId()}>
|
|
118
|
+
<svg
|
|
119
|
+
class="h-4 w-4 shrink-0"
|
|
120
|
+
viewBox="0 0 24 24"
|
|
121
|
+
fill="none"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
stroke-width="2"
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
>
|
|
126
|
+
<polyline points="20 6 9 17 4 12" />
|
|
127
|
+
</svg>
|
|
128
|
+
</Show>
|
|
129
|
+
</button>
|
|
130
|
+
)}
|
|
131
|
+
</For>
|
|
132
|
+
<Show when={orgs().length === 0}>
|
|
133
|
+
<div class="px-3 py-2 text-sm text-muted-foreground">No organizations.</div>
|
|
134
|
+
</Show>
|
|
135
|
+
</div>
|
|
136
|
+
</Show>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
};
|