diff --git a/apps/backend/db_patches/0180_CreateUserRoles.sql b/apps/backend/db_patches/0180_CreateUserRoles.sql new file mode 100644 index 0000000000..b6205dab99 --- /dev/null +++ b/apps/backend/db_patches/0180_CreateUserRoles.sql @@ -0,0 +1,17 @@ +DO +$$ +BEGIN + IF register_patch('0180_Create_user_roles.sql', 'Fredrik Bolmsten', 'Create dynamic user roles', '2025-05-06') THEN + BEGIN + + ALTER TABLE roles + ADD COLUMN permissions TEXT[] NOT NULL DEFAULT '{}'; + + ALTER TABLE roles + ADD COLUMN data_access TEXT[] NOT NULL DEFAULT '{}'; + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0002_InstrumentScientiests.sql b/apps/backend/db_patches/db_seeds/0002_InstrumentScientiests.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 74d732d70a..d1edcc9300 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -1,10 +1,4 @@ services: - duo-cron-job: - image: ghcr.io/userofficeproject/user-office-cron:develop - environment: - API_URL: http://host.docker.internal:4000/graphql - API_AUTH_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoyLCJ1c2VyX3RpdGxlIjoiTXIuIiwiZmlyc3RuYW1lIjoiQW5kZXJzIiwibWlkZGxlbmFtZSI6IkFkYW0iLCJsYXN0bmFtZSI6IkFuZGVyc3NvbiIsInVzZXJuYW1lIjoidGVzdG9mZmljZXIiLCJwcmVmZXJyZWRuYW1lIjoiUmhpYW5ub24iLCJvcmNpZCI6Ijg3ODMyMTg5NyIsImdlbmRlciI6Im1hbGUiLCJuYXRpb25hbGl0eSI6IkZyZW5jaCIsImJpcnRoZGF0ZSI6IjE5ODEtMDgtMDRUMjI6MDA6MDAuMDAwWiIsIm9yZ2FuaXNhdGlvbiI6IlBmYW5uZXJzdGlsbCBhbmQgU29ucyIsImRlcGFydG1lbnQiOiJJVCBkZXBhcnRtZW50Iiwib3JnYW5pc2F0aW9uX2FkZHJlc3MiOiJDb25nbywgQWxsZW5ldmlsbGUsIDM1ODIzIE11ZWxsZXIgR2xlbnMiLCJwb3NpdGlvbiI6IkxpYWlzb24iLCJlbWFpbCI6IkFhcm9uX0hhcnJpczQ5QGdtYWlsLmNvbSIsImVtYWlsVmVyaWZpZWQiOnRydWUsInRlbGVwaG9uZSI6IjcxMS0zMTYtNTcyOCIsInRlbGVwaG9uZV9hbHQiOiIxLTM1OS04NjQtMzQ4OSB4NzM5MCIsImNyZWF0ZWQiOiIyMDE5LTEwLTE3VDEwOjU4OjM4LjczNVoiLCJ1cGRhdGVkIjoiMjAxOS0xMC0xN1QxMDo1ODozOC43MzVaIn0sInJvbGVzIjpbeyJpZCI6Miwic2hvcnRDb2RlIjoidXNlcl9vZmZpY2VyIiwidGl0bGUiOiJVc2VyIE9mZmljZXIifV0sImlhdCI6MTU3MTMyNzQ2Mn0.NinmUuwuu0D6syqwd2z5J1BaqhwRPlFaxtML8sA2Ang - db: image: postgres:16-alpine restart: always diff --git a/apps/backend/src/auth/ProposalAuthorization.spec.ts b/apps/backend/src/auth/ProposalAuthorization.spec.ts index 940b55287a..7aaded459c 100644 --- a/apps/backend/src/auth/ProposalAuthorization.spec.ts +++ b/apps/backend/src/auth/ProposalAuthorization.spec.ts @@ -114,6 +114,8 @@ test('A instrument sci cannot access proposals they are not on', async () => { title: 'Instrument Scientist', shortCode: 'instrument_scientist', description: '', + permissions: [], + dataAccess: [], }, }, 1 diff --git a/apps/backend/src/auth/ProposalAuthorization.ts b/apps/backend/src/auth/ProposalAuthorization.ts index e98aa30a1a..38b8d9077d 100644 --- a/apps/backend/src/auth/ProposalAuthorization.ts +++ b/apps/backend/src/auth/ProposalAuthorization.ts @@ -4,11 +4,12 @@ import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; +import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { ReviewDataSource } from '../datasources/ReviewDataSource'; import { StatusDataSource } from '../datasources/StatusDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; -import { Roles } from '../models/Role'; +import { Role, Roles } from '../models/Role'; import { ProposalStatusDefaultShortCodes } from '../models/Status'; import { UserWithRole } from '../models/User'; import { Proposal } from '../resolvers/types/Proposal'; @@ -33,6 +34,8 @@ export class ProposalAuthorization { private callDataSource: CallDataSource, @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource, + @inject(Tokens.InstrumentDataSource) + private instrumentDataSource: InstrumentDataSource, @inject(Tokens.DataAccessUsersDataSource) private dataAccessUsersDataSource: DataAccessUsersDataSource, @inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization @@ -260,6 +263,41 @@ export class ProposalAuthorization { let hasAccess = false; + // Check data access + + const proposalIsntruments = + await this.instrumentDataSource.getInstrumentsByProposalPk( + proposal.primaryKey + ); + + const rolesArray: Role[] = await this.userDataSource.getUserRoles(agent.id); + const userRoles: Record = + rolesArray.reduce( + (acc, role) => { + acc[role.shortCode] = { dataAccess: role.dataAccess }; + + return acc; + }, + {} as Record + ); + proposalIsntruments.forEach((instrument) => { + agent.currentRole?.shortCode && + userRoles[agent.currentRole.shortCode]?.dataAccess.some( + (dataAccess) => { + if ( + dataAccess.toLowerCase() === instrument.shortCode.toLowerCase() + ) { + hasAccess = true; + + return true; + } + } + ); + }); + + if (hasAccess) { + return true; + } switch (currentRole) { case Roles.USER: hasAccess = diff --git a/apps/backend/src/auth/UserAuthorization.ts b/apps/backend/src/auth/UserAuthorization.ts index e2bd4aaa0b..81d25c7c4d 100644 --- a/apps/backend/src/auth/UserAuthorization.ts +++ b/apps/backend/src/auth/UserAuthorization.ts @@ -37,6 +37,10 @@ export abstract class UserAuthorization { return agent?.currentRole?.shortCode === Roles.USER_OFFICER; } + isDynamicProposalReader(agent: UserWithRole | null) { + return agent?.currentRole?.shortCode === Roles.DYNAMIC_PROPOSAL_READER; + } + isExperimentSafetyReviewer(agent: UserWithRole | null) { return agent?.currentRole?.shortCode === Roles.EXPERIMENT_SAFETY_REVIEWER; } diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index 6cd43080cd..ea70e97c03 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -16,7 +16,9 @@ export interface ProposalDataSource { offset?: number, sortField?: string, sortDirection?: string, - searchText?: string + searchText?: string, + principleInvestigator?: number[], + instrumentFilter?: string[] ): Promise<{ totalCount: number; proposalViews: ProposalView[] }>; // Read get(primaryKey: number): Promise; diff --git a/apps/backend/src/datasources/UserDataSource.ts b/apps/backend/src/datasources/UserDataSource.ts index 29c9447fa7..20592e4b81 100644 --- a/apps/backend/src/datasources/UserDataSource.ts +++ b/apps/backend/src/datasources/UserDataSource.ts @@ -3,6 +3,7 @@ import { Institution } from '../models/Institution'; import { Role, Roles } from '../models/Role'; import { BasicUserDetails, User, UserRole } from '../models/User'; import { AddUserRoleArgs } from '../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../resolvers/mutations/CreateUserByEmailInviteMutation'; import { UpdateUserByOidcSubArgs, @@ -99,6 +100,9 @@ export interface UserDataSource { ): Promise; getRoleByShortCode(roleShortCode: Roles): Promise; mergeUsers(fromUserId: number, intoUserId: number): Promise; + createRole(args: CreateRoleArgs): Promise; + updateRole(args: CreateRoleArgs): Promise; + deleteRole(id: number): Promise; getApprovedProposalVisitorsWithInstitution(proposalPk: number): Promise< { user: User; diff --git a/apps/backend/src/datasources/mockups/FapDataSource.ts b/apps/backend/src/datasources/mockups/FapDataSource.ts index 217cfbb6c1..8672032f90 100644 --- a/apps/backend/src/datasources/mockups/FapDataSource.ts +++ b/apps/backend/src/datasources/mockups/FapDataSource.ts @@ -443,6 +443,8 @@ export class FapDataSourceMock implements FapDataSource { shortCode: 'fap_chair', title: 'Fap Chair', description: '', + permissions: [], + dataAccess: [], }; } diff --git a/apps/backend/src/datasources/mockups/UserDataSource.ts b/apps/backend/src/datasources/mockups/UserDataSource.ts index 7e49445120..5491febc97 100644 --- a/apps/backend/src/datasources/mockups/UserDataSource.ts +++ b/apps/backend/src/datasources/mockups/UserDataSource.ts @@ -8,6 +8,7 @@ import { UserWithRole, } from '../../models/User'; import { AddUserRoleArgs } from '../../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../../resolvers/mutations/CreateUserByEmailInviteMutation'; import { UpdateUserByIdArgs, @@ -78,6 +79,8 @@ export const dummyUserOfficerWithRole: UserWithRole = { title: 'User Officer', shortCode: 'user_officer', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -113,13 +116,22 @@ export const dummyPrincipalInvestigatorWithRole: UserWithRole = { title: 'Principal investigator', shortCode: 'user', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; export const dummyUserWithRole: UserWithRole = { ...dummyUser, - currentRole: { id: 1, title: 'User', shortCode: 'user', description: '' }, + currentRole: { + id: 1, + title: 'User', + shortCode: 'user', + description: '', + permissions: [], + dataAccess: [], + }, externalTokenValid: true, }; @@ -130,6 +142,8 @@ export const dummyFapChairWithRole: UserWithRole = { title: 'Fap Chair', shortCode: 'fap_chair', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -141,6 +155,8 @@ export const dummyFapSecretaryWithRole: UserWithRole = { title: 'Fap Secretary', shortCode: 'fap_secretary', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -152,6 +168,8 @@ export const dummyFapReviewerWithRole: UserWithRole = { title: 'Fap Reviewer', shortCode: 'fap_reviewer', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -163,6 +181,8 @@ export const dummySampleReviewer: UserWithRole = { title: 'Experiment Safety Reviewer', shortCode: 'experiment_safety_reviewer', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -174,6 +194,8 @@ export const dummyInternalReviewer: UserWithRole = { title: 'Internal Reviewer', shortCode: 'internal_reviewer', description: '', + permissions: [], + dataAccess: [], }, }; @@ -185,6 +207,8 @@ export const dummyInstrumentScientist: UserWithRole = { title: 'Instrument Scientist', shortCode: 'instrument_scientist', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -197,6 +221,8 @@ export const dummyVisitorWithRole: UserWithRole = { title: 'Visitor', shortCode: 'user', description: '', + permissions: [], + dataAccess: [], }, externalTokenValid: true, }; @@ -249,11 +275,27 @@ export const dummyUserNotOnProposal = new User( export const dummyUserNotOnProposalWithRole: UserWithRole = { ...dummyUserNotOnProposal, - currentRole: { id: 1, title: 'User', shortCode: 'user', description: '' }, + currentRole: { + id: 1, + title: 'User', + shortCode: 'user', + description: '', + permissions: [], + dataAccess: [], + }, externalTokenValid: true, }; export class UserDataSourceMock implements UserDataSource { + createRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + updateRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + deleteRole(id: number): Promise { + throw new Error('Method not implemented.'); + } async delete(id: number): Promise { return dummyUser; } @@ -355,6 +397,8 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'user_officer', title: 'User Officer', description: '', + permissions: [], + dataAccess: [], }, ]; } else if (id === dummyInstrumentScientist.id) { @@ -364,6 +408,8 @@ export class UserDataSourceMock implements UserDataSource { title: 'Instrument Scientist', shortCode: 'instrument_scientist', description: '', + permissions: [], + dataAccess: [], }, ]; } else if (id === 1001) { @@ -373,14 +419,32 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'fap_reviewer', title: 'Fap Reviewer', description: '', + permissions: [], + dataAccess: [], }, ]; } else if (id === dummyFapChairWithRole.id) { return [ - { id: 4, shortCode: 'fap_chair', title: 'Fap Chair', description: '' }, + { + id: 4, + shortCode: 'fap_chair', + title: 'Fap Chair', + description: '', + permissions: [], + dataAccess: [], + }, ]; } else { - return [{ id: 2, shortCode: 'user', title: 'User', description: '' }]; + return [ + { + id: 2, + shortCode: 'user', + title: 'User', + description: '', + permissions: [], + dataAccess: [], + }, + ]; } } @@ -391,8 +455,17 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'user_officer', title: 'User Officer', description: '', + permissions: [], + dataAccess: [], + }, + { + id: 2, + shortCode: 'user', + title: 'User', + description: '', + permissions: [], + dataAccess: [], }, - { id: 2, shortCode: 'user', title: 'User', description: '' }, ]; } @@ -562,6 +635,8 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'user_officer', title: 'User Officer', description: '', + permissions: [], + dataAccess: [], }; } diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index e1609b21f0..4cf879390b 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -384,12 +384,15 @@ export default class PostgresProposalDataSource implements ProposalDataSource { sortField?: string, sortDirection?: string, searchText?: string, - principleInvestigator?: number[] + principleInvestigator?: number[], + instrumentFilter?: string[] ): Promise<{ totalCount: number; proposalViews: ProposalView[] }> { const principalInvestigator = principleInvestigator ? principleInvestigator : []; + const instrumentFilters = instrumentFilter ? instrumentFilter : []; + return database .select(['*', database.raw('count(*) OVER() AS full_count')]) .from('proposal_table_view') @@ -404,10 +407,22 @@ export default class PostgresProposalDataSource implements ProposalDataSource { query.where('call_id', filter?.callId); } + // New: filter by instrumentFilter param (array of ids or names) + if (instrumentFilters.length) { + query.andWhere(function () { + instrumentFilters.forEach((inst) => { + this.orWhereRaw( + "jsonb_path_exists(instruments, '$[*].name \\? (@ == :instrumentName:)')", + { instrumentName: inst } + ); + }); + }); + } + if (filter?.instrumentFilter?.showMultiInstrumentProposals) { query.whereRaw('jsonb_array_length(instruments) > 1'); } else if (filter?.instrumentFilter?.instrumentId) { - // NOTE: Using jsonpath we check the jsonb (instruments) field if it contains object with id equal to filter.instrumentId + // NOTE: Using jsonpath we check the jsonb (instruments) field if it contains object with id equal to filter.instrumentFilter.instrumentId query.whereRaw( 'jsonb_path_exists(instruments, \'$[*].id \\? (@.type() == "number" && @ == :instrumentId:)\')', { instrumentId: filter.instrumentFilter.instrumentId } diff --git a/apps/backend/src/datasources/postgres/UserDataSource.ts b/apps/backend/src/datasources/postgres/UserDataSource.ts index 57bab767d8..7d342cee1b 100644 --- a/apps/backend/src/datasources/postgres/UserDataSource.ts +++ b/apps/backend/src/datasources/postgres/UserDataSource.ts @@ -13,7 +13,9 @@ import { UserRoleShortCodeMap, } from '../../models/User'; import { AddUserRoleArgs } from '../../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../../resolvers/mutations/CreateUserByEmailInviteMutation'; +import { UpdateRoleArgs } from '../../resolvers/mutations/UpdateRoleMutation'; import { UpdateUserByIdArgs, UpdateUserByOidcSubArgs, @@ -218,7 +220,9 @@ export default class PostgresUserDataSource implements UserDataSource { role.role_id, role.short_code, role.title, - role.description + role.description, + role.permissions, + role.data_access ) ) ); @@ -238,7 +242,9 @@ export default class PostgresUserDataSource implements UserDataSource { role.role_id, role.short_code, role.title, - role.description + role.description, + role.permissions, + role.data_access ) ) ); @@ -821,7 +827,14 @@ export default class PostgresUserDataSource implements UserDataSource { .first() .then( (role: RoleRecord) => - new Role(role.role_id, role.short_code, role.title, role.description) + new Role( + role.role_id, + role.short_code, + role.title, + role.description, + role.permissions, + role.data_access + ) ); } @@ -853,6 +866,95 @@ export default class PostgresUserDataSource implements UserDataSource { }); } + private toPostgresArray(array: string[]): string { + return `{${array.map((item) => `"${item}"`).join(',')}}`; + } + + async createRole(args: CreateRoleArgs): Promise { + const { shortCode, title, description, permissions, dataAccess } = args; + + const postgresPermissions = Array.isArray(permissions) + ? this.toPostgresArray(permissions) + : permissions; + + const postgresDataAccess = Array.isArray(dataAccess) + ? this.toPostgresArray(dataAccess) + : dataAccess; + + const [roleRecord] = await database + .insert({ + short_code: shortCode, + title, + description, + permissions: postgresPermissions, + data_access: postgresDataAccess, + }) + .into('roles') + .returning('*'); + + return new Role( + roleRecord.role_id, + roleRecord.short_code, + roleRecord.title, + roleRecord.description, + roleRecord.permissions, // No need to parse as it's already in array format + roleRecord.data_access // No need to parse as it's already in array format + ); + } + + async updateRole(args: UpdateRoleArgs): Promise { + const { roleID, shortCode, title, description, permissions, dataAccess } = + args; + + const postgresPermissions = Array.isArray(permissions) + ? this.toPostgresArray(permissions) + : permissions; + + const postgresDataAccess = Array.isArray(dataAccess) + ? this.toPostgresArray(dataAccess) + : dataAccess; + + const [roleRecord] = await database + .update({ + title, + description, + permissions: postgresPermissions, + data_access: postgresDataAccess, + }) + .from('roles') + .where('role_id', roleID) + .returning('*'); + + return new Role( + roleRecord.role_id, + roleRecord.short_code, + roleRecord.title, + roleRecord.description, + roleRecord.permissions, // No need to parse as it's already in array format + roleRecord.data_access // No need to parse as it's already in array format + ); + } + + async deleteRole(id: number): Promise { + const [deletedRole] = await database('roles') + .where('role_id', id) + .del() + .returning('*'); + + if (!deletedRole) { + return null; + } + + return new Role( + deletedRole.role_id, + deletedRole.short_code, + deletedRole.title, + deletedRole.description, + deletedRole.permissions, + deletedRole.data_access + ); + } + async getApprovedProposalVisitorsWithInstitution(proposalPk: number): Promise< { user: User; diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index d3abb964b8..348c081efb 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -286,6 +286,8 @@ export interface RoleRecord { readonly short_code: string; readonly title: string; readonly description: string; + readonly permissions: string[]; // Changed from string to string[] + readonly data_access: string[]; // Changed from string to string[] } export interface ReviewRecord { @@ -1207,7 +1209,14 @@ export const createFapReviewerObject = (fapMember: FapReviewerRecord) => { }; export const createRoleObject = (role: RoleRecord) => { - return new Role(role.role_id, role.short_code, role.title, role.description); + return new Role( + role.role_id, + role.short_code, + role.title, + role.description, + role.permissions, + role.data_access + ); }; export const createVisitObject = (visit: VisitRecord) => { diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts index 2c8c429a6e..8f84d8c094 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts @@ -144,9 +144,16 @@ describe('Role tests', () => { const mockGetRoles = jest.spyOn(StfcUserDataSource.prototype, 'getRoles'); mockGetRoles.mockImplementation(() => Promise.resolve([ - new Role(1, Roles.USER, 'User', ''), - new Role(2, Roles.USER_OFFICER, 'User Officer', ''), - new Role(3, Roles.INSTRUMENT_SCIENTIST, 'Instrument Scientist', ''), + new Role(1, Roles.USER, 'User', '', [], []), + new Role(2, Roles.USER_OFFICER, 'User Officer', '', [], []), + new Role( + 3, + Roles.INSTRUMENT_SCIENTIST, + 'Instrument Scientist', + '', + [], + [] + ), ]) ); @@ -169,7 +176,7 @@ describe('Role tests', () => { return expect(roles[0]).toEqual( expect.objectContaining( - new Role(expect.any(Number), Roles.USER, 'User', '') + new Role(expect.any(Number), Roles.USER, 'User', '', [], []) ) ); }); @@ -181,9 +188,16 @@ describe('Role tests', () => { userdataSource.getUserRoles(dummyUserNumber) ).resolves.toEqual( expect.arrayContaining([ - new Role(1, Roles.USER, 'User', ''), - new Role(2, Roles.USER_OFFICER, 'User Officer', ''), - new Role(3, Roles.INSTRUMENT_SCIENTIST, 'Instrument Scientist', ''), + new Role(1, Roles.USER, 'User', '', [], []), + new Role(2, Roles.USER_OFFICER, 'User Officer', '', [], []), + new Role( + 3, + Roles.INSTRUMENT_SCIENTIST, + 'Instrument Scientist', + '', + [], + [] + ), ]) ); }); diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index 300185b29a..cc28ec8c48 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -8,6 +8,7 @@ import { Institution } from '../../models/Institution'; import { Role, Roles } from '../../models/Role'; import { BasicUserDetails, User, UserRole } from '../../models/User'; import { AddUserRoleArgs } from '../../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../../resolvers/mutations/CreateUserByEmailInviteMutation'; import { UpdateUserByIdArgs, @@ -138,6 +139,15 @@ function toEssUser(stfcUser: StfcBasicPersonDetails): User { } export class StfcUserDataSource implements UserDataSource { + createRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + updateRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + deleteRole(id: number): Promise { + throw new Error('Method not implemented.'); + } private static readonly userDetailsCacheMaxElements = 1000; private static readonly userDetailsCacheSecondsToLive = 600; // 10 minutes private static readonly rolesCacheMaxElements = 1000; diff --git a/apps/backend/src/decorators/Authorized.ts b/apps/backend/src/decorators/Authorized.ts index f7403cee5e..c921c1a96a 100644 --- a/apps/backend/src/decorators/Authorized.ts +++ b/apps/backend/src/decorators/Authorized.ts @@ -1,8 +1,11 @@ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; +import { container } from 'tsyringe'; +import { Tokens } from '../config/Tokens'; +import { UserDataSource } from '../datasources/UserDataSource'; import { Rejection, rejection } from '../models/Rejection'; -import { Roles } from '../models/Role'; +import { Role, Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; const Authorized = (roles: Roles[] = []) => { @@ -17,7 +20,6 @@ const Authorized = (roles: Roles[] = []) => { } ) => { const originalMethod = descriptor.value; - descriptor.value = async function (...args) { const [agent] = args; const isMutation = target.constructor.name.includes('Mutation'); @@ -55,6 +57,32 @@ const Authorized = (roles: Roles[] = []) => { return await originalMethod?.apply(this, args); } + const userDataSource = container.resolve( + Tokens.UserDataSource + ); + + const rolesArray: Role[] = await userDataSource.getUserRoles(agent.id); + const userRoles: Record = + rolesArray.reduce( + (acc, role) => { + acc[role.shortCode] = { permissions: role.permissions }; + + return acc; + }, + {} as Record + ); + + //check if user has dynamic role with permissions for this method + if ( + agent.currentRole?.shortCode && + userRoles[agent.currentRole.shortCode]?.permissions.some( + (permission: string) => + permission === `${target.constructor.name}.${name}` + ) + ) { + return await originalMethod?.apply(this, args); + } + const hasAccessRights = roles.some( (role) => role === agent.currentRole?.shortCode ); diff --git a/apps/backend/src/models/Role.ts b/apps/backend/src/models/Role.ts index 409c33a888..cd86b9830b 100644 --- a/apps/backend/src/models/Role.ts +++ b/apps/backend/src/models/Role.ts @@ -3,7 +3,9 @@ export class Role { public id: number, public shortCode: string, public title: string, - public description: string + public description: string, + public permissions: string[], // New field for permissions + public dataAccess: string[] // New field for data access ) {} } @@ -16,4 +18,5 @@ export enum Roles { INSTRUMENT_SCIENTIST = 'instrument_scientist', EXPERIMENT_SAFETY_REVIEWER = 'experiment_safety_reviewer', INTERNAL_REVIEWER = 'internal_reviewer', + DYNAMIC_PROPOSAL_READER = 'dynamic_proposal_reader', } diff --git a/apps/backend/src/mutations/UserMutations.ts b/apps/backend/src/mutations/UserMutations.ts index 6a7a0604d1..4af130ad22 100644 --- a/apps/backend/src/mutations/UserMutations.ts +++ b/apps/backend/src/mutations/UserMutations.ts @@ -30,7 +30,9 @@ import { UserWithRole, } from '../models/User'; import { AddUserRoleArgs } from '../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../resolvers/mutations/CreateUserByEmailInviteMutation'; +import { UpdateRoleArgs } from '../resolvers/mutations/UpdateRoleMutation'; import { UpdateUserRolesArgs, UpdateUserByOidcSubArgs, @@ -512,6 +514,45 @@ export default class UserMutations { return this.dataSource.setUserNotPlaceholder(id); } + @Authorized([Roles.USER_OFFICER]) + async createRole( + _: UserWithRole | null, + args: CreateRoleArgs + ): Promise { + try { + const role = await this.dataSource.createRole(args); + + return role; + } catch (err) { + return rejection('Could not create role', { args }, err); + } + } + + @Authorized([Roles.USER_OFFICER]) + async updateRole( + _: UserWithRole | null, + args: UpdateRoleArgs + ): Promise { + try { + const role = await this.dataSource.updateRole(args); + + return role; + } catch (err) { + return rejection('Could not update role', { args }, err); + } + } + + @Authorized([Roles.USER_OFFICER]) + async deleteRole(_: UserWithRole | null, id: number): Promise { + try { + const role = await this.dataSource.deleteRole(id); + + return role; + } catch (err) { + return null; + } + } + @Authorized([Roles.USER_OFFICER]) async upsertUserByOidcSub( agent: UserWithRole | null, diff --git a/apps/backend/src/queries/ProposalQueries.ts b/apps/backend/src/queries/ProposalQueries.ts index 909d2797b4..213643020d 100644 --- a/apps/backend/src/queries/ProposalQueries.ts +++ b/apps/backend/src/queries/ProposalQueries.ts @@ -8,6 +8,7 @@ import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { ProposalInternalCommentsDataSource } from '../datasources/ProposalInternalCommentsDataSource'; import { ReviewDataSource } from '../datasources/ReviewDataSource'; +import { UserDataSource } from '../datasources/UserDataSource'; import { Authorized } from '../decorators'; import { Proposal } from '../models/Proposal'; import { rejection } from '../models/Rejection'; @@ -20,6 +21,7 @@ import { omit } from '../utils/helperFunctions'; export default class ProposalQueries { constructor( @inject(Tokens.ProposalDataSource) public dataSource: ProposalDataSource, + @inject(Tokens.UserDataSource) public userDataSource: UserDataSource, @inject(Tokens.ExperimentDataSource) public experimentDataSource: ExperimentDataSource, @inject(Tokens.ReviewDataSource) public reviewDataSource: ReviewDataSource, @@ -79,7 +81,11 @@ export default class ProposalQueries { return this.proposalAuth.hasReadRights(agent, proposal); } - @Authorized([Roles.USER_OFFICER, Roles.INSTRUMENT_SCIENTIST]) + @Authorized([ + Roles.USER_OFFICER, + Roles.INSTRUMENT_SCIENTIST, + Roles.DYNAMIC_PROPOSAL_READER, + ]) async getAll( agent: UserWithRole | null, filter?: ProposalsFilter, @@ -89,7 +95,7 @@ export default class ProposalQueries { return this.dataSource.getProposals(filter, first, offset); } - @Authorized([Roles.USER_OFFICER]) + @Authorized([Roles.USER_OFFICER, Roles.DYNAMIC_PROPOSAL_READER]) async getAllView( agent: UserWithRole | null, filter?: ProposalsFilter, @@ -99,16 +105,24 @@ export default class ProposalQueries { sortDirection?: string, searchText?: string ) { + let instrumentFilters: string[] = []; + if (agent && this.userAuth.isDynamicProposalReader(agent)) { + instrumentFilters = agent.currentRole?.dataAccess || []; + } + try { // leave await here because getProposalsFromView might thrown an exception // and we want to handle it here + return await this.dataSource.getProposalsFromView( filter, first, offset, sortField, sortDirection, - searchText + searchText, + undefined, + instrumentFilters ); } catch (e) { logger.logException('Method getAllView failed', e as Error, { filter }); diff --git a/apps/backend/src/resolvers/mutations/CreateRoleMutation.ts b/apps/backend/src/resolvers/mutations/CreateRoleMutation.ts new file mode 100644 index 0000000000..539fd1a83e --- /dev/null +++ b/apps/backend/src/resolvers/mutations/CreateRoleMutation.ts @@ -0,0 +1,57 @@ +import { Field, InputType, ObjectType } from 'type-graphql'; +import { Resolver, Mutation, Arg, Ctx } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Role } from '../types/Role'; // Adjust the path as necessary + +@InputType() +export class CreateRoleArgs { + @Field() + shortCode: string; + + @Field() + title: string; + + @Field() + description: string; + + @Field(() => [String]) + permissions: string[]; + + @Field(() => [String]) + dataAccess: string[]; +} + +@ObjectType() +export class CreateRoleResponse { + @Field() + success: boolean; + + @Field(() => Role, { nullable: true }) + role?: Role; +} + +@Resolver() +export class CreateRoleMutation { + @Mutation(() => CreateRoleResponse) + async createRole( + @Arg('args') args: CreateRoleArgs, + @Ctx() context: ResolverContext + ): Promise { + const role = (await context.mutations.user.createRole( + context.user, + args + )) as Role | Error; + if (role instanceof Error) { + return { + success: false, + role: undefined, + }; + } + + return { + success: true, + role, + }; + } +} diff --git a/apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts b/apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts new file mode 100644 index 0000000000..e3361d2ce0 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts @@ -0,0 +1,15 @@ +import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Role } from '../types/Role'; + +@Resolver() +export class DeleteRoleMutation { + @Mutation(() => Role) + deleteRole( + @Arg('roleId', () => Int) roleId: number, + @Ctx() context: ResolverContext + ) { + return context.mutations.user.deleteRole(context.user, roleId); + } +} diff --git a/apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts b/apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts new file mode 100644 index 0000000000..096057122e --- /dev/null +++ b/apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts @@ -0,0 +1,60 @@ +import { Field, InputType, Int, ObjectType } from 'type-graphql'; +import { Resolver, Mutation, Arg, Ctx } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Role } from '../types/Role'; // Adjust the path as necessary + +@InputType() +export class UpdateRoleArgs { + @Field(() => Int) + public roleID: number; + + @Field() + shortCode: string; + + @Field() + title: string; + + @Field() + description: string; + + @Field(() => [String]) + permissions: string[]; + + @Field(() => [String]) + dataAccess: string[]; +} + +@ObjectType() +export class UpdateRoleResponse { + @Field() + success: boolean; + + @Field(() => Role, { nullable: true }) + role?: Role; +} + +@Resolver() +export class UpdateRoleMutation { + @Mutation(() => UpdateRoleResponse) + async updateRole( + @Arg('args') args: UpdateRoleArgs, + @Ctx() context: ResolverContext + ): Promise { + const role = (await context.mutations.user.updateRole( + context.user, + args + )) as Role | Error; + if (role instanceof Error) { + return { + success: false, + role: undefined, + }; + } + + return { + success: true, + role, + }; + } +} diff --git a/apps/backend/src/resolvers/types/Role.ts b/apps/backend/src/resolvers/types/Role.ts index 09e9366e54..7c32802a2f 100644 --- a/apps/backend/src/resolvers/types/Role.ts +++ b/apps/backend/src/resolvers/types/Role.ts @@ -1,8 +1,10 @@ import 'reflect-metadata'; import { Field, ObjectType, Int } from 'type-graphql'; +import { Role as RoleOrigin } from '../../models/Role'; + @ObjectType() -export class Role { +export class Role implements Partial { @Field(() => Int) public id: number; @@ -15,11 +17,19 @@ export class Role { @Field() public description: string; + @Field(() => [String]) + public dataAccess: string[]; + + @Field(() => [String]) + public permissions: string[]; + constructor(initObj: { id: number; shortCode: string; title: string; description: string; + dataAccess: string[]; + permissions: string[]; }) { Object.assign(this, initObj); } diff --git a/apps/frontend/src/components/AppRoutes.tsx b/apps/frontend/src/components/AppRoutes.tsx index 72efde899c..77144d5742 100644 --- a/apps/frontend/src/components/AppRoutes.tsx +++ b/apps/frontend/src/components/AppRoutes.tsx @@ -9,6 +9,7 @@ import { FeatureId, UserRole, WorkflowType } from 'generated/sdk'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; import { useTechniqueProposalAccess } from 'hooks/common/useTechniqueProposalAccess'; +import RoleManagement from './admin/RoleManagement'; import ChangeRole from './common/ChangeRole'; import OverviewPage from './pages/OverviewPage'; import ProposalPage from './proposal/ProposalPage'; @@ -226,6 +227,14 @@ const AppRoutes = () => { element={} />} /> )} + + } /> + } + /> + } />} diff --git a/apps/frontend/src/components/AppToolbar/RoleSelection.tsx b/apps/frontend/src/components/AppToolbar/RoleSelection.tsx index cdb597aa45..a9dc059fc6 100644 --- a/apps/frontend/src/components/AppToolbar/RoleSelection.tsx +++ b/apps/frontend/src/components/AppToolbar/RoleSelection.tsx @@ -1,11 +1,11 @@ import MaterialTable, { Column } from '@material-table/core'; import Button from '@mui/material/Button'; -import React, { useContext, useState, useEffect } from 'react'; -import { Navigate, useNavigate } from 'react-router-dom'; +import React, { useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { UserContext } from 'context/UserContextProvider'; import { Role } from 'generated/sdk'; -import { getUniqueArrayBy } from 'utils/helperFunctions'; +import { useMeData } from 'hooks/user/useMeData'; import { tableIcons } from 'utils/materialIcons'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import { FunctionType } from 'utils/utilTypes'; @@ -30,36 +30,10 @@ const RoleSelection = ({ onClose }: { onClose: FunctionType }) => { const [loading, setLoading] = useState(false); const { api } = useDataApiWithFeedback(); const navigate = useNavigate(); - const [roles, setRoles] = useState([]); + const { meData, loading: meDataLoading } = useMeData(); - useEffect(() => { - let unmounted = false; - - const getUserInformation = async () => { - setLoading(true); - const data = await api().getMyRoles(); - if (unmounted) { - return; - } - - if (data?.me) { - /** NOTE: We have roles that are duplicated in role_id and user_id but different only in fap_id - * which is used to determine if the user with that role is member of a Fap. - */ - setRoles(getUniqueArrayBy(data.me?.roles, 'id')); - setLoading(false); - } - }; - getUserInformation(); - - return () => { - unmounted = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!roles) { - return ; + if (!meData) { + return

Loading...

; } const selectUserRole = async (role: Role) => { @@ -98,7 +72,7 @@ const RoleSelection = ({ onClose }: { onClose: FunctionType }) => { ); - const rolesWithRoleAction = roles.map((role) => ({ + const rolesWithRoleAction = meData.roles.map((role) => ({ ...role, roleAction: RoleAction(role), })); @@ -110,7 +84,7 @@ const RoleSelection = ({ onClose }: { onClose: FunctionType }) => { title="User roles" columns={columns} data={rolesWithRoleAction} - isLoading={loading} + isLoading={loading || meDataLoading} options={{ search: false, paging: false, diff --git a/apps/frontend/src/components/admin/RoleManagement.tsx b/apps/frontend/src/components/admin/RoleManagement.tsx new file mode 100644 index 0000000000..52c1be4808 --- /dev/null +++ b/apps/frontend/src/components/admin/RoleManagement.tsx @@ -0,0 +1,153 @@ +import { Typography } from '@mui/material'; +import React, { useState, useEffect } from 'react'; + +import SuperMaterialTable from 'components/common/SuperMaterialTable'; +import { Role } from 'generated/sdk'; +import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import { FunctionType } from 'utils/utilTypes'; + +import RoleModal from './RoleModal'; + +const RoleManagement: React.FC = () => { + const [roles, setRoles] = useState< + { + id: number; + shortCode: string; + title: string; + description: string; + dataAccess: string[]; + permissions: string[]; + }[] + >([]); + + const { api } = useDataApiWithFeedback(); + + useEffect(() => { + const fetchRoles = async () => { + try { + const response = await api().getRoles(); + setRoles( + (response.roles || []).map((role) => ({ + ...role, + dataAccess: role.dataAccess || undefined, + permissions: role.permissions || undefined, + })) + ); + } catch (error) { + console.error('Error fetching roles:', error); + } + }; + + fetchRoles(); + }, [api]); + + const handleRoleSubmit = async () => { + // Refresh roles after create/update + api() + .getRoles() + .then((response) => + setRoles( + (response.roles || []).map((role) => ({ + ...role, + dataAccess: role.dataAccess || undefined, + permissions: role.permissions || undefined, + })) + ) + ); + }; + + interface RoleRow { + id: number; + shortCode: string; + title: string; + description: string; + dataAccess: string[]; + permissions: string[]; + } + + const columns: Array<{ + title: string; + field: keyof RoleRow; + render?: (rowData: RoleRow) => React.ReactNode; + }> = [ + { title: 'Short Code', field: 'shortCode' }, + { title: 'Title', field: 'title' }, + { title: 'Description', field: 'description' }, + { + title: 'Data Access', + field: 'dataAccess', + render: (rowData) => rowData.dataAccess?.join(', ') || '-', + }, + { + title: 'Permissions', + field: 'permissions', + render: (rowData) => rowData.permissions?.join(', ') || '-', + }, + ]; + const deleteRole = async (id: number | string) => { + try { + await api({ + toastSuccessMessage: 'Call deleted successfully', + }).deleteRole({ + id: id as number, + }); + const newObjectsArray = roles.filter( + (objectItem) => objectItem.id !== id + ); + setRoles(newObjectsArray); + + return true; + } catch (error) { + return false; + } + }; + + const createModal = ( + onUpdate: FunctionType, + onCreate: FunctionType, + editRole: Role | null + ) => ( + (!!editRole ? onUpdate(null) : onCreate(null))} + role={editRole} // Pass `null` for create mode, or the role object for update mode + onSubmit={() => { + handleRoleSubmit(); + !!editRole ? onUpdate(null) : onCreate(null); + }} + /> + ); + + return ( + + + Role Management + + Existing Roles + + } + columns={columns} + data={roles} + isLoading={false} + options={{ + search: true, + paging: true, + }} + /> + + + ); +}; + +export default RoleManagement; diff --git a/apps/frontend/src/components/admin/RoleModal.tsx b/apps/frontend/src/components/admin/RoleModal.tsx new file mode 100644 index 0000000000..2df1261f74 --- /dev/null +++ b/apps/frontend/src/components/admin/RoleModal.tsx @@ -0,0 +1,195 @@ +import { + Dialog, + DialogContent, + Typography, + TextField, + Button, + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + Grid, +} from '@mui/material'; +import React, { useState, useEffect } from 'react'; + +import { useInstrumentsData } from 'hooks/instrument/useInstrumentsData'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; + +interface RoleModalProps { + open: boolean; + onClose: () => void; + role: { + id: number; + shortCode: string; + title: string; + description: string; + dataAccess?: string[]; + permissions?: string[]; + } | null; + onSubmit: () => void; +} + +const RoleModal: React.FC = ({ + open, + onClose, + role, + onSubmit, +}) => { + const isEditMode = !!role; + const [shortCode, setShortCode] = useState(''); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [dataAccess, setDataAccess] = useState([]); + const [permissions, setPermissions] = useState([]); + const { api } = useDataApiWithFeedback(); + const { instruments, loadingInstruments } = useInstrumentsData(); + + useEffect(() => { + if (isEditMode && role) { + setShortCode(role.shortCode || ''); + setTitle(role.title || ''); + setDescription(role.description || ''); + setDataAccess(role.dataAccess || []); + setPermissions(role.permissions || []); + } else { + setShortCode(''); + setTitle(''); + setDescription(''); + setDataAccess([]); + setPermissions([]); + } + }, [isEditMode, role]); + + const handleSubmit = async () => { + const apiCall = isEditMode + ? api({ + toastSuccessMessage: 'Role updated successfully', + }).updateRole({ + args: { + roleID: role.id, + shortCode, + title, + description, + dataAccess, + permissions, + }, + }) + : api({ + toastSuccessMessage: 'Role created successfully', + }).createRole({ + args: { + shortCode, + title, + description, + dataAccess, + permissions, + }, + }); + + try { + await apiCall; + onSubmit(); + onClose(); + } catch (error) { + console.error(error); + alert( + `An error occurred while ${ + isEditMode ? 'updating' : 'creating' + } the role.` + ); + } + }; + + const renderDataAccess = () => { + if (loadingInstruments) { + return Loading data access options...; + } + + return ( + + Instruments + + + {instruments.map((instrument, index) => ( + + { + if (e.target.checked) { + setDataAccess([...dataAccess, instrument.name]); + } else { + setDataAccess( + dataAccess.filter( + (access) => access !== instrument.name + ) + ); + } + }} + /> + } + label={instrument.name} + /> + + ))} + + + + ); + }; + + return ( + + + + {isEditMode ? 'Update Role' : 'Create New Role'} + + setShortCode(e.target.value)} + fullWidth + margin="normal" + disabled={isEditMode} + /> + setTitle(e.target.value)} + fullWidth + margin="normal" + /> + setDescription(e.target.value)} + fullWidth + margin="normal" + /> + {/* Data Access Section */} +
+ Data Access + {renderDataAccess()} +
+ +
+
+ ); +}; + +export default RoleModal; diff --git a/apps/frontend/src/components/menu/MenuItems.tsx b/apps/frontend/src/components/menu/MenuItems.tsx index e60c5e4141..1f867b4b50 100644 --- a/apps/frontend/src/components/menu/MenuItems.tsx +++ b/apps/frontend/src/components/menu/MenuItems.tsx @@ -326,6 +326,17 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { ); + const dynamicReadUser = ( +
+ + + + + + +
+ ); + switch (currentRole) { case UserRole.USER: return user; @@ -342,7 +353,7 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { case UserRole.INTERNAL_REVIEWER: return internalReviewer; default: - return null; + return dynamicReadUser; } }; diff --git a/apps/frontend/src/components/menu/SettingsMenuListItem.tsx b/apps/frontend/src/components/menu/SettingsMenuListItem.tsx index e323a3cc23..20f3e8d8b6 100644 --- a/apps/frontend/src/components/menu/SettingsMenuListItem.tsx +++ b/apps/frontend/src/components/menu/SettingsMenuListItem.tsx @@ -3,6 +3,7 @@ import DisplaySettingsIcon from '@mui/icons-material/DisplaySettings'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import FunctionsIcon from '@mui/icons-material/Functions'; +import People from '@mui/icons-material/People'; import Settings from '@mui/icons-material/Settings'; import SettingsApplications from '@mui/icons-material/SettingsApplications'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; @@ -27,6 +28,7 @@ const menuMap = { ApiAccessTokens: '/ApiAccessTokens', Features: '/Features', Settings: '/Settings', + RoleManagement: '/admin/roles', SampleEsiTemplates: '/SampleEsiTemplates', }; @@ -126,6 +128,14 @@ const SettingsMenuListItem = () => { + + + + + + + + diff --git a/apps/frontend/src/components/pages/OverviewPage.tsx b/apps/frontend/src/components/pages/OverviewPage.tsx index b1d42c5946..5fd25db7c0 100644 --- a/apps/frontend/src/components/pages/OverviewPage.tsx +++ b/apps/frontend/src/components/pages/OverviewPage.tsx @@ -2,6 +2,7 @@ import parse from 'html-react-parser'; import React, { useContext } from 'react'; import ProposalTableInstrumentScientist from 'components/proposal/ProposalTableInstrumentScientist'; +import ProposalTableOfficer from 'components/proposal/ProposalTableOfficer'; import ProposalTableUser from 'components/proposal/ProposalTableUser'; import UserUpcomingExperimentsTable from 'components/proposalBooking/UserUpcomingExperimentsTable'; import ProposalTableReviewer from 'components/review/ProposalTableReviewer'; @@ -55,26 +56,42 @@ export default function OverviewPage(props: { userRole: UserRole }) { ); break; - default: + case UserRole.EXPERIMENT_SAFETY_REVIEWER: + case UserRole.FAP_CHAIR: + case UserRole.FAP_REVIEWER: + case UserRole.FAP_SECRETARY: roleBasedOverView = ( ); break; + default: + roleBasedOverView = ( + + + + ); + break; } return ( - {props.userRole !== UserRole.INSTRUMENT_SCIENTIST && ( - - {loadingContent ? ( -
Loading...
- ) : ( - parse(pageContent as string) - )} -
- )} + {props.userRole !== UserRole.INSTRUMENT_SCIENTIST && + Object.values(UserRole).includes(props.userRole) && ( + + {loadingContent ? ( +
Loading...
+ ) : ( + parse(pageContent as string) + )} +
+ )} {roleBasedOverView}
); diff --git a/apps/frontend/src/graphql/admin/createRole.graphql b/apps/frontend/src/graphql/admin/createRole.graphql new file mode 100644 index 0000000000..d613f6796e --- /dev/null +++ b/apps/frontend/src/graphql/admin/createRole.graphql @@ -0,0 +1,5 @@ +mutation createRole($args: CreateRoleArgs!) { + createRole(args: $args) { + success + } +} diff --git a/apps/frontend/src/graphql/admin/deleteRole.graphql b/apps/frontend/src/graphql/admin/deleteRole.graphql new file mode 100644 index 0000000000..4dc8090f7f --- /dev/null +++ b/apps/frontend/src/graphql/admin/deleteRole.graphql @@ -0,0 +1,5 @@ +mutation deleteRole($id: Int!) { + deleteRole(roleId: $id) { + id + } +} diff --git a/apps/frontend/src/graphql/admin/updateRole.graphql b/apps/frontend/src/graphql/admin/updateRole.graphql new file mode 100644 index 0000000000..2c6f357f49 --- /dev/null +++ b/apps/frontend/src/graphql/admin/updateRole.graphql @@ -0,0 +1,5 @@ +mutation updateRole($args: UpdateRoleArgs!) { + updateRole(args: $args) { + success + } +} diff --git a/apps/frontend/src/graphql/user/getMyRoles.graphql b/apps/frontend/src/graphql/user/getMyRoles.graphql index 10ec82d36d..66e085a71f 100644 --- a/apps/frontend/src/graphql/user/getMyRoles.graphql +++ b/apps/frontend/src/graphql/user/getMyRoles.graphql @@ -7,6 +7,8 @@ query getMyRoles { shortCode title description + dataAccess + permissions } } } diff --git a/apps/frontend/src/graphql/user/getRoles.graphql b/apps/frontend/src/graphql/user/getRoles.graphql index a91839aeb7..b84ef4fc65 100644 --- a/apps/frontend/src/graphql/user/getRoles.graphql +++ b/apps/frontend/src/graphql/user/getRoles.graphql @@ -4,5 +4,7 @@ query getRoles { shortCode title description + dataAccess + permissions } } diff --git a/apps/frontend/src/graphql/user/getUserWithRoles.graphql b/apps/frontend/src/graphql/user/getUserWithRoles.graphql index d5fa26e0d3..f8def29f8b 100644 --- a/apps/frontend/src/graphql/user/getUserWithRoles.graphql +++ b/apps/frontend/src/graphql/user/getUserWithRoles.graphql @@ -7,6 +7,8 @@ query getUserWithRoles($userId: Int!) { shortCode title description + dataAccess + permissions } } } diff --git a/apps/frontend/src/hooks/user/useMeData.ts b/apps/frontend/src/hooks/user/useMeData.ts new file mode 100644 index 0000000000..749298025a --- /dev/null +++ b/apps/frontend/src/hooks/user/useMeData.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +import { GetMyRolesQuery } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useMeData() { + const api = useDataApi(); + const [meData, setMeData] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let unmounted = false; + + setLoading(true); + api() + .getMyRoles() + .then((data) => { + if (unmounted) { + return; + } + + setMeData(data.me); + setLoading(false); + }); + + return () => { + unmounted = true; + }; + }, [api]); + + return { loading, meData, setMeData } as const; +}