@byline/admin 2.4.0 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abilities.js +5 -24
- package/dist/index.js +8 -30
- package/dist/lib/assert-admin-actor.js +13 -74
- package/dist/lib/create-command.js +6 -16
- package/dist/modules/admin-account/commands.js +35 -24
- package/dist/modules/admin-account/components/change-password.d.ts +8 -0
- package/dist/modules/admin-account/components/change-password.js +192 -0
- package/dist/modules/admin-account/components/change-password.module.js +8 -0
- package/dist/modules/admin-account/components/change-password_module.css +27 -0
- package/dist/modules/admin-account/components/container.d.ts +29 -0
- package/dist/modules/admin-account/components/container.js +298 -0
- package/dist/modules/admin-account/components/container.module.js +28 -0
- package/dist/modules/admin-account/components/container_module.css +106 -0
- package/dist/modules/admin-account/components/update.d.ts +8 -0
- package/dist/modules/admin-account/components/update.js +207 -0
- package/dist/modules/admin-account/components/update.module.js +8 -0
- package/dist/modules/admin-account/components/update_module.css +27 -0
- package/dist/modules/admin-account/errors.js +14 -45
- package/dist/modules/admin-account/index.js +4 -34
- package/dist/modules/admin-account/schemas.js +25 -59
- package/dist/modules/admin-account/service.js +56 -61
- package/dist/modules/admin-permissions/abilities.js +6 -24
- package/dist/modules/admin-permissions/commands.js +42 -28
- package/dist/modules/admin-permissions/components/inspector.d.ts +4 -0
- package/dist/modules/admin-permissions/components/inspector.js +284 -0
- package/dist/modules/admin-permissions/components/inspector.module.js +56 -0
- package/dist/modules/admin-permissions/components/inspector_module.css +238 -0
- package/dist/modules/admin-permissions/dto.js +3 -16
- package/dist/modules/admin-permissions/errors.js +14 -27
- package/dist/modules/admin-permissions/index.js +6 -26
- package/dist/modules/admin-permissions/repository.js +1 -8
- package/dist/modules/admin-permissions/schemas.js +33 -70
- package/dist/modules/admin-permissions/service.js +88 -92
- package/dist/modules/admin-roles/abilities.js +8 -30
- package/dist/modules/admin-roles/commands.js +89 -55
- package/dist/modules/admin-roles/components/create.d.ts +7 -0
- package/dist/modules/admin-roles/components/create.js +177 -0
- package/dist/modules/admin-roles/components/create.module.js +8 -0
- package/dist/modules/admin-roles/components/create_module.css +27 -0
- package/dist/modules/admin-roles/components/permissions.d.ts +10 -0
- package/dist/modules/admin-roles/components/permissions.js +303 -0
- package/dist/modules/admin-roles/components/permissions.module.js +44 -0
- package/dist/modules/admin-roles/components/permissions_module.css +192 -0
- package/dist/modules/admin-roles/components/update.d.ts +8 -0
- package/dist/modules/admin-roles/components/update.js +166 -0
- package/dist/modules/admin-roles/components/update.module.js +8 -0
- package/dist/modules/admin-roles/components/update_module.css +27 -0
- package/dist/modules/admin-roles/dto.js +3 -16
- package/dist/modules/admin-roles/errors.js +16 -40
- package/dist/modules/admin-roles/index.js +6 -26
- package/dist/modules/admin-roles/repository.js +1 -8
- package/dist/modules/admin-roles/schemas.js +41 -71
- package/dist/modules/admin-roles/service.js +79 -82
- package/dist/modules/admin-users/abilities.js +9 -38
- package/dist/modules/admin-users/commands.js +92 -50
- package/dist/modules/admin-users/components/create.d.ts +8 -0
- package/dist/modules/admin-users/components/create.js +268 -0
- package/dist/modules/admin-users/components/create.module.js +10 -0
- package/dist/modules/admin-users/components/create_module.css +45 -0
- package/dist/modules/admin-users/components/roles.d.ts +11 -0
- package/dist/modules/admin-users/components/roles.js +148 -0
- package/dist/modules/admin-users/components/roles.module.js +18 -0
- package/dist/modules/admin-users/components/roles_module.css +75 -0
- package/dist/modules/admin-users/components/set-password.d.ts +8 -0
- package/dist/modules/admin-users/components/set-password.js +170 -0
- package/dist/modules/admin-users/components/set-password.module.js +9 -0
- package/dist/modules/admin-users/components/set-password_module.css +31 -0
- package/dist/modules/admin-users/components/update.d.ts +8 -0
- package/dist/modules/admin-users/components/update.js +254 -0
- package/dist/modules/admin-users/components/update.module.js +9 -0
- package/dist/modules/admin-users/components/update_module.css +34 -0
- package/dist/modules/admin-users/dto.js +3 -18
- package/dist/modules/admin-users/errors.js +17 -43
- package/dist/modules/admin-users/index.js +7 -27
- package/dist/modules/admin-users/repository.js +1 -8
- package/dist/modules/admin-users/schemas.js +44 -75
- package/dist/modules/admin-users/seed-super-admin.js +9 -34
- package/dist/modules/admin-users/service.js +76 -91
- package/dist/modules/auth/components/sign-in-form.d.ts +12 -0
- package/dist/modules/auth/components/sign-in-form.js +115 -0
- package/dist/modules/auth/components/sign-in-form.module.js +12 -0
- package/dist/modules/auth/components/sign-in-form_module.css +41 -0
- package/dist/modules/auth/index.js +3 -24
- package/dist/modules/auth/jwt-session-provider.js +179 -149
- package/dist/modules/auth/password.js +11 -53
- package/dist/modules/auth/phc.js +21 -54
- package/dist/modules/auth/refresh-tokens-repository.js +1 -8
- package/dist/modules/auth/resolve-actor.js +6 -28
- package/dist/services/admin-services-context.d.ts +16 -0
- package/dist/services/admin-services-context.js +13 -0
- package/dist/services/admin-services-types.d.ts +129 -0
- package/dist/services/admin-services-types.js +1 -0
- package/dist/store.js +1 -8
- package/dist/vendor/noble-argon2/_blake.js +277 -45
- package/dist/vendor/noble-argon2/_md.js +81 -136
- package/dist/vendor/noble-argon2/_u64.js +65 -67
- package/dist/vendor/noble-argon2/argon2.js +181 -342
- package/dist/vendor/noble-argon2/blake2.js +252 -327
- package/dist/vendor/noble-argon2/utils.js +110 -490
- package/dist/vendor/noble-argon2/utils.js.LICENSE.txt +1 -0
- package/package.json +89 -10
- package/src/abilities.ts +32 -0
- package/src/declarations.d.ts +4 -0
- package/src/index.ts +39 -0
- package/src/lib/assert-admin-actor.ts +90 -0
- package/src/lib/create-command.ts +109 -0
- package/src/modules/admin-account/commands.ts +76 -0
- package/src/modules/admin-account/components/change-password.module.css +40 -0
- package/src/modules/admin-account/components/change-password.tsx +232 -0
- package/src/modules/admin-account/components/container.module.css +158 -0
- package/src/modules/admin-account/components/container.tsx +229 -0
- package/src/modules/admin-account/components/update.module.css +40 -0
- package/src/modules/admin-account/components/update.tsx +263 -0
- package/src/modules/admin-account/errors.ts +75 -0
- package/src/modules/admin-account/index.ts +60 -0
- package/src/modules/admin-account/schemas.ts +84 -0
- package/src/modules/admin-account/service.ts +92 -0
- package/src/modules/admin-permissions/abilities.ts +46 -0
- package/src/modules/admin-permissions/commands.ts +103 -0
- package/src/modules/admin-permissions/components/inspector.module.css +326 -0
- package/src/modules/admin-permissions/components/inspector.tsx +298 -0
- package/src/modules/admin-permissions/dto.ts +28 -0
- package/src/modules/admin-permissions/errors.ts +57 -0
- package/src/modules/admin-permissions/index.ts +72 -0
- package/src/modules/admin-permissions/repository.ts +49 -0
- package/src/modules/admin-permissions/schemas.ts +128 -0
- package/src/modules/admin-permissions/service.ts +137 -0
- package/src/modules/admin-roles/abilities.ts +62 -0
- package/src/modules/admin-roles/commands.ts +161 -0
- package/src/modules/admin-roles/components/create.module.css +40 -0
- package/src/modules/admin-roles/components/create.tsx +218 -0
- package/src/modules/admin-roles/components/permissions.module.css +279 -0
- package/src/modules/admin-roles/components/permissions.tsx +396 -0
- package/src/modules/admin-roles/components/update.module.css +40 -0
- package/src/modules/admin-roles/components/update.tsx +218 -0
- package/src/modules/admin-roles/dto.ts +30 -0
- package/src/modules/admin-roles/errors.ts +76 -0
- package/src/modules/admin-roles/index.ts +81 -0
- package/src/modules/admin-roles/repository.ts +96 -0
- package/src/modules/admin-roles/schemas.ts +139 -0
- package/src/modules/admin-roles/service.ts +136 -0
- package/src/modules/admin-users/abilities.ts +76 -0
- package/src/modules/admin-users/commands.ts +157 -0
- package/src/modules/admin-users/components/create.module.css +63 -0
- package/src/modules/admin-users/components/create.tsx +323 -0
- package/src/modules/admin-users/components/roles.module.css +119 -0
- package/src/modules/admin-users/components/roles.tsx +172 -0
- package/src/modules/admin-users/components/set-password.module.css +46 -0
- package/src/modules/admin-users/components/set-password.tsx +199 -0
- package/src/modules/admin-users/components/update.module.css +49 -0
- package/src/modules/admin-users/components/update.tsx +328 -0
- package/src/modules/admin-users/dto.ts +39 -0
- package/src/modules/admin-users/errors.ts +84 -0
- package/src/modules/admin-users/index.ts +91 -0
- package/src/modules/admin-users/repository.ts +161 -0
- package/src/modules/admin-users/schemas.ts +168 -0
- package/src/modules/admin-users/seed-super-admin.ts +102 -0
- package/src/modules/admin-users/service.ts +166 -0
- package/src/modules/auth/components/sign-in-form.module.css +62 -0
- package/src/modules/auth/components/sign-in-form.tsx +132 -0
- package/src/modules/auth/index.ts +31 -0
- package/src/modules/auth/jwt-session-provider.ts +301 -0
- package/src/modules/auth/password.ts +94 -0
- package/src/modules/auth/phc.ts +121 -0
- package/src/modules/auth/refresh-tokens-repository.ts +74 -0
- package/src/modules/auth/resolve-actor.ts +42 -0
- package/src/services/admin-services-context.tsx +52 -0
- package/src/services/admin-services-types.ts +177 -0
- package/src/store.ts +32 -0
- package/src/vendor/noble-argon2/LICENSE +21 -0
- package/src/vendor/noble-argon2/README.md +87 -0
- package/src/vendor/noble-argon2/_blake.ts +58 -0
- package/src/vendor/noble-argon2/_md.ts +223 -0
- package/src/vendor/noble-argon2/_u64.ts +118 -0
- package/src/vendor/noble-argon2/argon2.ts +668 -0
- package/src/vendor/noble-argon2/blake2.ts +583 -0
- 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
|
+
)
|