@byline/admin 2.5.1 → 2.6.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 (260) hide show
  1. package/dist/fields/array/array-field.d.ts +14 -0
  2. package/dist/fields/array/array-field.js +177 -0
  3. package/dist/fields/array/array-field.module.js +11 -0
  4. package/dist/fields/array/array-field_module.css +32 -0
  5. package/dist/fields/blocks/blocks-field.d.ts +13 -0
  6. package/dist/fields/blocks/blocks-field.js +245 -0
  7. package/dist/fields/blocks/blocks-field.module.js +26 -0
  8. package/dist/fields/blocks/blocks-field_module.css +107 -0
  9. package/dist/fields/checkbox/checkbox-field.d.ts +16 -0
  10. package/dist/fields/checkbox/checkbox-field.js +28 -0
  11. package/dist/fields/checkbox/checkbox-field.module.js +6 -0
  12. package/dist/fields/checkbox/checkbox-field_module.css +4 -0
  13. package/dist/fields/column-formatter.d.ts +20 -0
  14. package/dist/fields/column-formatter.js +15 -0
  15. package/dist/fields/date-time-formatter.d.ts +16 -0
  16. package/dist/fields/date-time-formatter.js +8 -0
  17. package/dist/fields/datetime/datetime-field.d.ts +16 -0
  18. package/dist/fields/datetime/datetime-field.js +37 -0
  19. package/dist/fields/datetime/datetime-field.module.js +5 -0
  20. package/dist/fields/datetime/datetime-field_module.css +4 -0
  21. package/dist/fields/draggable-context-menu.d.ts +6 -0
  22. package/dist/fields/draggable-context-menu.js +85 -0
  23. package/dist/fields/draggable-context-menu.module.js +15 -0
  24. package/dist/fields/draggable-context-menu_module.css +91 -0
  25. package/dist/fields/field-helpers.d.ts +26 -0
  26. package/dist/fields/field-helpers.js +50 -0
  27. package/dist/fields/field-renderer.d.ts +37 -0
  28. package/dist/fields/field-renderer.js +206 -0
  29. package/dist/fields/field-renderer.module.js +8 -0
  30. package/dist/fields/field-renderer_module.css +11 -0
  31. package/dist/fields/field-services-context.d.ts +16 -0
  32. package/dist/fields/field-services-context.js +13 -0
  33. package/dist/fields/field-services-types.d.ts +63 -0
  34. package/dist/fields/field-services-types.js +1 -0
  35. package/dist/fields/file/file-field.d.ts +19 -0
  36. package/dist/fields/file/file-field.js +225 -0
  37. package/dist/fields/file/file-field.module.js +18 -0
  38. package/dist/fields/file/file-field_module.css +131 -0
  39. package/dist/fields/file/file-upload-field.d.ts +21 -0
  40. package/dist/fields/file/file-upload-field.js +130 -0
  41. package/dist/fields/file/file-upload-field.module.js +15 -0
  42. package/dist/fields/file/file-upload-field_module.css +74 -0
  43. package/dist/fields/group/group-field.d.ts +15 -0
  44. package/dist/fields/group/group-field.js +59 -0
  45. package/dist/fields/group/group-field.module.js +9 -0
  46. package/dist/fields/group/group-field_module.css +27 -0
  47. package/dist/fields/image/image-field.d.ts +19 -0
  48. package/dist/fields/image/image-field.js +241 -0
  49. package/dist/fields/image/image-field.module.js +22 -0
  50. package/dist/fields/image/image-field_module.css +121 -0
  51. package/dist/fields/image/image-upload-field.d.ts +21 -0
  52. package/dist/fields/image/image-upload-field.js +190 -0
  53. package/dist/fields/image/image-upload-field.module.js +19 -0
  54. package/dist/fields/image/image-upload-field_module.css +92 -0
  55. package/dist/fields/local-date-time.d.ts +27 -0
  56. package/dist/fields/local-date-time.js +49 -0
  57. package/dist/fields/locale-badge.d.ts +18 -0
  58. package/dist/fields/locale-badge.js +10 -0
  59. package/dist/fields/locale-badge.module.js +5 -0
  60. package/dist/fields/locale-badge_module.css +27 -0
  61. package/dist/fields/numerical/numerical-field.d.ts +18 -0
  62. package/dist/fields/numerical/numerical-field.js +74 -0
  63. package/dist/fields/relation/relation-display.d.ts +40 -0
  64. package/dist/fields/relation/relation-display.js +58 -0
  65. package/dist/fields/relation/relation-display.module.js +9 -0
  66. package/dist/fields/relation/relation-display_module.css +21 -0
  67. package/dist/fields/relation/relation-field.d.ts +18 -0
  68. package/dist/fields/relation/relation-field.js +138 -0
  69. package/dist/fields/relation/relation-field.module.js +13 -0
  70. package/dist/fields/relation/relation-field_module.css +62 -0
  71. package/dist/fields/relation/relation-picker.d.ts +49 -0
  72. package/dist/fields/relation/relation-picker.js +236 -0
  73. package/dist/fields/relation/relation-picker.module.js +26 -0
  74. package/dist/fields/relation/relation-picker_module.css +124 -0
  75. package/dist/fields/relation/relation-summary.d.ts +31 -0
  76. package/dist/fields/relation/relation-summary.js +50 -0
  77. package/dist/fields/relation/relation-summary.module.js +11 -0
  78. package/dist/fields/relation/relation-summary_module.css +37 -0
  79. package/dist/fields/select/select-field.d.ts +16 -0
  80. package/dist/fields/select/select-field.js +50 -0
  81. package/dist/fields/select/select-field.module.js +5 -0
  82. package/dist/fields/select/select-field_module.css +4 -0
  83. package/dist/fields/sortable-item.d.ts +15 -0
  84. package/dist/fields/sortable-item.js +81 -0
  85. package/dist/fields/sortable-item.module.js +22 -0
  86. package/dist/fields/sortable-item_module.css +124 -0
  87. package/dist/fields/text/text-field.d.ts +20 -0
  88. package/dist/fields/text/text-field.js +104 -0
  89. package/dist/fields/text/text-field.module.js +6 -0
  90. package/dist/fields/text/text-field_module.css +5 -0
  91. package/dist/fields/text-area/text-area-field.d.ts +20 -0
  92. package/dist/fields/text-area/text-area-field.js +105 -0
  93. package/dist/fields/text-area/text-area-field.module.js +6 -0
  94. package/dist/fields/text-area/text-area-field_module.css +5 -0
  95. package/dist/fields/use-field-change-handler.d.ts +23 -0
  96. package/dist/fields/use-field-change-handler.js +52 -0
  97. package/dist/forms/document-actions.d.ts +48 -0
  98. package/dist/forms/document-actions.js +475 -0
  99. package/dist/forms/document-actions.module.js +34 -0
  100. package/dist/forms/document-actions_module.css +118 -0
  101. package/dist/forms/form-context.d.ts +89 -0
  102. package/dist/forms/form-context.js +466 -0
  103. package/dist/forms/form-renderer.d.ts +98 -0
  104. package/dist/forms/form-renderer.js +597 -0
  105. package/dist/forms/form-renderer.module.js +46 -0
  106. package/dist/forms/form-renderer_module.css +245 -0
  107. package/dist/forms/navigation-guard.d.ts +54 -0
  108. package/dist/forms/navigation-guard.js +22 -0
  109. package/dist/forms/path-widget.d.ts +36 -0
  110. package/dist/forms/path-widget.js +116 -0
  111. package/dist/forms/path-widget.module.js +8 -0
  112. package/dist/forms/path-widget_module.css +29 -0
  113. package/dist/forms/upload-executor.d.ts +57 -0
  114. package/dist/forms/upload-executor.js +94 -0
  115. package/dist/lib/translate-validation-error.d.ts +36 -0
  116. package/dist/lib/translate-validation-error.js +11 -0
  117. package/dist/modules/admin-account/commands.d.ts +2 -1
  118. package/dist/modules/admin-account/commands.js +13 -2
  119. package/dist/modules/admin-account/components/change-password.js +45 -36
  120. package/dist/modules/admin-account/components/container.js +185 -134
  121. package/dist/modules/admin-account/components/preferences.d.ts +8 -0
  122. package/dist/modules/admin-account/components/preferences.js +152 -0
  123. package/dist/modules/admin-account/components/preferences.module.js +11 -0
  124. package/dist/modules/admin-account/components/preferences_module.css +41 -0
  125. package/dist/modules/admin-account/components/update.js +50 -31
  126. package/dist/modules/admin-account/index.d.ts +3 -3
  127. package/dist/modules/admin-account/index.js +2 -2
  128. package/dist/modules/admin-account/schemas.d.ts +4 -0
  129. package/dist/modules/admin-account/schemas.js +4 -1
  130. package/dist/modules/admin-account/service.d.ts +1 -0
  131. package/dist/modules/admin-account/service.js +8 -0
  132. package/dist/modules/admin-permissions/components/inspector.js +31 -41
  133. package/dist/modules/admin-roles/components/create.js +43 -26
  134. package/dist/modules/admin-roles/components/permissions.js +26 -35
  135. package/dist/modules/admin-roles/components/update.js +26 -16
  136. package/dist/modules/admin-users/components/create.js +60 -40
  137. package/dist/modules/admin-users/components/roles.js +9 -15
  138. package/dist/modules/admin-users/components/set-password.js +30 -31
  139. package/dist/modules/admin-users/components/update.js +58 -39
  140. package/dist/modules/admin-users/dto.js +1 -0
  141. package/dist/modules/admin-users/repository.d.ts +17 -0
  142. package/dist/modules/admin-users/schemas.d.ts +4 -0
  143. package/dist/modules/admin-users/schemas.js +6 -2
  144. package/dist/modules/auth/components/sign-in-form.js +10 -8
  145. package/dist/presentation/group.d.ts +27 -0
  146. package/dist/presentation/group.js +14 -0
  147. package/dist/presentation/group.module.js +6 -0
  148. package/dist/presentation/group_module.css +19 -0
  149. package/dist/presentation/row.d.ts +25 -0
  150. package/dist/presentation/row.js +8 -0
  151. package/dist/presentation/row.module.js +5 -0
  152. package/dist/presentation/row_module.css +18 -0
  153. package/dist/presentation/tabs.d.ts +25 -0
  154. package/dist/presentation/tabs.js +39 -0
  155. package/dist/presentation/tabs.module.js +10 -0
  156. package/dist/presentation/tabs_module.css +68 -0
  157. package/dist/react.d.ts +66 -0
  158. package/dist/react.js +36 -0
  159. package/dist/services/admin-services-types.d.ts +16 -0
  160. package/dist/widgets/diff-viewer/diff-modal.d.ts +22 -0
  161. package/dist/widgets/diff-viewer/diff-modal.js +149 -0
  162. package/dist/widgets/diff-viewer/diff-modal.module.js +14 -0
  163. package/dist/widgets/diff-viewer/diff-modal_module.css +56 -0
  164. package/dist/widgets/status-badge/status-badge.d.ts +25 -0
  165. package/dist/widgets/status-badge/status-badge.js +37 -0
  166. package/dist/widgets/status-badge/status-badge.module.js +7 -0
  167. package/dist/widgets/status-badge/status-badge_module.css +20 -0
  168. package/package.json +14 -4
  169. package/src/fields/array/array-field.module.css +48 -0
  170. package/src/fields/array/array-field.tsx +267 -0
  171. package/src/fields/blocks/blocks-field.module.css +148 -0
  172. package/src/fields/blocks/blocks-field.tsx +323 -0
  173. package/src/fields/checkbox/checkbox-field.module.css +4 -0
  174. package/src/fields/checkbox/checkbox-field.tsx +54 -0
  175. package/src/fields/column-formatter.tsx +31 -0
  176. package/src/fields/date-time-formatter.tsx +22 -0
  177. package/src/fields/datetime/datetime-field.module.css +13 -0
  178. package/src/fields/datetime/datetime-field.tsx +54 -0
  179. package/src/fields/draggable-context-menu.module.css +127 -0
  180. package/src/fields/draggable-context-menu.tsx +87 -0
  181. package/src/fields/field-helpers.ts +69 -0
  182. package/src/fields/field-renderer.module.css +22 -0
  183. package/src/fields/field-renderer.tsx +288 -0
  184. package/src/fields/field-services-context.tsx +35 -0
  185. package/src/fields/field-services-types.ts +68 -0
  186. package/src/fields/file/file-field.module.css +153 -0
  187. package/src/fields/file/file-field.tsx +286 -0
  188. package/src/fields/file/file-upload-field.module.css +101 -0
  189. package/src/fields/file/file-upload-field.tsx +187 -0
  190. package/src/fields/group/group-field.module.css +43 -0
  191. package/src/fields/group/group-field.tsx +84 -0
  192. package/src/fields/image/image-field.module.css +155 -0
  193. package/src/fields/image/image-field.tsx +306 -0
  194. package/src/fields/image/image-upload-field.module.css +123 -0
  195. package/src/fields/image/image-upload-field.tsx +276 -0
  196. package/src/fields/local-date-time.tsx +88 -0
  197. package/src/fields/locale-badge.module.css +37 -0
  198. package/src/fields/locale-badge.tsx +32 -0
  199. package/src/fields/numerical/numerical-field.tsx +114 -0
  200. package/src/fields/relation/relation-display.module.css +36 -0
  201. package/src/fields/relation/relation-display.tsx +130 -0
  202. package/src/fields/relation/relation-field.module.css +83 -0
  203. package/src/fields/relation/relation-field.tsx +211 -0
  204. package/src/fields/relation/relation-picker.module.css +168 -0
  205. package/src/fields/relation/relation-picker.tsx +326 -0
  206. package/src/fields/relation/relation-summary.module.css +55 -0
  207. package/src/fields/relation/relation-summary.tsx +123 -0
  208. package/src/fields/select/select-field.module.css +13 -0
  209. package/src/fields/select/select-field.tsx +61 -0
  210. package/src/fields/sortable-item.module.css +167 -0
  211. package/src/fields/sortable-item.tsx +106 -0
  212. package/src/fields/text/text-field.module.css +13 -0
  213. package/src/fields/text/text-field.tsx +146 -0
  214. package/src/fields/text-area/text-area-field.module.css +13 -0
  215. package/src/fields/text-area/text-area-field.tsx +147 -0
  216. package/src/fields/use-field-change-handler.ts +112 -0
  217. package/src/forms/document-actions.module.css +160 -0
  218. package/src/forms/document-actions.tsx +482 -0
  219. package/src/forms/form-context.tsx +704 -0
  220. package/src/forms/form-renderer.module.css +321 -0
  221. package/src/forms/form-renderer.tsx +891 -0
  222. package/src/forms/navigation-guard.tsx +98 -0
  223. package/src/forms/path-widget.module.css +41 -0
  224. package/src/forms/path-widget.test.tsx +217 -0
  225. package/src/forms/path-widget.tsx +183 -0
  226. package/src/forms/upload-executor.ts +192 -0
  227. package/src/lib/translate-validation-error.ts +56 -0
  228. package/src/modules/admin-account/commands.ts +13 -0
  229. package/src/modules/admin-account/components/change-password.tsx +46 -31
  230. package/src/modules/admin-account/components/container.tsx +83 -38
  231. package/src/modules/admin-account/components/preferences.module.css +60 -0
  232. package/src/modules/admin-account/components/preferences.tsx +203 -0
  233. package/src/modules/admin-account/components/update.tsx +53 -27
  234. package/src/modules/admin-account/index.ts +3 -0
  235. package/src/modules/admin-account/schemas.ts +13 -0
  236. package/src/modules/admin-account/service.ts +12 -0
  237. package/src/modules/admin-permissions/components/inspector.tsx +22 -14
  238. package/src/modules/admin-roles/components/create.tsx +51 -23
  239. package/src/modules/admin-roles/components/permissions.tsx +25 -21
  240. package/src/modules/admin-roles/components/update.tsx +37 -19
  241. package/src/modules/admin-users/components/create.tsx +63 -34
  242. package/src/modules/admin-users/components/roles.tsx +9 -8
  243. package/src/modules/admin-users/components/set-password.tsx +34 -28
  244. package/src/modules/admin-users/components/update.tsx +58 -36
  245. package/src/modules/admin-users/dto.ts +1 -0
  246. package/src/modules/admin-users/repository.ts +17 -0
  247. package/src/modules/admin-users/schemas.ts +12 -0
  248. package/src/modules/auth/components/sign-in-form.tsx +14 -8
  249. package/src/presentation/group.module.css +41 -0
  250. package/src/presentation/group.tsx +40 -0
  251. package/src/presentation/row.module.css +32 -0
  252. package/src/presentation/row.tsx +33 -0
  253. package/src/presentation/tabs.module.css +107 -0
  254. package/src/presentation/tabs.tsx +84 -0
  255. package/src/react.ts +84 -0
  256. package/src/services/admin-services-types.ts +18 -0
  257. package/src/widgets/diff-viewer/diff-modal.module.css +79 -0
  258. package/src/widgets/diff-viewer/diff-modal.tsx +186 -0
  259. package/src/widgets/status-badge/status-badge.module.css +31 -0
  260. package/src/widgets/status-badge/status-badge.tsx +71 -0
@@ -20,9 +20,10 @@
20
20
  * a reload prompt.
21
21
  */
22
22
 
23
- import { useState } from 'react'
23
+ import { useMemo, useState } from 'react'
24
24
  import { revalidateLogic, useForm } from '@tanstack/react-form-start'
25
25
 
26
+ import { useTranslation } from '@byline/i18n/react'
26
27
  import { Alert, Button, Checkbox, Input, LoaderEllipsis } from '@byline/ui/react'
27
28
  import cx from 'classnames'
28
29
  import { z } from 'zod'
@@ -31,20 +32,19 @@ import { useBylineAdminServices } from '../../../services/admin-services-context
31
32
  import styles from './update.module.css'
32
33
  import type { AdminUserResponse } from '../index.js'
33
34
 
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
- })
35
+ const MAX_NAME = 100
36
+ const MAX_USERNAME = 100
37
+ const MAX_EMAIL = 254
46
38
 
47
- type UpdateUserValues = z.infer<typeof updateUserSchema>
39
+ type UpdateUserValues = {
40
+ given_name: string
41
+ family_name: string
42
+ username: string
43
+ email: string
44
+ is_super_admin: boolean
45
+ is_enabled: boolean
46
+ is_email_verified: boolean
47
+ }
48
48
 
49
49
  function defaultsFrom(user: AdminUserResponse): UpdateUserValues {
50
50
  return {
@@ -95,9 +95,33 @@ interface UpdateUserProps {
95
95
 
96
96
  export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
97
97
  const { updateAdminUser } = useBylineAdminServices()
98
+ const { t } = useTranslation('byline-admin')
98
99
  const [formError, setFormError] = useState<string | null>(null)
99
100
  const [successMessage, setSuccessMessage] = useState<string | null>(null)
100
101
 
102
+ const updateUserSchema = useMemo(
103
+ () =>
104
+ z.object({
105
+ given_name: z
106
+ .string()
107
+ .max(MAX_NAME, t('account.update.errors.givenNameTooLong', { max: MAX_NAME })),
108
+ family_name: z
109
+ .string()
110
+ .max(MAX_NAME, t('account.update.errors.familyNameTooLong', { max: MAX_NAME })),
111
+ username: z
112
+ .string()
113
+ .max(MAX_USERNAME, t('account.update.errors.usernameTooLong', { max: MAX_USERNAME })),
114
+ email: z
115
+ .email({ message: t('account.update.errors.invalidEmail') })
116
+ .min(3)
117
+ .max(MAX_EMAIL, t('account.update.errors.emailTooLong', { max: MAX_EMAIL })),
118
+ is_super_admin: z.boolean(),
119
+ is_enabled: z.boolean(),
120
+ is_email_verified: z.boolean(),
121
+ }),
122
+ [t]
123
+ )
124
+
101
125
  const form = useForm({
102
126
  defaultValues: defaultsFrom(user),
103
127
  validationLogic: revalidateLogic({
@@ -112,7 +136,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
112
136
  setSuccessMessage(null)
113
137
  const patch = buildPatch(value, user)
114
138
  if (Object.keys(patch).length === 0) {
115
- setSuccessMessage('No changes to save.')
139
+ setSuccessMessage(t('common.feedback.noChanges'))
116
140
  return
117
141
  }
118
142
 
@@ -120,30 +144,28 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
120
144
  const updated = await updateAdminUser({
121
145
  data: { id: user.id, vid: user.vid, patch },
122
146
  })
123
- setSuccessMessage('Saved.')
147
+ setSuccessMessage(t('common.feedback.saved'))
124
148
  onSuccess?.(updated)
125
149
  } catch (err) {
126
150
  const code = getErrorCode(err)
127
151
  if (code === 'admin.users.emailInUse') {
128
- // Surface on the email field directly.
152
+ const message = t('account.update.errors.emailInUse')
129
153
  form.setFieldMeta('email', (meta) => ({
130
154
  ...meta,
131
- errorMap: { ...meta.errorMap, onServer: 'This email is already in use.' },
132
- errors: ['This email is already in use.'],
155
+ errorMap: { ...meta.errorMap, onServer: message },
156
+ errors: [message],
133
157
  }))
134
158
  return
135
159
  }
136
160
  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
- )
161
+ setFormError(t('adminUsers.update.errors.versionConflict'))
140
162
  return
141
163
  }
142
164
  if (code === 'admin.users.notFound') {
143
- setFormError('This user no longer exists.')
165
+ setFormError(t('adminUsers.update.errors.notFound'))
144
166
  return
145
167
  }
146
- setFormError('Could not save changes. Please try again.')
168
+ setFormError(t('common.errors.couldNotSave'))
147
169
  }
148
170
  },
149
171
  })
@@ -165,7 +187,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
165
187
  <form.Field name="given_name">
166
188
  {(field) => (
167
189
  <Input
168
- label="Given name"
190
+ label={t('account.update.fields.givenName')}
169
191
  id="given_name"
170
192
  name={field.name}
171
193
  value={field.state.value}
@@ -181,7 +203,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
181
203
  <form.Field name="family_name">
182
204
  {(field) => (
183
205
  <Input
184
- label="Family name"
206
+ label={t('account.update.fields.familyName')}
185
207
  id="family_name"
186
208
  name={field.name}
187
209
  value={field.state.value}
@@ -197,7 +219,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
197
219
  <form.Field name="username">
198
220
  {(field) => (
199
221
  <Input
200
- label="Username"
222
+ label={t('account.update.fields.username')}
201
223
  id="username"
202
224
  name={field.name}
203
225
  value={field.state.value}
@@ -205,7 +227,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
205
227
  onChange={(e) => field.handleChange(e.currentTarget.value)}
206
228
  error={field.state.meta.errors.length > 0}
207
229
  errorText={firstError(field.state.meta.errors)}
208
- helpText="Optional. Leave blank to clear."
230
+ helpText={t('account.update.fields.usernameHelp')}
209
231
  autoComplete="username"
210
232
  />
211
233
  )}
@@ -214,7 +236,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
214
236
  <form.Field name="email">
215
237
  {(field) => (
216
238
  <Input
217
- label="Email"
239
+ label={t('common.fields.email')}
218
240
  id="email"
219
241
  name={field.name}
220
242
  type="email"
@@ -235,10 +257,10 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
235
257
  <Checkbox
236
258
  id="is_enabled"
237
259
  name={field.name}
238
- label="Enabled"
260
+ label={t('adminUsers.create.flags.enabledLabel')}
239
261
  checked={field.state.value}
240
262
  onCheckedChange={(checked) => field.handleChange(checked === true)}
241
- helpText="Disabled accounts cannot sign in."
263
+ helpText={t('adminUsers.create.flags.enabledHelp')}
242
264
  />
243
265
  )}
244
266
  </form.Field>
@@ -248,7 +270,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
248
270
  <Checkbox
249
271
  id="is_email_verified"
250
272
  name={field.name}
251
- label="Email verified"
273
+ label={t('adminUsers.create.flags.emailVerifiedLabel')}
252
274
  checked={field.state.value}
253
275
  onCheckedChange={(checked) => field.handleChange(checked === true)}
254
276
  />
@@ -260,10 +282,10 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
260
282
  <Checkbox
261
283
  id="is_super_admin"
262
284
  name={field.name}
263
- label="Super admin"
285
+ label={t('adminUsers.create.flags.superAdminLabel')}
264
286
  checked={field.state.value}
265
287
  onCheckedChange={(checked) => field.handleChange(checked === true)}
266
- helpText="Super admins bypass every ability check — grant with care."
288
+ helpText={t('adminUsers.create.flags.superAdminHelp')}
267
289
  />
268
290
  )}
269
291
  </form.Field>
@@ -277,7 +299,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
277
299
  onClick={onClose}
278
300
  className={cx('byline-user-update-action', styles.action)}
279
301
  >
280
- {successMessage ? 'Close' : 'Cancel'}
302
+ {successMessage ? t('common.actions.close') : t('common.actions.cancel')}
281
303
  </Button>
282
304
  <form.Subscribe
283
305
  selector={(state) => ({
@@ -294,7 +316,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
294
316
  disabled={!canSubmit || isSubmitting}
295
317
  className={cx('byline-user-update-action', styles.action)}
296
318
  >
297
- {isSubmitting === true ? <LoaderEllipsis size={42} /> : 'Save'}
319
+ {isSubmitting === true ? <LoaderEllipsis size={42} /> : t('common.actions.save')}
298
320
  </Button>
299
321
  )}
300
322
  </form.Subscribe>
@@ -33,6 +33,7 @@ export function toAdminUser(row: AdminUserRow): AdminUserResponse {
33
33
  is_super_admin: row.is_super_admin,
34
34
  is_enabled: row.is_enabled,
35
35
  is_email_verified: row.is_email_verified,
36
+ preferred_locale: row.preferred_locale,
36
37
  created_at: row.created_at,
37
38
  updated_at: row.updated_at,
38
39
  }
@@ -50,6 +50,13 @@ export interface AdminUserRow {
50
50
  is_super_admin: boolean
51
51
  is_enabled: boolean
52
52
  is_email_verified: boolean
53
+ /**
54
+ * Admin interface locale preference. `null` means "use the detection
55
+ * cascade" (cookie → Accept-Language → defaultLocale). Stored as a
56
+ * BCP 47 code; validated at the command layer against the host's
57
+ * `i18n.interface.locales`.
58
+ */
59
+ preferred_locale: string | null
53
60
  created_at: Date
54
61
  updated_at: Date
55
62
  }
@@ -73,6 +80,8 @@ export interface CreateAdminUserInput {
73
80
  is_super_admin?: boolean
74
81
  is_enabled?: boolean
75
82
  is_email_verified?: boolean
83
+ /** Initial locale preference. `null` defers to the detection cascade. */
84
+ preferred_locale?: string | null
76
85
  }
77
86
 
78
87
  export interface UpdateAdminUserInput {
@@ -84,6 +93,8 @@ export interface UpdateAdminUserInput {
84
93
  is_enabled?: boolean
85
94
  is_email_verified?: boolean
86
95
  remember_me?: boolean
96
+ /** Pass `null` to clear and fall back to the detection cascade. */
97
+ preferred_locale?: string | null
87
98
  }
88
99
 
89
100
  export type AdminUserListOrder =
@@ -151,6 +162,12 @@ export interface AdminUsersRepository {
151
162
  setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise<AdminUserRow>
152
163
  /** Toggle enabled state. Vid-less — admin intent is independent of other edits. */
153
164
  setEnabled(id: string, enabled: boolean): Promise<void>
165
+ /**
166
+ * Set the admin interface locale preference. Vid-less — user preference
167
+ * is independent of content state. Pass `null` to clear and fall back
168
+ * to the detection cascade (cookie → Accept-Language → defaultLocale).
169
+ */
170
+ setPreferredLocale(id: string, locale: string | null): Promise<void>
154
171
  recordLoginSuccess(id: string, ip: string | null): Promise<void>
155
172
  recordLoginFailure(id: string): Promise<void>
156
173
  /**
@@ -46,6 +46,15 @@ const emailSchema = z
46
46
 
47
47
  const nameSchema = z.string().min(1).max(100)
48
48
 
49
+ // BCP 47 locale codes — `en`, `pt-BR`, `zh-Hans-CN`, etc. The 16-char
50
+ // ceiling matches the DB column width and is wider than any real-world
51
+ // locale tag.
52
+ const preferredLocaleSchema = z
53
+ .string()
54
+ .min(2)
55
+ .max(16)
56
+ .regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'must be a BCP 47 locale tag')
57
+
49
58
  const orderSchema = z.enum([
50
59
  'given_name',
51
60
  'family_name',
@@ -82,6 +91,7 @@ export const createAdminUserRequestSchema = z.object({
82
91
  is_super_admin: z.boolean().optional(),
83
92
  is_enabled: z.boolean().optional(),
84
93
  is_email_verified: z.boolean().optional(),
94
+ preferred_locale: preferredLocaleSchema.nullish(),
85
95
  })
86
96
  export type CreateAdminUserRequest = z.infer<typeof createAdminUserRequestSchema>
87
97
 
@@ -97,6 +107,7 @@ export const updateAdminUserRequestSchema = z.object({
97
107
  is_super_admin: z.boolean().optional(),
98
108
  is_enabled: z.boolean().optional(),
99
109
  is_email_verified: z.boolean().optional(),
110
+ preferred_locale: preferredLocaleSchema.nullish(),
100
111
  })
101
112
  .refine((p) => Object.keys(p).length > 0, { message: 'patch cannot be empty' }),
102
113
  })
@@ -144,6 +155,7 @@ export const adminUserResponseSchema = z.object({
144
155
  is_super_admin: z.boolean(),
145
156
  is_enabled: z.boolean(),
146
157
  is_email_verified: z.boolean(),
158
+ preferred_locale: z.string().nullable(),
147
159
  created_at: z.date(),
148
160
  updated_at: z.date(),
149
161
  })
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { type FormEvent, useState } from 'react'
27
27
 
28
+ import { useTranslation } from '@byline/i18n/react'
28
29
  import { Alert, Button, Card, Input, LoaderEllipsis } from '@byline/ui/react'
29
30
  import cx from 'classnames'
30
31
 
@@ -44,6 +45,7 @@ interface SignInFormProps {
44
45
 
45
46
  export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
46
47
  const { adminSignIn } = useBylineAdminServices()
48
+ const { t } = useTranslation('byline-admin')
47
49
  const [email, setEmail] = useState('')
48
50
  const [password, setPassword] = useState('')
49
51
  const [pending, setPending] = useState(false)
@@ -53,7 +55,7 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
53
55
  event.preventDefault()
54
56
  if (pending) return
55
57
  if (email.trim().length === 0 || password.length === 0) {
56
- setError('Enter your email and password.')
58
+ setError(t('auth.signIn.errors.empty'))
57
59
  return
58
60
  }
59
61
 
@@ -67,7 +69,7 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
67
69
  window.location.assign(target)
68
70
  } catch (err) {
69
71
  console.warn('sign-in failed', err)
70
- setError('Invalid credentials.')
72
+ setError(t('auth.signIn.errors.invalidCredentials'))
71
73
  setPending(false)
72
74
  }
73
75
  }
@@ -76,9 +78,9 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
76
78
  <Card className={cx('byline-sign-in-card', styles.card)}>
77
79
  <Card.Header>
78
80
  <Card.Title>
79
- <h2>Sign in</h2>
81
+ <h2>{t('auth.signIn.title')}</h2>
80
82
  </Card.Title>
81
- <Card.Description>Sign in to the Byline admin.</Card.Description>
83
+ <Card.Description>{t('auth.signIn.description')}</Card.Description>
82
84
  {error && (
83
85
  <Alert intent="danger" className={cx('byline-sign-in-alert', styles.alert)}>
84
86
  {error}
@@ -89,7 +91,7 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
89
91
  <form onSubmit={handleSubmit} noValidate className={cx('byline-sign-in-form', styles.form)}>
90
92
  <div className={cx('byline-sign-in-fields', styles.fields)}>
91
93
  <Input
92
- label="Email"
94
+ label={t('common.fields.email')}
93
95
  id="email"
94
96
  name="email"
95
97
  type="email"
@@ -100,7 +102,7 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
100
102
  disabled={pending}
101
103
  />
102
104
  <Input
103
- label="Password"
105
+ label={t('common.fields.password')}
104
106
  id="password"
105
107
  name="password"
106
108
  type="password"
@@ -114,7 +116,7 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
114
116
  <div className={cx('byline-sign-in-actions', styles.actions)}>
115
117
  {homeUrl && (
116
118
  <a href={homeUrl} className={cx('byline-sign-in-home-link', styles['home-link'])}>
117
- Home
119
+ {t('common.actions.home')}
118
120
  </a>
119
121
  )}
120
122
  <Button
@@ -122,7 +124,11 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
122
124
  disabled={pending}
123
125
  className={cx('byline-sign-in-button', styles.button)}
124
126
  >
125
- {pending ? <LoaderEllipsis size={30} color="#aaaaaa" /> : <span>Sign In</span>}
127
+ {pending ? (
128
+ <LoaderEllipsis size={30} color="#aaaaaa" />
129
+ ) : (
130
+ <span>{t('common.actions.signIn')}</span>
131
+ )}
126
132
  </Button>
127
133
  </div>
128
134
  </form>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Group — labelled fieldset clustering related fields together.
3
+ *
4
+ * Used by `FormRenderer` when a `CollectionAdminConfig` declares a
5
+ * `groups` primitive. Renders a bordered, padded `<fieldset>` with an
6
+ * optional `<legend>` for the label.
7
+ *
8
+ * Override handles: `.byline-admin-group` (the fieldset) and
9
+ * `.byline-admin-group-legend` (the optional heading) are exposed as
10
+ * stable global classes for host overrides.
11
+ *
12
+ * Note: this is the *admin layout* group primitive, not the schema-level
13
+ * `group` field type. Field widgets use `byline-field-*` handles to keep
14
+ * the namespaces disambiguated.
15
+ */
16
+
17
+ .group,
18
+ :global(.byline-admin-group) {
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--spacing-16);
22
+ padding: var(--spacing-12);
23
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
24
+ border-radius: var(--border-radius-md);
25
+ }
26
+
27
+ .legend,
28
+ :global(.byline-admin-group-legend) {
29
+ padding-inline: var(--spacing-4);
30
+ font-size: var(--font-size-sm);
31
+ font-weight: var(--font-weight-medium);
32
+ }
33
+
34
+ /* ─── Dark theme variants ───────────────────────────────────── */
35
+
36
+ :is([data-theme="dark"], :global(.dark)) {
37
+ .group,
38
+ :global(.byline-admin-group) {
39
+ border-color: var(--gray-700);
40
+ }
41
+ }
@@ -0,0 +1,40 @@
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 { ReactNode } from 'react'
10
+
11
+ import cx from 'classnames'
12
+
13
+ import styles from './group.module.css'
14
+
15
+ interface AdminGroupProps {
16
+ /** Optional heading rendered as a `<legend>` above the cluster. */
17
+ label?: string
18
+ children: ReactNode
19
+ className?: string
20
+ }
21
+
22
+ /**
23
+ * Labelled fieldset clustering related fields together.
24
+ *
25
+ * Used by `FormRenderer` when a `CollectionAdminConfig` declares a `groups`
26
+ * primitive. Renders a bordered, padded `<fieldset>` with an optional
27
+ * `<legend>` for the label.
28
+ *
29
+ * Stable override handles: `.byline-admin-group` on the fieldset and
30
+ * `.byline-admin-group-legend` on the legend (alongside the hashed
31
+ * CSS-modules locals).
32
+ */
33
+ export const AdminGroup = ({ label, children, className }: AdminGroupProps) => {
34
+ return (
35
+ <fieldset className={cx('byline-admin-group', styles.group, className)}>
36
+ {label && <legend className={cx('byline-admin-group-legend', styles.legend)}>{label}</legend>}
37
+ {children}
38
+ </fieldset>
39
+ )
40
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Row — horizontal flex layout for admin form fields.
3
+ *
4
+ * Members render side-by-side at `sm` and above, stack vertically below.
5
+ * `flex: 1` + `min-width: 0` on direct children lets two text inputs
6
+ * share the row evenly without overflowing.
7
+ *
8
+ * Override handle: `.byline-admin-row` is exposed as a stable global
9
+ * class so hosts can target this element from their own stylesheet
10
+ * without referring to the hashed local name.
11
+ */
12
+
13
+ .row,
14
+ :global(.byline-admin-row) {
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: flex-start;
18
+ gap: var(--spacing-16);
19
+ }
20
+
21
+ .row > *,
22
+ :global(.byline-admin-row) > * {
23
+ flex: 1;
24
+ min-width: 0;
25
+ }
26
+
27
+ @media (min-width: 40rem) {
28
+ .row,
29
+ :global(.byline-admin-row) {
30
+ flex-direction: row;
31
+ }
32
+ }
@@ -0,0 +1,33 @@
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 { ReactNode } from 'react'
10
+
11
+ import cx from 'classnames'
12
+
13
+ import styles from './row.module.css'
14
+
15
+ interface AdminRowProps {
16
+ children: ReactNode
17
+ className?: string
18
+ }
19
+
20
+ /**
21
+ * Horizontal flex-row layout for admin form fields.
22
+ *
23
+ * Used by `FormRenderer` when a `CollectionAdminConfig` declares a `rows`
24
+ * primitive. Members are rendered side-by-side above the `sm` breakpoint
25
+ * and stack vertically below it. `flex-1` + `min-width: 0` lets two text
26
+ * inputs share the row evenly without overflowing.
27
+ *
28
+ * The element carries `.byline-admin-row` as a stable global class for
29
+ * host overrides (alongside the hashed CSS-modules local).
30
+ */
31
+ export const AdminRow = ({ children, className }: AdminRowProps) => {
32
+ return <div className={cx('byline-admin-row', styles.row, className)}>{children}</div>
33
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Tabs — navigation bar for admin form layouts.
3
+ *
4
+ * Override handles:
5
+ * .byline-admin-tabs — the tablist container
6
+ * .byline-admin-tab — each tab button
7
+ * .byline-admin-tab-active — added when the tab is the active one
8
+ * .byline-admin-tab-label — the inner label span (with optional badge)
9
+ * .byline-admin-tab-badge — the inline error-count badge
10
+ */
11
+
12
+ .tabs,
13
+ :global(.byline-admin-tabs) {
14
+ display: flex;
15
+ gap: var(--spacing-16);
16
+ border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
17
+ }
18
+
19
+ .tab,
20
+ :global(.byline-admin-tab) {
21
+ position: relative;
22
+ margin-bottom: -1px;
23
+ padding: 0.625rem 0;
24
+ border-bottom: 2px solid transparent;
25
+ background: none;
26
+ color: var(--gray-500);
27
+ font-size: 1.1rem;
28
+ font-weight: var(--font-weight-medium);
29
+ cursor: pointer;
30
+ transition:
31
+ color 150ms ease,
32
+ border-color 150ms ease;
33
+ outline: none;
34
+ }
35
+
36
+ .tab:hover,
37
+ :global(.byline-admin-tab):hover {
38
+ color: var(--gray-800);
39
+ border-bottom-color: var(--gray-300);
40
+ }
41
+
42
+ .tab:focus-visible,
43
+ :global(.byline-admin-tab):focus-visible {
44
+ box-shadow: 0 0 0 2px var(--blue-500);
45
+ }
46
+
47
+ .tab-active,
48
+ :global(.byline-admin-tab-active) {
49
+ color: var(--primary-600);
50
+ border-bottom-color: var(--primary-400);
51
+ }
52
+
53
+ .tab-active:hover,
54
+ :global(.byline-admin-tab-active):hover {
55
+ color: var(--primary-600);
56
+ border-bottom-color: var(--primary-400);
57
+ }
58
+
59
+ .label,
60
+ :global(.byline-admin-tab-label) {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.375rem;
64
+ }
65
+
66
+ .badge,
67
+ :global(.byline-admin-tab-badge) {
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ min-width: 1.25rem;
72
+ height: 1.25rem;
73
+ padding: 0 0.375rem;
74
+ font-size: 0.7rem;
75
+ }
76
+
77
+ /* ─── Dark theme variants ───────────────────────────────────── */
78
+
79
+ :is([data-theme="dark"], :global(.dark)) {
80
+ .tabs,
81
+ :global(.byline-admin-tabs) {
82
+ border-bottom-color: var(--gray-700);
83
+ }
84
+
85
+ .tab,
86
+ :global(.byline-admin-tab) {
87
+ color: var(--gray-400);
88
+ }
89
+
90
+ .tab:hover,
91
+ :global(.byline-admin-tab):hover {
92
+ color: var(--gray-200);
93
+ border-bottom-color: var(--gray-600);
94
+ }
95
+
96
+ .tab-active,
97
+ :global(.byline-admin-tab-active) {
98
+ color: var(--primary-200);
99
+ border-bottom-color: var(--primary-400);
100
+ }
101
+
102
+ .tab-active:hover,
103
+ :global(.byline-admin-tab-active):hover {
104
+ color: var(--primary-200);
105
+ border-bottom-color: var(--primary-400);
106
+ }
107
+ }