@workos-inc/widgets 0.0.0-pre.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 (230) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/cjs/index.d.ts +3 -0
  4. package/dist/cjs/index.d.ts.map +1 -0
  5. package/dist/cjs/index.js +8 -0
  6. package/dist/cjs/index.js.map +1 -0
  7. package/dist/cjs/lib/api/config.d.ts +9 -0
  8. package/dist/cjs/lib/api/config.d.ts.map +1 -0
  9. package/dist/cjs/lib/api/config.js +12 -0
  10. package/dist/cjs/lib/api/config.js.map +1 -0
  11. package/dist/cjs/lib/api/role.d.ts +9 -0
  12. package/dist/cjs/lib/api/role.d.ts.map +1 -0
  13. package/dist/cjs/lib/api/role.js +94 -0
  14. package/dist/cjs/lib/api/role.js.map +1 -0
  15. package/dist/cjs/lib/api/user.d.ts +61 -0
  16. package/dist/cjs/lib/api/user.d.ts.map +1 -0
  17. package/dist/cjs/lib/api/user.js +312 -0
  18. package/dist/cjs/lib/api/user.js.map +1 -0
  19. package/dist/cjs/lib/constants.d.ts +3 -0
  20. package/dist/cjs/lib/constants.d.ts.map +1 -0
  21. package/dist/cjs/lib/constants.js +6 -0
  22. package/dist/cjs/lib/constants.js.map +1 -0
  23. package/dist/cjs/lib/delete-user-dialog.d.ts +12 -0
  24. package/dist/cjs/lib/delete-user-dialog.d.ts.map +1 -0
  25. package/dist/cjs/lib/delete-user-dialog.js +37 -0
  26. package/dist/cjs/lib/delete-user-dialog.js.map +1 -0
  27. package/dist/cjs/lib/edit-user-details-dialog.d.ts +12 -0
  28. package/dist/cjs/lib/edit-user-details-dialog.d.ts.map +1 -0
  29. package/dist/cjs/lib/edit-user-details-dialog.js +81 -0
  30. package/dist/cjs/lib/edit-user-details-dialog.js.map +1 -0
  31. package/dist/cjs/lib/elements.d.ts +32 -0
  32. package/dist/cjs/lib/elements.d.ts.map +1 -0
  33. package/dist/cjs/lib/elements.js +57 -0
  34. package/dist/cjs/lib/elements.js.map +1 -0
  35. package/dist/cjs/lib/invite-user-dialog.d.ts +7 -0
  36. package/dist/cjs/lib/invite-user-dialog.d.ts.map +1 -0
  37. package/dist/cjs/lib/invite-user-dialog.js +167 -0
  38. package/dist/cjs/lib/invite-user-dialog.js.map +1 -0
  39. package/dist/cjs/lib/label.d.ts +7 -0
  40. package/dist/cjs/lib/label.d.ts.map +1 -0
  41. package/dist/cjs/lib/label.js +9 -0
  42. package/dist/cjs/lib/label.js.map +1 -0
  43. package/dist/cjs/lib/pagination.d.ts +8 -0
  44. package/dist/cjs/lib/pagination.d.ts.map +1 -0
  45. package/dist/cjs/lib/pagination.js +67 -0
  46. package/dist/cjs/lib/pagination.js.map +1 -0
  47. package/dist/cjs/lib/resend-invite-dialog.d.ts +10 -0
  48. package/dist/cjs/lib/resend-invite-dialog.d.ts.map +1 -0
  49. package/dist/cjs/lib/resend-invite-dialog.js +71 -0
  50. package/dist/cjs/lib/resend-invite-dialog.js.map +1 -0
  51. package/dist/cjs/lib/revoke-invite-dialog.d.ts +10 -0
  52. package/dist/cjs/lib/revoke-invite-dialog.d.ts.map +1 -0
  53. package/dist/cjs/lib/revoke-invite-dialog.js +37 -0
  54. package/dist/cjs/lib/revoke-invite-dialog.js.map +1 -0
  55. package/dist/cjs/lib/search-provider.d.ts +11 -0
  56. package/dist/cjs/lib/search-provider.d.ts.map +1 -0
  57. package/dist/cjs/lib/search-provider.js +55 -0
  58. package/dist/cjs/lib/search-provider.js.map +1 -0
  59. package/dist/cjs/lib/use-is-hydrated.d.ts +2 -0
  60. package/dist/cjs/lib/use-is-hydrated.d.ts.map +1 -0
  61. package/dist/cjs/lib/use-is-hydrated.js +34 -0
  62. package/dist/cjs/lib/use-is-hydrated.js.map +1 -0
  63. package/dist/cjs/lib/user-actions-dropdown.d.ts +9 -0
  64. package/dist/cjs/lib/user-actions-dropdown.d.ts.map +1 -0
  65. package/dist/cjs/lib/user-actions-dropdown.js +83 -0
  66. package/dist/cjs/lib/user-actions-dropdown.js.map +1 -0
  67. package/dist/cjs/lib/users-filter.d.ts +9 -0
  68. package/dist/cjs/lib/users-filter.d.ts.map +1 -0
  69. package/dist/cjs/lib/users-filter.js +63 -0
  70. package/dist/cjs/lib/users-filter.js.map +1 -0
  71. package/dist/cjs/lib/users-management-context.d.ts +23 -0
  72. package/dist/cjs/lib/users-management-context.d.ts.map +1 -0
  73. package/dist/cjs/lib/users-management-context.js +83 -0
  74. package/dist/cjs/lib/users-management-context.js.map +1 -0
  75. package/dist/cjs/lib/users-management-state.d.ts +22 -0
  76. package/dist/cjs/lib/users-management-state.d.ts.map +1 -0
  77. package/dist/cjs/lib/users-management-state.js +143 -0
  78. package/dist/cjs/lib/users-management-state.js.map +1 -0
  79. package/dist/cjs/lib/users-management.d.ts +12 -0
  80. package/dist/cjs/lib/users-management.d.ts.map +1 -0
  81. package/dist/cjs/lib/users-management.js +141 -0
  82. package/dist/cjs/lib/users-management.js.map +1 -0
  83. package/dist/cjs/lib/users-search.d.ts +3 -0
  84. package/dist/cjs/lib/users-search.d.ts.map +1 -0
  85. package/dist/cjs/lib/users-search.js +65 -0
  86. package/dist/cjs/lib/users-search.js.map +1 -0
  87. package/dist/cjs/lib/utils.d.ts +15 -0
  88. package/dist/cjs/lib/utils.d.ts.map +1 -0
  89. package/dist/cjs/lib/utils.js +78 -0
  90. package/dist/cjs/lib/utils.js.map +1 -0
  91. package/dist/cjs/lib/widgets-context.d.ts +11 -0
  92. package/dist/cjs/lib/widgets-context.d.ts.map +1 -0
  93. package/dist/cjs/lib/widgets-context.js +45 -0
  94. package/dist/cjs/lib/widgets-context.js.map +1 -0
  95. package/dist/cjs/users-management.client.d.ts +6 -0
  96. package/dist/cjs/users-management.client.d.ts.map +1 -0
  97. package/dist/cjs/users-management.client.js +57 -0
  98. package/dist/cjs/users-management.client.js.map +1 -0
  99. package/dist/cjs/workos-widgets.client.d.ts +17 -0
  100. package/dist/cjs/workos-widgets.client.d.ts.map +1 -0
  101. package/dist/cjs/workos-widgets.client.js +55 -0
  102. package/dist/cjs/workos-widgets.client.js.map +1 -0
  103. package/dist/esm/index.d.ts +3 -0
  104. package/dist/esm/index.d.ts.map +1 -0
  105. package/dist/esm/index.js +3 -0
  106. package/dist/esm/index.js.map +1 -0
  107. package/dist/esm/lib/api/config.d.ts +9 -0
  108. package/dist/esm/lib/api/config.d.ts.map +1 -0
  109. package/dist/esm/lib/api/config.js +9 -0
  110. package/dist/esm/lib/api/config.js.map +1 -0
  111. package/dist/esm/lib/api/role.d.ts +9 -0
  112. package/dist/esm/lib/api/role.d.ts.map +1 -0
  113. package/dist/esm/lib/api/role.js +89 -0
  114. package/dist/esm/lib/api/role.js.map +1 -0
  115. package/dist/esm/lib/api/user.d.ts +61 -0
  116. package/dist/esm/lib/api/user.d.ts.map +1 -0
  117. package/dist/esm/lib/api/user.js +302 -0
  118. package/dist/esm/lib/api/user.js.map +1 -0
  119. package/dist/esm/lib/constants.d.ts +3 -0
  120. package/dist/esm/lib/constants.d.ts.map +1 -0
  121. package/dist/esm/lib/constants.js +3 -0
  122. package/dist/esm/lib/constants.js.map +1 -0
  123. package/dist/esm/lib/delete-user-dialog.d.ts +12 -0
  124. package/dist/esm/lib/delete-user-dialog.d.ts.map +1 -0
  125. package/dist/esm/lib/delete-user-dialog.js +33 -0
  126. package/dist/esm/lib/delete-user-dialog.js.map +1 -0
  127. package/dist/esm/lib/edit-user-details-dialog.d.ts +12 -0
  128. package/dist/esm/lib/edit-user-details-dialog.d.ts.map +1 -0
  129. package/dist/esm/lib/edit-user-details-dialog.js +54 -0
  130. package/dist/esm/lib/edit-user-details-dialog.js.map +1 -0
  131. package/dist/esm/lib/elements.d.ts +32 -0
  132. package/dist/esm/lib/elements.d.ts.map +1 -0
  133. package/dist/esm/lib/elements.js +54 -0
  134. package/dist/esm/lib/elements.js.map +1 -0
  135. package/dist/esm/lib/invite-user-dialog.d.ts +7 -0
  136. package/dist/esm/lib/invite-user-dialog.d.ts.map +1 -0
  137. package/dist/esm/lib/invite-user-dialog.js +140 -0
  138. package/dist/esm/lib/invite-user-dialog.js.map +1 -0
  139. package/dist/esm/lib/label.d.ts +7 -0
  140. package/dist/esm/lib/label.d.ts.map +1 -0
  141. package/dist/esm/lib/label.js +6 -0
  142. package/dist/esm/lib/label.js.map +1 -0
  143. package/dist/esm/lib/pagination.d.ts +8 -0
  144. package/dist/esm/lib/pagination.d.ts.map +1 -0
  145. package/dist/esm/lib/pagination.js +40 -0
  146. package/dist/esm/lib/pagination.js.map +1 -0
  147. package/dist/esm/lib/resend-invite-dialog.d.ts +10 -0
  148. package/dist/esm/lib/resend-invite-dialog.d.ts.map +1 -0
  149. package/dist/esm/lib/resend-invite-dialog.js +44 -0
  150. package/dist/esm/lib/resend-invite-dialog.js.map +1 -0
  151. package/dist/esm/lib/revoke-invite-dialog.d.ts +10 -0
  152. package/dist/esm/lib/revoke-invite-dialog.d.ts.map +1 -0
  153. package/dist/esm/lib/revoke-invite-dialog.js +33 -0
  154. package/dist/esm/lib/revoke-invite-dialog.js.map +1 -0
  155. package/dist/esm/lib/search-provider.d.ts +11 -0
  156. package/dist/esm/lib/search-provider.d.ts.map +1 -0
  157. package/dist/esm/lib/search-provider.js +27 -0
  158. package/dist/esm/lib/search-provider.js.map +1 -0
  159. package/dist/esm/lib/use-is-hydrated.d.ts +2 -0
  160. package/dist/esm/lib/use-is-hydrated.d.ts.map +1 -0
  161. package/dist/esm/lib/use-is-hydrated.js +8 -0
  162. package/dist/esm/lib/use-is-hydrated.js.map +1 -0
  163. package/dist/esm/lib/user-actions-dropdown.d.ts +9 -0
  164. package/dist/esm/lib/user-actions-dropdown.d.ts.map +1 -0
  165. package/dist/esm/lib/user-actions-dropdown.js +56 -0
  166. package/dist/esm/lib/user-actions-dropdown.js.map +1 -0
  167. package/dist/esm/lib/users-filter.d.ts +9 -0
  168. package/dist/esm/lib/users-filter.d.ts.map +1 -0
  169. package/dist/esm/lib/users-filter.js +36 -0
  170. package/dist/esm/lib/users-filter.js.map +1 -0
  171. package/dist/esm/lib/users-management-context.d.ts +23 -0
  172. package/dist/esm/lib/users-management-context.d.ts.map +1 -0
  173. package/dist/esm/lib/users-management-context.js +54 -0
  174. package/dist/esm/lib/users-management-context.js.map +1 -0
  175. package/dist/esm/lib/users-management-state.d.ts +22 -0
  176. package/dist/esm/lib/users-management-state.d.ts.map +1 -0
  177. package/dist/esm/lib/users-management-state.js +117 -0
  178. package/dist/esm/lib/users-management-state.js.map +1 -0
  179. package/dist/esm/lib/users-management.d.ts +12 -0
  180. package/dist/esm/lib/users-management.d.ts.map +1 -0
  181. package/dist/esm/lib/users-management.js +114 -0
  182. package/dist/esm/lib/users-management.js.map +1 -0
  183. package/dist/esm/lib/users-search.d.ts +3 -0
  184. package/dist/esm/lib/users-search.d.ts.map +1 -0
  185. package/dist/esm/lib/users-search.js +39 -0
  186. package/dist/esm/lib/users-search.js.map +1 -0
  187. package/dist/esm/lib/utils.d.ts +15 -0
  188. package/dist/esm/lib/utils.d.ts.map +1 -0
  189. package/dist/esm/lib/utils.js +70 -0
  190. package/dist/esm/lib/utils.js.map +1 -0
  191. package/dist/esm/lib/widgets-context.d.ts +11 -0
  192. package/dist/esm/lib/widgets-context.d.ts.map +1 -0
  193. package/dist/esm/lib/widgets-context.js +17 -0
  194. package/dist/esm/lib/widgets-context.js.map +1 -0
  195. package/dist/esm/users-management.client.d.ts +6 -0
  196. package/dist/esm/users-management.client.d.ts.map +1 -0
  197. package/dist/esm/users-management.client.js +30 -0
  198. package/dist/esm/users-management.client.js.map +1 -0
  199. package/dist/esm/workos-widgets.client.d.ts +17 -0
  200. package/dist/esm/workos-widgets.client.d.ts.map +1 -0
  201. package/dist/esm/workos-widgets.client.js +28 -0
  202. package/dist/esm/workos-widgets.client.js.map +1 -0
  203. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  204. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  205. package/package.json +69 -0
  206. package/src/index.ts +5 -0
  207. package/src/lib/api/config.ts +9 -0
  208. package/src/lib/api/role.ts +124 -0
  209. package/src/lib/api/user.ts +458 -0
  210. package/src/lib/constants.ts +2 -0
  211. package/src/lib/delete-user-dialog.tsx +103 -0
  212. package/src/lib/edit-user-details-dialog.tsx +170 -0
  213. package/src/lib/elements.tsx +175 -0
  214. package/src/lib/invite-user-dialog.tsx +319 -0
  215. package/src/lib/label.tsx +14 -0
  216. package/src/lib/pagination.tsx +69 -0
  217. package/src/lib/resend-invite-dialog.tsx +136 -0
  218. package/src/lib/revoke-invite-dialog.tsx +104 -0
  219. package/src/lib/search-provider.tsx +51 -0
  220. package/src/lib/use-is-hydrated.ts +13 -0
  221. package/src/lib/user-actions-dropdown.tsx +161 -0
  222. package/src/lib/users-filter.tsx +122 -0
  223. package/src/lib/users-management-context.tsx +89 -0
  224. package/src/lib/users-management-state.ts +165 -0
  225. package/src/lib/users-management.tsx +461 -0
  226. package/src/lib/users-search.tsx +130 -0
  227. package/src/lib/utils.ts +94 -0
  228. package/src/lib/widgets-context.ts +29 -0
  229. package/src/users-management.client.tsx +59 -0
  230. package/src/workos-widgets.client.tsx +73 -0
@@ -0,0 +1,461 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ Box,
6
+ Flex,
7
+ Grid,
8
+ Skeleton,
9
+ Table,
10
+ Text,
11
+ type TextProps,
12
+ VisuallyHidden,
13
+ } from "@radix-ui/themes";
14
+ import type { Role } from "./api/role";
15
+ import type { Paginated, User } from "./api/user";
16
+ import {
17
+ Avatar,
18
+ Badge,
19
+ IconButton,
20
+ PrimaryButton,
21
+ SecondaryButton,
22
+ } from "./elements";
23
+ import { InviteUserDialog } from "./invite-user-dialog";
24
+ import { Pagination } from "./pagination";
25
+ import { SearchProvider, useSearchContext } from "./search-provider";
26
+ import { useIsHydrated } from "./use-is-hydrated";
27
+ import { UserActionsDropdown } from "./user-actions-dropdown";
28
+ import { UsersFilter } from "./users-filter";
29
+ import { UsersSearch } from "./users-search";
30
+ import { getBestName, getComparativeReadableDate } from "./utils";
31
+ import { USER_ROW_LIMIT } from "./constants";
32
+ import { useUsersManagementContext } from "./users-management-context";
33
+
34
+ interface UsersManagementProps {
35
+ rolesData: Role[] | undefined;
36
+ userData: Paginated<User[]> | undefined;
37
+
38
+ // Render the rows with an alternate background color
39
+ alternate?: boolean;
40
+ // When the users list is loading new users
41
+ isPending?: boolean;
42
+ // Show the skeleton UI
43
+ isInitialLoading?: boolean;
44
+ }
45
+
46
+ export const UsersManagement = ({
47
+ userData,
48
+ rolesData,
49
+ alternate,
50
+ isPending,
51
+ isInitialLoading,
52
+ }: UsersManagementProps) => {
53
+ const users = userData?.data;
54
+ const usersCount = users?.length ?? 0;
55
+ const isHydrated = useIsHydrated();
56
+ return (
57
+ <SearchProvider>
58
+ <Flex direction="column" gap="3">
59
+ <Grid columns="1fr auto" gap="2">
60
+ <Flex gap="2" align="center">
61
+ <Skeleton loading={isInitialLoading}>
62
+ <Box flexBasis="380px" flexGrow="0" flexShrink="1">
63
+ <UsersSearch />
64
+ </Box>
65
+ </Skeleton>
66
+ <Skeleton loading={isInitialLoading}>
67
+ <Box flexGrow="0" flexShrink="0">
68
+ <UsersFilter roles={rolesData} />
69
+ </Box>
70
+ </Skeleton>
71
+ </Flex>
72
+
73
+ <Skeleton loading={isInitialLoading}>
74
+ <Box flexGrow="0" flexShrink="0" style={{ placeSelf: "flex-end" }}>
75
+ <InviteUserDialog>
76
+ <PrimaryButton>Invite user</PrimaryButton>
77
+ </InviteUserDialog>
78
+ </Box>
79
+ </Skeleton>
80
+ </Grid>
81
+ <Table.Root variant="ghost" size="1">
82
+ <Table.Header>
83
+ <Table.Row>
84
+ <Table.ColumnHeaderCell width="260px">
85
+ <Skeleton loading={isInitialLoading}>User</Skeleton>
86
+ </Table.ColumnHeaderCell>
87
+ <Table.ColumnHeaderCell width="100px">
88
+ <Skeleton loading={isInitialLoading}>Role</Skeleton>
89
+ </Table.ColumnHeaderCell>
90
+ <Table.ColumnHeaderCell width="140px">
91
+ <Skeleton loading={isInitialLoading}>Last active</Skeleton>
92
+ </Table.ColumnHeaderCell>
93
+ <Table.ColumnHeaderCell width="28px" />
94
+ </Table.Row>
95
+ </Table.Header>
96
+
97
+ <Table.Body
98
+ style={{
99
+ transition: `opacity 0.2s ease-out ${isPending ? "0.2s" : "0s"}`,
100
+ opacity: isPending && usersCount > 0 ? 0.5 : 1,
101
+ }}
102
+ >
103
+ {isInitialLoading && (
104
+ <SkeletonRows length={USER_ROW_LIMIT} alternate={alternate} />
105
+ )}
106
+ {users?.map((user, i) => {
107
+ // TODO only support one role for now
108
+ const userRole = user.roles[0]?.name;
109
+ const userDisplayName = getBestName(user);
110
+ const dimText =
111
+ user.status === "InviteRevoked" ||
112
+ user.status === "InviteExpired";
113
+ return (
114
+ <Table.Row
115
+ key={user.id}
116
+ align="center"
117
+ style={{
118
+ background:
119
+ alternate && i % 2 === 1 ? "var(--gray-a1)" : undefined,
120
+ }}
121
+ >
122
+ <Table.RowHeaderCell>
123
+ <Flex
124
+ align="center"
125
+ gap="3"
126
+ overflow="hidden"
127
+ height="var(--space-7)"
128
+ >
129
+ <Avatar
130
+ size="2"
131
+ fallback={<FallbackUserIcon />}
132
+ src={user.profilePictureUrl ?? undefined}
133
+ dim={dimText}
134
+ />
135
+
136
+ {userDisplayName ? (
137
+ <Flex
138
+ direction="column"
139
+ align="start"
140
+ height="var(--space-7)"
141
+ justify="center"
142
+ overflow="hidden"
143
+ >
144
+ <Flex gap="2" align="center" minWidth="0">
145
+ <TableCellText dim={dimText}>
146
+ {userDisplayName}
147
+ </TableCellText>
148
+ <UserBadge user={user} />
149
+ </Flex>
150
+ <TableCellText
151
+ level="secondary"
152
+ title={user.email}
153
+ dim={dimText}
154
+ >
155
+ {user.email}
156
+ </TableCellText>
157
+ </Flex>
158
+ ) : (
159
+ <Flex gap="2" align="center" minWidth="0">
160
+ <TableCellText dim={dimText} title={user.email}>
161
+ {user.email}
162
+ </TableCellText>
163
+ <UserBadge user={user} />
164
+ </Flex>
165
+ )}
166
+ </Flex>
167
+ </Table.RowHeaderCell>
168
+ <Table.Cell>
169
+ <TableCellText dim={dimText}>
170
+ {userRole || (
171
+ <>
172
+ <VisuallyHidden>No roles assigned</VisuallyHidden>
173
+ <span aria-hidden style={{ userSelect: "none" }}>
174
+
175
+ </span>
176
+ </>
177
+ )}
178
+ </TableCellText>
179
+ </Table.Cell>
180
+ <Table.Cell>
181
+ <LastActive
182
+ user={user}
183
+ isHydrated={isHydrated}
184
+ dim={dimText}
185
+ />
186
+ </Table.Cell>
187
+ <Table.Cell justify="end">
188
+ <UserActionsDropdown user={user}>
189
+ <IconButton title="User actions">
190
+ <VisuallyHidden>User actions</VisuallyHidden>
191
+ <svg
192
+ xmlns="http://www.w3.org/2000/svg"
193
+ fill="none"
194
+ viewBox="0 0 24 24"
195
+ width="16"
196
+ height="16"
197
+ strokeWidth={1.5}
198
+ stroke="currentColor"
199
+ aria-hidden
200
+ >
201
+ <path
202
+ strokeLinecap="round"
203
+ strokeLinejoin="round"
204
+ d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
205
+ />
206
+ </svg>
207
+ </IconButton>
208
+ </UserActionsDropdown>
209
+ </Table.Cell>
210
+ </Table.Row>
211
+ );
212
+ })}
213
+
214
+ {users?.length === 0 && (
215
+ <Table.Row align="center">
216
+ <Table.Cell colSpan={4}>
217
+ <UsersManagementEmptyState />
218
+ </Table.Cell>
219
+ </Table.Row>
220
+ )}
221
+ </Table.Body>
222
+ </Table.Root>
223
+
224
+ <Pagination isPending={isPending} pagination={userData?.pagination} />
225
+ </Flex>
226
+ </SearchProvider>
227
+ );
228
+ };
229
+
230
+ function UserBadge({ user }: { user: User }) {
231
+ // TODO: This is not yet available in the data. Update here after API is updated.
232
+ if (user.isLoggedInUser) {
233
+ return (
234
+ <Badge color="gray" style={{ userSelect: "none" }}>
235
+ You
236
+ </Badge>
237
+ );
238
+ }
239
+ if (user.status === "Invited") {
240
+ return (
241
+ <Badge color="amber" style={{ userSelect: "none" }}>
242
+ <VisuallyHidden>Status: </VisuallyHidden>Invited
243
+ </Badge>
244
+ );
245
+ }
246
+ if (user.status === "InviteExpired") {
247
+ return (
248
+ <Badge color="red" style={{ userSelect: "none" }}>
249
+ <VisuallyHidden>Status: Invite </VisuallyHidden>
250
+ Expired
251
+ </Badge>
252
+ );
253
+ }
254
+ if (user.status === "InviteRevoked") {
255
+ return (
256
+ <Badge color="red" style={{ userSelect: "none" }}>
257
+ <VisuallyHidden>Status: Invite </VisuallyHidden>
258
+ Revoked
259
+ </Badge>
260
+ );
261
+ }
262
+ return null;
263
+ }
264
+
265
+ interface LastActiveProps {
266
+ user: User;
267
+ isHydrated: boolean;
268
+ dim?: boolean;
269
+ }
270
+
271
+ function LastActive(props: LastActiveProps) {
272
+ if (!props.user.lastActivityAt) {
273
+ return (
274
+ <>
275
+ <VisuallyHidden>
276
+ {props.user.status === "Active" ? "Never" : "Not active"}
277
+ </VisuallyHidden>
278
+ <TableCellText
279
+ dim={props.dim}
280
+ aria-hidden
281
+ style={{ userSelect: "none" }}
282
+ >
283
+
284
+ </TableCellText>
285
+ </>
286
+ );
287
+ }
288
+ return <LastActiveImpl {...props} date={props.user.lastActivityAt} />;
289
+ }
290
+
291
+ function LastActiveImpl({
292
+ date,
293
+ isHydrated,
294
+ dim,
295
+ }: LastActiveProps & { date: string }) {
296
+ const { lastActiveDateTime, lastActiveDisplay } = React.useMemo(() => {
297
+ const defaultTimeZone = "America/Los_Angeles";
298
+ const lastActiveDate = new Date(date);
299
+ const lastActiveDateTime = lastActiveDate.toLocaleTimeString("en-US", {
300
+ // hard-coded timezone before hydration to prevent server/client mismatch
301
+ timeZone: isHydrated ? undefined : defaultTimeZone,
302
+ month: "long",
303
+ day: "numeric",
304
+ year: "numeric",
305
+ hour: "numeric",
306
+ minute: "numeric",
307
+ });
308
+
309
+ // Server and client may produce a different 'now' date, so only
310
+ // show comparative date if the component is hydrated to prevent a
311
+ // server/client mismatch
312
+ const lastActiveDisplay = isHydrated
313
+ ? getComparativeReadableDate(new Date(), lastActiveDate)
314
+ : lastActiveDate.toLocaleDateString("en-US", {
315
+ // hard-coded timezone to prevent server/client mismatch
316
+ timeZone: defaultTimeZone,
317
+ month: "long",
318
+ day: "numeric",
319
+ year: "numeric",
320
+ });
321
+
322
+ return { lastActiveDateTime, lastActiveDisplay };
323
+ }, [isHydrated, date]);
324
+
325
+ // handle cases where the DB might return an invalid date string
326
+ if (lastActiveDisplay === "Invalid Date") {
327
+ return (
328
+ <>
329
+ <VisuallyHidden>Unknown</VisuallyHidden>
330
+ <TableCellText dim={dim} aria-hidden style={{ userSelect: "none" }}>
331
+
332
+ </TableCellText>
333
+ </>
334
+ );
335
+ }
336
+
337
+ return (
338
+ <TableCellText asChild dim={dim}>
339
+ <time dateTime={date} title={lastActiveDateTime}>
340
+ {lastActiveDisplay}
341
+ </time>
342
+ </TableCellText>
343
+ );
344
+ }
345
+
346
+ const SkeletonRows = ({
347
+ length,
348
+ alternate = false,
349
+ }: {
350
+ length: number;
351
+ alternate?: boolean;
352
+ }) => {
353
+ return Array.from({ length }, (_, index) => {
354
+ return (
355
+ <Table.Row
356
+ key={index}
357
+ align="center"
358
+ style={{
359
+ background:
360
+ alternate && index % 2 === 1 ? "var(--gray-a1)" : undefined,
361
+ }}
362
+ >
363
+ <Table.RowHeaderCell>
364
+ <Flex align="center" gap="3">
365
+ <Skeleton>
366
+ <Avatar size="2" fallback="F" />
367
+ </Skeleton>
368
+
369
+ <Flex direction="column" height="var(--space-7)" justify="center">
370
+ <Skeleton width="180px" height="var(--space-4)" />
371
+ <Skeleton width="90px" height="var(--space-3)" mt="1" />
372
+ </Flex>
373
+ </Flex>
374
+ </Table.RowHeaderCell>
375
+ <Table.Cell>
376
+ <Flex wrap="wrap" gap="1">
377
+ <Skeleton width="75px" height="var(--space-4)" />
378
+ </Flex>
379
+ </Table.Cell>
380
+ <Table.Cell>
381
+ <Skeleton width="120px" height="var(--space-4)" />
382
+ </Table.Cell>
383
+ <Table.Cell justify="end" />
384
+ </Table.Row>
385
+ );
386
+ });
387
+ };
388
+
389
+ const TableCellText = React.forwardRef<HTMLSpanElement, TableCellTextProps>(
390
+ function TableCellText(
391
+ { children, dim, level = "primary", ...props },
392
+ forwardedRef,
393
+ ) {
394
+ return (
395
+ <Text
396
+ ref={forwardedRef}
397
+ color={level === "secondary" ? "gray" : undefined}
398
+ weight={level === "secondary" ? "regular" : "medium"}
399
+ size={level === "secondary" ? "1" : "2"}
400
+ truncate
401
+ {...props}
402
+ style={
403
+ dim
404
+ ? {
405
+ // TODO: use CSS var instead of hard-coded value for opacity
406
+ opacity: 0.6,
407
+ ...props.style,
408
+ }
409
+ : props.style
410
+ }
411
+ >
412
+ {children}
413
+ </Text>
414
+ );
415
+ },
416
+ );
417
+
418
+ type TableCellTextProps = TextProps & {
419
+ level?: "primary" | "secondary";
420
+ dim?: boolean;
421
+ };
422
+
423
+ const FallbackUserIcon = () => (
424
+ <svg
425
+ xmlns="http://www.w3.org/2000/svg"
426
+ width="20"
427
+ height="20"
428
+ fill="currentColor"
429
+ viewBox="0 0 256 256"
430
+ >
431
+ <title>User icon</title>
432
+ <path d="M229.19,213c-15.81-27.32-40.63-46.49-69.47-54.62a70,70,0,1,0-63.44,0C67.44,166.5,42.62,185.67,26.81,213a6,6,0,1,0,10.38,6C56.4,185.81,90.34,166,128,166s71.6,19.81,90.81,53a6,6,0,1,0,10.38-6ZM70,96a58,58,0,1,1,58,58A58.07,58.07,0,0,1,70,96Z" />
433
+ </svg>
434
+ );
435
+
436
+ const UsersManagementEmptyState = () => {
437
+ const { clearSearch } = useSearchContext();
438
+ const {
439
+ state: { searchQuery },
440
+ } = useUsersManagementContext();
441
+
442
+ if (searchQuery) {
443
+ return (
444
+ <Flex align="center" justify="center" py="8" direction="column" gap="2">
445
+ <Text size="2">
446
+ No users found for query <Text weight="medium">“{searchQuery}”</Text>
447
+ </Text>
448
+
449
+ <SecondaryButton size="1" onClick={clearSearch}>
450
+ Clear search
451
+ </SecondaryButton>
452
+ </Flex>
453
+ );
454
+ }
455
+
456
+ return (
457
+ <Flex align="center" justify="center" py="8" gap="2">
458
+ <Text size="2">No users found</Text>
459
+ </Flex>
460
+ );
461
+ };
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
4
+ import { Cross2Icon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
5
+ import { Checkbox, DropdownMenu, Flex, IconButton } from "@radix-ui/themes";
6
+ import * as React from "react";
7
+ import { useDebouncedCallback } from "use-debounce";
8
+ import { PrimaryMenuItem, TextField, TextFieldSlot } from "./elements";
9
+ import { useSearchContext } from "./search-provider";
10
+ import { useUsersManagementContext } from "./users-management-context";
11
+
12
+ type UsersSearchProps = React.ComponentPropsWithoutRef<typeof TextField>;
13
+
14
+ export const UsersSearch = React.forwardRef<HTMLInputElement, UsersSearchProps>(
15
+ (props, ref) => {
16
+ const { inputRef, clearSearch, searchValue, setSearchValue } =
17
+ useSearchContext();
18
+ const { dispatch } = useUsersManagementContext();
19
+
20
+ const filter = useDebouncedCallback((value) => {
21
+ dispatch({ type: "FILTER_BY_SEARCH", searchQuery: value });
22
+ }, 200);
23
+
24
+ const resetSearch = () => {
25
+ clearSearch();
26
+ filter.cancel();
27
+ };
28
+
29
+ return (
30
+ <TextField
31
+ ref={useComposedRefs(inputRef, ref)}
32
+ autoComplete="off"
33
+ placeholder="Search by name or e-mail"
34
+ value={searchValue}
35
+ onChange={(event) => {
36
+ const value = event.target.value;
37
+ setSearchValue(value);
38
+ filter(value);
39
+ }}
40
+ onKeyDown={(event) => {
41
+ if (event.key === "Escape") {
42
+ event.preventDefault();
43
+ resetSearch();
44
+ }
45
+ }}
46
+ {...props}
47
+ >
48
+ <TextFieldSlot side="left">
49
+ <MagnifyingGlassIcon aria-hidden="true" height="16" width="16" />
50
+ </TextFieldSlot>
51
+
52
+ <TextFieldSlot side="right">
53
+ {searchValue && (
54
+ <IconButton
55
+ size="1"
56
+ color="gray"
57
+ variant="ghost"
58
+ radius="full"
59
+ onClick={resetSearch}
60
+ aria-label="Clear search"
61
+ title="Clear search"
62
+ >
63
+ <Cross2Icon aria-hidden="true" />
64
+ </IconButton>
65
+ )}
66
+ {/* <FilterMenu /> */}
67
+ </TextFieldSlot>
68
+ </TextField>
69
+ );
70
+ },
71
+ );
72
+
73
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
74
+ function FilterMenu() {
75
+ return (
76
+ <DropdownMenu.Root>
77
+ <DropdownMenu.Trigger>
78
+ <IconButton
79
+ size="1"
80
+ color="gray"
81
+ variant="ghost"
82
+ radius="full"
83
+ aria-label="Filter users"
84
+ title="Filter users"
85
+ >
86
+ <FilterIcon aria-hidden="true" />
87
+ </IconButton>
88
+ </DropdownMenu.Trigger>
89
+ <DropdownMenu.Content size="2" align="end">
90
+ <PrimaryMenuItem>
91
+ <Flex gap="2" align="center">
92
+ <Checkbox variant="surface" />
93
+ One
94
+ </Flex>
95
+ </PrimaryMenuItem>
96
+ <PrimaryMenuItem>
97
+ <Flex gap="2" align="center">
98
+ <Checkbox />
99
+ Two
100
+ </Flex>
101
+ </PrimaryMenuItem>
102
+ </DropdownMenu.Content>
103
+ </DropdownMenu.Root>
104
+ );
105
+ }
106
+
107
+ const FilterIcon = React.forwardRef<
108
+ SVGSVGElement,
109
+ React.ComponentPropsWithRef<"svg">
110
+ >(function FilterIcon({ children, ...props }, forwardedRef) {
111
+ return (
112
+ <svg
113
+ width="15"
114
+ height="15"
115
+ viewBox="0 0 15 15"
116
+ fill="currentColor"
117
+ xmlns="http://www.w3.org/2000/svg"
118
+ ref={forwardedRef}
119
+ {...props}
120
+ >
121
+ {children}
122
+ <path
123
+ fillRule="evenodd"
124
+ clipRule="evenodd"
125
+ d="M1.5 1C1.22386 1 1 1.22386 1 1.5V4.5C1 4.66316 1.07961 4.81605 1.21327 4.90962L6 8.26033V13.5C6 13.6733 6.08973 13.8342 6.23713 13.9253C6.38454 14.0164 6.56861 14.0247 6.72361 13.9472L8.72361 12.9472C8.893 12.8625 9 12.6894 9 12.5V8.26033L13.7867 4.90962C13.9204 4.81605 14 4.66316 14 4.5V1.5C14 1.22386 13.7761 1 13.5 1H1.5ZM2 4.23967V2H13V4.23967L8.21327 7.59038C8.07961 7.68395 8 7.83684 8 8V12.191L7 12.691V8C7 7.83684 6.92039 7.68395 6.78673 7.59038L2 4.23967ZM12 3H3V4H12V3Z"
126
+ fill="black"
127
+ />
128
+ </svg>
129
+ );
130
+ });
@@ -0,0 +1,94 @@
1
+ import type { User } from "./api/user";
2
+
3
+ export const canUseDOM = !!(
4
+ typeof window !== "undefined" &&
5
+ window.document &&
6
+ window.document.createElement
7
+ );
8
+
9
+ export function getBestName({
10
+ firstName,
11
+ lastName,
12
+ }: Pick<User, "firstName" | "lastName">) {
13
+ return [firstName, lastName].filter(Boolean).join(" ") || null;
14
+ }
15
+
16
+ export function getComparativeReadableDate(
17
+ now: Date,
18
+ then: Date,
19
+ options?: { timeZone?: string },
20
+ ): string {
21
+ const timeSince = now.getTime() - then.getTime();
22
+
23
+ // Has it been less than a minute?
24
+ if (timeSince < 60_000) {
25
+ return "Just now";
26
+ }
27
+
28
+ // Has it been less than an hour?
29
+ if (timeSince < 3_600_000) {
30
+ const timePassed = Math.floor(timeSince / 60_000);
31
+ return timePassed === 1 ? "1 minute ago" : `${timePassed} minutes ago`;
32
+ }
33
+
34
+ // Has it been less than a day?
35
+ if (timeSince < 86_400_000) {
36
+ const timePassed = Math.floor(timeSince / 3_600_000);
37
+ return timePassed === 1 ? "1 hour ago" : `${timePassed} hours ago`;
38
+ }
39
+
40
+ // Has it been less than a week?
41
+ if (timeSince < 604_800_000) {
42
+ const timePassed = Math.floor(timeSince / 86_400_000);
43
+ return timePassed === 1 ? "1 day ago" : `${timePassed} days ago`;
44
+ }
45
+
46
+ // Has it been less than a month?
47
+ if (timeSince < 2_592_000_000) {
48
+ const timePassed = Math.floor(timeSince / 604_800_000);
49
+ return timePassed === 1 ? "1 week ago" : `${timePassed} weeks ago`;
50
+ }
51
+
52
+ // Any later?
53
+ return then.toLocaleDateString("en-US", {
54
+ timeZone: options?.timeZone,
55
+ month: "long",
56
+ day: "numeric",
57
+ // omit year if it's the same as the current year
58
+ year: now.getFullYear() !== then.getFullYear() ? "numeric" : undefined,
59
+ });
60
+ }
61
+
62
+ export function isObjectLike(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null;
64
+ }
65
+
66
+ export function isErrorLike(
67
+ value: unknown,
68
+ ): value is Record<string, unknown> & { message: string } {
69
+ return isObjectLike(value) && typeof value.message === "string";
70
+ }
71
+
72
+ export async function parseErrorResponse(
73
+ response: Response,
74
+ ): Promise<{ message: string; status: number }> {
75
+ try {
76
+ const json = await response.json();
77
+ if (!isObjectLike(json) || typeof json.message !== "string") {
78
+ return {
79
+ status: response.status,
80
+ message: response.statusText,
81
+ };
82
+ }
83
+ return {
84
+ ...json,
85
+ status: response.status,
86
+ message: json.message || response.statusText,
87
+ };
88
+ } catch {
89
+ return {
90
+ status: response.status,
91
+ message: response.statusText,
92
+ };
93
+ }
94
+ }