diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts index a63cfe7e4..5d551776f 100644 --- a/apps/api/src/users/dto/create-user.dto.ts +++ b/apps/api/src/users/dto/create-user.dto.ts @@ -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({ @@ -32,6 +45,9 @@ export class CreateUserDto implements CreateUserData { @ApiProperty({ description: 'Date of Birth' }) dateOfBirth?: Date; + @ApiProperty({ description: 'Email' }) + email?: string; + @ApiProperty({ description: 'First Name' }) firstName: string; @@ -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; diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 57bb6bf3c..7b827ef52 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 })) { @@ -73,12 +84,14 @@ export class UsersService { additionalPermissions: [], basePermissionLevel, dateOfBirth, + email, firstName, groups: { connect: groupIds.map((id) => ({ id })) }, hashedPassword, lastName, + phoneNumber, sex, username: username }, diff --git a/apps/web/src/routes/_app/admin/users/create.tsx b/apps/web/src/routes/_app/admin/users/create.tsx index 54989be8b..717979bf8 100644 --- a/apps/web/src/routes/_app/admin/users/create.tsx +++ b/apps/web/src/routes/_app/admin/users/create.tsx @@ -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(); @@ -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', @@ -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 ?? []) })} /> diff --git a/apps/web/src/routes/_app/admin/users/index.tsx b/apps/web/src/routes/_app/admin/users/index.tsx index 90911e447..3b6dce47e 100644 --- a/apps/web/src/routes/_app/admin/users/index.tsx +++ b/apps/web/src/routes/_app/admin/users/index.tsx @@ -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[]; confirmPassword?: string | undefined; + email?: string | undefined; groupIds: Set; password?: string | undefined; + phoneNumber?: string | undefined; }; type UpdateUserFormInputData = { @@ -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]; @@ -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; }, [resolvedLanguage]); @@ -107,6 +123,12 @@ 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({ @@ -114,6 +136,24 @@ const UpdateUserForm: React.FC<{ 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.', @@ -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]); @@ -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(() => { diff --git a/apps/web/src/routes/_app/user.tsx b/apps/web/src/routes/_app/user.tsx index 3694d245b..cc9cc01b3 100644 --- a/apps/web/src/routes/_app/user.tsx +++ b/apps/web/src/routes/_app/user.tsx @@ -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; @@ -24,8 +25,6 @@ type UpdateUserFormData = { sex?: undefined | z.infer; }; -const phoneRegex = new RegExp(/^\+?\(?\d{1,4}\)?[\s.-]?\d{1,4}[\s.-]?\d{1,9}$/); - const RouteComponent = () => { const currentUser = useAppStore((store) => store.currentUser); @@ -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) => { diff --git a/apps/web/src/translations/common.json b/apps/web/src/translations/common.json index 77aab9469..2fe273763 100644 --- a/apps/web/src/translations/common.json +++ b/apps/web/src/translations/common.json @@ -47,6 +47,10 @@ "en": "Delete", "fr": "Supprimer" }, + "email": { + "en": "Email", + "fr": "Adresse courriel" + }, "entity": { "en": "Entity", "fr": "Entité" @@ -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" diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts new file mode 100644 index 000000000..36fd54d9f --- /dev/null +++ b/apps/web/src/utils/validation.ts @@ -0,0 +1 @@ +export const PHONE_REGEX = new RegExp(/^(?=.{5,})\+?\(?\d{1,4}\)?[\s.-]?\d{1,4}[\s.-]?\d{1,9}$/);