@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.211.0

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.
@@ -1,270 +0,0 @@
1
- // @runtime client
2
- // MembersScreen — active members + pending invitations for the current tenant.
3
- // Read-only member list; invite + cancel only (no updateMemberRoles — SystemAdmin-only).
4
-
5
- import { AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/constants";
6
- import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
7
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
8
- import { type ReactNode, useCallback, useEffect, useState } from "react";
9
- import { DEFAULT_INVITE_ROLE_OPTIONS, TenantHandlers, TenantQueries } from "../constants";
10
-
11
- type MemberRow = {
12
- readonly id: string;
13
- readonly userId: string;
14
- readonly tenantId: string;
15
- readonly email: string | null;
16
- readonly displayName: string | null;
17
- readonly roles: readonly string[];
18
- };
19
-
20
- type InvitationRow = {
21
- readonly id: string;
22
- readonly email: string;
23
- readonly role: string;
24
- readonly status: string;
25
- readonly expiresAt: string;
26
- };
27
-
28
- type State =
29
- | { readonly kind: "loading" }
30
- | { readonly kind: "error"; readonly message: string }
31
- | {
32
- readonly kind: "ready";
33
- readonly members: readonly MemberRow[];
34
- readonly invitations: readonly InvitationRow[];
35
- };
36
-
37
- type RoleOption = (typeof DEFAULT_INVITE_ROLE_OPTIONS)[number];
38
-
39
- export type MembersScreenProps = {
40
- /** Invite-role allowlist; defaults to User/Admin/Editor — never reserved/global roles. */
41
- readonly inviteRoleOptions?: readonly RoleOption[];
42
- };
43
-
44
- export function MembersScreen({
45
- inviteRoleOptions = DEFAULT_INVITE_ROLE_OPTIONS,
46
- }: MembersScreenProps): ReactNode {
47
- const t = useTranslation();
48
- const { Banner, Button, Card, DataTable, Field, Form, Heading, Input, Text } = usePrimitives();
49
- const dispatcher = useDispatcher();
50
- const [state, setState] = useState<State>({ kind: "loading" });
51
- const [inviteEmail, setInviteEmail] = useState("");
52
- const [inviteRole, setInviteRole] = useState<RoleOption>(inviteRoleOptions[0] ?? "User");
53
- const [submitting, setSubmitting] = useState(false);
54
- const [actionError, setActionError] = useState<string | null>(null);
55
- const [lastInvited, setLastInvited] = useState<string | null>(null);
56
-
57
- const refresh = useCallback(async (): Promise<void> => {
58
- const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
59
- if (!membersRes.isSuccess) {
60
- setState({ kind: "error", message: membersRes.error.message });
61
- return;
62
- }
63
- const invitationsRes = await dispatcher.query<readonly InvitationRow[]>(
64
- TenantQueries.invitations,
65
- {},
66
- );
67
- if (!invitationsRes.isSuccess) {
68
- setState({ kind: "error", message: invitationsRes.error.message });
69
- return;
70
- }
71
- setState({ kind: "ready", members: membersRes.data, invitations: invitationsRes.data });
72
- }, [dispatcher]);
73
-
74
- useEffect(() => {
75
- void refresh();
76
- }, [refresh]);
77
-
78
- const onInvite = (e?: React.FormEvent): void => {
79
- e?.preventDefault();
80
- void (async (): Promise<void> => {
81
- setActionError(null);
82
- setSubmitting(true);
83
- const email = inviteEmail.trim();
84
- const res = await dispatcher.write(AuthHandlers.inviteCreate, { email, role: inviteRole });
85
- setSubmitting(false);
86
- if (!res.isSuccess) {
87
- setActionError(res.error.message);
88
- return;
89
- }
90
- setLastInvited(email);
91
- setInviteEmail("");
92
- await refresh();
93
- })();
94
- };
95
-
96
- const onCancel = async (invitationId: string): Promise<void> => {
97
- setActionError(null);
98
- const res = await dispatcher.write(TenantHandlers.cancelInvitation, { invitationId });
99
- if (!res.isSuccess) {
100
- setActionError(res.error.message);
101
- return;
102
- }
103
- await refresh();
104
- };
105
-
106
- if (state.kind === "loading") {
107
- return (
108
- <FormScreenShell testId="members-screen">
109
- <Text variant="small">{t("tenant.members.loading")}</Text>
110
- </FormScreenShell>
111
- );
112
- }
113
-
114
- if (state.kind === "error") {
115
- return (
116
- <FormScreenShell testId="members-screen">
117
- <Banner variant="error">{state.message}</Banner>
118
- </FormScreenShell>
119
- );
120
- }
121
-
122
- const roleSelectOptions = inviteRoleOptions.map((r) => ({ value: r, label: r }));
123
-
124
- return (
125
- <FormScreenShell testId="members-screen" className="flex flex-col gap-6">
126
- <Heading variant="page">{t("tenant.members.title")}</Heading>
127
-
128
- <Card
129
- slots={{ title: `${t("tenant.members.active")} (${state.members.length})` }}
130
- options={{ padded: false }}
131
- >
132
- <DataTable
133
- testId="members-active-table"
134
- columns={[
135
- {
136
- field: "email",
137
- label: t("tenant.members.col.email"),
138
- type: "string",
139
- sortable: false,
140
- },
141
- {
142
- field: "roles",
143
- label: t("tenant.members.col.roles"),
144
- type: "string",
145
- sortable: false,
146
- },
147
- ]}
148
- rows={state.members.map((m) => ({
149
- id: m.id,
150
- values: {
151
- email: m.email ?? `${m.userId.slice(0, 8)}…`,
152
- roles: m.roles.join(", "),
153
- },
154
- }))}
155
- />
156
- </Card>
157
-
158
- <Form
159
- testId="invite-form"
160
- title={t("tenant.members.invite.title")}
161
- onSubmit={onInvite}
162
- actions={
163
- <Button
164
- type="submit"
165
- variant="primary"
166
- loading={submitting}
167
- disabled={submitting}
168
- testId="invite-submit"
169
- >
170
- {submitting ? t("tenant.members.invite.submitting") : t("tenant.members.invite.submit")}
171
- </Button>
172
- }
173
- >
174
- <div className="grid gap-4 sm:grid-cols-2">
175
- <Field id="invite-email" label={t("tenant.members.invite.email")} required>
176
- <Input
177
- kind="email"
178
- id="invite-email"
179
- name="invite-email"
180
- value={inviteEmail}
181
- onChange={setInviteEmail}
182
- disabled={submitting}
183
- required
184
- autoComplete="email"
185
- />
186
- </Field>
187
- <Field id="invite-role" label={t("tenant.members.invite.role")} required>
188
- <Input
189
- kind="select"
190
- id="invite-role"
191
- name="invite-role"
192
- value={inviteRole}
193
- onChange={(v) => setInviteRole(v as RoleOption)}
194
- options={roleSelectOptions}
195
- disabled={submitting}
196
- required
197
- />
198
- </Field>
199
- </div>
200
- {lastInvited !== null && !submitting && (
201
- <Banner variant="info">
202
- {t("tenant.members.invite.success", { email: lastInvited })}
203
- </Banner>
204
- )}
205
- {actionError !== null && <Banner variant="error">{actionError}</Banner>}
206
- </Form>
207
-
208
- <Card
209
- slots={{ title: `${t("tenant.members.pending")} (${state.invitations.length})` }}
210
- options={{ padded: false }}
211
- >
212
- <DataTable
213
- testId="pending-list"
214
- columns={[
215
- {
216
- field: "email",
217
- label: t("tenant.members.col.email"),
218
- type: "string",
219
- sortable: false,
220
- },
221
- {
222
- field: "role",
223
- label: t("tenant.members.col.roles"),
224
- type: "string",
225
- sortable: false,
226
- },
227
- {
228
- field: "expires",
229
- label: t("tenant.members.col.expires"),
230
- type: "string",
231
- sortable: false,
232
- },
233
- ]}
234
- rows={state.invitations.map((inv) => ({
235
- id: inv.id,
236
- values: {
237
- email: inv.email,
238
- role: inv.role,
239
- expires: formatExpiresAt(inv.expiresAt),
240
- },
241
- }))}
242
- rowActions={[
243
- {
244
- id: "cancel",
245
- label: t("tenant.members.cancel"),
246
- style: "danger",
247
- onTrigger: (row) => void onCancel(row.id),
248
- },
249
- ]}
250
- rowActionMode="inline"
251
- emptyState={<Text variant="small">{t("tenant.members.pending.empty")}</Text>}
252
- />
253
- </Card>
254
- </FormScreenShell>
255
- );
256
- }
257
-
258
- function formatExpiresAt(iso: string): string {
259
- try {
260
- return new Date(iso).toLocaleString(undefined, {
261
- day: "2-digit",
262
- month: "2-digit",
263
- year: "numeric",
264
- hour: "2-digit",
265
- minute: "2-digit",
266
- });
267
- } catch {
268
- return iso;
269
- }
270
- }