Skip to content

Commit 0d3c342

Browse files
committed
feat: allow admin to update user passwords
1 parent d4a6e92 commit 0d3c342

File tree

2 files changed

+75
-32
lines changed

2 files changed

+75
-32
lines changed

apps/api/src/users/users.service.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,22 @@ export class UsersService {
123123
return user;
124124
}
125125

126-
async updateById(id: string, { groupIds, ...data }: UpdateUserDto, { ability }: EntityOperationOptions = {}) {
126+
async updateById(
127+
id: string,
128+
{ groupIds, password, ...data }: UpdateUserDto,
129+
{ ability }: EntityOperationOptions = {}
130+
) {
131+
let hashedPassword: string | undefined;
132+
if (password) {
133+
hashedPassword = await this.cryptoService.hashPassword(password);
134+
}
127135
return this.userModel.update({
128136
data: {
129137
...data,
130138
groups: {
131139
connect: groupIds?.map((id) => ({ id }))
132-
}
140+
},
141+
hashedPassword
133142
},
134143
omit: {
135144
hashedPassword: true

apps/web/src/features/admin/components/UpdateUserForm.tsx

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { useState } from 'react';
1+
import React, { useMemo, useState } from 'react';
22

33
import { isAllUndefined } from '@douglasneuroinformatics/libjs';
4+
import { estimatePasswordStrength } from '@douglasneuroinformatics/libpasswd';
45
import { Button, Dialog, Form } from '@douglasneuroinformatics/libui/components';
56
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
67
import type { FormTypes } from '@opendatacapture/runtime-core';
@@ -9,34 +10,12 @@ import type { UserPermission } from '@opendatacapture/schemas/user';
910
import type { Promisable } from 'type-fest';
1011
import { z } from 'zod';
1112

12-
const $UpdateUserFormData = z
13-
.object({
14-
additionalPermissions: z.array($UserPermission.partial()).optional(),
15-
groupIds: z.set(z.string())
16-
})
17-
.transform((arg) => {
18-
const firstPermission = arg.additionalPermissions?.[0];
19-
if (firstPermission && isAllUndefined(firstPermission)) {
20-
arg.additionalPermissions?.pop();
21-
}
22-
return arg;
23-
})
24-
.superRefine((arg, ctx) => {
25-
arg.additionalPermissions?.forEach((permission, i) => {
26-
Object.entries(permission).forEach(([key, val]) => {
27-
if ((val satisfies string) === undefined) {
28-
ctx.addIssue({
29-
code: z.ZodIssueCode.invalid_type,
30-
expected: 'string',
31-
path: ['additionalPermissions', i, key],
32-
received: 'undefined'
33-
});
34-
}
35-
});
36-
});
37-
});
38-
39-
type UpdateUserFormData = z.infer<typeof $UpdateUserFormData>;
13+
type UpdateUserFormData = {
14+
additionalPermissions?: Partial<UserPermission>[];
15+
confirmPassword?: string | undefined;
16+
groupIds: Set<string>;
17+
password?: string | undefined;
18+
};
4019

4120
export type UpdateUserFormInputData = {
4221
disableDelete: boolean;
@@ -52,9 +31,48 @@ export const UpdateUserForm: React.FC<{
5231
onSubmit: (data: UpdateUserFormData & { additionalPermissions?: UserPermission[] }) => Promisable<void>;
5332
}> = ({ data, onDelete, onSubmit }) => {
5433
const { disableDelete, groupOptions, initialValues } = data;
55-
const { t } = useTranslation();
34+
const { resolvedLanguage, t } = useTranslation();
5635
const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false);
5736

37+
const $UpdateUserFormData = useMemo(() => {
38+
return z
39+
.object({
40+
additionalPermissions: z.array($UserPermission.partial()).optional(),
41+
groupIds: z.set(z.string()),
42+
password: z.string().min(1).optional()
43+
})
44+
.transform((arg) => {
45+
const firstPermission = arg.additionalPermissions?.[0];
46+
if (firstPermission && isAllUndefined(firstPermission)) {
47+
arg.additionalPermissions?.pop();
48+
}
49+
return arg;
50+
})
51+
.superRefine((arg, ctx) => {
52+
if (arg.password && !estimatePasswordStrength(arg.password).success) {
53+
ctx.addIssue({
54+
code: z.ZodIssueCode.custom,
55+
fatal: true,
56+
message: t('common.insufficientPasswordStrength'),
57+
path: ['password']
58+
});
59+
return z.NEVER;
60+
}
61+
arg.additionalPermissions?.forEach((permission, i) => {
62+
Object.entries(permission).forEach(([key, val]) => {
63+
if ((val satisfies string) === undefined) {
64+
ctx.addIssue({
65+
code: z.ZodIssueCode.invalid_type,
66+
expected: 'string',
67+
path: ['additionalPermissions', i, key],
68+
received: 'undefined'
69+
});
70+
}
71+
});
72+
});
73+
}) satisfies z.ZodType<UpdateUserFormData>;
74+
}, [resolvedLanguage]);
75+
5876
return (
5977
<Dialog open={isConfirmDeleteOpen} onOpenChange={setIsConfirmDeleteOpen}>
6078
<Form
@@ -68,6 +86,22 @@ export const UpdateUserForm: React.FC<{
6886
)
6987
}}
7088
content={[
89+
{
90+
fields: {
91+
password: {
92+
calculateStrength: (password) => {
93+
return estimatePasswordStrength(password).score;
94+
},
95+
kind: 'string',
96+
label: t('common.password'),
97+
variant: 'password'
98+
}
99+
},
100+
title: t({
101+
en: 'Login Credentials',
102+
fr: 'Identifiants de connexion'
103+
})
104+
},
71105
{
72106
description: t({
73107
en: 'IMPORTANT: These permissions are not specific to any group. To manage granular permissions, please use the API.',

0 commit comments

Comments
 (0)