diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 1b13dfd2..60097ca4 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -189,8 +189,11 @@ export type Mutation = { deleteProject: Scalars['Boolean']['output']; deleteStudy: Scalars['Boolean']['output']; forgotPassword: Scalars['Boolean']['output']; + grantContributor: Scalars['Boolean']['output']; grantOwner: Scalars['Boolean']['output']; grantProjectPermissions: Scalars['Boolean']['output']; + grantStudyAdmin: Scalars['Boolean']['output']; + grantTrainedContributor: Scalars['Boolean']['output']; lexiconAddEntry: LexiconEntry; /** Remove all entries from a given lexicon */ lexiconClearEntries: Scalars['Boolean']['output']; @@ -312,6 +315,13 @@ export type MutationForgotPasswordArgs = { }; +export type MutationGrantContributorArgs = { + isContributor: Scalars['Boolean']['input']; + study: Scalars['ID']['input']; + user: Scalars['ID']['input']; +}; + + export type MutationGrantOwnerArgs = { targetUser: Scalars['ID']['input']; }; @@ -324,6 +334,20 @@ export type MutationGrantProjectPermissionsArgs = { }; +export type MutationGrantStudyAdminArgs = { + isAdmin: Scalars['Boolean']['input']; + study: Scalars['ID']['input']; + user: Scalars['ID']['input']; +}; + + +export type MutationGrantTrainedContributorArgs = { + isTrained: Scalars['Boolean']['input']; + study: Scalars['ID']['input']; + user: Scalars['ID']['input']; +}; + + export type MutationLexiconAddEntryArgs = { entry: LexiconAddEntry; }; @@ -417,14 +441,6 @@ export type OrganizationCreate = { projectId: Scalars['String']['input']; }; -export type Permission = { - __typename?: 'Permission'; - editable: Scalars['Boolean']['output']; - hasRole: Scalars['Boolean']['output']; - role: Roles; - user: UserModel; -}; - export type Project = { __typename?: 'Project'; _id: Scalars['ID']['output']; @@ -479,6 +495,13 @@ export type ProjectModel = { users: Array; }; +export type ProjectPermissionModel = { + __typename?: 'ProjectPermissionModel'; + editable: Scalars['Boolean']['output']; + isProjectAdmin: Scalars['Boolean']['output']; + user: UserModel; +}; + export type ProjectSettingsInput = { allowSignup?: InputMaybe; displayProjectName?: InputMaybe; @@ -501,8 +524,9 @@ export type Query = { getEntryUploadURL: Scalars['String']['output']; getOrganizations: Array; getProject: ProjectModel; - getProjectPermissions: Array; + getProjectPermissions: Array; getProjects: Array; + getStudyPermissions: Array; getUser: UserModel; invite: InviteModel; invites: Array; @@ -557,6 +581,11 @@ export type QueryGetProjectPermissionsArgs = { }; +export type QueryGetStudyPermissionsArgs = { + study: Scalars['ID']['input']; +}; + + export type QueryGetUserArgs = { id: Scalars['ID']['input']; }; @@ -611,13 +640,6 @@ export type ResetDto = { projectId: Scalars['String']['input']; }; -export enum Roles { - Contributor = 'CONTRIBUTOR', - Owner = 'OWNER', - ProjectAdmin = 'PROJECT_ADMIN', - StudyAdmin = 'STUDY_ADMIN' -} - export type Study = { __typename?: 'Study'; _id: Scalars['ID']['output']; @@ -638,6 +660,17 @@ export type StudyCreate = { tagsPerEntry: Scalars['Float']['input']; }; +export type StudyPermissionModel = { + __typename?: 'StudyPermissionModel'; + isContributor: Scalars['Boolean']['output']; + isContributorEditable: Scalars['Boolean']['output']; + isStudyAdmin: Scalars['Boolean']['output']; + isStudyAdminEditable: Scalars['Boolean']['output']; + isTrained: Scalars['Boolean']['output']; + isTrainedEditable: Scalars['Boolean']['output']; + user: UserModel; +}; + export type Tag = { __typename?: 'Tag'; _id: Scalars['String']['output']; diff --git a/packages/client/src/graphql/permission/permission.graphql b/packages/client/src/graphql/permission/permission.graphql index 7b838616..cc83040e 100644 --- a/packages/client/src/graphql/permission/permission.graphql +++ b/packages/client/src/graphql/permission/permission.graphql @@ -11,12 +11,45 @@ query getProjectPermissions($project: ID!) { updatedAt, deletedAt }, - hasRole, - editable, - role + isProjectAdmin, + editable } } mutation grantProjectPermissions($project: ID!, $user: ID!, $isAdmin: Boolean!) { grantProjectPermissions(project: $project, user: $user, isAdmin: $isAdmin) } + +query getStudyPermissions($study: ID!) { + getStudyPermissions(study: $study) { + user { + id, + projectId, + fullname, + username, + email, + role, + createdAt, + updatedAt, + deletedAt + }, + isStudyAdmin, + isStudyAdminEditable, + isContributor, + isContributorEditable, + isTrained, + isTrainedEditable + } +} + +mutation grantStudyAdmin($study: ID!, $user: ID!, $isAdmin: Boolean!) { + grantStudyAdmin(study: $study, user: $user, isAdmin: $isAdmin) +} + +mutation grantContributor($study: ID!, $user: ID!, $isContributor: Boolean!) { + grantContributor(study: $study, user: $user, isContributor: $isContributor) +} + +mutation grantTrainedContributor($study: ID!, $user: ID!, $isTrained: Boolean!) { + grantTrainedContributor(study: $study, user: $user, isTrained: $isTrained) +} diff --git a/packages/client/src/graphql/permission/permission.ts b/packages/client/src/graphql/permission/permission.ts index 17ebf1d9..1ff00999 100644 --- a/packages/client/src/graphql/permission/permission.ts +++ b/packages/client/src/graphql/permission/permission.ts @@ -10,7 +10,7 @@ export type GetProjectPermissionsQueryVariables = Types.Exact<{ }>; -export type GetProjectPermissionsQuery = { __typename?: 'Query', getProjectPermissions: Array<{ __typename?: 'Permission', hasRole: boolean, editable: boolean, role: Types.Roles, user: { __typename?: 'UserModel', id: string, projectId: string, fullname?: string | null, username?: string | null, email?: string | null, role: number, createdAt: any, updatedAt: any, deletedAt?: any | null } }> }; +export type GetProjectPermissionsQuery = { __typename?: 'Query', getProjectPermissions: Array<{ __typename?: 'ProjectPermissionModel', isProjectAdmin: boolean, editable: boolean, user: { __typename?: 'UserModel', id: string, projectId: string, fullname?: string | null, username?: string | null, email?: string | null, role: number, createdAt: any, updatedAt: any, deletedAt?: any | null } }> }; export type GrantProjectPermissionsMutationVariables = Types.Exact<{ project: Types.Scalars['ID']['input']; @@ -21,6 +21,40 @@ export type GrantProjectPermissionsMutationVariables = Types.Exact<{ export type GrantProjectPermissionsMutation = { __typename?: 'Mutation', grantProjectPermissions: boolean }; +export type GetStudyPermissionsQueryVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; +}>; + + +export type GetStudyPermissionsQuery = { __typename?: 'Query', getStudyPermissions: Array<{ __typename?: 'StudyPermissionModel', isStudyAdmin: boolean, isStudyAdminEditable: boolean, isContributor: boolean, isContributorEditable: boolean, isTrained: boolean, isTrainedEditable: boolean, user: { __typename?: 'UserModel', id: string, projectId: string, fullname?: string | null, username?: string | null, email?: string | null, role: number, createdAt: any, updatedAt: any, deletedAt?: any | null } }> }; + +export type GrantStudyAdminMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + user: Types.Scalars['ID']['input']; + isAdmin: Types.Scalars['Boolean']['input']; +}>; + + +export type GrantStudyAdminMutation = { __typename?: 'Mutation', grantStudyAdmin: boolean }; + +export type GrantContributorMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + user: Types.Scalars['ID']['input']; + isContributor: Types.Scalars['Boolean']['input']; +}>; + + +export type GrantContributorMutation = { __typename?: 'Mutation', grantContributor: boolean }; + +export type GrantTrainedContributorMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + user: Types.Scalars['ID']['input']; + isTrained: Types.Scalars['Boolean']['input']; +}>; + + +export type GrantTrainedContributorMutation = { __typename?: 'Mutation', grantTrainedContributor: boolean }; + export const GetProjectPermissionsDocument = gql` query getProjectPermissions($project: ID!) { @@ -36,9 +70,8 @@ export const GetProjectPermissionsDocument = gql` updatedAt deletedAt } - hasRole + isProjectAdmin editable - role } } `; @@ -102,4 +135,154 @@ export function useGrantProjectPermissionsMutation(baseOptions?: Apollo.Mutation } export type GrantProjectPermissionsMutationHookResult = ReturnType; export type GrantProjectPermissionsMutationResult = Apollo.MutationResult; -export type GrantProjectPermissionsMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file +export type GrantProjectPermissionsMutationOptions = Apollo.BaseMutationOptions; +export const GetStudyPermissionsDocument = gql` + query getStudyPermissions($study: ID!) { + getStudyPermissions(study: $study) { + user { + id + projectId + fullname + username + email + role + createdAt + updatedAt + deletedAt + } + isStudyAdmin + isStudyAdminEditable + isContributor + isContributorEditable + isTrained + isTrainedEditable + } +} + `; + +/** + * __useGetStudyPermissionsQuery__ + * + * To run a query within a React component, call `useGetStudyPermissionsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetStudyPermissionsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetStudyPermissionsQuery({ + * variables: { + * study: // value for 'study' + * }, + * }); + */ +export function useGetStudyPermissionsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetStudyPermissionsDocument, options); + } +export function useGetStudyPermissionsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetStudyPermissionsDocument, options); + } +export type GetStudyPermissionsQueryHookResult = ReturnType; +export type GetStudyPermissionsLazyQueryHookResult = ReturnType; +export type GetStudyPermissionsQueryResult = Apollo.QueryResult; +export const GrantStudyAdminDocument = gql` + mutation grantStudyAdmin($study: ID!, $user: ID!, $isAdmin: Boolean!) { + grantStudyAdmin(study: $study, user: $user, isAdmin: $isAdmin) +} + `; +export type GrantStudyAdminMutationFn = Apollo.MutationFunction; + +/** + * __useGrantStudyAdminMutation__ + * + * To run a mutation, you first call `useGrantStudyAdminMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGrantStudyAdminMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [grantStudyAdminMutation, { data, loading, error }] = useGrantStudyAdminMutation({ + * variables: { + * study: // value for 'study' + * user: // value for 'user' + * isAdmin: // value for 'isAdmin' + * }, + * }); + */ +export function useGrantStudyAdminMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GrantStudyAdminDocument, options); + } +export type GrantStudyAdminMutationHookResult = ReturnType; +export type GrantStudyAdminMutationResult = Apollo.MutationResult; +export type GrantStudyAdminMutationOptions = Apollo.BaseMutationOptions; +export const GrantContributorDocument = gql` + mutation grantContributor($study: ID!, $user: ID!, $isContributor: Boolean!) { + grantContributor(study: $study, user: $user, isContributor: $isContributor) +} + `; +export type GrantContributorMutationFn = Apollo.MutationFunction; + +/** + * __useGrantContributorMutation__ + * + * To run a mutation, you first call `useGrantContributorMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGrantContributorMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [grantContributorMutation, { data, loading, error }] = useGrantContributorMutation({ + * variables: { + * study: // value for 'study' + * user: // value for 'user' + * isContributor: // value for 'isContributor' + * }, + * }); + */ +export function useGrantContributorMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GrantContributorDocument, options); + } +export type GrantContributorMutationHookResult = ReturnType; +export type GrantContributorMutationResult = Apollo.MutationResult; +export type GrantContributorMutationOptions = Apollo.BaseMutationOptions; +export const GrantTrainedContributorDocument = gql` + mutation grantTrainedContributor($study: ID!, $user: ID!, $isTrained: Boolean!) { + grantTrainedContributor(study: $study, user: $user, isTrained: $isTrained) +} + `; +export type GrantTrainedContributorMutationFn = Apollo.MutationFunction; + +/** + * __useGrantTrainedContributorMutation__ + * + * To run a mutation, you first call `useGrantTrainedContributorMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGrantTrainedContributorMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [grantTrainedContributorMutation, { data, loading, error }] = useGrantTrainedContributorMutation({ + * variables: { + * study: // value for 'study' + * user: // value for 'user' + * isTrained: // value for 'isTrained' + * }, + * }); + */ +export function useGrantTrainedContributorMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GrantTrainedContributorDocument, options); + } +export type GrantTrainedContributorMutationHookResult = ReturnType; +export type GrantTrainedContributorMutationResult = Apollo.MutationResult; +export type GrantTrainedContributorMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/packages/client/src/pages/projects/ProjectUserPermissions.tsx b/packages/client/src/pages/projects/ProjectUserPermissions.tsx index 05b4f2ba..4d3a9674 100644 --- a/packages/client/src/pages/projects/ProjectUserPermissions.tsx +++ b/packages/client/src/pages/projects/ProjectUserPermissions.tsx @@ -2,7 +2,7 @@ import { Switch, Typography } from '@mui/material'; import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { ChangeEvent, useEffect, useState } from 'react'; import { useProject } from '../../context/Project.context'; -import { Permission, Project } from '../../graphql/graphql'; +import { ProjectPermissionModel, Project } from '../../graphql/graphql'; import { useGetProjectPermissionsQuery } from '../../graphql/permission/permission'; import { DecodedToken, useAuth } from '../../context/Auth.context'; import { useGrantProjectPermissionsMutation } from '../../graphql/permission/permission'; @@ -19,7 +19,7 @@ export const ProjectUserPermissions: React.FC = () => { }; interface EditAdminSwitchProps { - permission: Permission; + permission: ProjectPermissionModel; currentUser: DecodedToken; project: Project; refetch: () => void; @@ -46,7 +46,7 @@ const EditAdminSwitch: React.FC = (props) => { return ( @@ -60,7 +60,7 @@ const UserPermissionTable: React.FC<{ project: Project }> = ({ project }) => { } }); - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const { decodedToken } = useAuth(); useEffect(() => { diff --git a/packages/client/src/pages/studies/UserPermissions.tsx b/packages/client/src/pages/studies/UserPermissions.tsx index 2d7d22a0..f752d4cc 100644 --- a/packages/client/src/pages/studies/UserPermissions.tsx +++ b/packages/client/src/pages/studies/UserPermissions.tsx @@ -1,152 +1,196 @@ import { Switch, Typography } from '@mui/material'; -import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; -import { DataGrid, GridColDef, GridRenderCellParams, useGridApiContext } from '@mui/x-data-grid'; -import { GridRowModesModel } from '@mui/x-data-grid-pro'; -import { useRef, useState } from 'react'; - -const SwitchEditInputCell: React.FC = (props: GridRenderCellParams) => { - const { id, value, field, hasFocus } = props; - const apiRef = useGridApiContext(); - const ref = useRef(); - - const handleChange = (newValue: boolean | false) => { - apiRef.current.setEditCellValue({ id, field, value: newValue }); +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { useStudy } from '../../context/Study.context'; +import { Study, StudyPermissionModel } from '../../graphql/graphql'; +import { DecodedToken, useAuth } from '../../context/Auth.context'; +import { + useGetStudyPermissionsQuery, + useGrantStudyAdminMutation, + useGrantContributorMutation, + useGrantTrainedContributorMutation +} from '../../graphql/permission/permission'; +import { useEffect, useState } from 'react'; + +export const StudyUserPermissions: React.FC = () => { + const { study } = useStudy(); + + return ( + <> + User Permissions + {study && } + + ); +}; + +interface EditSwitchProps { + study: Study; + permission: StudyPermissionModel; + currentUser: DecodedToken; + refetch: () => void; +} + +const EditStudyAdminSwitch: React.FC = (props) => { + const [grantStudyAdmin, grantStudyAdminResults] = useGrantStudyAdminMutation(); + + const handleChange = (event: React.ChangeEvent) => { + grantStudyAdmin({ + variables: { + study: props.study._id, + user: props.permission.user.id, + isAdmin: event.target.checked + } + }); }; - useEnhancedEffect(() => { - if (hasFocus && ref.current) { - const input = ref.current.querySelector(`input[value="${value}"]`); - input?.focus(); + useEffect(() => { + if (grantStudyAdminResults.data) { + props.refetch(); } - }, [hasFocus, value]); + }, [grantStudyAdminResults]); - return handleChange} />; + return ( + + ); }; -const tableRows = [ - { - id: 1, - name: 'Prof Appavoo', - username: 'appavoo', - email: 'appavoo@bread.com', - adminSwitch: true, - visibleSwitch: false, - switch: true - }, - { - id: 2, - name: 'Heather', - username: 'Heather82', - email: 'heather@hotmail.com', - adminSwitch: false, - visibleSwitch: true, - switch: true - }, - { - id: 3, - name: 'Kamila', - username: 'kamila0509', - email: 'kamila@gmail.com' - }, - { - id: 4, - name: 'Mr Ronaldinho', - username: 'ron12345', - email: 'ron@bu.edu', - adminSwitch: true, - visibleSwitch: false, - switch: true - } -]; +const EditContributorSwitch: React.FC = (props) => { + const [grantContributor, grantContributorResults] = useGrantContributorMutation(); -export const StudyUserPermissions: React.FC = () => { - const [rows] = useState(tableRows); - const [rowModesModel, setRowModesModel] = useState({}); + const handleChange = (event: React.ChangeEvent) => { + grantContributor({ + variables: { + study: props.study._id, + user: props.permission.user.id, + isContributor: event.target.checked + } + }); + }; + + useEffect(() => { + if (grantContributorResults.data) { + props.refetch(); + } + }, [grantContributorResults]); + + return ( + + ); +}; + +const EditTrainedSwitch: React.FC = (props) => { + const [grantTrainedContributor, grantTrainedContributorResults] = useGrantTrainedContributorMutation(); - const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { - setRowModesModel(newRowModesModel); + const handleChange = (event: React.ChangeEvent) => { + grantTrainedContributor({ + variables: { + study: props.study._id, + user: props.permission.user.id, + isTrained: event.target.checked + } + }); }; + useEffect(() => { + if (grantTrainedContributorResults.data) { + props.refetch(); + } + }, [grantTrainedContributorResults]); + + return ( + + ); +}; + +const UserPermissionTable: React.FC<{ study: Study }> = ({ study }) => { + const { decodedToken } = useAuth(); + const { data, refetch } = useGetStudyPermissionsQuery({ + variables: { + study: study._id + } + }); + + const [permissions, setPermissions] = useState([]); + + useEffect(() => { + if (data) { + setPermissions(data.getStudyPermissions); + } + }, [data]); + const columns: GridColDef[] = [ - { - field: 'id', - headerName: 'ID', - flex: 1, - maxWidth: 100 - }, - { - field: 'name', - headerName: 'Name', - editable: true, - flex: 1, - maxWidth: 300 - }, - { - field: 'username', - headerName: 'Username', - editable: true, - flex: 1, - maxWidth: 300 - }, { field: 'email', headerName: 'Email', - flex: 1, - maxWidth: 300, - editable: true + valueGetter: (params) => params.row.user.email, + flex: 1.75, + editable: false }, { - field: 'adminSwitch', + field: 'studyAdmin', type: 'boolean', - editable: true, - maxWidth: 200, - flex: 1, headerName: 'Study Admin', - renderCell: (params) => , - renderEditCell: (params) => + valueGetter: (params) => params.row.isStudyAdmin, + renderCell: (params: GridRenderCellParams) => { + return ( + + ); + }, + editable: false, + flex: 1 }, { - field: 'visibleSwitch', - type: 'boolean', - editable: true, - headerName: 'Study Visible', - renderCell: (params) => , - maxWidth: 200, - flex: 1, - renderEditCell: (params) => + field: 'contributor', + headerName: 'Contributor', + valueGetter: (params) => params.row.isContributor, + renderCell: (params: GridRenderCellParams) => { + return ( + + ); + }, + editable: false, + flex: 1 }, { - field: 'switch', - type: 'boolean', - editable: true, - maxWidth: 200, - flex: 1, - headerName: 'Contribute', - renderCell: (params) => , - renderEditCell: (params) => + field: 'trained', + headerName: 'Trained', + valueGetter: (params) => params.row.isTrained, + renderCell: (params: GridRenderCellParams) => { + return ( + + ); + }, + editable: false, + flex: 1 } ]; - return ( - <> - User Permissions - 'auto'} - rows={rows} - columns={columns} - rowModesModel={rowModesModel} - onRowModesModelChange={handleRowModesModelChange} - initialState={{ - pagination: { - paginationModel: { - pageSize: 5 - } + 'auto'} + rows={permissions} + columns={columns} + getRowId={(row) => row.user.id} + initialState={{ + pagination: { + paginationModel: { + pageSize: 5 } - }} - pageSizeOptions={[5]} - checkboxSelection - disableRowSelectionOnClick - /> - + } + }} + pageSizeOptions={[5]} + checkboxSelection + disableRowSelectionOnClick + /> ); }; diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 2894f351..8e64ef5b 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -28,24 +28,6 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date """ scalar DateTime -type UserModel { - id: ID! -} - -type Permission { - user: UserModel! - role: Roles! - hasRole: Boolean! - editable: Boolean! -} - -enum Roles { - OWNER - PROJECT_ADMIN - STUDY_ADMIN - CONTRIBUTOR -} - type TagSchema { dataSchema: JSON! uiSchema: JSON! @@ -66,6 +48,26 @@ type Study { tagsPerEntry: Float! } +type UserModel { + id: ID! +} + +type ProjectPermissionModel { + user: UserModel! + isProjectAdmin: Boolean! + editable: Boolean! +} + +type StudyPermissionModel { + user: UserModel! + isStudyAdmin: Boolean! + isStudyAdminEditable: Boolean! + isContributor: Boolean! + isContributorEditable: Boolean! + isTrained: Boolean! + isTrainedEditable: Boolean! +} + type Entry { _id: String! organization: ID! @@ -126,7 +128,8 @@ type Query { getOrganizations: [Organization!]! exists(name: String!): Boolean! getDatasets: [Dataset!]! - getProjectPermissions(project: ID!): [Permission!]! + getProjectPermissions(project: ID!): [ProjectPermissionModel!]! + getStudyPermissions(study: ID!): [StudyPermissionModel!]! projectExists(name: String!): Boolean! getProjects: [Project!]! studyExists(name: String!, project: ID!): Boolean! @@ -144,8 +147,11 @@ type Mutation { createDataset(dataset: DatasetCreate!): Dataset! changeDatasetName(dataset: ID!, newName: String!): Boolean! changeDatasetDescription(dataset: ID!, newDescription: String!): Boolean! - grantOwner(targetUser: ID!): Boolean! grantProjectPermissions(project: ID!, user: ID!, isAdmin: Boolean!): Boolean! + grantOwner(targetUser: ID!): Boolean! + grantStudyAdmin(study: ID!, user: ID!, isAdmin: Boolean!): Boolean! + grantContributor(study: ID!, user: ID!, isContributor: Boolean!): Boolean! + grantTrainedContributor(study: ID!, user: ID!, isTrained: Boolean!): Boolean! signLabCreateProject(project: ProjectCreate!): Project! deleteProject(project: ID!): Boolean! createStudy(study: StudyCreate!): Study! diff --git a/packages/server/src/auth/user.model.ts b/packages/server/src/auth/user.model.ts new file mode 100644 index 00000000..d93b1c3d --- /dev/null +++ b/packages/server/src/auth/user.model.ts @@ -0,0 +1,11 @@ +import { ObjectType, Field, Directive, ID } from '@nestjs/graphql'; + +/** Definition for external user */ +@ObjectType() +@Directive('@key(fields: "id")') +@Directive('@extends') +export class UserModel { + @Field(() => ID) + @Directive('@external') + id: string; +} diff --git a/packages/server/src/permission/models/project.model.ts b/packages/server/src/permission/models/project.model.ts new file mode 100644 index 00000000..b528928a --- /dev/null +++ b/packages/server/src/permission/models/project.model.ts @@ -0,0 +1,14 @@ +import { ObjectType, Field } from '@nestjs/graphql'; +import { UserModel } from '../../auth/user.model'; + +@ObjectType() +export class ProjectPermissionModel { + @Field(() => UserModel) + user: string; + + @Field() + isProjectAdmin: boolean; + + @Field() + editable: boolean; +} diff --git a/packages/server/src/permission/models/study.model.ts b/packages/server/src/permission/models/study.model.ts new file mode 100644 index 00000000..9b5e73ab --- /dev/null +++ b/packages/server/src/permission/models/study.model.ts @@ -0,0 +1,26 @@ +import { ObjectType, Field } from '@nestjs/graphql'; +import { UserModel } from '../../auth/user.model'; + +@ObjectType() +export class StudyPermissionModel { + @Field(() => UserModel) + user: string; + + @Field() + isStudyAdmin: boolean; + + @Field() + isStudyAdminEditable: boolean; + + @Field() + isContributor: boolean; + + @Field() + isContributorEditable: boolean; + + @Field() + isTrained: boolean; + + @Field() + isTrainedEditable: boolean; +} diff --git a/packages/server/src/permission/permission.model.ts b/packages/server/src/permission/permission.model.ts deleted file mode 100644 index 1b3eff1c..00000000 --- a/packages/server/src/permission/permission.model.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ObjectType, registerEnumType, Field, Directive, ID } from '@nestjs/graphql'; -import { Roles } from './permissions/roles'; - -registerEnumType(Roles, { - name: 'Roles' -}); - -/** Definition for external user */ -@ObjectType() -@Directive('@key(fields: "id")') -@Directive('@extends') -export class UserModel { - @Field(() => ID) - @Directive('@external') - id: string; -} - -@ObjectType() -export class Permission { - @Field(() => UserModel) - user: string; - - @Field(() => Roles) - role: Roles; - - @Field() - hasRole: boolean; - - @Field() - editable: boolean; -} diff --git a/packages/server/src/permission/permission.module.ts b/packages/server/src/permission/permission.module.ts index 950bee2b..8d4915a2 100644 --- a/packages/server/src/permission/permission.module.ts +++ b/packages/server/src/permission/permission.module.ts @@ -1,13 +1,22 @@ import { Module, forwardRef } from '@nestjs/common'; import { casbinProvider } from './casbin.provider'; -import { PermissionResolver } from './permission.resolver'; import { PermissionService } from './permission.service'; import { ProjectModule } from '../project/project.module'; import { AuthModule } from '../auth/auth.module'; +import { StudyModule } from '../study/study.module'; +import { ProjectPermissionResolver } from './resolvers/project.resolver'; +import { OwnerPermissionResolver } from './resolvers/owner.resolver'; +import { StudyPermissionResolver } from './resolvers/study.resolver'; @Module({ - imports: [forwardRef(() => ProjectModule), AuthModule], - providers: [casbinProvider, PermissionResolver, PermissionService], + imports: [forwardRef(() => ProjectModule), AuthModule, forwardRef(() => StudyModule)], + providers: [ + casbinProvider, + PermissionService, + ProjectPermissionResolver, + OwnerPermissionResolver, + StudyPermissionResolver + ], exports: [casbinProvider] }) export class PermissionModule {} diff --git a/packages/server/src/permission/permission.service.ts b/packages/server/src/permission/permission.service.ts index 408ce965..3792ad0f 100644 --- a/packages/server/src/permission/permission.service.ts +++ b/packages/server/src/permission/permission.service.ts @@ -5,7 +5,9 @@ import { Roles } from './permissions/roles'; import { UserService } from '../auth/services/user.service'; import { Project } from '../project/project.model'; import { TokenPayload } from '../jwt/token.dto'; -import { Permission } from './permission.model'; +import { Study } from '../study/study.model'; +import { ProjectPermissionModel } from './models/project.model'; +import { StudyPermissionModel } from './models/study.model'; @Injectable() export class PermissionService { @@ -15,11 +17,11 @@ export class PermissionService { ) {} /** requestingUser must be an owner themselves */ - async grantOwner(targetUser: string, requestingUser: string, organization: string): Promise { + async grantOwner(targetUser: string, organization: string): Promise { await this.enforcer.addPolicy(targetUser, Roles.OWNER, organization); } - async getProjectPermissions(project: Project, requestingUser: TokenPayload): Promise { + async getProjectPermissions(project: Project, requestingUser: TokenPayload): Promise { // Get all the users associated with the organization const users = await this.userService.getUsersForProject(requestingUser.projectId); @@ -31,8 +33,7 @@ export class PermissionService { return { user: user.id, - role: Roles.PROJECT_ADMIN, - hasRole, + isProjectAdmin: hasRole, editable }; }) @@ -67,4 +68,99 @@ export class PermissionService { return true; } + + async getStudyPermissions(study: Study, requestingUser: TokenPayload): Promise { + // Get all the users associated with the organization + const users = await this.userService.getUsersForProject(requestingUser.projectId); + + // Create the cooresponding permission representation + const permissions = await Promise.all( + users.map(async (user) => { + const isStudyAdmin = await this.enforcer.enforce(user.id, Roles.STUDY_ADMIN, study._id.toString()); + const isStudyAdminEditable = !(await this.enforcer.enforce(user.id, Roles.PROJECT_ADMIN, study._id.toString())); + console.log(user, isStudyAdminEditable); + + const isContributor = await this.enforcer.enforce(user.id, Roles.CONTRIBUTOR, study._id.toString()); + const isContributorEditable = !(await this.enforcer.enforce(user.id, Roles.STUDY_ADMIN, study._id.toString())); + + const isTrained = await this.enforcer.enforce(user.id, Roles.TRAINED_CONTRIBUTOR, study._id.toString()); + + return { + user: user.id, + isStudyAdmin, + isStudyAdminEditable, + isContributor, + isContributorEditable, + isTrained, + isTrainedEditable: true + }; + }) + ); + + // return permissions; + return permissions; + } + + async grantStudyAdmin(study: Study, user: string, isAdmin: boolean, requestingUser: TokenPayload): Promise { + // Make sure the target user is not a project admin + const isProjectAdmin = await this.enforcer.enforce(user, Roles.PROJECT_ADMIN, study._id); + if (isProjectAdmin) { + throw new UnauthorizedException('Target user is an owner'); + } + + // The user cannot change its own permissions + if (user === requestingUser.id) { + throw new UnauthorizedException('Cannot change your own permissions'); + } + + // Otherwise grant the permissions + if (isAdmin) { + await this.enforcer.addPolicy(user, Roles.STUDY_ADMIN, study._id.toString()); + } else { + await this.enforcer.removePolicy(user, Roles.STUDY_ADMIN, study._id.toString()); + } + + return true; + } + + async grantContributor( + study: Study, + user: string, + isContributor: boolean, + requestingUser: TokenPayload + ): Promise { + // Make sure the target user is not a study admin + const isStudyAdmin = await this.enforcer.enforce(user, Roles.STUDY_ADMIN, study._id.toString()); + if (isStudyAdmin) { + throw new UnauthorizedException('Target user is an owner'); + } + + // The user cannot change its own permissions + if (user === requestingUser.id) { + throw new UnauthorizedException('Cannot change your own permissions'); + } + + // Otherwise grant the permissions + if (isContributor) { + await this.enforcer.addPolicy(user, Roles.CONTRIBUTOR, study._id.toString()); + } else { + await this.enforcer.removePolicy(user, Roles.CONTRIBUTOR, study._id.toString()); + } + + return true; + } + + async grantTrainedContributor( + study: Study, + user: string, + isTrained: boolean, + _requestingUser: TokenPayload + ): Promise { + if (isTrained) { + await this.enforcer.addPolicy(user, Roles.TRAINED_CONTRIBUTOR, study._id.toString()); + } else { + await this.enforcer.removePolicy(user, Roles.TRAINED_CONTRIBUTOR, study._id.toString()); + } + return true; + } } diff --git a/packages/server/src/permission/permissions/project.ts b/packages/server/src/permission/permissions/project.ts index 3fa625b1..b4f56cd0 100644 --- a/packages/server/src/permission/permissions/project.ts +++ b/packages/server/src/permission/permissions/project.ts @@ -23,4 +23,6 @@ export const roleToProjectPermissions: string[][] = [ // CONTRIBUTOR permissions [Roles.CONTRIBUTOR, ProjectPermissions.READ] + + // TRAINED_CONTRIBUTOR permissions ]; diff --git a/packages/server/src/permission/permissions/roles.ts b/packages/server/src/permission/permissions/roles.ts index f228a43a..10586534 100644 --- a/packages/server/src/permission/permissions/roles.ts +++ b/packages/server/src/permission/permissions/roles.ts @@ -2,6 +2,7 @@ export enum Roles { OWNER = 'owner', PROJECT_ADMIN = 'project_admin', STUDY_ADMIN = 'study_admin', + TRAINED_CONTRIBUTOR = 'trained_contributor', CONTRIBUTOR = 'contributor' } diff --git a/packages/server/src/permission/permissions/study.ts b/packages/server/src/permission/permissions/study.ts index df831450..e9e78679 100644 --- a/packages/server/src/permission/permissions/study.ts +++ b/packages/server/src/permission/permissions/study.ts @@ -23,4 +23,6 @@ export const roleToStudyPermissions: string[][] = [ // CONTRIBUTOR permissions [Roles.CONTRIBUTOR, StudyPermissions.READ] + + // TRAINED_CONTRIBUTOR permissions ]; diff --git a/packages/server/src/permission/permissions/tag.ts b/packages/server/src/permission/permissions/tag.ts index 81715590..71177fa9 100644 --- a/packages/server/src/permission/permissions/tag.ts +++ b/packages/server/src/permission/permissions/tag.ts @@ -18,5 +18,7 @@ export const roleToTagPermissions: string[][] = [ [Roles.STUDY_ADMIN, TagPermissions.UPDATE], // CONTRIBUTOR permissions - [Roles.CONTRIBUTOR, TagPermissions.CREATE] + + // TRAINED_CONTRIBUTOR permissions + [Roles.TRAINED_CONTRIBUTOR, TagPermissions.CREATE] ]; diff --git a/packages/server/src/permission/resolvers/owner.resolver.ts b/packages/server/src/permission/resolvers/owner.resolver.ts new file mode 100644 index 00000000..97481416 --- /dev/null +++ b/packages/server/src/permission/resolvers/owner.resolver.ts @@ -0,0 +1,34 @@ +import { Args, Resolver, Mutation, ID } from '@nestjs/graphql'; +import { TokenContext } from '../../jwt/token.context'; +import { OrganizationContext } from '../../organization/organization.context'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Organization } from '../../organization/organization.model'; +import * as casbin from 'casbin'; +import { CASBIN_PROVIDER } from '../casbin.provider'; +import { Inject, UnauthorizedException } from '@nestjs/common'; +import { Roles } from '../permissions/roles'; +import { PermissionService } from '../permission.service'; + +@Resolver() +export class OwnerPermissionResolver { + constructor( + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer, + private readonly permissionService: PermissionService + ) {} + + @Mutation(() => Boolean) + async grantOwner( + @Args('targetUser', { type: () => ID }) targetUser: string, + @TokenContext() requestingUser: TokenPayload, + @OrganizationContext() organization: Organization + ): Promise { + // Make sure the requesting user is an owner + const isOwner = await this.enforcer.enforce(requestingUser, Roles.OWNER, organization); + if (!isOwner) { + throw new UnauthorizedException('Requesting user is not an owner'); + } + + await this.permissionService.grantOwner(targetUser, organization._id); + return true; + } +} diff --git a/packages/server/src/permission/permission.resolver.ts b/packages/server/src/permission/resolvers/project.resolver.ts similarity index 52% rename from packages/server/src/permission/permission.resolver.ts rename to packages/server/src/permission/resolvers/project.resolver.ts index 734bad19..3fd50710 100644 --- a/packages/server/src/permission/permission.resolver.ts +++ b/packages/server/src/permission/resolvers/project.resolver.ts @@ -1,48 +1,30 @@ import { Resolver, Mutation, Args, ID, Query, ResolveField, Parent } from '@nestjs/graphql'; -import { JwtAuthGuard } from '../jwt/jwt.guard'; +import { JwtAuthGuard } from '../../jwt/jwt.guard'; import { UseGuards, Inject, UnauthorizedException } from '@nestjs/common'; -import { TokenContext } from '../jwt/token.context'; -import { TokenPayload } from '../jwt/token.dto'; -import { PermissionService } from './permission.service'; -import { OrganizationContext } from '../organization/organization.context'; -import { Organization } from '../organization/organization.model'; -import { ProjectPipe } from '../project/pipes/project.pipe'; -import { Project } from '../project/project.model'; -import { Permission, UserModel } from './permission.model'; +import { ProjectPermissionModel } from '../models/project.model'; +import { TokenContext } from '../../jwt/token.context'; +import { TokenPayload } from '../../jwt/token.dto'; +import { ProjectPipe } from '../../project/pipes/project.pipe'; +import { Project } from '../../project/project.model'; import * as casbin from 'casbin'; -import { CASBIN_PROVIDER } from './casbin.provider'; -import { Roles } from './permissions/roles'; -import { ProjectPermissions } from './permissions/project'; +import { CASBIN_PROVIDER } from '../casbin.provider'; +import { ProjectPermissions } from '../permissions/project'; +import { PermissionService } from '../permission.service'; +import { UserModel } from '../../auth/user.model'; @UseGuards(JwtAuthGuard) -@Resolver(() => Permission) -export class PermissionResolver { +@Resolver(() => ProjectPermissionModel) +export class ProjectPermissionResolver { constructor( - private readonly permissionService: PermissionService, - @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer, + private readonly permissionService: PermissionService ) {} - @Mutation(() => Boolean) - async grantOwner( - @Args('targetUser', { type: () => ID }) targetUser: string, - @TokenContext() requestingUser: TokenPayload, - @OrganizationContext() organization: Organization - ): Promise { - // Make sure the requesting user is an owner - const isOwner = await this.enforcer.enforce(requestingUser, Roles.OWNER, organization); - if (!isOwner) { - throw new UnauthorizedException('Requesting user is not an owner'); - } - - await this.permissionService.grantOwner(targetUser, requestingUser.id, organization._id); - return true; - } - - @Query(() => [Permission]) + @Query(() => [ProjectPermissionModel]) async getProjectPermissions( @Args('project', { type: () => ID }, ProjectPipe) project: Project, @TokenContext() requestingUser: TokenPayload - ): Promise { + ): Promise { // Make sure the user has the ability to manage project permissions const hasPermission = await this.enforcer.enforce(requestingUser.id, ProjectPermissions.GRANT_ADMIN, project._id); if (!hasPermission) { @@ -68,7 +50,7 @@ export class PermissionResolver { } @ResolveField('user', () => UserModel) - resolveUser(@Parent() permission: Permission): any { + resolveUser(@Parent() permission: ProjectPermissionModel): any { return { __typename: 'UserModel', id: permission.user }; } } diff --git a/packages/server/src/permission/resolvers/study.resolver.ts b/packages/server/src/permission/resolvers/study.resolver.ts new file mode 100644 index 00000000..39f3a64e --- /dev/null +++ b/packages/server/src/permission/resolvers/study.resolver.ts @@ -0,0 +1,101 @@ +import { Resolver, Args, ID, Query, ResolveField, Parent, Mutation } from '@nestjs/graphql'; +import { JwtAuthGuard } from '../../jwt/jwt.guard'; +import { UseGuards, Inject, UnauthorizedException } from '@nestjs/common'; +import { StudyPermissionModel } from '../models/study.model'; +import { TokenContext } from '../../jwt/token.context'; +import { TokenPayload } from '../../jwt/token.dto'; +import { StudyPipe } from '../../study/pipes/study.pipe'; +import { Study } from '../../study/study.model'; +import * as casbin from 'casbin'; +import { CASBIN_PROVIDER } from '../casbin.provider'; +import { StudyPermissions } from '../permissions/study'; +import { PermissionService } from '../permission.service'; +import { UserModel } from '../../auth/user.model'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => StudyPermissionModel) +export class StudyPermissionResolver { + constructor( + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer, + private readonly permissionService: PermissionService + ) {} + + @Query(() => [StudyPermissionModel]) + async getStudyPermissions( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @TokenContext() requestingUser: TokenPayload + ): Promise { + // Make sure the user has the ability to manage study permissions + const hasPermission = await this.enforcer.enforce(requestingUser.id, StudyPermissions.GRANT_ACCESS, study._id); + if (!hasPermission) { + throw new UnauthorizedException('Requesting user does not have permission to manage study permissions'); + } + + return this.permissionService.getStudyPermissions(study, requestingUser); + } + + @Mutation(() => Boolean) + async grantStudyAdmin( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @Args('user', { type: () => ID }) user: string, + @Args('isAdmin', { type: () => Boolean }) isAdmin: boolean, + @TokenContext() requestingUser: TokenPayload + ): Promise { + // Make sure the user has the ability to manage study permissions + const hasPermission = await this.enforcer.enforce( + requestingUser.id, + StudyPermissions.GRANT_ACCESS, + study._id.toString() + ); + if (!hasPermission) { + throw new UnauthorizedException('Requesting user does not have permission to manage study permissions'); + } + + return this.permissionService.grantStudyAdmin(study, user, isAdmin, requestingUser); + } + + @Mutation(() => Boolean) + async grantContributor( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @Args('user', { type: () => ID }) user: string, + @Args('isContributor', { type: () => Boolean }) isContributor: boolean, + @TokenContext() requestingUser: TokenPayload + ): Promise { + // Make sure the user has the ability to manage study permissions + const hasPermission = await this.enforcer.enforce( + requestingUser.id, + StudyPermissions.GRANT_ACCESS, + study._id.toString() + ); + if (!hasPermission) { + throw new UnauthorizedException('Requesting user does not have permission to manage study permissions'); + } + + return this.permissionService.grantContributor(study, user, isContributor, requestingUser); + } + + @Mutation(() => Boolean) + async grantTrainedContributor( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @Args('user', { type: () => ID }) user: string, + @Args('isTrained', { type: () => Boolean }) isTrained: boolean, + @TokenContext() requestingUser: TokenPayload + ): Promise { + // Make sure the user has the ability to manage study permissions + const hasPermission = await this.enforcer.enforce( + requestingUser.id, + StudyPermissions.GRANT_ACCESS, + study._id.toString() + ); + if (!hasPermission) { + throw new UnauthorizedException('Requesting user does not have permission to manage study permissions'); + } + + return this.permissionService.grantTrainedContributor(study, user, isTrained, requestingUser); + } + + @ResolveField('user', () => UserModel) + resolveUser(@Parent() permission: StudyPermissionModel): any { + return { __typename: 'UserModel', id: permission.user }; + } +} diff --git a/packages/server/src/study/study.module.ts b/packages/server/src/study/study.module.ts index 8ff10dd2..4d38bf84 100644 --- a/packages/server/src/study/study.module.ts +++ b/packages/server/src/study/study.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { StudyService } from './study.service'; import { StudyResolver } from './study.resolver'; import { MongooseModule } from '@nestjs/mongoose'; @@ -33,7 +33,7 @@ import { PermissionModule } from '../permission/permission.module'; ProjectModule, SharedModule, JwtModule, - PermissionModule + forwardRef(() => PermissionModule) ], providers: [StudyService, StudyResolver, StudyPipe, StudyCreatePipe], exports: [StudyService, StudyPipe]