@byline/admin 2.4.0 → 2.4.1

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 (177) hide show
  1. package/dist/abilities.js +5 -24
  2. package/dist/index.js +8 -30
  3. package/dist/lib/assert-admin-actor.js +13 -74
  4. package/dist/lib/create-command.js +6 -16
  5. package/dist/modules/admin-account/commands.js +35 -24
  6. package/dist/modules/admin-account/components/change-password.d.ts +8 -0
  7. package/dist/modules/admin-account/components/change-password.js +192 -0
  8. package/dist/modules/admin-account/components/change-password.module.js +8 -0
  9. package/dist/modules/admin-account/components/change-password_module.css +27 -0
  10. package/dist/modules/admin-account/components/container.d.ts +29 -0
  11. package/dist/modules/admin-account/components/container.js +298 -0
  12. package/dist/modules/admin-account/components/container.module.js +28 -0
  13. package/dist/modules/admin-account/components/container_module.css +106 -0
  14. package/dist/modules/admin-account/components/update.d.ts +8 -0
  15. package/dist/modules/admin-account/components/update.js +207 -0
  16. package/dist/modules/admin-account/components/update.module.js +8 -0
  17. package/dist/modules/admin-account/components/update_module.css +27 -0
  18. package/dist/modules/admin-account/errors.js +14 -45
  19. package/dist/modules/admin-account/index.js +4 -34
  20. package/dist/modules/admin-account/schemas.js +25 -59
  21. package/dist/modules/admin-account/service.js +56 -61
  22. package/dist/modules/admin-permissions/abilities.js +6 -24
  23. package/dist/modules/admin-permissions/commands.js +42 -28
  24. package/dist/modules/admin-permissions/components/inspector.d.ts +4 -0
  25. package/dist/modules/admin-permissions/components/inspector.js +284 -0
  26. package/dist/modules/admin-permissions/components/inspector.module.js +56 -0
  27. package/dist/modules/admin-permissions/components/inspector_module.css +238 -0
  28. package/dist/modules/admin-permissions/dto.js +3 -16
  29. package/dist/modules/admin-permissions/errors.js +14 -27
  30. package/dist/modules/admin-permissions/index.js +6 -26
  31. package/dist/modules/admin-permissions/repository.js +1 -8
  32. package/dist/modules/admin-permissions/schemas.js +33 -70
  33. package/dist/modules/admin-permissions/service.js +88 -92
  34. package/dist/modules/admin-roles/abilities.js +8 -30
  35. package/dist/modules/admin-roles/commands.js +89 -55
  36. package/dist/modules/admin-roles/components/create.d.ts +7 -0
  37. package/dist/modules/admin-roles/components/create.js +177 -0
  38. package/dist/modules/admin-roles/components/create.module.js +8 -0
  39. package/dist/modules/admin-roles/components/create_module.css +27 -0
  40. package/dist/modules/admin-roles/components/permissions.d.ts +10 -0
  41. package/dist/modules/admin-roles/components/permissions.js +303 -0
  42. package/dist/modules/admin-roles/components/permissions.module.js +44 -0
  43. package/dist/modules/admin-roles/components/permissions_module.css +192 -0
  44. package/dist/modules/admin-roles/components/update.d.ts +8 -0
  45. package/dist/modules/admin-roles/components/update.js +166 -0
  46. package/dist/modules/admin-roles/components/update.module.js +8 -0
  47. package/dist/modules/admin-roles/components/update_module.css +27 -0
  48. package/dist/modules/admin-roles/dto.js +3 -16
  49. package/dist/modules/admin-roles/errors.js +16 -40
  50. package/dist/modules/admin-roles/index.js +6 -26
  51. package/dist/modules/admin-roles/repository.js +1 -8
  52. package/dist/modules/admin-roles/schemas.js +41 -71
  53. package/dist/modules/admin-roles/service.js +79 -82
  54. package/dist/modules/admin-users/abilities.js +9 -38
  55. package/dist/modules/admin-users/commands.js +92 -50
  56. package/dist/modules/admin-users/components/create.d.ts +8 -0
  57. package/dist/modules/admin-users/components/create.js +268 -0
  58. package/dist/modules/admin-users/components/create.module.js +10 -0
  59. package/dist/modules/admin-users/components/create_module.css +45 -0
  60. package/dist/modules/admin-users/components/roles.d.ts +11 -0
  61. package/dist/modules/admin-users/components/roles.js +148 -0
  62. package/dist/modules/admin-users/components/roles.module.js +18 -0
  63. package/dist/modules/admin-users/components/roles_module.css +75 -0
  64. package/dist/modules/admin-users/components/set-password.d.ts +8 -0
  65. package/dist/modules/admin-users/components/set-password.js +170 -0
  66. package/dist/modules/admin-users/components/set-password.module.js +9 -0
  67. package/dist/modules/admin-users/components/set-password_module.css +31 -0
  68. package/dist/modules/admin-users/components/update.d.ts +8 -0
  69. package/dist/modules/admin-users/components/update.js +254 -0
  70. package/dist/modules/admin-users/components/update.module.js +9 -0
  71. package/dist/modules/admin-users/components/update_module.css +34 -0
  72. package/dist/modules/admin-users/dto.js +3 -18
  73. package/dist/modules/admin-users/errors.js +17 -43
  74. package/dist/modules/admin-users/index.js +7 -27
  75. package/dist/modules/admin-users/repository.js +1 -8
  76. package/dist/modules/admin-users/schemas.js +44 -75
  77. package/dist/modules/admin-users/seed-super-admin.js +9 -34
  78. package/dist/modules/admin-users/service.js +76 -91
  79. package/dist/modules/auth/components/sign-in-form.d.ts +12 -0
  80. package/dist/modules/auth/components/sign-in-form.js +115 -0
  81. package/dist/modules/auth/components/sign-in-form.module.js +12 -0
  82. package/dist/modules/auth/components/sign-in-form_module.css +41 -0
  83. package/dist/modules/auth/index.js +3 -24
  84. package/dist/modules/auth/jwt-session-provider.js +179 -149
  85. package/dist/modules/auth/password.js +11 -53
  86. package/dist/modules/auth/phc.js +21 -54
  87. package/dist/modules/auth/refresh-tokens-repository.js +1 -8
  88. package/dist/modules/auth/resolve-actor.js +6 -28
  89. package/dist/services/admin-services-context.d.ts +16 -0
  90. package/dist/services/admin-services-context.js +13 -0
  91. package/dist/services/admin-services-types.d.ts +129 -0
  92. package/dist/services/admin-services-types.js +1 -0
  93. package/dist/store.js +1 -8
  94. package/dist/vendor/noble-argon2/_blake.js +277 -45
  95. package/dist/vendor/noble-argon2/_md.js +81 -136
  96. package/dist/vendor/noble-argon2/_u64.js +65 -67
  97. package/dist/vendor/noble-argon2/argon2.js +181 -342
  98. package/dist/vendor/noble-argon2/blake2.js +252 -327
  99. package/dist/vendor/noble-argon2/utils.js +110 -490
  100. package/dist/vendor/noble-argon2/utils.js.LICENSE.txt +1 -0
  101. package/package.json +89 -10
  102. package/src/abilities.ts +32 -0
  103. package/src/declarations.d.ts +4 -0
  104. package/src/index.ts +39 -0
  105. package/src/lib/assert-admin-actor.ts +90 -0
  106. package/src/lib/create-command.ts +109 -0
  107. package/src/modules/admin-account/commands.ts +76 -0
  108. package/src/modules/admin-account/components/change-password.module.css +40 -0
  109. package/src/modules/admin-account/components/change-password.tsx +232 -0
  110. package/src/modules/admin-account/components/container.module.css +158 -0
  111. package/src/modules/admin-account/components/container.tsx +229 -0
  112. package/src/modules/admin-account/components/update.module.css +40 -0
  113. package/src/modules/admin-account/components/update.tsx +263 -0
  114. package/src/modules/admin-account/errors.ts +75 -0
  115. package/src/modules/admin-account/index.ts +60 -0
  116. package/src/modules/admin-account/schemas.ts +84 -0
  117. package/src/modules/admin-account/service.ts +92 -0
  118. package/src/modules/admin-permissions/abilities.ts +46 -0
  119. package/src/modules/admin-permissions/commands.ts +103 -0
  120. package/src/modules/admin-permissions/components/inspector.module.css +326 -0
  121. package/src/modules/admin-permissions/components/inspector.tsx +298 -0
  122. package/src/modules/admin-permissions/dto.ts +28 -0
  123. package/src/modules/admin-permissions/errors.ts +57 -0
  124. package/src/modules/admin-permissions/index.ts +72 -0
  125. package/src/modules/admin-permissions/repository.ts +49 -0
  126. package/src/modules/admin-permissions/schemas.ts +128 -0
  127. package/src/modules/admin-permissions/service.ts +137 -0
  128. package/src/modules/admin-roles/abilities.ts +62 -0
  129. package/src/modules/admin-roles/commands.ts +161 -0
  130. package/src/modules/admin-roles/components/create.module.css +40 -0
  131. package/src/modules/admin-roles/components/create.tsx +218 -0
  132. package/src/modules/admin-roles/components/permissions.module.css +279 -0
  133. package/src/modules/admin-roles/components/permissions.tsx +396 -0
  134. package/src/modules/admin-roles/components/update.module.css +40 -0
  135. package/src/modules/admin-roles/components/update.tsx +218 -0
  136. package/src/modules/admin-roles/dto.ts +30 -0
  137. package/src/modules/admin-roles/errors.ts +76 -0
  138. package/src/modules/admin-roles/index.ts +81 -0
  139. package/src/modules/admin-roles/repository.ts +96 -0
  140. package/src/modules/admin-roles/schemas.ts +139 -0
  141. package/src/modules/admin-roles/service.ts +136 -0
  142. package/src/modules/admin-users/abilities.ts +76 -0
  143. package/src/modules/admin-users/commands.ts +157 -0
  144. package/src/modules/admin-users/components/create.module.css +63 -0
  145. package/src/modules/admin-users/components/create.tsx +323 -0
  146. package/src/modules/admin-users/components/roles.module.css +119 -0
  147. package/src/modules/admin-users/components/roles.tsx +172 -0
  148. package/src/modules/admin-users/components/set-password.module.css +46 -0
  149. package/src/modules/admin-users/components/set-password.tsx +199 -0
  150. package/src/modules/admin-users/components/update.module.css +49 -0
  151. package/src/modules/admin-users/components/update.tsx +328 -0
  152. package/src/modules/admin-users/dto.ts +39 -0
  153. package/src/modules/admin-users/errors.ts +84 -0
  154. package/src/modules/admin-users/index.ts +91 -0
  155. package/src/modules/admin-users/repository.ts +161 -0
  156. package/src/modules/admin-users/schemas.ts +168 -0
  157. package/src/modules/admin-users/seed-super-admin.ts +102 -0
  158. package/src/modules/admin-users/service.ts +166 -0
  159. package/src/modules/auth/components/sign-in-form.module.css +62 -0
  160. package/src/modules/auth/components/sign-in-form.tsx +132 -0
  161. package/src/modules/auth/index.ts +31 -0
  162. package/src/modules/auth/jwt-session-provider.ts +301 -0
  163. package/src/modules/auth/password.ts +94 -0
  164. package/src/modules/auth/phc.ts +121 -0
  165. package/src/modules/auth/refresh-tokens-repository.ts +74 -0
  166. package/src/modules/auth/resolve-actor.ts +42 -0
  167. package/src/services/admin-services-context.tsx +52 -0
  168. package/src/services/admin-services-types.ts +177 -0
  169. package/src/store.ts +32 -0
  170. package/src/vendor/noble-argon2/LICENSE +21 -0
  171. package/src/vendor/noble-argon2/README.md +87 -0
  172. package/src/vendor/noble-argon2/_blake.ts +58 -0
  173. package/src/vendor/noble-argon2/_md.ts +223 -0
  174. package/src/vendor/noble-argon2/_u64.ts +118 -0
  175. package/src/vendor/noble-argon2/argon2.ts +668 -0
  176. package/src/vendor/noble-argon2/blake2.ts +583 -0
  177. package/src/vendor/noble-argon2/utils.ts +849 -0
@@ -0,0 +1,199 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ /**
12
+ * Set-password drawer form.
13
+ *
14
+ * Admin-facing "set this user's password" flow — used when an admin is
15
+ * resetting someone else's password. Server-side policy (min 12 chars,
16
+ * max 256) is duplicated client-side via Zod for immediate field
17
+ * validation. A matching-confirmation field catches typos without a
18
+ * round-trip.
19
+ *
20
+ * The server fn returns the updated user so we can lift the bumped
21
+ * `vid` back into the container; the drawer doesn't need to re-fetch.
22
+ */
23
+
24
+ import { useState } from 'react'
25
+ import { revalidateLogic, useForm } from '@tanstack/react-form-start'
26
+
27
+ import { passwordSchema } from '@byline/core/validation'
28
+ import { Alert, Button, InputPassword, LoaderEllipsis } from '@byline/ui/react'
29
+ import cx from 'classnames'
30
+ import { z } from 'zod'
31
+
32
+ import { useBylineAdminServices } from '../../../services/admin-services-context.js'
33
+ import styles from './set-password.module.css'
34
+ import type { AdminUserResponse } from '../index.js'
35
+
36
+ const setPasswordFormSchema = z
37
+ .object({
38
+ password: passwordSchema,
39
+ confirm: z.string({ message: 'Please confirm the password' }),
40
+ })
41
+ .refine((v) => v.password === v.confirm, {
42
+ message: 'Passwords do not match',
43
+ path: ['confirm'],
44
+ })
45
+
46
+ type SetPasswordValues = z.infer<typeof setPasswordFormSchema>
47
+
48
+ interface SetPasswordProps {
49
+ user: AdminUserResponse
50
+ onClose?: () => void
51
+ onSuccess?: (user: AdminUserResponse) => void
52
+ }
53
+
54
+ export function SetPassword({ user, onClose, onSuccess }: SetPasswordProps) {
55
+ const { setAdminUserPassword } = useBylineAdminServices()
56
+ const [formError, setFormError] = useState<string | null>(null)
57
+ const [successMessage, setSuccessMessage] = useState<string | null>(null)
58
+
59
+ const form = useForm({
60
+ defaultValues: { password: '', confirm: '' } as SetPasswordValues,
61
+ validationLogic: revalidateLogic({
62
+ mode: 'blur',
63
+ modeAfterSubmission: 'change',
64
+ }),
65
+ validators: {
66
+ onDynamic: setPasswordFormSchema,
67
+ },
68
+ onSubmit: async ({ value }) => {
69
+ setFormError(null)
70
+ setSuccessMessage(null)
71
+ try {
72
+ const updated = await setAdminUserPassword({
73
+ data: { id: user.id, vid: user.vid, password: value.password },
74
+ })
75
+ setSuccessMessage('Password updated.')
76
+ form.reset({ password: '', confirm: '' })
77
+ onSuccess?.(updated)
78
+ } catch (err) {
79
+ const code = getErrorCode(err)
80
+ if (code === 'admin.users.versionConflict') {
81
+ setFormError(
82
+ 'This user has been modified elsewhere since you opened this form. Reload to refresh and try again.'
83
+ )
84
+ return
85
+ }
86
+ if (code === 'admin.users.notFound') {
87
+ setFormError('This user no longer exists.')
88
+ return
89
+ }
90
+ setFormError('Could not set the password. Please try again.')
91
+ }
92
+ },
93
+ })
94
+
95
+ return (
96
+ <div className={cx('byline-user-set-password-wrap', styles.wrap)}>
97
+ <form
98
+ noValidate
99
+ onSubmit={(event) => {
100
+ event.preventDefault()
101
+ event.stopPropagation()
102
+ void form.handleSubmit()
103
+ }}
104
+ className={cx('byline-user-set-password-form', styles.form)}
105
+ >
106
+ {formError ? <Alert intent="danger">{formError}</Alert> : null}
107
+ {successMessage ? <Alert intent="success">{successMessage}</Alert> : null}
108
+
109
+ <p className="muted">
110
+ Sets a new password for{' '}
111
+ <span className={cx('byline-user-set-password-target', styles.target)}>{user.email}</span>
112
+ . The user will need to sign in again with the new password.
113
+ </p>
114
+
115
+ <form.Field name="password">
116
+ {(field) => (
117
+ <InputPassword
118
+ label="New password"
119
+ id="password"
120
+ name={field.name}
121
+ value={field.state.value}
122
+ onBlur={field.handleBlur}
123
+ onChange={(e) => field.handleChange(e.currentTarget.value)}
124
+ error={field.state.meta.errors.length > 0}
125
+ errorText={firstError(field.state.meta.errors)}
126
+ autoComplete="new-password"
127
+ required
128
+ />
129
+ )}
130
+ </form.Field>
131
+
132
+ <form.Field name="confirm">
133
+ {(field) => (
134
+ <InputPassword
135
+ label="Confirm new password"
136
+ id="confirm"
137
+ name={field.name}
138
+ value={field.state.value}
139
+ onBlur={field.handleBlur}
140
+ onChange={(e) => field.handleChange(e.currentTarget.value)}
141
+ error={field.state.meta.errors.length > 0}
142
+ errorText={firstError(field.state.meta.errors)}
143
+ autoComplete="new-password"
144
+ required
145
+ />
146
+ )}
147
+ </form.Field>
148
+
149
+ <div className={cx('byline-user-set-password-actions', styles.actions)}>
150
+ <Button
151
+ type="button"
152
+ intent="secondary"
153
+ size="sm"
154
+ onClick={onClose}
155
+ className={cx('byline-user-set-password-action', styles.action)}
156
+ >
157
+ {successMessage ? 'Close' : 'Cancel'}
158
+ </Button>
159
+ <form.Subscribe
160
+ selector={(state) => ({
161
+ canSubmit: state.canSubmit,
162
+ isSubmitting: state.isSubmitting,
163
+ isDirty: state.isDirty,
164
+ })}
165
+ >
166
+ {({ canSubmit, isSubmitting }) => (
167
+ <Button
168
+ size="sm"
169
+ intent="primary"
170
+ type="submit"
171
+ disabled={!canSubmit || isSubmitting}
172
+ className={cx('byline-user-set-password-action', styles.action)}
173
+ >
174
+ {isSubmitting === true ? <LoaderEllipsis size={42} /> : 'Save'}
175
+ </Button>
176
+ )}
177
+ </form.Subscribe>
178
+ </div>
179
+ </form>
180
+ </div>
181
+ )
182
+ }
183
+
184
+ function firstError(errors: readonly unknown[]): string | undefined {
185
+ for (const err of errors) {
186
+ if (typeof err === 'string') return err
187
+ if (err && typeof err === 'object' && 'message' in err) {
188
+ const msg = (err as { message?: unknown }).message
189
+ if (typeof msg === 'string') return msg
190
+ }
191
+ }
192
+ return undefined
193
+ }
194
+
195
+ function getErrorCode(err: unknown): string | null {
196
+ return typeof (err as { code?: unknown })?.code === 'string'
197
+ ? (err as { code: string }).code
198
+ : null
199
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * UpdateAdminUser — drawer form for editing an admin user.
3
+ *
4
+ * Override handles:
5
+ * .byline-user-update-wrap — outer container
6
+ * .byline-user-update-form — vertical-stack form element
7
+ * .byline-user-update-flags — checkbox stack (enabled / verified / super)
8
+ * .byline-user-update-actions — Cancel/Save row
9
+ * .byline-user-update-action — buttons in the actions row
10
+ */
11
+
12
+ .wrap,
13
+ :global(.byline-user-update-wrap) {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--spacing-8);
17
+ padding: var(--spacing-4);
18
+ margin-top: var(--spacing-4);
19
+ }
20
+
21
+ .form,
22
+ :global(.byline-user-update-form) {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--spacing-16);
26
+ padding-top: var(--spacing-8);
27
+ }
28
+
29
+ .flags,
30
+ :global(.byline-user-update-flags) {
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: var(--spacing-8);
34
+ padding: var(--spacing-4);
35
+ }
36
+
37
+ .actions,
38
+ :global(.byline-user-update-actions) {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: flex-end;
42
+ gap: var(--spacing-8);
43
+ margin-top: var(--spacing-16);
44
+ }
45
+
46
+ .action,
47
+ :global(.byline-user-update-action) {
48
+ min-width: 4rem;
49
+ }
@@ -0,0 +1,328 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ /**
12
+ * Account details drawer form.
13
+ *
14
+ * Client-side validation runs through TanStack Form's `onDynamic` +
15
+ * Zod — same rules the server uses, re-declared here so field errors
16
+ * show up without a network round-trip. On submit the form diffs
17
+ * against the original row and sends only the *changed* fields as a
18
+ * patch, plus the `vid` the form was opened with, so a concurrent edit
19
+ * elsewhere comes back as `admin.users.versionConflict` and we surface
20
+ * a reload prompt.
21
+ */
22
+
23
+ import { useState } from 'react'
24
+ import { revalidateLogic, useForm } from '@tanstack/react-form-start'
25
+
26
+ import { Alert, Button, Checkbox, Input, LoaderEllipsis } from '@byline/ui/react'
27
+ import cx from 'classnames'
28
+ import { z } from 'zod'
29
+
30
+ import { useBylineAdminServices } from '../../../services/admin-services-context.js'
31
+ import styles from './update.module.css'
32
+ import type { AdminUserResponse } from '../index.js'
33
+
34
+ const updateUserSchema = z.object({
35
+ given_name: z.string().max(100, 'Given name must not exceed 100 characters'),
36
+ family_name: z.string().max(100, 'Family name must not exceed 100 characters'),
37
+ username: z.string().max(100, 'Username must not exceed 100 characters'),
38
+ email: z
39
+ .email({ message: 'Enter a valid email address' })
40
+ .min(3)
41
+ .max(254, 'Email must not exceed 254 characters'),
42
+ is_super_admin: z.boolean(),
43
+ is_enabled: z.boolean(),
44
+ is_email_verified: z.boolean(),
45
+ })
46
+
47
+ type UpdateUserValues = z.infer<typeof updateUserSchema>
48
+
49
+ function defaultsFrom(user: AdminUserResponse): UpdateUserValues {
50
+ return {
51
+ given_name: user.given_name ?? '',
52
+ family_name: user.family_name ?? '',
53
+ username: user.username ?? '',
54
+ email: user.email,
55
+ is_super_admin: user.is_super_admin,
56
+ is_enabled: user.is_enabled,
57
+ is_email_verified: user.is_email_verified,
58
+ }
59
+ }
60
+
61
+ /** Build a patch object containing only fields whose values differ from the original user row. */
62
+ function buildPatch(values: UpdateUserValues, user: AdminUserResponse) {
63
+ const patch: {
64
+ given_name?: string | null
65
+ family_name?: string | null
66
+ username?: string | null
67
+ email?: string
68
+ is_super_admin?: boolean
69
+ is_enabled?: boolean
70
+ is_email_verified?: boolean
71
+ } = {}
72
+ // Text fields: treat empty string as null (clear). null === null matches,
73
+ // '' → null ≠ current null stays consistent.
74
+ const normaliseText = (value: string): string | null => (value.trim().length > 0 ? value : null)
75
+ const nextGiven = normaliseText(values.given_name)
76
+ const nextFamily = normaliseText(values.family_name)
77
+ const nextUsername = normaliseText(values.username)
78
+
79
+ if (nextGiven !== user.given_name) patch.given_name = nextGiven
80
+ if (nextFamily !== user.family_name) patch.family_name = nextFamily
81
+ if (nextUsername !== user.username) patch.username = nextUsername
82
+ if (values.email !== user.email) patch.email = values.email
83
+ if (values.is_super_admin !== user.is_super_admin) patch.is_super_admin = values.is_super_admin
84
+ if (values.is_enabled !== user.is_enabled) patch.is_enabled = values.is_enabled
85
+ if (values.is_email_verified !== user.is_email_verified)
86
+ patch.is_email_verified = values.is_email_verified
87
+ return patch
88
+ }
89
+
90
+ interface UpdateUserProps {
91
+ user: AdminUserResponse
92
+ onClose?: () => void
93
+ onSuccess?: (user: AdminUserResponse) => void
94
+ }
95
+
96
+ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
97
+ const { updateAdminUser } = useBylineAdminServices()
98
+ const [formError, setFormError] = useState<string | null>(null)
99
+ const [successMessage, setSuccessMessage] = useState<string | null>(null)
100
+
101
+ const form = useForm({
102
+ defaultValues: defaultsFrom(user),
103
+ validationLogic: revalidateLogic({
104
+ mode: 'blur',
105
+ modeAfterSubmission: 'change',
106
+ }),
107
+ validators: {
108
+ onDynamic: updateUserSchema,
109
+ },
110
+ onSubmit: async ({ value }) => {
111
+ setFormError(null)
112
+ setSuccessMessage(null)
113
+ const patch = buildPatch(value, user)
114
+ if (Object.keys(patch).length === 0) {
115
+ setSuccessMessage('No changes to save.')
116
+ return
117
+ }
118
+
119
+ try {
120
+ const updated = await updateAdminUser({
121
+ data: { id: user.id, vid: user.vid, patch },
122
+ })
123
+ setSuccessMessage('Saved.')
124
+ onSuccess?.(updated)
125
+ } catch (err) {
126
+ const code = getErrorCode(err)
127
+ if (code === 'admin.users.emailInUse') {
128
+ // Surface on the email field directly.
129
+ form.setFieldMeta('email', (meta) => ({
130
+ ...meta,
131
+ errorMap: { ...meta.errorMap, onServer: 'This email is already in use.' },
132
+ errors: ['This email is already in use.'],
133
+ }))
134
+ return
135
+ }
136
+ if (code === 'admin.users.versionConflict') {
137
+ setFormError(
138
+ 'This user has been modified elsewhere since you opened this form. Reload to get the latest values and try again.'
139
+ )
140
+ return
141
+ }
142
+ if (code === 'admin.users.notFound') {
143
+ setFormError('This user no longer exists.')
144
+ return
145
+ }
146
+ setFormError('Could not save changes. Please try again.')
147
+ }
148
+ },
149
+ })
150
+
151
+ return (
152
+ <div className={cx('byline-user-update-wrap', styles.wrap)}>
153
+ <form
154
+ noValidate
155
+ onSubmit={(event) => {
156
+ event.preventDefault()
157
+ event.stopPropagation()
158
+ void form.handleSubmit()
159
+ }}
160
+ className={cx('byline-user-update-form', styles.form)}
161
+ >
162
+ {formError ? <Alert intent="danger">{formError}</Alert> : null}
163
+ {successMessage ? <Alert intent="success">{successMessage}</Alert> : null}
164
+
165
+ <form.Field name="given_name">
166
+ {(field) => (
167
+ <Input
168
+ label="Given name"
169
+ id="given_name"
170
+ name={field.name}
171
+ value={field.state.value}
172
+ onBlur={field.handleBlur}
173
+ onChange={(e) => field.handleChange(e.currentTarget.value)}
174
+ error={field.state.meta.errors.length > 0}
175
+ errorText={firstError(field.state.meta.errors)}
176
+ autoComplete="given-name"
177
+ />
178
+ )}
179
+ </form.Field>
180
+
181
+ <form.Field name="family_name">
182
+ {(field) => (
183
+ <Input
184
+ label="Family name"
185
+ id="family_name"
186
+ name={field.name}
187
+ value={field.state.value}
188
+ onBlur={field.handleBlur}
189
+ onChange={(e) => field.handleChange(e.currentTarget.value)}
190
+ error={field.state.meta.errors.length > 0}
191
+ errorText={firstError(field.state.meta.errors)}
192
+ autoComplete="family-name"
193
+ />
194
+ )}
195
+ </form.Field>
196
+
197
+ <form.Field name="username">
198
+ {(field) => (
199
+ <Input
200
+ label="Username"
201
+ id="username"
202
+ name={field.name}
203
+ value={field.state.value}
204
+ onBlur={field.handleBlur}
205
+ onChange={(e) => field.handleChange(e.currentTarget.value)}
206
+ error={field.state.meta.errors.length > 0}
207
+ errorText={firstError(field.state.meta.errors)}
208
+ helpText="Optional. Leave blank to clear."
209
+ autoComplete="username"
210
+ />
211
+ )}
212
+ </form.Field>
213
+
214
+ <form.Field name="email">
215
+ {(field) => (
216
+ <Input
217
+ label="Email"
218
+ id="email"
219
+ name={field.name}
220
+ type="email"
221
+ value={field.state.value}
222
+ onBlur={field.handleBlur}
223
+ onChange={(e) => field.handleChange(e.currentTarget.value)}
224
+ error={field.state.meta.errors.length > 0}
225
+ errorText={firstError(field.state.meta.errors)}
226
+ autoComplete="email"
227
+ required
228
+ />
229
+ )}
230
+ </form.Field>
231
+
232
+ <div className={cx('byline-user-update-flags', styles.flags)}>
233
+ <form.Field name="is_enabled">
234
+ {(field) => (
235
+ <Checkbox
236
+ id="is_enabled"
237
+ name={field.name}
238
+ label="Enabled"
239
+ checked={field.state.value}
240
+ onCheckedChange={(checked) => field.handleChange(checked === true)}
241
+ helpText="Disabled accounts cannot sign in."
242
+ />
243
+ )}
244
+ </form.Field>
245
+
246
+ <form.Field name="is_email_verified">
247
+ {(field) => (
248
+ <Checkbox
249
+ id="is_email_verified"
250
+ name={field.name}
251
+ label="Email verified"
252
+ checked={field.state.value}
253
+ onCheckedChange={(checked) => field.handleChange(checked === true)}
254
+ />
255
+ )}
256
+ </form.Field>
257
+
258
+ <form.Field name="is_super_admin">
259
+ {(field) => (
260
+ <Checkbox
261
+ id="is_super_admin"
262
+ name={field.name}
263
+ label="Super admin"
264
+ checked={field.state.value}
265
+ onCheckedChange={(checked) => field.handleChange(checked === true)}
266
+ helpText="Super admins bypass every ability check — grant with care."
267
+ />
268
+ )}
269
+ </form.Field>
270
+ </div>
271
+
272
+ <div className={cx('byline-user-update-actions', styles.actions)}>
273
+ <Button
274
+ type="button"
275
+ intent="secondary"
276
+ size="sm"
277
+ onClick={onClose}
278
+ className={cx('byline-user-update-action', styles.action)}
279
+ >
280
+ {successMessage ? 'Close' : 'Cancel'}
281
+ </Button>
282
+ <form.Subscribe
283
+ selector={(state) => ({
284
+ canSubmit: state.canSubmit,
285
+ isSubmitting: state.isSubmitting,
286
+ isDirty: state.isDirty,
287
+ })}
288
+ >
289
+ {({ canSubmit, isSubmitting }) => (
290
+ <Button
291
+ size="sm"
292
+ intent="primary"
293
+ type="submit"
294
+ disabled={!canSubmit || isSubmitting}
295
+ className={cx('byline-user-update-action', styles.action)}
296
+ >
297
+ {isSubmitting === true ? <LoaderEllipsis size={42} /> : 'Save'}
298
+ </Button>
299
+ )}
300
+ </form.Subscribe>
301
+ </div>
302
+ </form>
303
+ </div>
304
+ )
305
+ }
306
+
307
+ function firstError(errors: readonly unknown[]): string | undefined {
308
+ for (const err of errors) {
309
+ if (typeof err === 'string') return err
310
+ if (err && typeof err === 'object' && 'message' in err) {
311
+ const msg = (err as { message?: unknown }).message
312
+ if (typeof msg === 'string') return msg
313
+ }
314
+ }
315
+ return undefined
316
+ }
317
+
318
+ /**
319
+ * Extract the admin-users error code from a thrown server-fn response.
320
+ * Typed errors (`AdminUsersError`, `AuthError`) survive the server-fn
321
+ * boundary with their `code` intact thanks to the `BylineCodedError`
322
+ * serialization adapter registered in `src/start.ts`.
323
+ */
324
+ function getErrorCode(err: unknown): string | null {
325
+ return typeof (err as { code?: unknown })?.code === 'string'
326
+ ? (err as { code: string }).code
327
+ : null
328
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { AdminUserRow } from './repository.js'
10
+ import type { AdminUserResponse } from './schemas.js'
11
+
12
+ /**
13
+ * Shape an `AdminUserRow` into its public `AdminUserResponse` form.
14
+ *
15
+ * The row type from the repository already omits `password_hash`, so
16
+ * this is effectively an identity map — the indirection exists so that
17
+ * if internal fields ever get added to the row (e.g. tenant id,
18
+ * soft-delete timestamp), they are explicitly opted out of the public
19
+ * shape here rather than leaking by default.
20
+ */
21
+ export function toAdminUser(row: AdminUserRow): AdminUserResponse {
22
+ return {
23
+ id: row.id,
24
+ vid: row.vid,
25
+ email: row.email,
26
+ given_name: row.given_name,
27
+ family_name: row.family_name,
28
+ username: row.username,
29
+ remember_me: row.remember_me,
30
+ last_login: row.last_login,
31
+ last_login_ip: row.last_login_ip,
32
+ failed_login_attempts: row.failed_login_attempts,
33
+ is_super_admin: row.is_super_admin,
34
+ is_enabled: row.is_enabled,
35
+ is_email_verified: row.is_email_verified,
36
+ created_at: row.created_at,
37
+ updated_at: row.updated_at,
38
+ }
39
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Module-local error codes for admin-users.
11
+ *
12
+ * Follows the same `code + factory` shape used by `AuthError` in
13
+ * `@byline/auth`, but with its own class so consumers can distinguish
14
+ * admin-users-specific failures from generic auth failures (e.g. to
15
+ * translate `EMAIL_IN_USE` into a 409 at a transport boundary while
16
+ * `FORBIDDEN` maps to 403).
17
+ *
18
+ * The codes are intentionally prefixed — `admin.users.*` — so they sort
19
+ * alongside the matching ability keys in logs and admin UI messages.
20
+ */
21
+
22
+ export const AdminUsersErrorCodes = {
23
+ NOT_FOUND: 'admin.users.notFound',
24
+ EMAIL_IN_USE: 'admin.users.emailInUse',
25
+ SELF_DELETE_FORBIDDEN: 'admin.users.selfDeleteForbidden',
26
+ SELF_DISABLE_FORBIDDEN: 'admin.users.selfDisableForbidden',
27
+ VERSION_CONFLICT: 'admin.users.versionConflict',
28
+ } as const
29
+
30
+ export type AdminUsersErrorCode = (typeof AdminUsersErrorCodes)[keyof typeof AdminUsersErrorCodes]
31
+
32
+ export interface AdminUsersErrorOptions {
33
+ message?: string
34
+ cause?: unknown
35
+ }
36
+
37
+ export class AdminUsersError extends Error {
38
+ public readonly code: AdminUsersErrorCode
39
+
40
+ constructor(code: AdminUsersErrorCode, options: { message: string; cause?: unknown }) {
41
+ super(options.message, options.cause != null ? { cause: options.cause } : undefined)
42
+ this.name = 'AdminUsersError'
43
+ this.code = code
44
+ }
45
+ }
46
+
47
+ const make =
48
+ (code: AdminUsersErrorCode, defaultMessage: string) =>
49
+ (options?: AdminUsersErrorOptions): AdminUsersError =>
50
+ new AdminUsersError(code, {
51
+ message: options?.message ?? defaultMessage,
52
+ cause: options?.cause,
53
+ })
54
+
55
+ /** The referenced admin user id does not exist. */
56
+ export const ERR_ADMIN_USER_NOT_FOUND = make(AdminUsersErrorCodes.NOT_FOUND, 'admin user not found')
57
+
58
+ /** Creating or updating an admin user conflicts with an existing email. */
59
+ export const ERR_ADMIN_USER_EMAIL_IN_USE = make(
60
+ AdminUsersErrorCodes.EMAIL_IN_USE,
61
+ 'email already in use'
62
+ )
63
+
64
+ /** The actor attempted to delete their own admin-user row. */
65
+ export const ERR_ADMIN_USER_SELF_DELETE = make(
66
+ AdminUsersErrorCodes.SELF_DELETE_FORBIDDEN,
67
+ 'cannot delete your own admin account'
68
+ )
69
+
70
+ /** The actor attempted to disable their own admin-user row. */
71
+ export const ERR_ADMIN_USER_SELF_DISABLE = make(
72
+ AdminUsersErrorCodes.SELF_DISABLE_FORBIDDEN,
73
+ 'cannot disable your own admin account'
74
+ )
75
+
76
+ /**
77
+ * The stored `vid` does not match the client-supplied `expectedVid` —
78
+ * the caller is holding a stale version of the row. Typical admin-UI
79
+ * response is to reload the edit form with the current values.
80
+ */
81
+ export const ERR_ADMIN_USER_VERSION_CONFLICT = make(
82
+ AdminUsersErrorCodes.VERSION_CONFLICT,
83
+ 'admin user has been modified elsewhere — please reload and try again'
84
+ )