Skip to content
Merged
15 changes: 1 addition & 14 deletions apps/api/src/users/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,10 @@ import type { Sex } from '@opendatacapture/schemas/subject';
import { $CreateUserData } from '@opendatacapture/schemas/user';
import type { BasePermissionLevel, CreateUserData } from '@opendatacapture/schemas/user';

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

// Note: password strength, username-match, and breached-password checks are enforced
// centrally in `UsersService.validatePassword` so they apply to every flow that sets a
// password (user creation, admin edits, self-service updates, and initial setup).
@ValidationSchema(
$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']
});
}
})
)
@ValidationSchema($CreateUserData)
export class CreateUserDto implements CreateUserData {
@ApiProperty({
description: "Determines the user's base permissions, which may later be modified by an admin",
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/users/dto/update-user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { PartialType } from '@nestjs/swagger';
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
import { $UpdateUserData } from '@opendatacapture/schemas/user';
import type { UpdateUserData } from '@opendatacapture/schemas/user';

import { CreateUserDto } from './create-user.dto';

/** Contact details are redeclared because `$UpdateUserData` widens them to null, so an update can clear one. */
@ValidationSchema($UpdateUserData)
export class UpdateUserDto extends PartialType(CreateUserDto) {}
export class UpdateUserDto extends PartialType(OmitType(CreateUserDto, ['email', 'phoneNumber'] as const)) {
@ApiProperty({ description: 'Email, or null to clear the one on record' })
email?: UpdateUserData['email'];

@ApiProperty({ description: 'Phone Number, or null to clear the one on record' })
phoneNumber?: UpdateUserData['phoneNumber'];
}
13 changes: 5 additions & 8 deletions apps/web/src/components/UserDropup/UserDropup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from 'react';
import { ArrowToggle, DropdownMenu } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { useNavigate } from '@tanstack/react-router';
import { Info, LogOutIcon, SchoolIcon, SettingsIcon } from 'lucide-react';
import { Info, LogOutIcon, SchoolIcon, UserCogIcon } from 'lucide-react';

import { useAppStore } from '@/store';

Expand All @@ -17,7 +17,7 @@ export const UserDropup = () => {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);

const { t } = useTranslation('layout');
const { t } = useTranslation();

return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
Expand Down Expand Up @@ -64,16 +64,13 @@ export const UserDropup = () => {
</DropdownMenu.Item>
<DropdownMenu.Item
className="gap-2 hover:bg-slate-700 hover:text-slate-100 focus:bg-slate-700 focus:text-slate-100"
data-testid="user-dropup-preferences"
data-testid="user-dropup-account"
onClick={() => {
void navigate({ to: '/user' });
}}
>
<SettingsIcon />
{t({
en: 'Preferences',
fr: 'Préférences'
})}
<UserCogIcon />
{t('user.account')}
</DropdownMenu.Item>
<DropdownMenu.Item
className="gap-2 hover:bg-slate-700 hover:text-slate-100 focus:bg-slate-700 focus:text-slate-100"
Expand Down
27 changes: 13 additions & 14 deletions apps/web/src/routes/_app/admin/users/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +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';
import { $Email, $PhoneNumber, omittedIfBlank } from '@/utils/validation';

const PASSWORD_ERROR_TRANSLATION_KEYS = {
INSUFFICIENT_PASSWORD_STRENGTH: 'common.insufficientPasswordStrength',
Expand Down Expand Up @@ -189,6 +189,7 @@ const RouteComponent = () => {
})
}
]}
data-testid="create-user-form"
initialValues={{
disabled: false
}}
Expand All @@ -199,7 +200,9 @@ const RouteComponent = () => {
.extend({
basePermissionLevel: $BasePermissionLevel,
groupIds: z.set(z.string()).optional(),
confirmPassword: z.string().min(1)
confirmPassword: z.string().min(1),
email: $Email(t).optional(),
phoneNumber: $PhoneNumber(t).optional()
})
.check((ctx) => {
if (!estimatePasswordStrength(ctx.value.password).success) {
Expand Down Expand Up @@ -230,19 +233,15 @@ 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 ?? []) })}
onSubmit={({ email, groupIds, phoneNumber, ...data }) =>
handleSubmit({
...data,
email: omittedIfBlank(email),
groupIds: Array.from(groupIds ?? []),
phoneNumber: omittedIfBlank(phoneNumber)
})
}
/>
</div>
);
Expand Down
27 changes: 17 additions & 10 deletions apps/web/src/routes/_app/admin/users/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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';
import { $Email, $PhoneNumber, clearedIfBlank, omittedIfUnchanged } from '@/utils/validation';

type UpdateUserFormData = {
additionalPermissions?: Partial<UserPermission>[];
Expand Down Expand Up @@ -55,10 +55,10 @@ const UpdateUserForm: React.FC<{
additionalPermissions: z.array($UserPermission.partial()).optional(),
confirmPassword: z.string().min(1).optional(),
disabled: z.boolean().optional(),
email: z.union([z.literal(''), z.email()]).optional(),
email: $Email(t).optional(),
groupIds: z.set(z.string()),
password: z.string().min(1).optional(),
phoneNumber: z.union([z.literal(''), z.string().regex(PHONE_REGEX)]).optional()
phoneNumber: $PhoneNumber(t, initialValues?.phoneNumber).optional()
})
.transform((arg) => {
const firstPermission = arg.additionalPermissions?.[0];
Expand Down Expand Up @@ -115,7 +115,7 @@ const UpdateUserForm: React.FC<{
});
}
}) satisfies z.ZodType<UpdateUserFormData>;
}, [resolvedLanguage]);
}, [resolvedLanguage, initialValues?.phoneNumber]);

return (
<Dialog open={isConfirmDeleteOpen} onOpenChange={setIsConfirmDeleteOpen}>
Expand Down Expand Up @@ -455,12 +455,19 @@ const RouteComponent = () => {
deleteUserMutation.mutate({ id: selectedUser!.id });
setSelectedUser(null);
},
onSubmit: ({ confirmPassword: _, groupIds, ...data }) => {
void updateUserMutation
.mutateAsync({ data: { groupIds: Array.from(groupIds), ...data }, id: selectedUser!.id })
.then(() => {
setSelectedUser(null);
});
onSubmit: ({ confirmPassword: _, email, groupIds, phoneNumber, ...data }) => {
updateUserMutation.mutate(
{
data: {
...data,
email: clearedIfBlank(email),
groupIds: Array.from(groupIds),
phoneNumber: omittedIfUnchanged(phoneNumber, selectedUser!.phoneNumber)
},
id: selectedUser!.id
},
{ onSuccess: () => setSelectedUser(null) }
);
}
}}
/>
Expand Down
Loading
Loading