@workos-inc/widgets 1.1.4 → 1.2.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.
- package/LICENSE +21 -0
- package/dist/cjs/lib/organization-switcher.d.ts +10 -1
- package/dist/cjs/lib/organization-switcher.d.ts.map +1 -1
- package/dist/cjs/lib/organization-switcher.js +31 -3
- package/dist/cjs/lib/organization-switcher.js.map +1 -1
- package/dist/cjs/workos-widgets.client.d.ts +6 -0
- package/dist/cjs/workos-widgets.client.d.ts.map +1 -1
- package/dist/cjs/workos-widgets.client.js +23 -3
- package/dist/cjs/workos-widgets.client.js.map +1 -1
- package/dist/esm/lib/organization-switcher.d.ts +10 -1
- package/dist/esm/lib/organization-switcher.d.ts.map +1 -1
- package/dist/esm/lib/organization-switcher.js +31 -3
- package/dist/esm/lib/organization-switcher.js.map +1 -1
- package/dist/esm/workos-widgets.client.d.ts +6 -0
- package/dist/esm/workos-widgets.client.d.ts.map +1 -1
- package/dist/esm/workos-widgets.client.js +25 -5
- package/dist/esm/workos-widgets.client.js.map +1 -1
- package/package.json +40 -47
- package/src/api/api-provider.tsx +0 -158
- package/src/api/constants.ts +0 -1
- package/src/api/endpoint.ts +0 -3097
- package/src/api/errors.ts +0 -48
- package/src/api/index.ts +0 -2
- package/src/api/utils.ts +0 -42
- package/src/api/widgets-api-client.ts +0 -87
- package/src/card-list.tsx +0 -26
- package/src/index.ts +0 -9
- package/src/lib/add-mfa-dialog.tsx +0 -379
- package/src/lib/api/config.ts +0 -9
- package/src/lib/api/user.ts +0 -98
- package/src/lib/change-password-dialog.tsx +0 -290
- package/src/lib/constants.ts +0 -3
- package/src/lib/copy-button.tsx +0 -53
- package/src/lib/delete-user-dialog.tsx +0 -110
- package/src/lib/edit-user-profile-dialog.tsx +0 -181
- package/src/lib/edit-user-role-dialog.tsx +0 -178
- package/src/lib/elements.tsx +0 -428
- package/src/lib/elevated-access.tsx +0 -261
- package/src/lib/error-boundary.tsx +0 -166
- package/src/lib/errors.ts +0 -49
- package/src/lib/generic-error.tsx +0 -70
- package/src/lib/icon-panel.tsx +0 -26
- package/src/lib/icons.tsx +0 -21
- package/src/lib/invite-user-dialog.tsx +0 -327
- package/src/lib/logout-all-sessions-dialog.tsx +0 -82
- package/src/lib/logout-dialog.tsx +0 -85
- package/src/lib/marker.tsx +0 -39
- package/src/lib/oauth-icons.tsx +0 -138
- package/src/lib/organization-switcher.tsx +0 -156
- package/src/lib/otp-input.tsx +0 -276
- package/src/lib/resend-invite-dialog.tsx +0 -145
- package/src/lib/reset-mfa-dialog.tsx +0 -104
- package/src/lib/revoke-invite-dialog.tsx +0 -111
- package/src/lib/save-button.tsx +0 -113
- package/src/lib/search-provider.tsx +0 -51
- package/src/lib/set-password-dialog.tsx +0 -204
- package/src/lib/use-dialog-close.tsx +0 -19
- package/src/lib/use-is-hydrated.ts +0 -13
- package/src/lib/use-layout-effect.ts +0 -6
- package/src/lib/use-security-settings.tsx +0 -49
- package/src/lib/user-actions-dropdown.tsx +0 -157
- package/src/lib/user-profile.tsx +0 -227
- package/src/lib/user-security.tsx +0 -187
- package/src/lib/user-sessions.tsx +0 -204
- package/src/lib/users-filter.tsx +0 -62
- package/src/lib/users-management-context.tsx +0 -74
- package/src/lib/users-management-state.ts +0 -165
- package/src/lib/users-management.tsx +0 -594
- package/src/lib/users-search.tsx +0 -73
- package/src/lib/utils.ts +0 -131
- package/src/lib/widgets-context.ts +0 -29
- package/src/organization-switcher.client.tsx +0 -81
- package/src/user-profile.client.tsx +0 -55
- package/src/user-security.client.tsx +0 -55
- package/src/user-sessions.client.tsx +0 -100
- package/src/users-management.client.tsx +0 -73
- package/src/workos-widgets.client.tsx +0 -75
- /package/{src → dist/css}/base.css +0 -0
- /package/{src → dist/css}/lib/card-list.css +0 -0
- /package/{src → dist/css}/lib/marker.css +0 -0
- /package/{src → dist/css}/lib/save-button.css +0 -0
- /package/{src → dist/css}/styles.css +0 -0
- /package/{src → dist/css}/users-management.css +0 -0
|
@@ -1,594 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import {
|
|
5
|
-
Box,
|
|
6
|
-
Flex,
|
|
7
|
-
Grid,
|
|
8
|
-
Select,
|
|
9
|
-
Separator,
|
|
10
|
-
Table,
|
|
11
|
-
Text,
|
|
12
|
-
type TextProps,
|
|
13
|
-
VisuallyHidden,
|
|
14
|
-
} from "@radix-ui/themes";
|
|
15
|
-
import {
|
|
16
|
-
Avatar,
|
|
17
|
-
Badge,
|
|
18
|
-
IconButton,
|
|
19
|
-
PrimaryButton,
|
|
20
|
-
SecondaryButton,
|
|
21
|
-
SelectContent,
|
|
22
|
-
SelectItem,
|
|
23
|
-
SelectTrigger,
|
|
24
|
-
Skeleton,
|
|
25
|
-
TextField,
|
|
26
|
-
} from "./elements";
|
|
27
|
-
import { InviteUserDialog } from "./invite-user-dialog";
|
|
28
|
-
import { SearchProvider, useSearchContext } from "./search-provider";
|
|
29
|
-
import { useIsHydrated } from "./use-is-hydrated";
|
|
30
|
-
import { UserActionsDropdown } from "./user-actions-dropdown";
|
|
31
|
-
import { UsersFilter } from "./users-filter";
|
|
32
|
-
import { UsersSearch } from "./users-search";
|
|
33
|
-
import { getBestName, getComparativeReadableDate } from "./utils";
|
|
34
|
-
import { USER_ROW_LIMIT } from "./constants";
|
|
35
|
-
import { useUsersManagementContext } from "./users-management-context";
|
|
36
|
-
import clsx from "clsx";
|
|
37
|
-
import { Member, MemberRole, MembersQueryResult } from "../api";
|
|
38
|
-
import { GenericError } from "./generic-error";
|
|
39
|
-
|
|
40
|
-
interface UsersManagementProps {
|
|
41
|
-
rolesData: MemberRole[];
|
|
42
|
-
userData: MembersQueryResult;
|
|
43
|
-
disableRolesFilter?: boolean;
|
|
44
|
-
// When the users list is loading new users
|
|
45
|
-
isPending: boolean;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const UsersManagement = ({
|
|
49
|
-
userData,
|
|
50
|
-
rolesData,
|
|
51
|
-
isPending,
|
|
52
|
-
disableRolesFilter,
|
|
53
|
-
}: UsersManagementProps) => {
|
|
54
|
-
const users = userData?.data ?? [];
|
|
55
|
-
const usersCount = users?.length ?? 0;
|
|
56
|
-
const isHydrated = useIsHydrated();
|
|
57
|
-
const { listMetadata: pagination = {} } = userData;
|
|
58
|
-
const { dispatch } = useUsersManagementContext();
|
|
59
|
-
|
|
60
|
-
// we only want to show the loading indicator for some buttons if the request
|
|
61
|
-
// is still pending after 500ms. If the request is fast enough the indicator
|
|
62
|
-
// is a bit jarring.
|
|
63
|
-
const [deferredLoading, setDeferredLoading] = React.useState(false);
|
|
64
|
-
React.useEffect(() => {
|
|
65
|
-
if (isPending) {
|
|
66
|
-
const timeoutId = window.setTimeout(() => {
|
|
67
|
-
setDeferredLoading(true);
|
|
68
|
-
}, 500);
|
|
69
|
-
return () => {
|
|
70
|
-
window.clearTimeout(timeoutId);
|
|
71
|
-
};
|
|
72
|
-
} else {
|
|
73
|
-
setDeferredLoading(false);
|
|
74
|
-
}
|
|
75
|
-
}, [isPending]);
|
|
76
|
-
|
|
77
|
-
const showPagination = !!(pagination.before || pagination.after);
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<SearchProvider>
|
|
81
|
-
<UsersManagementRoot>
|
|
82
|
-
<Grid columns="1fr auto" gap="2">
|
|
83
|
-
<Flex gap="2" align="center">
|
|
84
|
-
<Box flexBasis="380px" flexGrow="0" flexShrink="1">
|
|
85
|
-
<UsersSearch />
|
|
86
|
-
</Box>
|
|
87
|
-
<Box flexGrow="0" flexShrink="0">
|
|
88
|
-
<UsersFilter roles={rolesData} disabled={disableRolesFilter} />
|
|
89
|
-
</Box>
|
|
90
|
-
</Flex>
|
|
91
|
-
<Box flexGrow="0" flexShrink="0" style={{ placeSelf: "flex-end" }}>
|
|
92
|
-
<InviteUserDialog>
|
|
93
|
-
<PrimaryButton>Invite user</PrimaryButton>
|
|
94
|
-
</InviteUserDialog>
|
|
95
|
-
</Box>
|
|
96
|
-
</Grid>
|
|
97
|
-
<Table.Root variant="ghost" size="1">
|
|
98
|
-
<Table.Header>
|
|
99
|
-
<Table.Row>
|
|
100
|
-
<Table.ColumnHeaderCell width="260px">
|
|
101
|
-
User
|
|
102
|
-
</Table.ColumnHeaderCell>
|
|
103
|
-
<Table.ColumnHeaderCell width="100px">
|
|
104
|
-
Role
|
|
105
|
-
</Table.ColumnHeaderCell>
|
|
106
|
-
<Table.ColumnHeaderCell width="140px">
|
|
107
|
-
Last active
|
|
108
|
-
</Table.ColumnHeaderCell>
|
|
109
|
-
<Table.ColumnHeaderCell width="28px" />
|
|
110
|
-
</Table.Row>
|
|
111
|
-
</Table.Header>
|
|
112
|
-
|
|
113
|
-
<Table.Body
|
|
114
|
-
style={{
|
|
115
|
-
transition: `opacity 0.2s ease-out ${isPending ? "0.2s" : "0s"}`,
|
|
116
|
-
opacity: isPending && usersCount > 0 ? 0.5 : 1,
|
|
117
|
-
}}
|
|
118
|
-
>
|
|
119
|
-
{users.length > 0 ? (
|
|
120
|
-
users.map((user) => {
|
|
121
|
-
// TODO only support one role for now
|
|
122
|
-
const userRole = user?.roles?.at(0)?.name;
|
|
123
|
-
const userDisplayName = getBestName(user);
|
|
124
|
-
const dimText =
|
|
125
|
-
user.status === "InviteRevoked" ||
|
|
126
|
-
user.status === "InviteExpired";
|
|
127
|
-
return (
|
|
128
|
-
<Table.Row key={user.id} align="center">
|
|
129
|
-
<Table.RowHeaderCell>
|
|
130
|
-
<Flex
|
|
131
|
-
align="center"
|
|
132
|
-
gap="3"
|
|
133
|
-
overflow="hidden"
|
|
134
|
-
height="var(--space-7)"
|
|
135
|
-
>
|
|
136
|
-
<Avatar
|
|
137
|
-
size="2"
|
|
138
|
-
fallback={<FallbackUserIcon />}
|
|
139
|
-
src={user.profilePictureUrl ?? undefined}
|
|
140
|
-
dim={dimText}
|
|
141
|
-
/>
|
|
142
|
-
|
|
143
|
-
{userDisplayName ? (
|
|
144
|
-
<Flex
|
|
145
|
-
direction="column"
|
|
146
|
-
align="start"
|
|
147
|
-
height="var(--space-7)"
|
|
148
|
-
justify="center"
|
|
149
|
-
overflow="hidden"
|
|
150
|
-
>
|
|
151
|
-
<Flex gap="2" align="center" minWidth="0">
|
|
152
|
-
<TableCellText dim={dimText}>
|
|
153
|
-
{userDisplayName}
|
|
154
|
-
</TableCellText>
|
|
155
|
-
<UserBadge user={user} />
|
|
156
|
-
</Flex>
|
|
157
|
-
<TableCellText
|
|
158
|
-
level="secondary"
|
|
159
|
-
title={user.email}
|
|
160
|
-
dim={dimText}
|
|
161
|
-
>
|
|
162
|
-
{user.email}
|
|
163
|
-
</TableCellText>
|
|
164
|
-
</Flex>
|
|
165
|
-
) : (
|
|
166
|
-
<Flex gap="2" align="center" minWidth="0">
|
|
167
|
-
<TableCellText dim={dimText} title={user.email}>
|
|
168
|
-
{user.email}
|
|
169
|
-
</TableCellText>
|
|
170
|
-
<UserBadge user={user} />
|
|
171
|
-
</Flex>
|
|
172
|
-
)}
|
|
173
|
-
</Flex>
|
|
174
|
-
</Table.RowHeaderCell>
|
|
175
|
-
<Table.Cell>
|
|
176
|
-
<TableCellText dim={dimText}>
|
|
177
|
-
{userRole || (
|
|
178
|
-
<>
|
|
179
|
-
<VisuallyHidden>No roles assigned</VisuallyHidden>
|
|
180
|
-
<span aria-hidden style={{ userSelect: "none" }}>
|
|
181
|
-
–
|
|
182
|
-
</span>
|
|
183
|
-
</>
|
|
184
|
-
)}
|
|
185
|
-
</TableCellText>
|
|
186
|
-
</Table.Cell>
|
|
187
|
-
<Table.Cell>
|
|
188
|
-
<LastActive user={user} isHydrated={isHydrated} />
|
|
189
|
-
</Table.Cell>
|
|
190
|
-
<Table.Cell justify="end">
|
|
191
|
-
<UserActionsDropdown user={user}>
|
|
192
|
-
<IconButton title="User actions">
|
|
193
|
-
<VisuallyHidden>User actions</VisuallyHidden>
|
|
194
|
-
<svg
|
|
195
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
196
|
-
fill="none"
|
|
197
|
-
viewBox="0 0 24 24"
|
|
198
|
-
width="16"
|
|
199
|
-
height="16"
|
|
200
|
-
strokeWidth={1.5}
|
|
201
|
-
stroke="currentColor"
|
|
202
|
-
aria-hidden
|
|
203
|
-
>
|
|
204
|
-
<path
|
|
205
|
-
strokeLinecap="round"
|
|
206
|
-
strokeLinejoin="round"
|
|
207
|
-
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"
|
|
208
|
-
/>
|
|
209
|
-
</svg>
|
|
210
|
-
</IconButton>
|
|
211
|
-
</UserActionsDropdown>
|
|
212
|
-
</Table.Cell>
|
|
213
|
-
</Table.Row>
|
|
214
|
-
);
|
|
215
|
-
})
|
|
216
|
-
) : (
|
|
217
|
-
<Table.Row align="center">
|
|
218
|
-
<Table.Cell colSpan={4}>
|
|
219
|
-
<UsersManagementEmptyState isPending={isPending} />
|
|
220
|
-
</Table.Cell>
|
|
221
|
-
</Table.Row>
|
|
222
|
-
)}
|
|
223
|
-
</Table.Body>
|
|
224
|
-
</Table.Root>
|
|
225
|
-
|
|
226
|
-
{showPagination ? (
|
|
227
|
-
<Flex gap="2" justify="end">
|
|
228
|
-
<SecondaryButton
|
|
229
|
-
size="1"
|
|
230
|
-
disabled={!pagination.after || isPending || undefined}
|
|
231
|
-
loading={deferredLoading}
|
|
232
|
-
onClick={() => {
|
|
233
|
-
if (pagination.after) {
|
|
234
|
-
dispatch({
|
|
235
|
-
type: "SET_PAGINATION",
|
|
236
|
-
pagination: { after: pagination.after },
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
}}
|
|
240
|
-
>
|
|
241
|
-
Previous
|
|
242
|
-
</SecondaryButton>
|
|
243
|
-
<SecondaryButton
|
|
244
|
-
size="1"
|
|
245
|
-
disabled={!pagination.before || isPending || undefined}
|
|
246
|
-
loading={deferredLoading}
|
|
247
|
-
onClick={() => {
|
|
248
|
-
if (pagination.before) {
|
|
249
|
-
dispatch({
|
|
250
|
-
type: "SET_PAGINATION",
|
|
251
|
-
pagination: { before: pagination.before },
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}}
|
|
255
|
-
>
|
|
256
|
-
Next
|
|
257
|
-
</SecondaryButton>
|
|
258
|
-
</Flex>
|
|
259
|
-
) : null}
|
|
260
|
-
</UsersManagementRoot>
|
|
261
|
-
</SearchProvider>
|
|
262
|
-
);
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
export function UsersManagementLoading() {
|
|
266
|
-
return (
|
|
267
|
-
<UsersManagementRoot>
|
|
268
|
-
<Grid columns="1fr auto" gap="2">
|
|
269
|
-
<Flex gap="2" align="center">
|
|
270
|
-
<Skeleton loading>
|
|
271
|
-
<Box flexBasis="380px" flexGrow="0" flexShrink="1">
|
|
272
|
-
<TextField />
|
|
273
|
-
</Box>
|
|
274
|
-
</Skeleton>
|
|
275
|
-
<Skeleton loading>
|
|
276
|
-
<Box flexGrow="0" flexShrink="0">
|
|
277
|
-
<Select.Root value="all" onValueChange={() => void 0}>
|
|
278
|
-
<SelectTrigger>All</SelectTrigger>
|
|
279
|
-
<SelectContent>
|
|
280
|
-
<SelectItem value="all">All</SelectItem>
|
|
281
|
-
</SelectContent>
|
|
282
|
-
</Select.Root>
|
|
283
|
-
</Box>
|
|
284
|
-
</Skeleton>
|
|
285
|
-
</Flex>
|
|
286
|
-
<Skeleton loading>
|
|
287
|
-
<Box flexGrow="0" flexShrink="0" style={{ placeSelf: "flex-end" }}>
|
|
288
|
-
<PrimaryButton>Invite user</PrimaryButton>
|
|
289
|
-
</Box>
|
|
290
|
-
</Skeleton>
|
|
291
|
-
</Grid>
|
|
292
|
-
<Table.Root variant="ghost" size="1">
|
|
293
|
-
<Table.Header>
|
|
294
|
-
<Table.Row>
|
|
295
|
-
<Table.ColumnHeaderCell width="260px">
|
|
296
|
-
<Skeleton loading>User</Skeleton>
|
|
297
|
-
</Table.ColumnHeaderCell>
|
|
298
|
-
<Table.ColumnHeaderCell width="100px">
|
|
299
|
-
<Skeleton>Role</Skeleton>
|
|
300
|
-
</Table.ColumnHeaderCell>
|
|
301
|
-
<Table.ColumnHeaderCell width="140px">
|
|
302
|
-
<Skeleton>Last active</Skeleton>
|
|
303
|
-
</Table.ColumnHeaderCell>
|
|
304
|
-
<Table.ColumnHeaderCell width="28px" />
|
|
305
|
-
</Table.Row>
|
|
306
|
-
</Table.Header>
|
|
307
|
-
|
|
308
|
-
<Table.Body>
|
|
309
|
-
{Array.from({ length: USER_ROW_LIMIT }, (_, index) => (
|
|
310
|
-
<Table.Row key={index} align="center">
|
|
311
|
-
<Table.RowHeaderCell>
|
|
312
|
-
<Flex align="center" gap="3">
|
|
313
|
-
<Skeleton>
|
|
314
|
-
<Avatar size="2" fallback="F" />
|
|
315
|
-
</Skeleton>
|
|
316
|
-
|
|
317
|
-
<Flex
|
|
318
|
-
direction="column"
|
|
319
|
-
height="var(--space-7)"
|
|
320
|
-
justify="center"
|
|
321
|
-
>
|
|
322
|
-
<Skeleton width="180px" height="var(--space-4)" />
|
|
323
|
-
<Skeleton width="90px" height="var(--space-3)" mt="1" />
|
|
324
|
-
</Flex>
|
|
325
|
-
</Flex>
|
|
326
|
-
</Table.RowHeaderCell>
|
|
327
|
-
<Table.Cell>
|
|
328
|
-
<Flex wrap="wrap" gap="1">
|
|
329
|
-
<Skeleton width="75px" height="var(--space-4)" />
|
|
330
|
-
</Flex>
|
|
331
|
-
</Table.Cell>
|
|
332
|
-
<Table.Cell>
|
|
333
|
-
<Skeleton width="120px" height="var(--space-4)" />
|
|
334
|
-
</Table.Cell>
|
|
335
|
-
<Table.Cell justify="end" />
|
|
336
|
-
</Table.Row>
|
|
337
|
-
))}
|
|
338
|
-
</Table.Body>
|
|
339
|
-
</Table.Root>
|
|
340
|
-
|
|
341
|
-
<Flex gap="2" justify="end">
|
|
342
|
-
<Skeleton loading>
|
|
343
|
-
<SecondaryButton size="1">Previous</SecondaryButton>
|
|
344
|
-
</Skeleton>
|
|
345
|
-
<Skeleton loading>
|
|
346
|
-
<SecondaryButton size="1">Next</SecondaryButton>
|
|
347
|
-
</Skeleton>
|
|
348
|
-
</Flex>
|
|
349
|
-
</UsersManagementRoot>
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export function UsersManagementError({ error }: { error: unknown }) {
|
|
354
|
-
return (
|
|
355
|
-
<UsersManagementRoot
|
|
356
|
-
direction="row"
|
|
357
|
-
justify="center"
|
|
358
|
-
align="center"
|
|
359
|
-
minHeight="676px"
|
|
360
|
-
>
|
|
361
|
-
<GenericError error={error} />
|
|
362
|
-
</UsersManagementRoot>
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function UsersManagementRoot({
|
|
367
|
-
className,
|
|
368
|
-
children,
|
|
369
|
-
...props
|
|
370
|
-
}: React.ComponentProps<typeof Flex>) {
|
|
371
|
-
return (
|
|
372
|
-
<Flex
|
|
373
|
-
className={clsx("woswidgets-widget", className)}
|
|
374
|
-
data-woswidgets-widget-id="users-management"
|
|
375
|
-
direction="column"
|
|
376
|
-
gap="3"
|
|
377
|
-
{...props}
|
|
378
|
-
>
|
|
379
|
-
{children}
|
|
380
|
-
</Flex>
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function UserBadge({ user }: { user: Member }) {
|
|
385
|
-
// TODO: This is not yet available in the data. Update here after API is updated.
|
|
386
|
-
if (user.isLoggedInUser) {
|
|
387
|
-
return (
|
|
388
|
-
<Badge color="gray" style={{ userSelect: "none" }}>
|
|
389
|
-
You
|
|
390
|
-
</Badge>
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
if (user.status === "Invited") {
|
|
394
|
-
return (
|
|
395
|
-
<Badge color="amber" style={{ userSelect: "none" }}>
|
|
396
|
-
<VisuallyHidden>Status: </VisuallyHidden>Invited
|
|
397
|
-
</Badge>
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
if (user.status === "InviteExpired") {
|
|
401
|
-
return (
|
|
402
|
-
<Badge color="red" style={{ userSelect: "none" }}>
|
|
403
|
-
<VisuallyHidden>Status: Invite </VisuallyHidden>
|
|
404
|
-
Expired
|
|
405
|
-
</Badge>
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
if (user.status === "InviteRevoked") {
|
|
409
|
-
return (
|
|
410
|
-
<Badge color="red" style={{ userSelect: "none" }}>
|
|
411
|
-
<VisuallyHidden>Status: Invite </VisuallyHidden>
|
|
412
|
-
Revoked
|
|
413
|
-
</Badge>
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
return null;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
interface LastActiveProps {
|
|
420
|
-
user: Member;
|
|
421
|
-
isHydrated: boolean;
|
|
422
|
-
dim?: boolean;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function LastActive(props: LastActiveProps) {
|
|
426
|
-
if (!props.user.lastActivityAt) {
|
|
427
|
-
return (
|
|
428
|
-
<>
|
|
429
|
-
<VisuallyHidden>
|
|
430
|
-
{props.user.status === "Active" ? "Never" : "Not active"}
|
|
431
|
-
</VisuallyHidden>
|
|
432
|
-
<Separator />
|
|
433
|
-
</>
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
return <LastActiveImpl {...props} date={props.user.lastActivityAt} />;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function LastActiveImpl({
|
|
440
|
-
date,
|
|
441
|
-
isHydrated,
|
|
442
|
-
dim,
|
|
443
|
-
}: LastActiveProps & { date: string }) {
|
|
444
|
-
const { lastActiveDateTime, lastActiveDisplay } = React.useMemo(() => {
|
|
445
|
-
const defaultTimeZone = "America/Los_Angeles";
|
|
446
|
-
const lastActiveDate = new Date(date);
|
|
447
|
-
const lastActiveDateTime = lastActiveDate.toLocaleTimeString("en-US", {
|
|
448
|
-
// hard-coded timezone before hydration to prevent server/client mismatch
|
|
449
|
-
timeZone: isHydrated ? undefined : defaultTimeZone,
|
|
450
|
-
month: "long",
|
|
451
|
-
day: "numeric",
|
|
452
|
-
year: "numeric",
|
|
453
|
-
hour: "numeric",
|
|
454
|
-
minute: "numeric",
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
// Server and client may produce a different 'now' date, so only
|
|
458
|
-
// show comparative date if the component is hydrated to prevent a
|
|
459
|
-
// server/client mismatch
|
|
460
|
-
const lastActiveDisplay = isHydrated
|
|
461
|
-
? getComparativeReadableDate(new Date(), lastActiveDate)
|
|
462
|
-
: lastActiveDate.toLocaleDateString("en-US", {
|
|
463
|
-
// hard-coded timezone to prevent server/client mismatch
|
|
464
|
-
timeZone: defaultTimeZone,
|
|
465
|
-
month: "long",
|
|
466
|
-
day: "numeric",
|
|
467
|
-
year: "numeric",
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
return { lastActiveDateTime, lastActiveDisplay };
|
|
471
|
-
}, [isHydrated, date]);
|
|
472
|
-
|
|
473
|
-
// handle cases where the DB might return an invalid date string
|
|
474
|
-
if (lastActiveDisplay === "Invalid Date") {
|
|
475
|
-
return (
|
|
476
|
-
<>
|
|
477
|
-
<VisuallyHidden>Unknown</VisuallyHidden>
|
|
478
|
-
<Separator />
|
|
479
|
-
</>
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return (
|
|
484
|
-
<TableCellText asChild dim={dim}>
|
|
485
|
-
<time dateTime={date} title={lastActiveDateTime}>
|
|
486
|
-
{lastActiveDisplay}
|
|
487
|
-
</time>
|
|
488
|
-
</TableCellText>
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const TableCellText = React.forwardRef<HTMLSpanElement, TableCellTextProps>(
|
|
493
|
-
function TableCellText(
|
|
494
|
-
{ children, dim, level = "primary", ...props },
|
|
495
|
-
forwardedRef,
|
|
496
|
-
) {
|
|
497
|
-
return (
|
|
498
|
-
<Text
|
|
499
|
-
ref={forwardedRef}
|
|
500
|
-
color={level === "secondary" ? "gray" : undefined}
|
|
501
|
-
weight={level === "secondary" ? "regular" : "medium"}
|
|
502
|
-
size={level === "secondary" ? "1" : "2"}
|
|
503
|
-
truncate
|
|
504
|
-
{...props}
|
|
505
|
-
style={
|
|
506
|
-
dim
|
|
507
|
-
? {
|
|
508
|
-
// TODO: use CSS var instead of hard-coded value for opacity
|
|
509
|
-
opacity: 0.6,
|
|
510
|
-
...props.style,
|
|
511
|
-
}
|
|
512
|
-
: props.style
|
|
513
|
-
}
|
|
514
|
-
>
|
|
515
|
-
{children}
|
|
516
|
-
</Text>
|
|
517
|
-
);
|
|
518
|
-
},
|
|
519
|
-
);
|
|
520
|
-
|
|
521
|
-
type TableCellTextProps = TextProps & {
|
|
522
|
-
level?: "primary" | "secondary";
|
|
523
|
-
dim?: boolean;
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
const FallbackUserIcon = () => (
|
|
527
|
-
<svg
|
|
528
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
529
|
-
width="20"
|
|
530
|
-
height="20"
|
|
531
|
-
fill="currentColor"
|
|
532
|
-
viewBox="0 0 256 256"
|
|
533
|
-
>
|
|
534
|
-
<title>User icon</title>
|
|
535
|
-
<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" />
|
|
536
|
-
</svg>
|
|
537
|
-
);
|
|
538
|
-
|
|
539
|
-
const UsersManagementEmptyState = ({ isPending }: { isPending: boolean }) => {
|
|
540
|
-
const { clearSearch } = useSearchContext();
|
|
541
|
-
const {
|
|
542
|
-
state: { searchQuery },
|
|
543
|
-
} = useUsersManagementContext();
|
|
544
|
-
|
|
545
|
-
// When the search query is cleared, the users query is re-fetched which sends
|
|
546
|
-
// us into a pending state. When this happens we want to keep a snapshot of
|
|
547
|
-
// the previous search query while the query is revalidated. We can use this
|
|
548
|
-
// to keep the 'No users found for query' UI in place until re-fetching is
|
|
549
|
-
// complete, otherwise the view flips to 'No users found' very quickly before
|
|
550
|
-
// the full table is shown again.
|
|
551
|
-
const [{ isClearing, lastSearchQuery }, setClearing] = React.useState({
|
|
552
|
-
isClearing: false,
|
|
553
|
-
lastSearchQuery: null as null | string,
|
|
554
|
-
});
|
|
555
|
-
const [wasPending, setWasPending] = React.useState(isPending);
|
|
556
|
-
if (wasPending !== isPending) {
|
|
557
|
-
setWasPending(isPending);
|
|
558
|
-
if (!isPending) {
|
|
559
|
-
setClearing((prev) =>
|
|
560
|
-
prev.isClearing ? { isClearing: false, lastSearchQuery: null } : prev,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
if (searchQuery || isClearing) {
|
|
566
|
-
return (
|
|
567
|
-
<Flex align="center" justify="center" py="8" direction="column" gap="2">
|
|
568
|
-
<Text size="2">
|
|
569
|
-
No users found for query{" "}
|
|
570
|
-
<Text weight="medium">
|
|
571
|
-
“{isClearing ? lastSearchQuery : searchQuery}”
|
|
572
|
-
</Text>
|
|
573
|
-
</Text>
|
|
574
|
-
|
|
575
|
-
<SecondaryButton
|
|
576
|
-
size="1"
|
|
577
|
-
onClick={() => {
|
|
578
|
-
setClearing({ isClearing: true, lastSearchQuery: searchQuery });
|
|
579
|
-
clearSearch();
|
|
580
|
-
}}
|
|
581
|
-
loading={isPending}
|
|
582
|
-
>
|
|
583
|
-
Clear search
|
|
584
|
-
</SecondaryButton>
|
|
585
|
-
</Flex>
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
return (
|
|
590
|
-
<Flex align="center" justify="center" py="8" gap="2">
|
|
591
|
-
<Text size="2">No users found</Text>
|
|
592
|
-
</Flex>
|
|
593
|
-
);
|
|
594
|
-
};
|
package/src/lib/users-search.tsx
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
4
|
-
import { Cross2Icon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
|
5
|
-
import { IconButton } from "@radix-ui/themes";
|
|
6
|
-
import cx from "clsx";
|
|
7
|
-
import * as React from "react";
|
|
8
|
-
import { useDebouncedCallback } from "use-debounce";
|
|
9
|
-
import { TextField, TextFieldSlot } from "./elements";
|
|
10
|
-
import { useSearchContext } from "./search-provider";
|
|
11
|
-
import { useUsersManagementContext } from "./users-management-context";
|
|
12
|
-
import { namespaceClassNames } from "./utils";
|
|
13
|
-
|
|
14
|
-
type UsersSearchProps = React.ComponentPropsWithoutRef<typeof TextField>;
|
|
15
|
-
|
|
16
|
-
export const UsersSearch = React.forwardRef<HTMLInputElement, UsersSearchProps>(
|
|
17
|
-
({ className, ...props }, ref) => {
|
|
18
|
-
const { inputRef, clearSearch, searchValue, setSearchValue } =
|
|
19
|
-
useSearchContext();
|
|
20
|
-
const { dispatch } = useUsersManagementContext();
|
|
21
|
-
|
|
22
|
-
const filter = useDebouncedCallback((value) => {
|
|
23
|
-
dispatch({ type: "FILTER_BY_SEARCH", searchQuery: value });
|
|
24
|
-
}, 200);
|
|
25
|
-
|
|
26
|
-
const resetSearch = () => {
|
|
27
|
-
clearSearch();
|
|
28
|
-
filter.cancel();
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<TextField
|
|
33
|
-
ref={useComposedRefs(inputRef, ref)}
|
|
34
|
-
className={cx(namespaceClassNames("users-search"), className)}
|
|
35
|
-
autoComplete="off"
|
|
36
|
-
placeholder="Search by name or e-mail"
|
|
37
|
-
value={searchValue}
|
|
38
|
-
onChange={(event) => {
|
|
39
|
-
const value = event.target.value;
|
|
40
|
-
setSearchValue(value);
|
|
41
|
-
filter(value);
|
|
42
|
-
}}
|
|
43
|
-
onKeyDown={(event) => {
|
|
44
|
-
if (event.key === "Escape") {
|
|
45
|
-
event.preventDefault();
|
|
46
|
-
resetSearch();
|
|
47
|
-
}
|
|
48
|
-
}}
|
|
49
|
-
{...props}
|
|
50
|
-
>
|
|
51
|
-
<TextFieldSlot side="left">
|
|
52
|
-
<MagnifyingGlassIcon aria-hidden="true" height="16" width="16" />
|
|
53
|
-
</TextFieldSlot>
|
|
54
|
-
|
|
55
|
-
<TextFieldSlot side="right">
|
|
56
|
-
{searchValue && (
|
|
57
|
-
<IconButton
|
|
58
|
-
size="1"
|
|
59
|
-
color="gray"
|
|
60
|
-
variant="ghost"
|
|
61
|
-
radius="full"
|
|
62
|
-
onClick={resetSearch}
|
|
63
|
-
aria-label="Clear search"
|
|
64
|
-
title="Clear search"
|
|
65
|
-
>
|
|
66
|
-
<Cross2Icon aria-hidden="true" />
|
|
67
|
-
</IconButton>
|
|
68
|
-
)}
|
|
69
|
-
</TextFieldSlot>
|
|
70
|
-
</TextField>
|
|
71
|
-
);
|
|
72
|
-
},
|
|
73
|
-
);
|