Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0eb0233
chore: add translations for phone number and email, prettier changes
david-roper Apr 21, 2026
92c2704
feat: make phone regex an exported variable
david-roper Apr 21, 2026
13fd132
feat: add phone and email fields to admin create user form
david-roper Apr 21, 2026
c6ca53c
feat: add phone number and email fields to create user api
david-roper Apr 21, 2026
112c269
fix: fix input for phone number check
david-roper Apr 21, 2026
b921dbb
feat: add phone number and email to admin update user form
david-roper Apr 22, 2026
63c63ac
feat: add phone number and email to type and initial value logic
david-roper Apr 22, 2026
a29ea52
feat: fix typos and undefined phone and email
david-roper Apr 22, 2026
69ad348
Merge branch 'DouglasNeuroInformatics:main' into enhance-create-user
david-roper Apr 22, 2026
e524b87
feat: add previous groups as initial values in form
david-roper Apr 22, 2026
b6ec197
chore: update lockfile and run linter
david-roper Apr 22, 2026
f4f5491
feat: add confirm password check
david-roper Apr 22, 2026
a4d0140
feat: remove confirmPassword field from being submitted to api call
david-roper Apr 22, 2026
050ddec
feat: add phone number regex check to createUserData schema
david-roper Apr 22, 2026
b82fa41
feat: add phone number regex check to createUserData schema and just …
david-roper Apr 22, 2026
9c04f95
fix: fix translations for phone and email
david-roper Apr 22, 2026
be07dff
Merge branch 'DouglasNeuroInformatics:main' into enhance-create-user
david-roper Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions apps/api/src/users/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,33 @@ import { $CreateUserData } from '@opendatacapture/schemas/user';
import type { BasePermissionLevel, CreateUserData } from '@opendatacapture/schemas/user';
import { z } from 'zod/v4';

const regex = new RegExp(/^\+?\(?\d{1,4}\)?[\s.-]?\d{1,4}[\s.-]?\d{1,9}$/);

@ValidationSchema(
$CreateUserData.extend({
password: $CreateUserData.shape.password.superRefine((val, ctx) => {
const result = estimatePasswordStrength(val, {
feedbackLanguage: 'en'
});
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Insufficient password strength: ${result.score}`
$CreateUserData
.check((ctx) => {
if (ctx.value.phoneNumber && !regex.test(ctx.value.phoneNumber)) {
ctx.issues.push({
code: 'custom',
input: ctx.value.phoneNumber,
message: `Invalid phone number`,
path: ['phoneNumber']
});
}
})
})
.extend({
password: $CreateUserData.shape.password.superRefine((val, ctx) => {
const result = estimatePasswordStrength(val, {
feedbackLanguage: 'en'
});
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Insufficient password strength: ${result.score}`
});
}
})
})
)
export class CreateUserDto implements CreateUserData {
@ApiProperty({
Expand All @@ -32,6 +45,9 @@ export class CreateUserDto implements CreateUserData {
@ApiProperty({ description: 'Date of Birth' })
dateOfBirth?: Date;

@ApiProperty({ description: 'Email' })
email?: string;
Comment thread
david-roper marked this conversation as resolved.

@ApiProperty({ description: 'First Name' })
firstName: string;

Expand All @@ -48,6 +64,9 @@ export class CreateUserDto implements CreateUserData {
})
password: string;

@ApiProperty({ description: 'Phone Number' })
phoneNumber?: string;

@ApiProperty({ description: 'Sex at Birth' })
@ApiProperty()
sex?: Sex;
Expand Down
15 changes: 14 additions & 1 deletion apps/api/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,18 @@ export class UsersService {

/** Adds a new user to the database with default permissions, verifying the provided groups exist */
async create(
{ basePermissionLevel, dateOfBirth, firstName, groupIds, lastName, password, sex, username }: CreateUserDto,
{
basePermissionLevel,
dateOfBirth,
email,
firstName,
groupIds,
lastName,
password,
phoneNumber,
sex,
username
}: CreateUserDto,
options?: EntityOperationOptions
) {
if (await this.userModel.exists({ username })) {
Expand All @@ -73,12 +84,14 @@ export class UsersService {
additionalPermissions: [],
basePermissionLevel,
dateOfBirth,
email,
firstName,
groups: {
connect: groupIds.map((id) => ({ id }))
},
hashedPassword,
lastName,
phoneNumber,
sex,
username: username
},
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/routes/_app/admin/users/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { z } from 'zod/v4';
import { PageHeader } from '@/components/PageHeader';
import { useCreateUserMutation } from '@/hooks/useCreateUserMutation';
import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery';
import { PHONE_REGEX } from '@/utils/validation';

const RouteComponent = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -77,6 +78,24 @@ const RouteComponent = () => {
fr: 'Identifiants de connexion'
})
},
{
fields: {
email: {
kind: 'string',
label: t('common.email'),
variant: 'input'
},
phoneNumber: {
kind: 'string',
label: t('common.phoneNumber'),
variant: 'input'
}
},
title: t({
en: 'Contact information',
fr: 'Coordonnées'
})
},
{
title: t({
en: 'Permissions',
Expand Down Expand Up @@ -170,6 +189,17 @@ const RouteComponent = () => {
path: ['confirmPassword']
});
}
if (ctx.value.phoneNumber && !PHONE_REGEX.test(ctx.value.phoneNumber)) {
ctx.issues.push({
code: 'custom',
input: ctx.value.phoneNumber,
message: t({
en: 'Invalid Phone number',
fr: 'Numéro de téléphone invalide'
}),
path: ['phoneNumber']
});
}
})}
onSubmit={(data) => handleSubmit({ ...data, groupIds: Array.from(data.groupIds ?? []) })}
/>
Expand Down
55 changes: 51 additions & 4 deletions apps/web/src/routes/_app/admin/users/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery';
import { useUpdateUserMutation } from '@/hooks/useUpdateUserMutation';
import { usersQueryOptions, useUsersQuery } from '@/hooks/useUsersQuery';
import { useAppStore } from '@/store';
import { PHONE_REGEX } from '@/utils/validation';

type UpdateUserFormData = {
additionalPermissions?: Partial<UserPermission>[];
confirmPassword?: string | undefined;
email?: string | undefined;
groupIds: Set<string>;
password?: string | undefined;
phoneNumber?: string | undefined;
};

type UpdateUserFormInputData = {
Expand All @@ -48,8 +51,11 @@ const UpdateUserForm: React.FC<{
return z
.object({
additionalPermissions: z.array($UserPermission.partial()).optional(),
confirmPassword: z.string().min(1).optional(),
email: z.union([z.literal(''), z.email()]).optional(),
groupIds: z.set(z.string()),
password: z.string().min(1).optional()
password: z.string().min(1).optional(),
phoneNumber: z.union([z.literal(''), z.string().regex(PHONE_REGEX)]).optional()
})
.transform((arg) => {
const firstPermission = arg.additionalPermissions?.[0];
Expand Down Expand Up @@ -82,6 +88,16 @@ const UpdateUserForm: React.FC<{
}
});
});
})
.check((ctx) => {
if (ctx.value.confirmPassword !== ctx.value.password) {
ctx.issues.push({
code: 'custom',
input: ctx.value.confirmPassword,
message: t('common.passwordsMustMatch'),
path: ['confirmPassword']
});
}
}) satisfies z.ZodType<UpdateUserFormData>;
}, [resolvedLanguage]);

Expand All @@ -107,13 +123,37 @@ const UpdateUserForm: React.FC<{
kind: 'string',
label: t('common.password'),
variant: 'password'
},
// eslint-disable-next-line perfectionist/sort-objects
confirmPassword: {
kind: 'string',
label: t('common.confirmPassword'),
variant: 'password'
}
},
title: t({
en: 'Login Credentials',
fr: 'Identifiants de connexion'
})
},
{
fields: {
email: {
kind: 'string',
label: t('common.email'),
variant: 'input'
},
phoneNumber: {
kind: 'string',
label: t('common.phoneNumber'),
variant: 'input'
}
},
title: t({
en: 'Update Contact Information',
fr: 'Mettre à jour les coordonnées'
})
},
{
description: t({
en: 'IMPORTANT: These permissions are not specific to any group. To manage granular permissions, please use the API.',
Expand Down Expand Up @@ -280,9 +320,16 @@ const RouteComponent = () => {
groupOptions: Object.fromEntries(groups.map((group) => [group.id, group.name])),
initialValues: selectedUser?.additionalPermissions.length
? {
additionalPermissions: selectedUser.additionalPermissions
additionalPermissions: selectedUser.additionalPermissions,
email: selectedUser.email ?? undefined,
groupIds: new Set(selectedUser.groupIds),
phoneNumber: selectedUser.phoneNumber ?? undefined
}
: {
email: selectedUser.email ?? undefined,
groupIds: new Set(selectedUser.groupIds),
phoneNumber: selectedUser.phoneNumber ?? undefined
}
: undefined
});
}
}, [groupsQuery.data, selectedUser]);
Expand Down Expand Up @@ -357,7 +404,7 @@ const RouteComponent = () => {
deleteUserMutation.mutate({ id: selectedUser!.id });
setSelectedUser(null);
},
onSubmit: ({ groupIds, ...data }) => {
onSubmit: ({ confirmPassword: _, groupIds, ...data }) => {
void updateUserMutation
.mutateAsync({ data: { groupIds: Array.from(groupIds), ...data }, id: selectedUser!.id })
.then(() => {
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/routes/_app/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { UserIcon } from '@/components/UserIcon';
import { useFindUserQuery } from '@/hooks/useFindUserQuery';
import { useSelfUpdateUserMutation } from '@/hooks/useSelfUpdateUserMutation';
import { useAppStore } from '@/store';
import { PHONE_REGEX } from '@/utils/validation';

type UpdateUserFormData = {
confirmPassword?: string | undefined;
Expand All @@ -24,8 +25,6 @@ type UpdateUserFormData = {
sex?: undefined | z.infer<typeof $Sex>;
};

const phoneRegex = new RegExp(/^\+?\(?\d{1,4}\)?[\s.-]?\d{1,4}[\s.-]?\d{1,9}$/);

const RouteComponent = () => {
const currentUser = useAppStore((store) => store.currentUser);

Expand Down Expand Up @@ -70,7 +69,7 @@ const RouteComponent = () => {
// eslint-disable-next-line perfectionist/sort-objects
confirmPassword: z.string().min(1).optional(),
password: z.string().min(1).optional(),
phoneNumber: z.union([z.literal(''), z.string().regex(phoneRegex)]).optional(),
phoneNumber: z.union([z.literal(''), z.string().regex(PHONE_REGEX)]).optional(),
sex: $Sex.optional()
})
.check((ctx) => {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/translations/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"en": "Delete",
"fr": "Supprimer"
},
"email": {
"en": "Email",
"fr": "Adresse courriel"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"entity": {
"en": "Entity",
"fr": "Entité"
Expand Down Expand Up @@ -145,6 +149,10 @@
"en": "Personal Information",
"fr": "Renseignements personnels"
},
"phoneNumber": {
"en": "Phone Number",
"fr": "Numéro de téléphone"
},
"research": {
"en": "Research",
"fr": "Recherche"
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PHONE_REGEX = new RegExp(/^(?=.{5,})\+?\(?\d{1,4}\)?[\s.-]?\d{1,4}[\s.-]?\d{1,9}$/);
Loading