@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,170 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ Callout,
6
+ Dialog,
7
+ Flex,
8
+ Select,
9
+ Skeleton,
10
+ Text,
11
+ VisuallyHidden,
12
+ } from "@radix-ui/themes";
13
+ import { type ReactNode, useState } from "react";
14
+ import { useUpdateUserRole } from "./api/user";
15
+ import type { User } from "./api/user";
16
+ import { PrimaryButton, SecondaryButton } from "./elements";
17
+ import { getBestName } from "./utils";
18
+ import { useRoles } from "./api/role";
19
+ import { useElement } from "./widgets-context";
20
+
21
+ interface EditUserDialogProps extends Dialog.RootProps {
22
+ open: boolean;
23
+ onOpenChange: (open: boolean) => void;
24
+ user: User;
25
+ children?: ReactNode;
26
+ }
27
+
28
+ export const EditUserDetailsDialog = ({
29
+ children,
30
+ user,
31
+ ...props
32
+ }: EditUserDialogProps) => {
33
+ const displayName = getBestName(user) || user.email;
34
+ const rolesQuery = useRoles();
35
+ const { data: roles } = rolesQuery;
36
+ const updateUser = useUpdateUserRole();
37
+ const dropdownProps = useElement("dropdown");
38
+ const [selectedRole, setSelectedRole] = useState(
39
+ user.roles[0]?.slug || "Unknown",
40
+ );
41
+
42
+ const onSubmitForm = ({ id, roles }: { id: string; roles: string[] }) => {
43
+ updateUser.mutate(
44
+ { id, data: { roles } },
45
+ {
46
+ onSuccess: () => {
47
+ props.onOpenChange(false);
48
+ },
49
+ },
50
+ );
51
+ };
52
+
53
+ const rootId = React.useId();
54
+ const formId = `edit-user-form-${rootId}`;
55
+ const selectId = `role-select-${rootId}`;
56
+ const selectLabelId = `${selectId}-label`;
57
+ const infoId = `${selectId}-info`;
58
+ const errorId = `${selectId}-error`;
59
+ const showErrorMessage = !!rolesQuery.error;
60
+ const showSingleRoleInfo = rolesQuery.isSuccess && roles.length === 1;
61
+
62
+ return (
63
+ <Dialog.Root {...props}>
64
+ {children && <Dialog.Trigger>{children}</Dialog.Trigger>}
65
+ <Dialog.Content maxWidth="480px">
66
+ <Dialog.Title>Edit role</Dialog.Title>
67
+ <Dialog.Description>
68
+ Select the role to assign to <Text weight="bold">{displayName}</Text>
69
+ </Dialog.Description>
70
+ <Flex mt="2" direction="column" gap="1" asChild>
71
+ <form
72
+ id={formId}
73
+ onSubmit={async (event) => {
74
+ event.preventDefault();
75
+ onSubmitForm({ id: user.id, roles: [selectedRole] });
76
+ }}
77
+ >
78
+ <Select.Root
79
+ name="roles"
80
+ value={selectedRole ?? "Unknown"}
81
+ onValueChange={setSelectedRole}
82
+ disabled={rolesQuery.isLoading || showSingleRoleInfo}
83
+ >
84
+ <Skeleton loading={rolesQuery.isLoading}>
85
+ <Select.Trigger
86
+ id={selectId}
87
+ placeholder="Assign a role"
88
+ aria-labelledby={selectLabelId}
89
+ aria-invalid={showErrorMessage || undefined}
90
+ aria-describedby={
91
+ [showErrorMessage && errorId, showSingleRoleInfo && infoId]
92
+ .filter(Boolean)
93
+ .join(" ") || undefined
94
+ }
95
+ />
96
+ </Skeleton>
97
+
98
+ <Select.Content {...dropdownProps}>
99
+ {roles.map((role) => (
100
+ <Select.Item key={role.slug} value={role.slug}>
101
+ {role.name}
102
+ </Select.Item>
103
+ ))}
104
+ </Select.Content>
105
+ </Select.Root>
106
+
107
+ {showErrorMessage ? (
108
+ <Text color="red" size="2" id={errorId}>
109
+ {getRoleSelectErrorMessage(rolesQuery.error)}
110
+ </Text>
111
+ ) : null}
112
+
113
+ {showSingleRoleInfo ? (
114
+ <Text color="gray" size="2" id={infoId} mt="1">
115
+ You cannot update the role for this user as there is only one
116
+ role available.
117
+ </Text>
118
+ ) : null}
119
+ </form>
120
+ </Flex>
121
+
122
+ {updateUser.error ? (
123
+ <Callout.Root color="red" mt="4" mb="-2">
124
+ <Callout.Text>
125
+ {getMutationErrorMessage(updateUser.error)}
126
+ </Callout.Text>
127
+ </Callout.Root>
128
+ ) : null}
129
+
130
+ <Flex mt="5" gap="3" justify="end">
131
+ <Dialog.Close>
132
+ <SecondaryButton disabled={updateUser.isPending}>
133
+ Cancel
134
+ </SecondaryButton>
135
+ </Dialog.Close>
136
+
137
+ <PrimaryButton
138
+ form={formId}
139
+ loading={updateUser.isPending}
140
+ disabled={rolesQuery.isLoading || showSingleRoleInfo || undefined}
141
+ >
142
+ Save
143
+ </PrimaryButton>
144
+ </Flex>
145
+ {/* mirror errors in a live region */}
146
+ <VisuallyHidden asChild>
147
+ <section aria-live="polite">
148
+ {getMutationErrorMessage(updateUser.error)}
149
+ </section>
150
+ </VisuallyHidden>
151
+ </Dialog.Content>
152
+ </Dialog.Root>
153
+ );
154
+ };
155
+
156
+ function getRoleSelectErrorMessage(error: unknown) {
157
+ if (!error) {
158
+ return null;
159
+ }
160
+ // TODO Handle server errors
161
+ return "There was an error fetching roles. Please try again.";
162
+ }
163
+
164
+ function getMutationErrorMessage(error: unknown) {
165
+ if (!error) {
166
+ return null;
167
+ }
168
+ // TODO Handle server errors
169
+ return "There was an error updating the user role. Please try again.";
170
+ }
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import {
4
+ Button,
5
+ DropdownMenu,
6
+ Avatar as RadixAvatar,
7
+ AvatarProps as RadixAvatarProps,
8
+ Badge as RadixBadge,
9
+ IconButton as RadixIconButton,
10
+ TextField as RadixTextField,
11
+ } from "@radix-ui/themes";
12
+ import type {
13
+ GetPropDefTypes,
14
+ avatarPropDefs,
15
+ badgePropDefs,
16
+ buttonPropDefs,
17
+ dropdownMenuContentPropDefs,
18
+ dropdownMenuItemPropDefs,
19
+ iconButtonPropDefs,
20
+ textFieldRootPropDefs,
21
+ } from "@radix-ui/themes/props";
22
+ import { type ComponentPropsWithoutRef, forwardRef } from "react";
23
+ import { useElement } from "./widgets-context";
24
+
25
+ export const PrimaryButton = forwardRef<
26
+ HTMLButtonElement,
27
+ ComponentPropsWithoutRef<typeof Button>
28
+ >((props, ref) => {
29
+ const element = useElement("primaryButton");
30
+
31
+ return <Button ref={ref} variant="solid" {...props} {...element} />;
32
+ });
33
+
34
+ PrimaryButton.displayName = "PrimaryButton";
35
+
36
+ export const SecondaryButton = forwardRef<
37
+ HTMLButtonElement,
38
+ ComponentPropsWithoutRef<typeof Button>
39
+ >((props, ref) => {
40
+ const element = useElement("secondaryButton");
41
+
42
+ return (
43
+ <Button ref={ref} variant="surface" color="gray" {...props} {...element} />
44
+ );
45
+ });
46
+
47
+ SecondaryButton.displayName = "SecondaryButton";
48
+
49
+ export const DestructiveButton = forwardRef<
50
+ HTMLButtonElement,
51
+ ComponentPropsWithoutRef<typeof Button>
52
+ >((props, ref) => {
53
+ const element = useElement("destructiveButton");
54
+
55
+ return (
56
+ <Button ref={ref} variant="solid" color="red" {...props} {...element} />
57
+ );
58
+ });
59
+
60
+ DestructiveButton.displayName = "DestructiveButton";
61
+
62
+ export const IconButton = forwardRef<
63
+ HTMLButtonElement,
64
+ ComponentPropsWithoutRef<typeof RadixIconButton>
65
+ >((props, ref) => {
66
+ const element = useElement("iconButton");
67
+
68
+ return (
69
+ <RadixIconButton
70
+ ref={ref}
71
+ variant="ghost"
72
+ color="gray"
73
+ {...props}
74
+ {...element}
75
+ />
76
+ );
77
+ });
78
+
79
+ IconButton.displayName = "IconButton";
80
+
81
+ export const TextField = forwardRef<
82
+ HTMLInputElement,
83
+ ComponentPropsWithoutRef<typeof RadixTextField.Root>
84
+ >((props, ref) => {
85
+ const element = useElement("textfield");
86
+
87
+ return (
88
+ <RadixTextField.Root
89
+ data-1p-ignore
90
+ ref={ref}
91
+ variant="surface"
92
+ {...props}
93
+ {...element}
94
+ />
95
+ );
96
+ });
97
+
98
+ TextField.displayName = "TextField";
99
+
100
+ export const TextFieldSlot = RadixTextField.Slot;
101
+
102
+ export const Badge = forwardRef<
103
+ HTMLSpanElement,
104
+ ComponentPropsWithoutRef<typeof RadixBadge>
105
+ >((props, ref) => {
106
+ const element = useElement("badge");
107
+
108
+ return <RadixBadge ref={ref} {...element} {...props} />;
109
+ });
110
+
111
+ Badge.displayName = "Badge";
112
+
113
+ export const PrimaryMenuItem = forwardRef<
114
+ HTMLDivElement,
115
+ ComponentPropsWithoutRef<typeof DropdownMenu.Item>
116
+ >((props, ref) => {
117
+ const element = useElement("primaryMenuItem");
118
+
119
+ return <DropdownMenu.Item ref={ref} {...props} {...element} />;
120
+ });
121
+
122
+ PrimaryMenuItem.displayName = "PrimaryMenuItem";
123
+
124
+ export const DestructiveMenuItem = forwardRef<
125
+ HTMLDivElement,
126
+ ComponentPropsWithoutRef<typeof DropdownMenu.Item>
127
+ >((props, ref) => {
128
+ const element = useElement("destructiveMenuItem");
129
+
130
+ return <DropdownMenu.Item ref={ref} color="red" {...props} {...element} />;
131
+ });
132
+
133
+ DestructiveMenuItem.displayName = "DestructiveMenuItem";
134
+
135
+ interface AvatarProps extends RadixAvatarProps {
136
+ dim?: boolean;
137
+ }
138
+
139
+ export const Avatar = forwardRef<HTMLImageElement, AvatarProps>(
140
+ ({ dim, ...props }, ref) => {
141
+ const element = useElement("avatar");
142
+
143
+ return (
144
+ <RadixAvatar
145
+ ref={ref}
146
+ color="gray"
147
+ {...props}
148
+ {...element}
149
+ // TODO: use CSS var instead of hard-coded value for opacity
150
+ style={dim ? { opacity: 0.6, ...props.style } : props.style}
151
+ />
152
+ );
153
+ },
154
+ );
155
+
156
+ Avatar.displayName = "Avatar";
157
+
158
+ type OmitAsChild<T> = {
159
+ [K in keyof T]: T[K] extends undefined
160
+ ? undefined
161
+ : Omit<NonNullable<T[K]>, "asChild">;
162
+ };
163
+
164
+ export type Elements = OmitAsChild<{
165
+ primaryButton?: GetPropDefTypes<typeof buttonPropDefs>;
166
+ secondaryButton?: GetPropDefTypes<typeof buttonPropDefs>;
167
+ destructiveButton?: GetPropDefTypes<typeof buttonPropDefs>;
168
+ iconButton?: GetPropDefTypes<typeof iconButtonPropDefs>;
169
+ textfield?: GetPropDefTypes<typeof textFieldRootPropDefs>;
170
+ badge?: GetPropDefTypes<typeof badgePropDefs>;
171
+ dropdown?: GetPropDefTypes<typeof dropdownMenuContentPropDefs>;
172
+ primaryMenuItem?: GetPropDefTypes<typeof dropdownMenuItemPropDefs>;
173
+ destructiveMenuItem?: GetPropDefTypes<typeof dropdownMenuItemPropDefs>;
174
+ avatar?: Omit<GetPropDefTypes<typeof avatarPropDefs>, "fallback">;
175
+ }>;
@@ -0,0 +1,319 @@
1
+ "use client";
2
+
3
+ import {
4
+ Callout,
5
+ Dialog,
6
+ Flex,
7
+ Select,
8
+ Text,
9
+ VisuallyHidden,
10
+ } from "@radix-ui/themes";
11
+ import * as React from "react";
12
+ import { type Role, useRoles } from "./api/role";
13
+ import { type InviteUserPayload, useInviteUser } from "./api/user";
14
+ import { PrimaryButton, SecondaryButton, TextField } from "./elements";
15
+ import { Label } from "./label";
16
+ import { isErrorLike } from "./utils";
17
+
18
+ /**
19
+ * Used to stub a fake value for the role select. It will be selected by default
20
+ * before the role query resolves, or if the query results in an error. We do
21
+ * this because we need to provide _any_ value to the select to avoid
22
+ * controlled/uncontrolled bugs.
23
+ */
24
+ const PLACEHOLDER_ROLE = "_rolePlaceholder";
25
+
26
+ interface InviteUserDialogProps {
27
+ children?: React.ReactNode;
28
+ }
29
+
30
+ interface DialogFormContextValue {
31
+ dialogId: string;
32
+ }
33
+
34
+ const DialogFormContext = React.createContext<DialogFormContextValue>(null!);
35
+ DialogFormContext.displayName = "DialogFormContext";
36
+
37
+ export const InviteUserDialog = ({ children }: InviteUserDialogProps) => {
38
+ const [open, setOpen] = React.useState(false);
39
+ const dialogId = toId("invite-user", React.useId());
40
+ const formId = toId(dialogId, "form");
41
+
42
+ const inviteUser = useInviteUser();
43
+ const rolesQuery = useRoles();
44
+ const roles = rolesQuery.data;
45
+ const [selectedRole, setSelectedRole] = React.useState(
46
+ () => getDefaultRole(roles)?.slug || PLACEHOLDER_ROLE,
47
+ );
48
+ React.useEffect(() => {
49
+ // Update the selected role if it's not in the list (eg if the list was
50
+ // previously empty and the query resolved)
51
+ setSelectedRole((selectedRole) => {
52
+ if (roles.find((role) => role.slug === selectedRole)) {
53
+ // if current selected role is in the new list, don't change it
54
+ return selectedRole;
55
+ }
56
+ return getDefaultRole(roles)?.slug || PLACEHOLDER_ROLE;
57
+ });
58
+ }, [roles]);
59
+
60
+ const onSubmitForm = (data: InviteUserPayload) => {
61
+ if (inviteUser.isPending || rolesQuery.status !== "success") {
62
+ return;
63
+ }
64
+ inviteUser.mutate(data, {
65
+ onSuccess: () => {
66
+ setOpen(false);
67
+ },
68
+ });
69
+ };
70
+
71
+ const formErrors = getFormErrors(inviteUser.error);
72
+ useFormFieldFocusOnError(dialogId, inviteUser.error);
73
+
74
+ return (
75
+ <Dialog.Root open={open} onOpenChange={setOpen}>
76
+ {children && <Dialog.Trigger>{children}</Dialog.Trigger>}
77
+ <Dialog.Content maxWidth="480px" key={String(open)}>
78
+ <Dialog.Title>Invite user</Dialog.Title>
79
+ <Dialog.Description>
80
+ An invitation will be sent to this email address with a link to
81
+ complete their account.
82
+ </Dialog.Description>
83
+ <DialogFormContext.Provider value={{ dialogId }}>
84
+ <Flex direction="column" gap="4" mt="5" asChild>
85
+ <form
86
+ id={formId}
87
+ onSubmit={async (event) => {
88
+ event.preventDefault();
89
+ onSubmitForm({
90
+ email: event.currentTarget.email.value,
91
+ role: selectedRole,
92
+ });
93
+ }}
94
+ >
95
+ <FormField
96
+ name="email"
97
+ label="Email address"
98
+ error={formErrors.fields.email}
99
+ required
100
+ control={(props) => (
101
+ <TextField
102
+ {...props}
103
+ data-1p-ignore
104
+ type="email"
105
+ autoComplete="email"
106
+ placeholder="Enter an email address"
107
+ />
108
+ )}
109
+ />
110
+
111
+ <FormField
112
+ name="role"
113
+ label="Role"
114
+ error={formErrors.fields.role}
115
+ disabled={rolesQuery.isPending || roles.length <= 1}
116
+ info={
117
+ roles.length === 1 ? (
118
+ <>
119
+ New users will be invited with the{" "}
120
+ <Text weight="bold">{roles[0].name}</Text> role, as it is
121
+ the only one available.
122
+ </>
123
+ ) : undefined
124
+ }
125
+ control={({
126
+ id,
127
+ "aria-invalid": ariaInvalid,
128
+ "aria-describedby": ariaDescribedBy,
129
+ ...props
130
+ }) => (
131
+ <Select.Root
132
+ {...props}
133
+ value={selectedRole}
134
+ onValueChange={setSelectedRole}
135
+ >
136
+ <Select.Trigger
137
+ id={id}
138
+ aria-invalid={ariaInvalid}
139
+ aria-describedby={ariaDescribedBy}
140
+ />
141
+ <Select.Content>
142
+ <Select.Item value={PLACEHOLDER_ROLE} disabled>
143
+ Select a role
144
+ </Select.Item>
145
+ {roles.map((role) => (
146
+ <Select.Item key={role.slug} value={role.slug}>
147
+ {role.name}
148
+ </Select.Item>
149
+ ))}
150
+ </Select.Content>
151
+ </Select.Root>
152
+ )}
153
+ />
154
+ </form>
155
+ </Flex>
156
+ </DialogFormContext.Provider>
157
+
158
+ {formErrors.form ? (
159
+ <Callout.Root color="red" mt="4" mb="-2">
160
+ <Callout.Text>{formErrors.form}</Callout.Text>
161
+ </Callout.Root>
162
+ ) : null}
163
+
164
+ <Flex mt="5" gap="3" justify="end">
165
+ <Dialog.Close>
166
+ <SecondaryButton disabled={inviteUser.isPending}>
167
+ Cancel
168
+ </SecondaryButton>
169
+ </Dialog.Close>
170
+ <PrimaryButton
171
+ form={formId}
172
+ loading={inviteUser.isPending}
173
+ disabled={rolesQuery.isPending || undefined}
174
+ >
175
+ Invite
176
+ </PrimaryButton>
177
+ </Flex>
178
+ {/* mirror errors in a live region */}
179
+ <VisuallyHidden asChild>
180
+ <section aria-live="polite">{formErrors.form}</section>
181
+ </VisuallyHidden>
182
+ </Dialog.Content>
183
+ </Dialog.Root>
184
+ );
185
+ };
186
+
187
+ interface FormControlRenderProps {
188
+ id: string;
189
+ name: string;
190
+ "aria-describedby": string | undefined;
191
+ "aria-invalid"?: boolean;
192
+ required: boolean | undefined;
193
+ disabled: boolean | undefined;
194
+ }
195
+
196
+ function FormField({
197
+ name,
198
+ label,
199
+ error,
200
+ info,
201
+ control,
202
+ required,
203
+ disabled,
204
+ }: {
205
+ name: string;
206
+ label: string;
207
+ error?: React.ReactNode;
208
+ info?: React.ReactNode;
209
+ control: (props: FormControlRenderProps) => React.ReactNode;
210
+ required?: boolean;
211
+ disabled?: boolean;
212
+ }) {
213
+ const { dialogId } = React.useContext(DialogFormContext);
214
+ const fieldId = toId(dialogId, name);
215
+ const errorId = toId(dialogId, name, "error");
216
+ const infoId = toId(dialogId, name, "info");
217
+ return (
218
+ <Flex direction="column" gap="1">
219
+ <Label htmlFor={fieldId}>{label}</Label>
220
+ {control({
221
+ id: fieldId,
222
+ name,
223
+ "aria-describedby": (() => {
224
+ const tags: string[] = [];
225
+ if (error) {
226
+ tags.push(errorId);
227
+ }
228
+ if (info) {
229
+ tags.push(infoId);
230
+ }
231
+ if (tags.length === 0) {
232
+ return undefined;
233
+ }
234
+ return tags.join(" ");
235
+ })(),
236
+ "aria-invalid": !!error || undefined,
237
+ required: required || undefined,
238
+ disabled: disabled || undefined,
239
+ })}
240
+
241
+ {error ? (
242
+ <Text color="red" size="2" id={errorId}>
243
+ {error}
244
+ </Text>
245
+ ) : null}
246
+ {info ? (
247
+ <Text color="gray" size="2" id={infoId} mt="1">
248
+ {info}
249
+ </Text>
250
+ ) : null}
251
+ </Flex>
252
+ );
253
+ }
254
+
255
+ function toId(...parts: string[]) {
256
+ return parts.join("-");
257
+ }
258
+
259
+ function getFormErrors(queryError: unknown) {
260
+ const formErrors = {
261
+ form: null as string | null,
262
+ fields: {
263
+ email: null as string | null,
264
+ role: null as string | null,
265
+ },
266
+ };
267
+
268
+ if (queryError) {
269
+ if (!isErrorLike(queryError)) {
270
+ return {
271
+ ...formErrors,
272
+ form: "An unexpected error occurred. Please try again.",
273
+ };
274
+ }
275
+
276
+ switch (queryError.message.toLowerCase()) {
277
+ case "user already exists":
278
+ case "user already invited":
279
+ case "invalid email":
280
+ formErrors.fields.email = queryError.message;
281
+ break;
282
+ case "invalid role":
283
+ formErrors.fields.role = queryError.message;
284
+ break;
285
+ default:
286
+ // TODO handle more cases for various server errors
287
+ formErrors.form =
288
+ "There was an error inviting this user. Please refresh the page and try again.";
289
+ break;
290
+ }
291
+ }
292
+
293
+ return formErrors;
294
+ }
295
+
296
+ function useFormFieldFocusOnError(dialogId: string, queryError: unknown) {
297
+ React.useEffect(() => {
298
+ const fieldErrors = getFormErrors(queryError).fields;
299
+ for (const [name, error] of Object.entries(fieldErrors)) {
300
+ if (error) {
301
+ const fieldElement = document.getElementById(toId(dialogId, name)) as
302
+ | HTMLInputElement
303
+ | HTMLButtonElement
304
+ | null;
305
+ if (fieldElement) {
306
+ fieldElement?.focus();
307
+ if ("select" in fieldElement) {
308
+ fieldElement.select();
309
+ }
310
+ }
311
+ break;
312
+ }
313
+ }
314
+ }, [dialogId, queryError]);
315
+ }
316
+
317
+ function getDefaultRole(roles: Role[]) {
318
+ return roles.find((role) => role.default) || roles[0];
319
+ }
@@ -0,0 +1,14 @@
1
+ import { Text, type TextProps } from "@radix-ui/themes";
2
+ import { forwardRef } from "react";
3
+
4
+ type LabelProps = Omit<Extract<TextProps, { as: "label" }>, "as">;
5
+
6
+ export const Label = forwardRef<HTMLLabelElement, LabelProps>(
7
+ ({ children, ...props }, ref) => (
8
+ <Text as="label" ref={ref} weight="bold" size="2" {...props}>
9
+ {children}
10
+ </Text>
11
+ ),
12
+ );
13
+
14
+ Label.displayName = "Label";