From 534107d54a54ab00e7aa269d6865b16cc40199c5 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Tue, 29 Oct 2024 18:34:42 -0400 Subject: [PATCH] feat: add ability for admins to manage granular permissions --- apps/api/prisma/schema.prisma | 50 ++++-- .../ability/__tests__/ability.factory.spec.ts | 1 + apps/api/src/ability/ability.factory.ts | 6 +- apps/api/src/users/users.service.ts | 1 + .../admin/hooks/useUpdateUserMutation.ts | 18 +++ .../features/admin/pages/ManageUsersPage.tsx | 152 +++++++++++++++--- apps/web/src/testing/stubs.ts | 1 + package.json | 2 +- packages/schemas/src/core/core.ts | 27 ++-- packages/schemas/src/user/user.ts | 14 +- pnpm-lock.yaml | 22 +-- 11 files changed, 233 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/features/admin/hooks/useUpdateUserMutation.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index db15d7fbf..887158295 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 { diff --git a/apps/api/src/ability/__tests__/ability.factory.spec.ts b/apps/api/src/ability/__tests__/ability.factory.spec.ts index a0aec1d19..06a2e02cd 100644 --- a/apps/api/src/ability/__tests__/ability.factory.spec.ts +++ b/apps/api/src/ability/__tests__/ability.factory.spec.ts @@ -15,6 +15,7 @@ describe('AbilityFactory', () => { abilityFactory = module.get(AbilityFactory); userModelStub = { + additionalPermissions: [], basePermissionLevel: null, createdAt: new Date(), dateOfBirth: null, diff --git a/apps/api/src/ability/ability.factory.ts b/apps/api/src/ability/ability.factory.ts index 2f42c32a6..a8cd19813 100644 --- a/apps/api/src/ability/ability.factory.ts +++ b/apps/api/src/ability/ability.factory.ts @@ -40,7 +40,10 @@ 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; @@ -48,5 +51,6 @@ export class AbilityFactory { return detectSubjectType(object) as AppSubjectName; } }); + return appAbility; } } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 0af2d3ca5..a1f52897b 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -49,6 +49,7 @@ export class UsersService { return this.userModel.create({ data: { + additionalPermissions: [], basePermissionLevel, dateOfBirth, firstName, diff --git a/apps/web/src/features/admin/hooks/useUpdateUserMutation.ts b/apps/web/src/features/admin/hooks/useUpdateUserMutation.ts new file mode 100644 index 000000000..0006f14b5 --- /dev/null +++ b/apps/web/src/features/admin/hooks/useUpdateUserMutation.ts @@ -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'] }); + } + }); +} diff --git a/apps/web/src/features/admin/pages/ManageUsersPage.tsx b/apps/web/src/features/admin/pages/ManageUsersPage.tsx index b8bb653a3..93ed3bd4e 100644 --- a/apps/web/src/features/admin/pages/ManageUsersPage.tsx +++ b/apps/web/src/features/admin/pages/ManageUsersPage.tsx @@ -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); const { filteredData, searchTerm, setSearchTerm } = useSearch(usersQuery.data ?? [], 'username'); + const setupStateQuery = useSetupState(); const currentUserIsSelected = selectedUser?.username === currentUser?.username; @@ -86,26 +91,129 @@ export const ManageUsersPage = () => { })} - - - - - - - + + {setupStateQuery.data?.isExperimentalFeaturesEnabled && ( +
{ + deleteUserMutation.mutate({ id: selectedUser!.id }); + setSelectedUser(null); + }} + > + {t('core.delete')} + + ) + }} + 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); + }); + }} + /> + )} + ); diff --git a/apps/web/src/testing/stubs.ts b/apps/web/src/testing/stubs.ts index 672b2384b..b3bfe5b6f 100644 --- a/apps/web/src/testing/stubs.ts +++ b/apps/web/src/testing/stubs.ts @@ -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', diff --git a/package.json b/package.json index 401cfec4d..374adf539 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opendatacapture", "type": "module", - "version": "1.5.0", + "version": "1.6.0", "private": true, "packageManager": "pnpm@9.11.0", "license": "Apache-2.0", diff --git a/packages/schemas/src/core/core.ts b/packages/schemas/src/core/core.ts index a6ec7f010..14c9cfc89 100644 --- a/packages/schemas/src/core/core.ts +++ b/packages/schemas/src/core/core.ts @@ -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; +export const $AppAction = z.enum(['create', 'delete', 'manage', 'read', 'update']); + +export type AppSubjectName = z.infer; +export const $AppSubjectName = z.enum([ + 'all', + 'Assignment', + 'Group', + 'Instrument', + 'InstrumentRecord', + 'Session', + 'Subject', + 'Summary', + 'User' +]); export type BaseAppAbility = PureAbility<[AppAction, AppSubjectName]>; diff --git a/packages/schemas/src/user/user.ts b/packages/schemas/src/user/user.ts index c085b3c5c..7745fd37f 100644 --- a/packages/schemas/src/user/user.ts +++ b/packages/schemas/src/user/user.ts @@ -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; +const $AdditionalPermissions = z.array( + z.object({ + action: $AppAction, + subject: $AppSubjectName + }) +); + export type User = z.infer; export const $User = $BaseModel.extend({ + additionalPermissions: $AdditionalPermissions, basePermissionLevel: $BasePermissionLevel.nullable(), dateOfBirth: z.coerce.date().nullish(), firstName: z.string().min(1), @@ -35,4 +43,6 @@ export const $CreateUserData = $User }); export type UpdateUserData = z.infer; -export const $UpdateUserData = $CreateUserData.partial(); +export const $UpdateUserData = $CreateUserData.partial().extend({ + additionalPermissions: $AdditionalPermissions.optional() +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33bb595ef..eabe83c84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ catalogs: version: 0.1.0 '@douglasneuroinformatics/libui': specifier: latest - version: 3.6.6 + version: 3.7.0 '@douglasneuroinformatics/libui-form-types': specifier: latest version: 0.11.0 @@ -370,7 +370,7 @@ importers: version: 0.0.2 '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) + version: 3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) '@opendatacapture/instrument-renderer': specifier: workspace:* version: link:../../packages/instrument-renderer @@ -470,7 +470,7 @@ importers: dependencies: '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@3.23.8) + version: 3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@3.23.8) '@opendatacapture/schemas': specifier: workspace:* version: link:../../packages/schemas @@ -543,7 +543,7 @@ importers: version: 1.0.2(typescript@5.5.4) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) + version: 3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) '@monaco-editor/react': specifier: ^4.6.0 version: 4.6.0(monaco-editor@0.51.0)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x) @@ -658,7 +658,7 @@ importers: version: 0.0.3(typescript@5.5.4) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) + version: 3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) '@heroicons/react': specifier: ^2.1.5 version: 2.1.5(react@vendor+react@18.x) @@ -907,7 +907,7 @@ importers: version: 1.0.2(typescript@5.5.4) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) + version: 3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) '@opendatacapture/evaluate-instrument': specifier: workspace:* version: link:../evaluate-instrument @@ -1018,7 +1018,7 @@ importers: version: 1.0.2(typescript@5.5.4) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) + version: 3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x) '@opendatacapture/runtime-core': specifier: workspace:* version: link:../runtime-core @@ -2047,9 +2047,9 @@ packages: resolution: { integrity: sha512-erds8oNXFrWSJfCglR8S7I3Yfkgx2Vz6RIQTa5OFtVAVx8DTSFf5FbnHpp49l6BcQ4FCU5w/PLO5NWdx08cNUg== } - '@douglasneuroinformatics/libui@3.6.6': + '@douglasneuroinformatics/libui@3.7.0': resolution: - { integrity: sha512-vJV3bxoyoJ2GpdxlBawDoR2g6vmfv8t6kaEpgNCciXzRnakJ5EGpDiSv9mCY29P8t64i6ZZ+R4VfSwzArpidrg== } + { integrity: sha512-Ai66Qnn1Dt2RbICAIfIQVOg8SE3GMddqbH37OJHx3vvplm0Qwm82hpPd6IweK1NHpC9Fl9xc61l/wcVBXqiWbQ== } peerDependencies: react: ^18.2.0 react-dom: ^18.2.0 @@ -12987,7 +12987,7 @@ snapshots: dependencies: type-fest: 4.26.1 - '@douglasneuroinformatics/libui@3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@3.23.8)': + '@douglasneuroinformatics/libui@3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@3.23.8)': dependencies: '@douglasneuroinformatics/libjs': 0.8.0(typescript@5.5.4) '@douglasneuroinformatics/libui-form-types': 0.11.0 @@ -13041,7 +13041,7 @@ snapshots: - immer - typescript - '@douglasneuroinformatics/libui@3.6.6(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x)': + '@douglasneuroinformatics/libui@3.7.0(@types/react@18.3.12)(immer@10.1.1)(react-dom@vendor+react-dom@18.x)(react@vendor+react@18.x)(tailwindcss@3.4.14)(typescript@5.5.4)(zod@vendor+zod@3.23.x)': dependencies: '@douglasneuroinformatics/libjs': 0.8.0(typescript@5.5.4) '@douglasneuroinformatics/libui-form-types': 0.11.0