Skip to content

Commit 6d87f74

Browse files
authored
Merge pull request #1009 from joshunrau/permissions
2 parents 5b42737 + 534107d commit 6d87f74

11 files changed

Lines changed: 233 additions & 61 deletions

File tree

apps/api/prisma/schema.prisma

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,45 @@ enum BasePermissionLevel {
160160
STANDARD
161161
}
162162

163+
enum AppSubject {
164+
all
165+
Assignment
166+
Group
167+
Instrument
168+
InstrumentRecord
169+
Session
170+
Subject
171+
Summary
172+
User
173+
}
174+
175+
enum AppAction {
176+
create
177+
delete
178+
manage
179+
read
180+
update
181+
}
182+
183+
type AuthRule {
184+
action AppAction
185+
subject AppSubject
186+
}
187+
163188
model UserModel {
164-
createdAt DateTime @default(now()) @db.Date
165-
updatedAt DateTime @updatedAt @db.Date
166-
id String @id @default(auto()) @map("_id") @db.ObjectId
167-
basePermissionLevel BasePermissionLevel?
168-
firstName String
169-
groupIds String[] @db.ObjectId
170-
groups GroupModel[] @relation(fields: [groupIds], references: [id])
171-
lastName String
172-
password String
173-
username String
174-
sex Sex?
175-
dateOfBirth DateTime? @db.Date
189+
createdAt DateTime @default(now()) @db.Date
190+
updatedAt DateTime @updatedAt @db.Date
191+
id String @id @default(auto()) @map("_id") @db.ObjectId
192+
basePermissionLevel BasePermissionLevel?
193+
additionalPermissions AuthRule[]
194+
firstName String
195+
groupIds String[] @db.ObjectId
196+
groups GroupModel[] @relation(fields: [groupIds], references: [id])
197+
lastName String
198+
password String
199+
username String
200+
sex Sex?
201+
dateOfBirth DateTime? @db.Date
176202
}
177203

178204
enum SessionType {

apps/api/src/ability/__tests__/ability.factory.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('AbilityFactory', () => {
1515

1616
abilityFactory = module.get(AbilityFactory);
1717
userModelStub = {
18+
additionalPermissions: [],
1819
basePermissionLevel: null,
1920
createdAt: new Date(),
2021
dateOfBirth: null,

apps/api/src/ability/ability.factory.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,17 @@ export class AbilityFactory {
4040
ability.can('create', 'Subject');
4141
ability.can('read', 'Subject', { groupIds: { hasSome: user.groupIds } });
4242
}
43-
return ability.build({
43+
user.additionalPermissions.forEach(({ action, subject }) => {
44+
ability.can(action, subject);
45+
});
46+
const appAbility = ability.build({
4447
detectSubjectType: (object: { [key: string]: any }) => {
4548
if (object.__model__) {
4649
return object.__model__ as AppSubjectName;
4750
}
4851
return detectSubjectType(object) as AppSubjectName;
4952
}
5053
});
54+
return appAbility;
5155
}
5256
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class UsersService {
4949

5050
return this.userModel.create({
5151
data: {
52+
additionalPermissions: [],
5253
basePermissionLevel,
5354
dateOfBirth,
5455
firstName,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
2+
import type { UpdateUserData } from '@opendatacapture/schemas/user';
3+
import { useMutation, useQueryClient } from '@tanstack/react-query';
4+
import axios from 'axios';
5+
6+
export function useUpdateUserMutation() {
7+
const queryClient = useQueryClient();
8+
const addNotification = useNotificationsStore((store) => store.addNotification);
9+
return useMutation({
10+
mutationFn: async ({ data, id }: { data: UpdateUserData; id: string }) => {
11+
await axios.patch(`/v1/users/${id}`, data);
12+
},
13+
onSuccess() {
14+
addNotification({ type: 'success' });
15+
void queryClient.invalidateQueries({ queryKey: ['users'] });
16+
}
17+
});
18+
}

apps/web/src/features/admin/pages/ManageUsersPage.tsx

Lines changed: 130 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
import { useState } from 'react';
22

33
import { snakeToCamelCase } from '@douglasneuroinformatics/libjs';
4-
import { Button, ClientTable, Heading, SearchBar, Sheet } from '@douglasneuroinformatics/libui/components';
4+
import { Button, ClientTable, Form, Heading, SearchBar, Sheet } from '@douglasneuroinformatics/libui/components';
55
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
6-
import type { User } from '@opendatacapture/schemas/user';
6+
import { $UpdateUserData, type User } from '@opendatacapture/schemas/user';
77
import { Link } from 'react-router-dom';
88

99
import { PageHeader } from '@/components/PageHeader';
1010
import { useSearch } from '@/hooks/useSearch';
11+
import { useSetupState } from '@/hooks/useSetupState';
1112
import { useAppStore } from '@/store';
1213

1314
import { useDeleteUserMutation } from '../hooks/useDeleteUserMutation';
15+
import { useUpdateUserMutation } from '../hooks/useUpdateUserMutation';
1416
import { useUsersQuery } from '../hooks/useUsersQuery';
1517

18+
// eslint-disable-next-line max-lines-per-function
1619
export const ManageUsersPage = () => {
1720
const currentUser = useAppStore((store) => store.currentUser);
1821
const { t } = useTranslation();
1922
const usersQuery = useUsersQuery();
2023
const deleteUserMutation = useDeleteUserMutation();
24+
const updateUserMutation = useUpdateUserMutation();
2125
const [selectedUser, setSelectedUser] = useState<null | User>(null);
2226
const { filteredData, searchTerm, setSearchTerm } = useSearch(usersQuery.data ?? [], 'username');
27+
const setupStateQuery = useSetupState();
2328

2429
const currentUserIsSelected = selectedUser?.username === currentUser?.username;
2530

@@ -86,26 +91,129 @@ export const ManageUsersPage = () => {
8691
})}
8792
</Sheet.Description>
8893
</Sheet.Header>
89-
<Sheet.Body className="grid gap-4"></Sheet.Body>
90-
<Sheet.Footer>
91-
<Button
92-
className="w-full"
93-
disabled={currentUserIsSelected}
94-
type="button"
95-
variant="danger"
96-
onClick={() => {
97-
deleteUserMutation.mutate({ id: selectedUser!.id });
98-
setSelectedUser(null);
99-
}}
100-
>
101-
{t('core.delete')}
102-
</Button>
103-
<Sheet.Close asChild>
104-
<Button disabled className="w-full" type="submit">
105-
{t('core.save')}
106-
</Button>
107-
</Sheet.Close>
108-
</Sheet.Footer>
94+
<Sheet.Body className="grid gap-4">
95+
{setupStateQuery.data?.isExperimentalFeaturesEnabled && (
96+
<Form
97+
additionalButtons={{
98+
left: (
99+
<Button
100+
className="w-full"
101+
disabled={currentUserIsSelected}
102+
type="button"
103+
variant="danger"
104+
onClick={() => {
105+
deleteUserMutation.mutate({ id: selectedUser!.id });
106+
setSelectedUser(null);
107+
}}
108+
>
109+
{t('core.delete')}
110+
</Button>
111+
)
112+
}}
113+
content={{
114+
additionalPermissions: {
115+
fieldset: {
116+
action: {
117+
kind: 'string',
118+
label: t({
119+
en: 'Action',
120+
fr: 'Action'
121+
}),
122+
options: {
123+
create: t({
124+
en: 'Create',
125+
fr: 'Créer'
126+
}),
127+
delete: t({
128+
en: 'Delete',
129+
fr: 'Effacer'
130+
}),
131+
manage: t({
132+
en: 'Manage (All)',
133+
fr: 'Gérer (Tout)'
134+
}),
135+
read: t({
136+
en: 'Read',
137+
fr: 'Lire'
138+
}),
139+
update: t({
140+
en: 'Update',
141+
fr: 'Mettre à jour'
142+
})
143+
},
144+
variant: 'select'
145+
},
146+
subject: {
147+
kind: 'string',
148+
label: t({
149+
en: 'Resource',
150+
fr: 'Resource'
151+
}),
152+
options: {
153+
all: t({
154+
en: 'All',
155+
fr: 'Tous'
156+
}),
157+
Assignment: t({
158+
en: 'Assignment',
159+
fr: 'Devoir'
160+
}),
161+
Group: t({
162+
en: 'Group',
163+
fr: 'Groupe'
164+
}),
165+
Instrument: t({
166+
en: 'Instrument',
167+
fr: 'Instrument'
168+
}),
169+
InstrumentRecord: t({
170+
en: 'Instrument Record',
171+
fr: "Enregistrement de l'instrument"
172+
}),
173+
Session: t({
174+
en: 'Session',
175+
fr: 'Session'
176+
}),
177+
Subject: t({
178+
en: 'Subject',
179+
fr: 'Client'
180+
}),
181+
Summary: t({
182+
en: 'Summary',
183+
fr: 'Résumé'
184+
}),
185+
User: t({
186+
en: 'User',
187+
fr: 'Utilisateur'
188+
})
189+
},
190+
variant: 'select'
191+
}
192+
},
193+
kind: 'record-array',
194+
label: t({
195+
en: 'Permission',
196+
fr: 'Autorisations supplémentaires'
197+
})
198+
}
199+
}}
200+
initialValues={
201+
selectedUser?.additionalPermissions.length
202+
? {
203+
additionalPermissions: selectedUser.additionalPermissions
204+
}
205+
: undefined
206+
}
207+
submitBtnLabel={t('core.save')}
208+
validationSchema={$UpdateUserData.pick({ additionalPermissions: true }).required()}
209+
onSubmit={(data) => {
210+
void updateUserMutation.mutateAsync({ data, id: selectedUser!.id }).then(() => {
211+
setSelectedUser(null);
212+
});
213+
}}
214+
/>
215+
)}
216+
</Sheet.Body>
109217
</Sheet.Content>
110218
</Sheet>
111219
);

apps/web/src/testing/stubs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { User } from '@opendatacapture/schemas/user';
77
import type { CurrentUser } from '@/store/types';
88

99
export const adminUser: User = Object.freeze({
10+
additionalPermissions: [],
1011
basePermissionLevel: 'ADMIN',
1112
createdAt: new Date(),
1213
firstName: 'David',

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "opendatacapture",
33
"type": "module",
4-
"version": "1.5.0",
4+
"version": "1.6.0",
55
"private": true,
66
"packageManager": "pnpm@9.11.0",
77
"license": "Apache-2.0",

packages/schemas/src/core/core.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@ import type { Json, JsonLiteral, Language } from '@opendatacapture/runtime-core'
55
import type { Simplify } from 'type-fest';
66
import { z } from 'zod';
77

8-
export type AppAction = 'create' | 'delete' | 'manage' | 'read' | 'update';
9-
10-
export type AppSubjectName =
11-
| 'all'
12-
| 'Assignment'
13-
| 'Group'
14-
| 'Instrument'
15-
| 'InstrumentRecord'
16-
| 'Session'
17-
| 'Subject'
18-
| 'Summary'
19-
| 'User';
8+
export type AppAction = z.infer<typeof $AppAction>;
9+
export const $AppAction = z.enum(['create', 'delete', 'manage', 'read', 'update']);
10+
11+
export type AppSubjectName = z.infer<typeof $AppSubjectName>;
12+
export const $AppSubjectName = z.enum([
13+
'all',
14+
'Assignment',
15+
'Group',
16+
'Instrument',
17+
'InstrumentRecord',
18+
'Session',
19+
'Subject',
20+
'Summary',
21+
'User'
22+
]);
2023

2124
export type BaseAppAbility = PureAbility<[AppAction, AppSubjectName]>;
2225

packages/schemas/src/user/user.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { z } from 'zod';
22

3-
import { $BaseModel } from '../core/core.js';
3+
import { $AppAction, $AppSubjectName, $BaseModel } from '../core/core.js';
44
import { $Sex } from '../subject/subject.js';
55

66
export const $BasePermissionLevel = z.enum(['ADMIN', 'GROUP_MANAGER', 'STANDARD']);
77

88
export type BasePermissionLevel = z.infer<typeof $BasePermissionLevel>;
99

10+
const $AdditionalPermissions = z.array(
11+
z.object({
12+
action: $AppAction,
13+
subject: $AppSubjectName
14+
})
15+
);
16+
1017
export type User = z.infer<typeof $User>;
1118
export const $User = $BaseModel.extend({
19+
additionalPermissions: $AdditionalPermissions,
1220
basePermissionLevel: $BasePermissionLevel.nullable(),
1321
dateOfBirth: z.coerce.date().nullish(),
1422
firstName: z.string().min(1),
@@ -35,4 +43,6 @@ export const $CreateUserData = $User
3543
});
3644

3745
export type UpdateUserData = z.infer<typeof $UpdateUserData>;
38-
export const $UpdateUserData = $CreateUserData.partial();
46+
export const $UpdateUserData = $CreateUserData.partial().extend({
47+
additionalPermissions: $AdditionalPermissions.optional()
48+
});

0 commit comments

Comments
 (0)