Skip to content

Commit

Permalink
feat: add ability for admins to manage granular permissions
Browse files Browse the repository at this point in the history
  • Loading branch information
joshunrau committed Oct 29, 2024
1 parent 5b42737 commit 534107d
Show file tree
Hide file tree
Showing 11 changed files with 233 additions and 61 deletions.
50 changes: 38 additions & 12 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,45 @@ enum BasePermissionLevel {
STANDARD
}

enum AppSubject {
all
Assignment
Group
Instrument
InstrumentRecord
Session
Subject
Summary
User
}

enum AppAction {
create
delete
manage
read
update
}

type AuthRule {
action AppAction
subject AppSubject
}

model UserModel {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
basePermissionLevel BasePermissionLevel?
firstName String
groupIds String[] @db.ObjectId
groups GroupModel[] @relation(fields: [groupIds], references: [id])
lastName String
password String
username String
sex Sex?
dateOfBirth DateTime? @db.Date
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
basePermissionLevel BasePermissionLevel?
additionalPermissions AuthRule[]
firstName String
groupIds String[] @db.ObjectId
groups GroupModel[] @relation(fields: [groupIds], references: [id])
lastName String
password String
username String
sex Sex?
dateOfBirth DateTime? @db.Date
}

enum SessionType {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/ability/__tests__/ability.factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('AbilityFactory', () => {

abilityFactory = module.get(AbilityFactory);
userModelStub = {
additionalPermissions: [],
basePermissionLevel: null,
createdAt: new Date(),
dateOfBirth: null,
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/ability/ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ export class AbilityFactory {
ability.can('create', 'Subject');
ability.can('read', 'Subject', { groupIds: { hasSome: user.groupIds } });
}
return ability.build({
user.additionalPermissions.forEach(({ action, subject }) => {
ability.can(action, subject);
});
const appAbility = ability.build({
detectSubjectType: (object: { [key: string]: any }) => {
if (object.__model__) {
return object.__model__ as AppSubjectName;
}
return detectSubjectType(object) as AppSubjectName;
}
});
return appAbility;
}
}
1 change: 1 addition & 0 deletions apps/api/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class UsersService {

return this.userModel.create({
data: {
additionalPermissions: [],
basePermissionLevel,
dateOfBirth,
firstName,
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/features/admin/hooks/useUpdateUserMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
import type { UpdateUserData } from '@opendatacapture/schemas/user';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

export function useUpdateUserMutation() {
const queryClient = useQueryClient();
const addNotification = useNotificationsStore((store) => store.addNotification);
return useMutation({
mutationFn: async ({ data, id }: { data: UpdateUserData; id: string }) => {
await axios.patch(`/v1/users/${id}`, data);
},
onSuccess() {
addNotification({ type: 'success' });
void queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
}
152 changes: 130 additions & 22 deletions apps/web/src/features/admin/pages/ManageUsersPage.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import { useState } from 'react';

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

import { PageHeader } from '@/components/PageHeader';
import { useSearch } from '@/hooks/useSearch';
import { useSetupState } from '@/hooks/useSetupState';
import { useAppStore } from '@/store';

import { useDeleteUserMutation } from '../hooks/useDeleteUserMutation';
import { useUpdateUserMutation } from '../hooks/useUpdateUserMutation';
import { useUsersQuery } from '../hooks/useUsersQuery';

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

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

Expand Down Expand Up @@ -86,26 +91,129 @@ export const ManageUsersPage = () => {
})}
</Sheet.Description>
</Sheet.Header>
<Sheet.Body className="grid gap-4"></Sheet.Body>
<Sheet.Footer>
<Button
className="w-full"
disabled={currentUserIsSelected}
type="button"
variant="danger"
onClick={() => {
deleteUserMutation.mutate({ id: selectedUser!.id });
setSelectedUser(null);
}}
>
{t('core.delete')}
</Button>
<Sheet.Close asChild>
<Button disabled className="w-full" type="submit">
{t('core.save')}
</Button>
</Sheet.Close>
</Sheet.Footer>
<Sheet.Body className="grid gap-4">
{setupStateQuery.data?.isExperimentalFeaturesEnabled && (
<Form
additionalButtons={{
left: (
<Button
className="w-full"
disabled={currentUserIsSelected}
type="button"
variant="danger"
onClick={() => {
deleteUserMutation.mutate({ id: selectedUser!.id });
setSelectedUser(null);
}}
>
{t('core.delete')}
</Button>
)
}}
content={{
additionalPermissions: {
fieldset: {
action: {
kind: 'string',
label: t({
en: 'Action',
fr: 'Action'
}),
options: {
create: t({
en: 'Create',
fr: 'Créer'
}),
delete: t({
en: 'Delete',
fr: 'Effacer'
}),
manage: t({
en: 'Manage (All)',
fr: 'Gérer (Tout)'
}),
read: t({
en: 'Read',
fr: 'Lire'
}),
update: t({
en: 'Update',
fr: 'Mettre à jour'
})
},
variant: 'select'
},
subject: {
kind: 'string',
label: t({
en: 'Resource',
fr: 'Resource'
}),
options: {
all: t({
en: 'All',
fr: 'Tous'
}),
Assignment: t({
en: 'Assignment',
fr: 'Devoir'
}),
Group: t({
en: 'Group',
fr: 'Groupe'
}),
Instrument: t({
en: 'Instrument',
fr: 'Instrument'
}),
InstrumentRecord: t({
en: 'Instrument Record',
fr: "Enregistrement de l'instrument"
}),
Session: t({
en: 'Session',
fr: 'Session'
}),
Subject: t({
en: 'Subject',
fr: 'Client'
}),
Summary: t({
en: 'Summary',
fr: 'Résumé'
}),
User: t({
en: 'User',
fr: 'Utilisateur'
})
},
variant: 'select'
}
},
kind: 'record-array',
label: t({
en: 'Permission',
fr: 'Autorisations supplémentaires'
})
}
}}
initialValues={
selectedUser?.additionalPermissions.length
? {
additionalPermissions: selectedUser.additionalPermissions
}
: undefined
}
submitBtnLabel={t('core.save')}
validationSchema={$UpdateUserData.pick({ additionalPermissions: true }).required()}
onSubmit={(data) => {
void updateUserMutation.mutateAsync({ data, id: selectedUser!.id }).then(() => {
setSelectedUser(null);
});
}}
/>
)}
</Sheet.Body>
</Sheet.Content>
</Sheet>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/testing/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { User } from '@opendatacapture/schemas/user';
import type { CurrentUser } from '@/store/types';

export const adminUser: User = Object.freeze({
additionalPermissions: [],
basePermissionLevel: 'ADMIN',
createdAt: new Date(),
firstName: 'David',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "opendatacapture",
"type": "module",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"packageManager": "[email protected]",
"license": "Apache-2.0",
Expand Down
27 changes: 15 additions & 12 deletions packages/schemas/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ import type { Json, JsonLiteral, Language } from '@opendatacapture/runtime-core'
import type { Simplify } from 'type-fest';
import { z } from 'zod';

export type AppAction = 'create' | 'delete' | 'manage' | 'read' | 'update';

export type AppSubjectName =
| 'all'
| 'Assignment'
| 'Group'
| 'Instrument'
| 'InstrumentRecord'
| 'Session'
| 'Subject'
| 'Summary'
| 'User';
export type AppAction = z.infer<typeof $AppAction>;
export const $AppAction = z.enum(['create', 'delete', 'manage', 'read', 'update']);

export type AppSubjectName = z.infer<typeof $AppSubjectName>;
export const $AppSubjectName = z.enum([
'all',
'Assignment',
'Group',
'Instrument',
'InstrumentRecord',
'Session',
'Subject',
'Summary',
'User'
]);

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

Expand Down
14 changes: 12 additions & 2 deletions packages/schemas/src/user/user.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { z } from 'zod';

import { $BaseModel } from '../core/core.js';
import { $AppAction, $AppSubjectName, $BaseModel } from '../core/core.js';
import { $Sex } from '../subject/subject.js';

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

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

const $AdditionalPermissions = z.array(
z.object({
action: $AppAction,
subject: $AppSubjectName
})
);

export type User = z.infer<typeof $User>;
export const $User = $BaseModel.extend({
additionalPermissions: $AdditionalPermissions,
basePermissionLevel: $BasePermissionLevel.nullable(),
dateOfBirth: z.coerce.date().nullish(),
firstName: z.string().min(1),
Expand All @@ -35,4 +43,6 @@ export const $CreateUserData = $User
});

export type UpdateUserData = z.infer<typeof $UpdateUserData>;
export const $UpdateUserData = $CreateUserData.partial();
export const $UpdateUserData = $CreateUserData.partial().extend({
additionalPermissions: $AdditionalPermissions.optional()
});
Loading

0 comments on commit 534107d

Please sign in to comment.