diff --git a/packages/client/src/components/SideBar.component.tsx b/packages/client/src/components/SideBar.component.tsx index 91fcbdc4..ab1cc280 100644 --- a/packages/client/src/components/SideBar.component.tsx +++ b/packages/client/src/components/SideBar.component.tsx @@ -1,9 +1,13 @@ -import { FC, ReactNode, useState } from 'react'; +import { FC, ReactNode, useEffect, useState } from 'react'; import { Collapse, Divider, Drawer, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { ExpandMore, ExpandLess, School, Dataset, Work, Logout, GroupWork } from '@mui/icons-material'; import { useAuth } from '../context/Auth.context'; import { useNavigate } from 'react-router-dom'; import { Environment } from './Environment.component'; +import { Permission } from '../graphql/graphql'; +import { useGetRolesQuery } from '../graphql/permission/permission'; +import { useProject } from '../context/Project.context'; +import { useStudy } from '../context/Study.context'; interface SideBarProps { open: boolean; @@ -13,49 +17,70 @@ interface SideBarProps { export const SideBar: FC = ({ open, drawerWidth }) => { const { logout } = useAuth(); const navigate = useNavigate(); + const [permission, setPermission] = useState(null); + const { project } = useProject(); + const { study } = useStudy(); + const rolesQueryResults = useGetRolesQuery({ variables: { project: project?._id, study: study?._id } }); + + useEffect(() => { + if (rolesQueryResults.data) { + setPermission(rolesQueryResults.data.getRoles); + } + }, [rolesQueryResults.data]); const navItems: NavItemProps[] = [ { name: 'Projects', icon: , action: () => {}, + visible: (p) => p!.owner || p!.projectAdmin, + permission, subItems: [ - { name: 'New Project', action: () => navigate('/project/new') }, - { name: 'Project Control', action: () => navigate('/project/controls') }, - { name: 'User Permissions', action: () => navigate('/project/permissions') } + { name: 'New Project', action: () => navigate('/project/new'), visible: (p) => p!.owner }, + { name: 'Project Control', action: () => navigate('/project/controls'), visible: (p) => p!.owner }, + { name: 'User Permissions', action: () => navigate('/project/permissions'), visible: (p) => p!.projectAdmin } ] }, { name: 'Studies', action: () => {}, icon: , + visible: (p) => p!.projectAdmin || p!.studyAdmin, + permission, subItems: [ - { name: 'New Study', action: () => navigate('/study/new') }, - { name: 'Study Control', action: () => navigate('/study/controls') }, - { name: 'User Permissions', action: () => navigate('/study/permissions') }, - { name: 'Entry Controls', action: () => navigate('/study/controls') }, - { name: 'Download Tags', action: () => navigate('/study/tags') } + { name: 'New Study', action: () => navigate('/study/new'), visible: (p) => p!.projectAdmin }, + { name: 'Study Control', action: () => navigate('/study/controls'), visible: (p) => p!.projectAdmin }, + { name: 'User Permissions', action: () => navigate('/study/permissions'), visible: (p) => p!.studyAdmin }, + { name: 'Entry Controls', action: () => navigate('/study/controls'), visible: (p) => p!.studyAdmin }, + { name: 'Download Tags', action: () => navigate('/study/tags'), visible: (p) => p!.studyAdmin } ] }, { name: 'Datasets', action: () => {}, icon: , + visible: (p) => p!.owner, + permission, subItems: [ - { name: 'Dataset Control', action: () => navigate('/dataset/controls') }, - { name: 'Project Access', action: () => navigate('/dataset/projectaccess') } + { name: 'Dataset Control', action: () => navigate('/dataset/controls'), visible: (p) => p!.owner }, + { name: 'Project Access', action: () => navigate('/dataset/projectaccess'), visible: (p) => p!.owner } ] }, { name: 'Contribute', action: () => {}, icon: , - subItems: [{ name: 'Tag in Study', action: () => navigate('/contribute/landing') }] + permission, + visible: (p) => p!.contributor, + subItems: [ + { name: 'Tag in Study', action: () => navigate('/contribute/landing'), visible: (p) => p!.contributor } + ] }, { name: 'Logout', action: logout, - icon: + icon: , + visible: () => true } ]; @@ -77,11 +102,15 @@ export const SideBar: FC = ({ open, drawerWidth }) => { anchor="left" open={open} > - - {navItems.map((navItem) => ( - - ))} - + {permission && ( + + {navItems + .filter((navItem) => navItem.visible(permission)) + .map((navItem) => ( + + ))} + + )} ); @@ -92,9 +121,11 @@ interface NavItemProps { name: string; icon?: ReactNode; subItems?: NavItemProps[]; + permission?: Permission | null; + visible: (permission: Permission | null | undefined) => boolean; } -const NavItem: FC = ({ action, name, icon, subItems }) => { +const NavItem: FC = ({ action, name, icon, subItems, permission }) => { const isExpandable = subItems && subItems.length > 0; const [open, setOpen] = useState(false); @@ -107,9 +138,11 @@ const NavItem: FC = ({ action, name, icon, subItems }) => { - {subItems.map((item, index) => ( - - ))} + {subItems + .filter((subItem) => subItem.visible(permission)) + .map((item, index) => ( + + ))} ) : null; diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 498d85ef..ced0238e 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -461,6 +461,15 @@ export type OrganizationCreate = { projectId: Scalars['String']['input']; }; +export type Permission = { + __typename?: 'Permission'; + contributor: Scalars['Boolean']['output']; + owner: Scalars['Boolean']['output']; + projectAdmin: Scalars['Boolean']['output']; + studyAdmin: Scalars['Boolean']['output']; + trainedContributor: Scalars['Boolean']['output']; +}; + export type Project = { __typename?: 'Project'; _id: Scalars['ID']['output']; @@ -548,6 +557,7 @@ export type Query = { getProject: ProjectModel; getProjectPermissions: Array; getProjects: Array; + getRoles: Permission; getStudyPermissions: Array; getUser: UserModel; invite: InviteModel; @@ -613,6 +623,12 @@ export type QueryGetProjectPermissionsArgs = { }; +export type QueryGetRolesArgs = { + project?: InputMaybe; + study?: InputMaybe; +}; + + export type QueryGetStudyPermissionsArgs = { study: Scalars['ID']['input']; }; diff --git a/packages/client/src/graphql/permission/permission.graphql b/packages/client/src/graphql/permission/permission.graphql index 95dc0fc8..8b9e12b6 100644 --- a/packages/client/src/graphql/permission/permission.graphql +++ b/packages/client/src/graphql/permission/permission.graphql @@ -68,3 +68,13 @@ query getDatasetProjectPermissions($project: ID!) { mutation grantProjectDatasetAccess($project: ID!, $dataset: ID!, $hasAccess: Boolean!) { grantProjectDatasetAccess(project: $project, dataset: $dataset, hasAccess: $hasAccess) } + +query getRoles($project: ID, $study: ID) { + getRoles(project: $project, study: $study) { + owner, + projectAdmin, + studyAdmin, + trainedContributor, + contributor + } +} diff --git a/packages/client/src/graphql/permission/permission.ts b/packages/client/src/graphql/permission/permission.ts index 6408aaa3..bd4dbdd7 100644 --- a/packages/client/src/graphql/permission/permission.ts +++ b/packages/client/src/graphql/permission/permission.ts @@ -71,6 +71,14 @@ export type GrantProjectDatasetAccessMutationVariables = Types.Exact<{ export type GrantProjectDatasetAccessMutation = { __typename?: 'Mutation', grantProjectDatasetAccess: boolean }; +export type GetRolesQueryVariables = Types.Exact<{ + project?: Types.InputMaybe; + study?: Types.InputMaybe; +}>; + + +export type GetRolesQuery = { __typename?: 'Query', getRoles: { __typename?: 'Permission', owner: boolean, projectAdmin: boolean, studyAdmin: boolean, trainedContributor: boolean, contributor: boolean } }; + export const GetProjectPermissionsDocument = gql` query getProjectPermissions($project: ID!) { @@ -378,4 +386,44 @@ export function useGrantProjectDatasetAccessMutation(baseOptions?: Apollo.Mutati } export type GrantProjectDatasetAccessMutationHookResult = ReturnType; export type GrantProjectDatasetAccessMutationResult = Apollo.MutationResult; -export type GrantProjectDatasetAccessMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file +export type GrantProjectDatasetAccessMutationOptions = Apollo.BaseMutationOptions; +export const GetRolesDocument = gql` + query getRoles($project: ID, $study: ID) { + getRoles(project: $project, study: $study) { + owner + projectAdmin + studyAdmin + trainedContributor + contributor + } +} + `; + +/** + * __useGetRolesQuery__ + * + * To run a query within a React component, call `useGetRolesQuery` and pass it any options that fit your needs. + * When your component renders, `useGetRolesQuery` 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 } = useGetRolesQuery({ + * variables: { + * project: // value for 'project' + * study: // value for 'study' + * }, + * }); + */ +export function useGetRolesQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetRolesDocument, options); + } +export function useGetRolesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetRolesDocument, options); + } +export type GetRolesQueryHookResult = ReturnType; +export type GetRolesLazyQueryHookResult = ReturnType; +export type GetRolesQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 00e763c6..aab1322f 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -73,6 +73,14 @@ type DatasetProjectPermission { projectHasAccess: Boolean! } +type Permission { + owner: Boolean! + projectAdmin: Boolean! + studyAdmin: Boolean! + trainedContributor: Boolean! + contributor: Boolean! +} + type Entry { _id: String! organization: ID! @@ -137,6 +145,7 @@ type Query { getProjectPermissions(project: ID!): [ProjectPermissionModel!]! getStudyPermissions(study: ID!): [StudyPermissionModel!]! getDatasetProjectPermissions(project: ID!): [DatasetProjectPermission!]! + getRoles(project: ID, study: ID): Permission! projectExists(name: String!): Boolean! getProjects: [Project!]! studyExists(name: String!, project: ID!): Boolean! diff --git a/packages/server/src/permission/models/permission.model.ts b/packages/server/src/permission/models/permission.model.ts new file mode 100644 index 00000000..1f20d3e7 --- /dev/null +++ b/packages/server/src/permission/models/permission.model.ts @@ -0,0 +1,19 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class Permission { + @Field() + owner: boolean; + + @Field() + projectAdmin: boolean; + + @Field() + studyAdmin: boolean; + + @Field() + trainedContributor: boolean; + + @Field() + contributor: boolean; +} diff --git a/packages/server/src/permission/permission.module.ts b/packages/server/src/permission/permission.module.ts index a0f123f2..bbe82ac0 100644 --- a/packages/server/src/permission/permission.module.ts +++ b/packages/server/src/permission/permission.module.ts @@ -9,6 +9,7 @@ import { OwnerPermissionResolver } from './resolvers/owner.resolver'; import { StudyPermissionResolver } from './resolvers/study.resolver'; import { DatasetPermissionResolver } from './resolvers/dataset.resolver'; import { DatasetModule } from '../dataset/dataset.module'; +import { PermissionResolver } from './resolvers/permission.resolver'; @Module({ imports: [ @@ -23,7 +24,8 @@ import { DatasetModule } from '../dataset/dataset.module'; ProjectPermissionResolver, OwnerPermissionResolver, StudyPermissionResolver, - DatasetPermissionResolver + DatasetPermissionResolver, + PermissionResolver ], exports: [casbinProvider] }) diff --git a/packages/server/src/permission/permission.service.ts b/packages/server/src/permission/permission.service.ts index 242f5870..c66d8d31 100644 --- a/packages/server/src/permission/permission.service.ts +++ b/packages/server/src/permission/permission.service.ts @@ -11,6 +11,8 @@ import { StudyPermissionModel } from './models/study.model'; import { Dataset } from '../dataset/dataset.model'; import { DatasetService } from '../dataset/dataset.service'; import { DatasetProjectPermission } from './models/dataset.model'; +import { Permission } from './models/permission.model'; +import { Organization } from '../organization/organization.model'; @Injectable() export class PermissionService { @@ -195,4 +197,21 @@ export class PermissionService { }) ); } + + async getRoles( + user: TokenPayload, + organization: Organization, + project: Project | null, + study: Study | null + ): Promise { + return { + owner: await this.enforcer.enforce(user.id, Roles.OWNER, organization._id.toString()), + projectAdmin: project ? await this.enforcer.enforce(user.id, Roles.PROJECT_ADMIN, project._id.toString()) : false, + studyAdmin: study ? await this.enforcer.enforce(user.id, Roles.STUDY_ADMIN, study._id.toString()) : false, + trainedContributor: study + ? await this.enforcer.enforce(user.id, Roles.TRAINED_CONTRIBUTOR, study._id.toString()) + : false, + contributor: study ? await this.enforcer.enforce(user.id, Roles.CONTRIBUTOR, study._id.toString()) : false + }; + } } diff --git a/packages/server/src/permission/resolvers/permission.resolver.ts b/packages/server/src/permission/resolvers/permission.resolver.ts new file mode 100644 index 00000000..2a17c610 --- /dev/null +++ b/packages/server/src/permission/resolvers/permission.resolver.ts @@ -0,0 +1,43 @@ +import { Resolver, Query, Args, ID } from '@nestjs/graphql'; +import { StudyPipe } from '../../study/pipes/study.pipe'; +import { ProjectPipe } from '../../project/pipes/project.pipe'; +import { TokenContext } from '../../jwt/token.context'; +import { Permission } from '../models/permission.model'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Study } from '../../study/study.model'; +import { Project } from '../../project/project.model'; +import { PermissionService } from '../permission.service'; +import { OrganizationContext } from '../../organization/organization.context'; +import { Organization } from '../../organization/organization.model'; +import { UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../jwt/jwt.guard'; + +@UseGuards(JwtAuthGuard) +@Resolver() +export class PermissionResolver { + constructor( + private readonly studyPipe: StudyPipe, + private readonly projectPipe: ProjectPipe, + private readonly permissionService: PermissionService + ) {} + + @Query(() => Permission) + async getRoles( + @Args('project', { type: () => ID, nullable: true }) projectID: string | null, + @Args('study', { type: () => ID, nullable: true }) studyID: string | null, + @TokenContext() tokenContext: TokenPayload, + @OrganizationContext() organization: Organization + ): Promise { + let project: Project | null = null; + let study: Study | null = null; + + if (projectID) { + project = await this.projectPipe.transform(projectID); + } + if (studyID) { + study = await this.studyPipe.transform(studyID); + } + + return this.permissionService.getRoles(tokenContext, organization, project, study); + } +}