@m5kdev/web-ui 0.1.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.
Files changed (127) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +17 -0
  3. package/package.json +169 -0
  4. package/src/animations/card.motion.ts +9 -0
  5. package/src/components/AvatarUpload.tsx +133 -0
  6. package/src/components/Button.tsx +14 -0
  7. package/src/components/Calendar.css +684 -0
  8. package/src/components/Calendar.tsx +32 -0
  9. package/src/components/CardsSelect.tsx +155 -0
  10. package/src/components/CollapsibleSidebarMenuItem.tsx +57 -0
  11. package/src/components/ColorPicker.tsx +56 -0
  12. package/src/components/CopyButton.tsx +45 -0
  13. package/src/components/CropDialog.tsx +154 -0
  14. package/src/components/DialogProvider.tsx +105 -0
  15. package/src/components/ErrorFallback.tsx +17 -0
  16. package/src/components/FileDropzone.tsx +120 -0
  17. package/src/components/MultiSelectDropdown.tsx +233 -0
  18. package/src/components/Orb.tsx +288 -0
  19. package/src/components/PageAlert.tsx +121 -0
  20. package/src/components/SelectChips.tsx +40 -0
  21. package/src/components/SidebarItem.tsx +26 -0
  22. package/src/components/Steps.tsx +340 -0
  23. package/src/components/TablerIconPicker.tsx +4260 -0
  24. package/src/components/app-header.tsx +40 -0
  25. package/src/components/blur-card.tsx +132 -0
  26. package/src/components/features-section-demo-1.tsx +127 -0
  27. package/src/components/features-section-demo-2.tsx +102 -0
  28. package/src/components/features-section-demo-3.tsx +272 -0
  29. package/src/components/mode-toggle.tsx +31 -0
  30. package/src/components/nav-main.tsx +69 -0
  31. package/src/components/pricing-cards.tsx +133 -0
  32. package/src/components/shared/ButtonCopy.tsx +50 -0
  33. package/src/components/team-switcher.tsx +83 -0
  34. package/src/components/theme-provider.tsx +74 -0
  35. package/src/components/typewriter.tsx +90 -0
  36. package/src/components/ui/alert-dialog.tsx +133 -0
  37. package/src/components/ui/alert.tsx +60 -0
  38. package/src/components/ui/avatar.tsx +47 -0
  39. package/src/components/ui/badge.tsx +33 -0
  40. package/src/components/ui/bento-grid.tsx +54 -0
  41. package/src/components/ui/bento-grid2.tsx +66 -0
  42. package/src/components/ui/breadcrumb.tsx +101 -0
  43. package/src/components/ui/button.tsx +50 -0
  44. package/src/components/ui/card.tsx +55 -0
  45. package/src/components/ui/checkbox.tsx +26 -0
  46. package/src/components/ui/collapsible.tsx +9 -0
  47. package/src/components/ui/dialog.tsx +119 -0
  48. package/src/components/ui/dropdown-menu.tsx +186 -0
  49. package/src/components/ui/floating-navbar.tsx +78 -0
  50. package/src/components/ui/form.tsx +167 -0
  51. package/src/components/ui/image.tsx +55 -0
  52. package/src/components/ui/input.tsx +22 -0
  53. package/src/components/ui/label.tsx +19 -0
  54. package/src/components/ui/pagination.tsx +105 -0
  55. package/src/components/ui/progress.tsx +23 -0
  56. package/src/components/ui/resizable-navbar.tsx +260 -0
  57. package/src/components/ui/segment-control.tsx +143 -0
  58. package/src/components/ui/select.tsx +153 -0
  59. package/src/components/ui/separator.tsx +24 -0
  60. package/src/components/ui/sheet.tsx +121 -0
  61. package/src/components/ui/sidebar.tsx +736 -0
  62. package/src/components/ui/skeleton.tsx +7 -0
  63. package/src/components/ui/slider.tsx +23 -0
  64. package/src/components/ui/sonner.tsx +27 -0
  65. package/src/components/ui/spinner.tsx +45 -0
  66. package/src/components/ui/switch.tsx +27 -0
  67. package/src/components/ui/table.tsx +90 -0
  68. package/src/components/ui/tabs.tsx +52 -0
  69. package/src/components/ui/textarea.tsx +18 -0
  70. package/src/components/ui/timeline.tsx +95 -0
  71. package/src/components/ui/toast.tsx +126 -0
  72. package/src/components/ui/tooltip.tsx +55 -0
  73. package/src/components/ui/typewriter-effect.tsx +181 -0
  74. package/src/hooks/use-mobile.ts +19 -0
  75. package/src/hooks/useDialog.ts +25 -0
  76. package/src/icons/GoogleIcon.tsx +32 -0
  77. package/src/icons/LinkedInIcon.tsx +30 -0
  78. package/src/icons/MicrosoftIcon.tsx +21 -0
  79. package/src/lib/chatwoot.ts +51 -0
  80. package/src/lib/utils.ts +6 -0
  81. package/src/modules/app/components/AppLoader.tsx +9 -0
  82. package/src/modules/app/components/AppShell.tsx +21 -0
  83. package/src/modules/app/components/AppSidebar.tsx +26 -0
  84. package/src/modules/app/components/AppSidebarContent.tsx +73 -0
  85. package/src/modules/app/components/AppSidebarHeader.tsx +57 -0
  86. package/src/modules/app/components/AppSidebarInvites.tsx +32 -0
  87. package/src/modules/app/components/AppSidebarUser.tsx +128 -0
  88. package/src/modules/auth/components/AdminUserManagement.tsx +1136 -0
  89. package/src/modules/auth/components/AdminWaitlist.tsx +358 -0
  90. package/src/modules/auth/components/AuthLayout.tsx +13 -0
  91. package/src/modules/auth/components/AuthProviders.tsx +105 -0
  92. package/src/modules/auth/components/AuthRouter.tsx +29 -0
  93. package/src/modules/auth/components/ClaimAccountRoute.tsx +242 -0
  94. package/src/modules/auth/components/ErrorAuthRoute.tsx +121 -0
  95. package/src/modules/auth/components/ForgotPasswordForm.tsx +58 -0
  96. package/src/modules/auth/components/ForgotPasswordRoute.tsx +27 -0
  97. package/src/modules/auth/components/InviteFriends.tsx +273 -0
  98. package/src/modules/auth/components/LastUsedBadge.tsx +22 -0
  99. package/src/modules/auth/components/LoginForm.tsx +104 -0
  100. package/src/modules/auth/components/LoginRoute.tsx +31 -0
  101. package/src/modules/auth/components/LogoutRoute.tsx +21 -0
  102. package/src/modules/auth/components/OrganizationAcceptInvitationRoute.tsx +161 -0
  103. package/src/modules/auth/components/OrganizationMembersRoute.tsx +730 -0
  104. package/src/modules/auth/components/OrganizationSettingsRoute.tsx +280 -0
  105. package/src/modules/auth/components/OrganizationSwitcher.tsx +148 -0
  106. package/src/modules/auth/components/ProfileRoute.tsx +104 -0
  107. package/src/modules/auth/components/RangeNuqsDatePicker.tsx +365 -0
  108. package/src/modules/auth/components/ResetPasswordForm.tsx +103 -0
  109. package/src/modules/auth/components/ResetPasswordRoute.tsx +27 -0
  110. package/src/modules/auth/components/SignupFormRoute.tsx +189 -0
  111. package/src/modules/auth/components/SignupRoute.tsx +53 -0
  112. package/src/modules/auth/components/UserPreferences.tsx +144 -0
  113. package/src/modules/auth/components/WaitlistCard.tsx +78 -0
  114. package/src/modules/auth/components/WaitlistCodeValidation.tsx +79 -0
  115. package/src/modules/billing/components/BillingBetaPage.tsx +124 -0
  116. package/src/modules/billing/components/BillingInvoicePage.tsx +180 -0
  117. package/src/modules/billing/components/BillingPlanSelect.tsx +14 -0
  118. package/src/modules/billing/components/BillingRouter.tsx +20 -0
  119. package/src/modules/billing/components/BillingSinglePlanSelect.tsx +172 -0
  120. package/src/modules/table/components/ColumnOrderAndVisibility.tsx +127 -0
  121. package/src/modules/table/components/NuqsTable.tsx +396 -0
  122. package/src/modules/table/components/TableFiltering.tsx +520 -0
  123. package/src/modules/table/components/TablePagination.tsx +59 -0
  124. package/src/modules/table/components/table.types.ts +11 -0
  125. package/src/modules/table/filterTransformers.ts +323 -0
  126. package/src/types.ts +4 -0
  127. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,730 @@
1
+ import {
2
+ Button,
3
+ Card,
4
+ CardBody,
5
+ CardHeader,
6
+ Chip,
7
+ Input,
8
+ Select,
9
+ SelectItem,
10
+ Spinner,
11
+ Table,
12
+ TableBody,
13
+ TableCell,
14
+ TableColumn,
15
+ TableHeader,
16
+ TableRow,
17
+ } from "@heroui/react";
18
+ import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
19
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
20
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
21
+ import { Copy, Trash2, UserPlus, Users } from "lucide-react";
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import { useTranslation } from "react-i18next";
24
+ import { toast } from "sonner";
25
+
26
+ type OrganizationDetails = {
27
+ id: string;
28
+ name: string;
29
+ slug: string;
30
+ logo?: string | null;
31
+ metadata?: Record<string, unknown> | null;
32
+ };
33
+
34
+ type OrganizationMember = {
35
+ id: string;
36
+ userId: string;
37
+ role: string;
38
+ user: {
39
+ id: string;
40
+ name: string;
41
+ email: string;
42
+ image?: string | null;
43
+ };
44
+ };
45
+
46
+ type OrganizationInvitation = {
47
+ id: string;
48
+ organizationId: string;
49
+ email: string;
50
+ role: string;
51
+ status: string;
52
+ inviterId: string;
53
+ createdAt: Date | string;
54
+ expiresAt: Date | string;
55
+ };
56
+
57
+ type CombinedMemberRow =
58
+ | {
59
+ kind: "member";
60
+ id: string;
61
+ displayName: string;
62
+ email: string;
63
+ role: string;
64
+ status: "active";
65
+ memberId: string;
66
+ }
67
+ | {
68
+ kind: "invitation";
69
+ id: string;
70
+ displayName: string;
71
+ email: string;
72
+ role: string;
73
+ status: "invited";
74
+ invitationId: string;
75
+ };
76
+
77
+ const ORGANIZATION_ROLES = ["member", "admin", "owner"] as const;
78
+
79
+ export type OrganizationRole = (typeof ORGANIZATION_ROLES)[number];
80
+
81
+ function isOrganizationRole(role: string): role is OrganizationRole {
82
+ return (ORGANIZATION_ROLES as readonly string[]).includes(role);
83
+ }
84
+
85
+ export type OrganizationMembersRouteLabels = {
86
+ loadError: string;
87
+ membersTitle: string;
88
+ membersNoActive: string;
89
+ membersManageOnly: string;
90
+ membersDescription: (organizationName: string) => string;
91
+ defaultOrganizationName: string;
92
+ loadMembersError: string;
93
+ loadInvitationsError: string;
94
+ roleUpdateSuccess: string;
95
+ roleUpdateError: string;
96
+ removeMemberSuccess: string;
97
+ removeMemberError: string;
98
+ emailRequired: string;
99
+ inviteSuccess: string;
100
+ inviteError: string;
101
+ cancelInvitationSuccess: string;
102
+ cancelInvitationError: string;
103
+ inviteLinkCopied: string;
104
+ copyInviteLinkError: string;
105
+ unknownName: string;
106
+ invitedUser: string;
107
+ emailLabel: string;
108
+ emailPlaceholder: string;
109
+ roleLabel: string;
110
+ inviteButton: string;
111
+ tableTitle: string;
112
+ columnName: string;
113
+ columnEmail: string;
114
+ columnRole: string;
115
+ columnStatus: string;
116
+ columnActions: string;
117
+ tableEmpty: string;
118
+ roleFor: (name: string) => string;
119
+ statusActive: string;
120
+ statusInvited: string;
121
+ removeMember: string;
122
+ copyInviteLink: string;
123
+ cancelInvitation: string;
124
+ roleUnknown: string;
125
+ };
126
+
127
+ export type OrganizationMembersRouteProps = {
128
+ managerRoles?: string[];
129
+ assignableRoles?: OrganizationRole[];
130
+ invitationAcceptPath?: string;
131
+ onInvalidateScopedQueries?: () => void | Promise<void>;
132
+ };
133
+
134
+ function OrganizationStateCard({ title, message }: { title: string; message: string }) {
135
+ return (
136
+ <div className="p-6">
137
+ <Card>
138
+ <CardHeader className="text-lg font-semibold">{title}</CardHeader>
139
+ <CardBody>{message}</CardBody>
140
+ </Card>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function useOrganizationAccess({
146
+ managerRoles,
147
+ onInvalidateScopedQueries,
148
+ }: Pick<OrganizationMembersRouteProps, "managerRoles" | "onInvalidateScopedQueries">) {
149
+ const { data: session, registerSession } = useSession();
150
+ const queryClient = useQueryClient();
151
+
152
+ const activeOrganizationId = session?.session.activeOrganizationId ?? "";
153
+ const activeOrganizationRole =
154
+ (session?.session as { activeOrganizationRole?: string } | undefined)?.activeOrganizationRole ??
155
+ "";
156
+ const managerRoleSet = useMemo(() => new Set(managerRoles ?? ["admin", "owner"]), [managerRoles]);
157
+ const canManageOrganization = managerRoleSet.has(activeOrganizationRole);
158
+
159
+ const refreshOrganizationQueries = useCallback(async () => {
160
+ await Promise.allSettled([
161
+ queryClient.invalidateQueries({ queryKey: ["auth-organization-list"] }),
162
+ queryClient.invalidateQueries({
163
+ queryKey: ["auth-organization-details", activeOrganizationId],
164
+ }),
165
+ queryClient.invalidateQueries({
166
+ queryKey: ["auth-organization-members", activeOrganizationId],
167
+ }),
168
+ queryClient.invalidateQueries({
169
+ queryKey: ["auth-organization-invitations", activeOrganizationId],
170
+ }),
171
+ ]);
172
+
173
+ registerSession(() => {
174
+ void onInvalidateScopedQueries?.();
175
+ });
176
+ }, [activeOrganizationId, onInvalidateScopedQueries, queryClient, registerSession]);
177
+
178
+ return {
179
+ activeOrganizationId,
180
+ activeOrganizationRole,
181
+ canManageOrganization,
182
+ refreshOrganizationQueries,
183
+ };
184
+ }
185
+
186
+ function useOrganizationConfig({
187
+ assignableRoles,
188
+ }: Pick<OrganizationMembersRouteProps, "assignableRoles">) {
189
+ const { t } = useTranslation();
190
+
191
+ const translatedLabels = useMemo<OrganizationMembersRouteLabels>(
192
+ () => ({
193
+ loadError: t("web-ui:organization.members.loadError"),
194
+ membersTitle: t("web-ui:organization.members.title"),
195
+ membersNoActive: t("web-ui:organization.members.noActive"),
196
+ membersManageOnly: t("web-ui:organization.members.manageOnly"),
197
+ membersDescription: (organizationName: string) =>
198
+ t("web-ui:organization.members.description", { name: organizationName }),
199
+ defaultOrganizationName: t("web-ui:organization.members.defaultName"),
200
+ loadMembersError: t("web-ui:organization.members.loadMembersError"),
201
+ loadInvitationsError: t("web-ui:organization.members.loadInvitationsError"),
202
+ roleUpdateSuccess: t("web-ui:organization.members.roleUpdateSuccess"),
203
+ roleUpdateError: t("web-ui:organization.members.roleUpdateError"),
204
+ removeMemberSuccess: t("web-ui:organization.members.removeMemberSuccess"),
205
+ removeMemberError: t("web-ui:organization.members.removeMemberError"),
206
+ emailRequired: t("web-ui:organization.members.emailRequired"),
207
+ inviteSuccess: t("web-ui:organization.members.inviteSuccess"),
208
+ inviteError: t("web-ui:organization.members.inviteError"),
209
+ cancelInvitationSuccess: t("web-ui:organization.members.cancelInvitationSuccess"),
210
+ cancelInvitationError: t("web-ui:organization.members.cancelInvitationError"),
211
+ inviteLinkCopied: t("web-ui:organization.members.inviteLinkCopied"),
212
+ copyInviteLinkError: t("web-ui:organization.members.copyInviteLinkError"),
213
+ unknownName: t("web-ui:organization.members.unknownName"),
214
+ invitedUser: t("web-ui:organization.members.invitedUser"),
215
+ emailLabel: t("web-ui:organization.members.emailLabel"),
216
+ emailPlaceholder: t("web-ui:organization.members.emailPlaceholder"),
217
+ roleLabel: t("web-ui:organization.members.roleLabel"),
218
+ inviteButton: t("web-ui:organization.members.inviteButton"),
219
+ tableTitle: t("web-ui:organization.members.tableTitle"),
220
+ columnName: t("web-ui:organization.members.columnName"),
221
+ columnEmail: t("web-ui:organization.members.columnEmail"),
222
+ columnRole: t("web-ui:organization.members.columnRole"),
223
+ columnStatus: t("web-ui:organization.members.columnStatus"),
224
+ columnActions: t("web-ui:organization.members.columnActions"),
225
+ tableEmpty: t("web-ui:organization.members.tableEmpty"),
226
+ roleFor: (name: string) => t("web-ui:organization.members.roleFor", { name }),
227
+ statusActive: t("web-ui:organization.members.statusActive"),
228
+ statusInvited: t("web-ui:organization.members.statusInvited"),
229
+ removeMember: t("web-ui:organization.members.removeMember"),
230
+ copyInviteLink: t("web-ui:organization.members.copyInviteLink"),
231
+ cancelInvitation: t("web-ui:organization.members.cancelInvitation"),
232
+ roleUnknown: t("web-ui:organization.members.roleUnknown"),
233
+ }),
234
+ [t]
235
+ );
236
+
237
+ const translatedRoleLabels = useMemo<Record<string, string>>(
238
+ () => ({
239
+ member: t("web-ui:organization.roles.member"),
240
+ admin: t("web-ui:organization.roles.admin"),
241
+ owner: t("web-ui:organization.roles.owner"),
242
+ }),
243
+ [t]
244
+ );
245
+
246
+ const resolvedAssignableRoles = useMemo(
247
+ () =>
248
+ assignableRoles && assignableRoles.length > 0 ? assignableRoles : [...ORGANIZATION_ROLES],
249
+ [assignableRoles]
250
+ );
251
+
252
+ return {
253
+ resolvedLabels: translatedLabels,
254
+ resolvedRoleLabels: translatedRoleLabels,
255
+ resolvedAssignableRoles,
256
+ };
257
+ }
258
+
259
+ export function OrganizationMembersRoute({
260
+ managerRoles,
261
+ assignableRoles,
262
+ invitationAcceptPath,
263
+ onInvalidateScopedQueries,
264
+ }: OrganizationMembersRouteProps) {
265
+ const { resolvedLabels, resolvedRoleLabels, resolvedAssignableRoles } = useOrganizationConfig({
266
+ assignableRoles,
267
+ });
268
+
269
+ const {
270
+ activeOrganizationId,
271
+ activeOrganizationRole,
272
+ canManageOrganization,
273
+ refreshOrganizationQueries,
274
+ } = useOrganizationAccess({ managerRoles, onInvalidateScopedQueries });
275
+
276
+ const [inviteEmail, setInviteEmail] = useState("");
277
+ const [inviteRole, setInviteRole] = useState<OrganizationRole>(
278
+ resolvedAssignableRoles[0] ?? "member"
279
+ );
280
+ const isMountedRef = useRef(true);
281
+ useEffect(() => {
282
+ return () => {
283
+ isMountedRef.current = false;
284
+ };
285
+ }, []);
286
+
287
+ const refreshOrganizationQueriesStable = useCallback(
288
+ () => refreshOrganizationQueries(),
289
+ [refreshOrganizationQueries]
290
+ );
291
+
292
+ const updateRoleMutation = useMutation({
293
+ mutationFn: async ({
294
+ memberId,
295
+ role,
296
+ organizationId,
297
+ }: {
298
+ memberId: string;
299
+ role: OrganizationRole;
300
+ organizationId: string;
301
+ }) => {
302
+ const { error } = await authClient.organization.updateMemberRole({
303
+ memberId,
304
+ role,
305
+ organizationId,
306
+ });
307
+ if (error) throw new Error(error.message ?? resolvedLabels.roleUpdateError);
308
+ },
309
+ onSuccess: async () => {
310
+ await refreshOrganizationQueriesStable();
311
+ toast.success(resolvedLabels.roleUpdateSuccess);
312
+ },
313
+ onError: (error) => {
314
+ toast.error(error instanceof Error ? error.message : resolvedLabels.roleUpdateError);
315
+ },
316
+ });
317
+
318
+ const removeMemberMutation = useMutation({
319
+ mutationFn: async ({
320
+ memberId,
321
+ organizationId,
322
+ }: {
323
+ memberId: string;
324
+ organizationId: string;
325
+ }) => {
326
+ const { error } = await authClient.organization.removeMember({
327
+ memberIdOrEmail: memberId,
328
+ organizationId,
329
+ });
330
+ if (error) throw new Error(error.message ?? resolvedLabels.removeMemberError);
331
+ },
332
+ onSuccess: async () => {
333
+ await refreshOrganizationQueriesStable();
334
+ toast.success(resolvedLabels.removeMemberSuccess);
335
+ },
336
+ onError: (error) => {
337
+ toast.error(error instanceof Error ? error.message : resolvedLabels.removeMemberError);
338
+ },
339
+ });
340
+
341
+ const createInvitationMutation = useMutation({
342
+ mutationFn: async ({
343
+ email,
344
+ role,
345
+ organizationId,
346
+ }: {
347
+ email: string;
348
+ role: OrganizationRole;
349
+ organizationId: string;
350
+ }) => {
351
+ const { error } = await authClient.organization.inviteMember({
352
+ organizationId,
353
+ email: email.trim(),
354
+ role,
355
+ });
356
+ if (error) throw new Error(error.message ?? resolvedLabels.inviteError);
357
+ },
358
+ onSuccess: async () => {
359
+ await refreshOrganizationQueriesStable();
360
+ toast.success(resolvedLabels.inviteSuccess);
361
+ if (isMountedRef.current) {
362
+ setInviteEmail("");
363
+ setInviteRole(resolvedAssignableRoles[0] ?? "member");
364
+ }
365
+ },
366
+ onError: (error) => {
367
+ toast.error(error instanceof Error ? error.message : resolvedLabels.inviteError);
368
+ },
369
+ });
370
+
371
+ const cancelInvitationMutation = useMutation({
372
+ mutationFn: async ({ invitationId }: { invitationId: string }) => {
373
+ const { error } = await authClient.organization.cancelInvitation({ invitationId });
374
+ if (error) throw new Error(error.message ?? resolvedLabels.cancelInvitationError);
375
+ },
376
+ onSuccess: async () => {
377
+ await refreshOrganizationQueriesStable();
378
+ toast.success(resolvedLabels.cancelInvitationSuccess);
379
+ },
380
+ onError: (error) => {
381
+ toast.error(error instanceof Error ? error.message : resolvedLabels.cancelInvitationError);
382
+ },
383
+ });
384
+
385
+ const updatingMemberId =
386
+ updateRoleMutation.isPending && updateRoleMutation.variables
387
+ ? updateRoleMutation.variables.memberId
388
+ : null;
389
+ const removingMemberId =
390
+ removeMemberMutation.isPending && removeMemberMutation.variables
391
+ ? removeMemberMutation.variables.memberId
392
+ : null;
393
+ const cancelingInvitationId =
394
+ cancelInvitationMutation.isPending && cancelInvitationMutation.variables
395
+ ? cancelInvitationMutation.variables.invitationId
396
+ : null;
397
+
398
+ useEffect(() => {
399
+ if (!resolvedAssignableRoles.includes(inviteRole)) {
400
+ setInviteRole(resolvedAssignableRoles[0] ?? "member");
401
+ }
402
+ }, [inviteRole, resolvedAssignableRoles]);
403
+
404
+ const organizationQuery = useQuery({
405
+ queryKey: ["auth-organization-details", activeOrganizationId],
406
+ enabled: Boolean(activeOrganizationId && canManageOrganization),
407
+ queryFn: async () => {
408
+ const { data, error } = await authClient.organization.getFullOrganization({
409
+ query: {
410
+ organizationId: activeOrganizationId,
411
+ membersLimit: 200,
412
+ },
413
+ });
414
+ if (error) {
415
+ throw new Error(error.message ?? resolvedLabels.loadError);
416
+ }
417
+ return data as OrganizationDetails | null;
418
+ },
419
+ });
420
+
421
+ const membersQuery = useQuery({
422
+ queryKey: ["auth-organization-members", activeOrganizationId],
423
+ enabled: Boolean(activeOrganizationId && canManageOrganization),
424
+ queryFn: async () => {
425
+ const { data, error } = await authClient.organization.listMembers({
426
+ query: {
427
+ organizationId: activeOrganizationId,
428
+ limit: 200,
429
+ offset: 0,
430
+ },
431
+ });
432
+ if (error) {
433
+ throw new Error(error.message ?? resolvedLabels.loadMembersError);
434
+ }
435
+ return ((data as { members: OrganizationMember[] } | null)?.members ??
436
+ []) as OrganizationMember[];
437
+ },
438
+ });
439
+
440
+ const invitationsQuery = useQuery({
441
+ queryKey: ["auth-organization-invitations", activeOrganizationId],
442
+ enabled: Boolean(activeOrganizationId && canManageOrganization),
443
+ queryFn: async () => {
444
+ const { data, error } = await authClient.organization.listInvitations({
445
+ query: {
446
+ organizationId: activeOrganizationId,
447
+ },
448
+ });
449
+ if (error) {
450
+ throw new Error(error.message ?? resolvedLabels.loadInvitationsError);
451
+ }
452
+ return (data ?? []) as OrganizationInvitation[];
453
+ },
454
+ });
455
+
456
+ const members = membersQuery.data ?? [];
457
+ const invitations = invitationsQuery.data ?? [];
458
+
459
+ const rows = useMemo<CombinedMemberRow[]>(() => {
460
+ const memberRows: CombinedMemberRow[] = members.map((member) => ({
461
+ kind: "member",
462
+ id: `member-${member.id}`,
463
+ displayName: member.user?.name || resolvedLabels.unknownName,
464
+ email: member.user?.email || "-",
465
+ role: member.role,
466
+ status: "active",
467
+ memberId: member.id,
468
+ }));
469
+
470
+ const invitationRows: CombinedMemberRow[] = invitations.map((invitation) => ({
471
+ kind: "invitation",
472
+ id: `invitation-${invitation.id}`,
473
+ displayName: resolvedLabels.invitedUser,
474
+ email: invitation.email,
475
+ role: invitation.role,
476
+ status: "invited",
477
+ invitationId: invitation.id,
478
+ }));
479
+
480
+ return [...memberRows, ...invitationRows].sort((left, right) => {
481
+ if (left.status === right.status) {
482
+ return left.email.localeCompare(right.email);
483
+ }
484
+ return left.status === "active" ? -1 : 1;
485
+ });
486
+ }, [invitations, members, resolvedLabels.invitedUser, resolvedLabels.unknownName]);
487
+
488
+ const onUpdateMemberRole = useCallback(
489
+ (memberId: string, role: OrganizationRole) => {
490
+ if (!canManageOrganization || !activeOrganizationId) return;
491
+ updateRoleMutation.mutate({ memberId, role, organizationId: activeOrganizationId });
492
+ },
493
+ [canManageOrganization, activeOrganizationId, updateRoleMutation]
494
+ );
495
+
496
+ const onRemoveMember = useCallback(
497
+ (memberId: string) => {
498
+ if (!canManageOrganization || !activeOrganizationId) return;
499
+ removeMemberMutation.mutate({ memberId, organizationId: activeOrganizationId });
500
+ },
501
+ [canManageOrganization, activeOrganizationId, removeMemberMutation]
502
+ );
503
+
504
+ const onCreateInvitation = useCallback(() => {
505
+ if (!canManageOrganization || !activeOrganizationId) return;
506
+ if (!inviteEmail.trim()) {
507
+ toast.error(resolvedLabels.emailRequired);
508
+ return;
509
+ }
510
+ createInvitationMutation.mutate({
511
+ email: inviteEmail,
512
+ role: inviteRole,
513
+ organizationId: activeOrganizationId,
514
+ });
515
+ }, [
516
+ canManageOrganization,
517
+ activeOrganizationId,
518
+ inviteEmail,
519
+ inviteRole,
520
+ resolvedLabels.emailRequired,
521
+ createInvitationMutation,
522
+ ]);
523
+
524
+ const onCancelInvitation = useCallback(
525
+ (invitationId: string) => {
526
+ if (!canManageOrganization) return;
527
+ cancelInvitationMutation.mutate({ invitationId });
528
+ },
529
+ [canManageOrganization, cancelInvitationMutation]
530
+ );
531
+
532
+ const invitationLinkBase = useMemo(() => {
533
+ const invitationPath = invitationAcceptPath ?? "/organization/accept-invitation";
534
+ if (/^https?:\/\//i.test(invitationPath)) {
535
+ return `${invitationPath}?id=`;
536
+ }
537
+ return typeof window === "undefined"
538
+ ? `${invitationPath}?id=`
539
+ : `${window.location.origin}${invitationPath}?id=`;
540
+ }, [invitationAcceptPath]);
541
+
542
+ const onCopyInvitationLink = async (invitationId: string) => {
543
+ try {
544
+ await navigator.clipboard.writeText(`${invitationLinkBase}${invitationId}`);
545
+ toast.success(resolvedLabels.inviteLinkCopied);
546
+ } catch {
547
+ toast.error(resolvedLabels.copyInviteLinkError);
548
+ }
549
+ };
550
+
551
+ const getRoleLabel = (role: string) => resolvedRoleLabels[role] ?? resolvedLabels.roleUnknown;
552
+
553
+ if (!activeOrganizationId) {
554
+ return (
555
+ <OrganizationStateCard
556
+ title={resolvedLabels.membersTitle}
557
+ message={resolvedLabels.membersNoActive}
558
+ />
559
+ );
560
+ }
561
+
562
+ if (!canManageOrganization) {
563
+ return (
564
+ <OrganizationStateCard
565
+ title={resolvedLabels.membersTitle}
566
+ message={resolvedLabels.membersManageOnly}
567
+ />
568
+ );
569
+ }
570
+
571
+ if (organizationQuery.isLoading || membersQuery.isLoading || invitationsQuery.isLoading) {
572
+ return (
573
+ <div className="p-6 flex justify-center">
574
+ <Spinner />
575
+ </div>
576
+ );
577
+ }
578
+
579
+ return (
580
+ <div className="p-6 space-y-6">
581
+ <Card>
582
+ <CardHeader className="flex items-center justify-between">
583
+ <div className="flex items-center gap-2">
584
+ <Users className="h-4 w-4" />
585
+ <div className="flex flex-col">
586
+ <h2 className="text-lg font-semibold">{resolvedLabels.membersTitle}</h2>
587
+ <p className="text-sm text-default-500">
588
+ {resolvedLabels.membersDescription(
589
+ organizationQuery.data?.name ?? resolvedLabels.defaultOrganizationName
590
+ )}
591
+ </p>
592
+ </div>
593
+ </div>
594
+ <Chip variant="flat" color="primary">
595
+ {getRoleLabel(activeOrganizationRole || "member")}
596
+ </Chip>
597
+ </CardHeader>
598
+ <CardBody className="space-y-4">
599
+ <div className="grid gap-3 md:grid-cols-[1fr_160px_auto]">
600
+ <Input
601
+ type="email"
602
+ label={resolvedLabels.emailLabel}
603
+ value={inviteEmail}
604
+ onValueChange={setInviteEmail}
605
+ placeholder={resolvedLabels.emailPlaceholder}
606
+ />
607
+ <Select
608
+ label={resolvedLabels.roleLabel}
609
+ selectedKeys={[inviteRole]}
610
+ disallowEmptySelection
611
+ onSelectionChange={(keys) => {
612
+ const role = Array.from(keys as Set<string>)[0];
613
+ if (role && isOrganizationRole(role) && resolvedAssignableRoles.includes(role)) {
614
+ setInviteRole(role);
615
+ }
616
+ }}
617
+ >
618
+ {resolvedAssignableRoles.map((role) => (
619
+ <SelectItem key={role}>{getRoleLabel(role)}</SelectItem>
620
+ ))}
621
+ </Select>
622
+ <Button
623
+ color="primary"
624
+ startContent={<UserPlus className="h-4 w-4" />}
625
+ onPress={onCreateInvitation}
626
+ isLoading={createInvitationMutation.isPending}
627
+ >
628
+ {resolvedLabels.inviteButton}
629
+ </Button>
630
+ </div>
631
+
632
+ <Table aria-label={resolvedLabels.tableTitle}>
633
+ <TableHeader>
634
+ <TableColumn>{resolvedLabels.columnName}</TableColumn>
635
+ <TableColumn>{resolvedLabels.columnEmail}</TableColumn>
636
+ <TableColumn>{resolvedLabels.columnRole}</TableColumn>
637
+ <TableColumn>{resolvedLabels.columnStatus}</TableColumn>
638
+ <TableColumn className="text-right">{resolvedLabels.columnActions}</TableColumn>
639
+ </TableHeader>
640
+ <TableBody emptyContent={resolvedLabels.tableEmpty}>
641
+ {rows.map((row) => (
642
+ <TableRow key={row.id}>
643
+ <TableCell>{row.displayName}</TableCell>
644
+ <TableCell>{row.email}</TableCell>
645
+ <TableCell>
646
+ {row.kind === "member" ? (
647
+ <Select
648
+ size="sm"
649
+ selectedKeys={[row.role]}
650
+ disallowEmptySelection
651
+ isDisabled={updatingMemberId === row.memberId}
652
+ aria-label={resolvedLabels.roleFor(row.displayName)}
653
+ onSelectionChange={(keys) => {
654
+ const role = Array.from(keys as Set<string>)[0];
655
+ if (
656
+ role &&
657
+ role !== row.role &&
658
+ isOrganizationRole(role) &&
659
+ resolvedAssignableRoles.includes(role)
660
+ ) {
661
+ void onUpdateMemberRole(row.memberId, role);
662
+ }
663
+ }}
664
+ >
665
+ {resolvedAssignableRoles.map((role) => (
666
+ <SelectItem key={role}>{getRoleLabel(role)}</SelectItem>
667
+ ))}
668
+ </Select>
669
+ ) : (
670
+ getRoleLabel(row.role)
671
+ )}
672
+ </TableCell>
673
+ <TableCell>
674
+ <Chip
675
+ size="sm"
676
+ variant="flat"
677
+ color={row.status === "active" ? "success" : "warning"}
678
+ >
679
+ {row.status === "active"
680
+ ? resolvedLabels.statusActive
681
+ : resolvedLabels.statusInvited}
682
+ </Chip>
683
+ </TableCell>
684
+ <TableCell className="text-right">
685
+ {row.kind === "member" ? (
686
+ <Button
687
+ size="sm"
688
+ color="danger"
689
+ variant="light"
690
+ isIconOnly
691
+ onPress={() => void onRemoveMember(row.memberId)}
692
+ isDisabled={removingMemberId === row.memberId}
693
+ aria-label={resolvedLabels.removeMember}
694
+ >
695
+ <Trash2 className="h-4 w-4" />
696
+ </Button>
697
+ ) : (
698
+ <div className="flex justify-end gap-2">
699
+ <Button
700
+ size="sm"
701
+ variant="light"
702
+ isIconOnly
703
+ onPress={() => void onCopyInvitationLink(row.invitationId)}
704
+ aria-label={resolvedLabels.copyInviteLink}
705
+ >
706
+ <Copy className="h-4 w-4" />
707
+ </Button>
708
+ <Button
709
+ size="sm"
710
+ color="danger"
711
+ variant="light"
712
+ isIconOnly
713
+ onPress={() => void onCancelInvitation(row.invitationId)}
714
+ isDisabled={cancelingInvitationId === row.invitationId}
715
+ aria-label={resolvedLabels.cancelInvitation}
716
+ >
717
+ <Trash2 className="h-4 w-4" />
718
+ </Button>
719
+ </div>
720
+ )}
721
+ </TableCell>
722
+ </TableRow>
723
+ ))}
724
+ </TableBody>
725
+ </Table>
726
+ </CardBody>
727
+ </Card>
728
+ </div>
729
+ );
730
+ }