diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 4c80664336..78a3c1f712 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -496,6 +496,7 @@ jobs: FBS_EMAIL: FacilitiesBusinessSystem@stfc.ac.uk PROFILE_PAGE_LINK: http://localhost:9003/auth/ManageDetails.aspx EXTERNAL_AUTH_HOMEPAGE_URL: http://localhost:9003/auth/Menu.aspx + TRANSLATION_PATH: /config/locales/ run: | REPO_DIR_NAME=$(basename $GITHUB_WORKSPACE) diff --git a/apps/backend/i18next.ts b/apps/backend/i18next.ts new file mode 100644 index 0000000000..b15c28aba4 --- /dev/null +++ b/apps/backend/i18next.ts @@ -0,0 +1,20 @@ +import path from 'path'; + +import i18next from 'i18next'; +import Backend from 'i18next-fs-backend'; + +i18next.use(Backend).init({ + initAsync: false, + lng: 'override', + fallbackLng: 'override', + backend: { + loadPath: path.resolve( + process.env.TRANSLATION_PATH || '', + '{{lng}}/translation.json' + ), + }, +}); + +i18next.languages = ['override']; + +export default i18next; diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index a78ed249a1..946d2de7eb 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -46,6 +46,8 @@ "graphql": "^16.13.2", "graphql-request": "^7.4.0", "graphql-tag": "^2.12.6", + "i18next": "^26.3.4", + "i18next-fs-backend": "^2.6.6", "jsonpath": "^1.2.1", "jsonwebtoken": "^9.0.1", "knex": "^3.1.0", @@ -78,6 +80,7 @@ "@types/email-templates": "^8.0.4", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", + "@types/i18n": "^0.13.12", "@types/jest": "^29.5.6", "@types/jsonpath": "^0.2.0", "@types/jsonwebtoken": "^9.0.2", @@ -7178,6 +7181,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/i18n": { + "version": "0.13.12", + "resolved": "https://registry.npmjs.org/@types/i18n/-/i18n-0.13.12.tgz", + "integrity": "sha512-iAd2QjKh+0ToBXocmCS3m38GskiaGzmSV1MTQz2GaOraqSqBiLf46J7u3EGINl+st+Uk4lO3OL7QyIjTJlrWIg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.5", "dev": true, @@ -11485,6 +11495,40 @@ "version": "2.1.3", "license": "MIT" }, + "node_modules/i18next": { + "version": "26.3.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz", + "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-fs-backend": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.6.tgz", + "integrity": "sha512-mYGu6Nt8RIp3X/U8Y+Gej1wo5xmYWmGKLqBGMCC2OCAou5rW5epeHgHmVcw20mJs9Z9+DAPHIxQPNCgFyPRMeg==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -16810,7 +16854,7 @@ }, "node_modules/typescript": { "version": "5.4.5", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/apps/backend/package.json b/apps/backend/package.json index 8f131bfc3f..ee5f138e68 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -69,6 +69,8 @@ "graphql": "^16.13.2", "graphql-request": "^7.4.0", "graphql-tag": "^2.12.6", + "i18next": "^26.3.4", + "i18next-fs-backend": "^2.6.6", "jsonpath": "^1.2.1", "jsonwebtoken": "^9.0.1", "knex": "^3.1.0", @@ -101,6 +103,7 @@ "@types/email-templates": "^8.0.4", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", + "@types/i18n": "^0.13.12", "@types/jest": "^29.5.6", "@types/jsonpath": "^0.2.0", "@types/jsonwebtoken": "^9.0.2", diff --git a/apps/backend/src/auth/UserAuthorization.ts b/apps/backend/src/auth/UserAuthorization.ts index 4a8f55e703..3983a4f28e 100644 --- a/apps/backend/src/auth/UserAuthorization.ts +++ b/apps/backend/src/auth/UserAuthorization.ts @@ -7,6 +7,7 @@ import { Tokens } from '../config/Tokens'; import { FapDataSource } from '../datasources/FapDataSource'; import { InternalReviewDataSource } from '../datasources/InternalReviewDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; +import { RoleDataSource } from '../datasources/RoleDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; import { Institution } from '../models/Institution'; @@ -36,6 +37,10 @@ export abstract class UserAuthorization { Tokens.AdminDataSource ); + protected roleDataSource: RoleDataSource = container.resolve( + Tokens.RoleDataSource + ); + protected getUniqueId(user: ValidUserInfo) { return user.sub; } @@ -197,6 +202,12 @@ export abstract class UserAuthorization { return readableUsers.includes(id); } + async getCurrentRoleTags(agent: UserWithRole | null) { + return agent?.currentRole?.id == null + ? [] + : this.roleDataSource.getTagsByRoleId(agent.currentRole.id); + } + abstract externalTokenLogin( token: string, redirectUri: string, diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts index 9bbb77a4ca..5f76383a94 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts @@ -1,6 +1,7 @@ import { StfcUserDataSource } from './StfcUserDataSource'; import { Roles, createRole } from '../../models/Role'; import { dummyUser } from '../mockups/UserDataSource'; +import RoleDataSource from '../postgres/RoleDataSource'; import PostgresUserDataSource from '../postgres/UserDataSource'; jest.mock('../postgres/UserDataSource.ts'); @@ -164,6 +165,12 @@ describe('Role tests', () => { ); mockGetUserRoles.mockImplementation(() => Promise.resolve([])); + const mockGetTagsByRoleId = jest.spyOn( + RoleDataSource.prototype, + 'getTagsByRoleId' + ); + mockGetTagsByRoleId.mockImplementation(() => Promise.resolve([])); + const mockEnsureDummyUserExists = jest.spyOn( StfcUserDataSource.prototype, 'ensureDummyUserExists' @@ -187,6 +194,7 @@ describe('Role tests', () => { title: 'User', description: '', isRootRole: true, + tags: [], }) ); }); @@ -198,16 +206,25 @@ describe('Role tests', () => { userdataSource.getUserRoles(dummyUserNumber) ).resolves.toEqual( expect.arrayContaining([ - createRole(1, Roles.USER, 'User', '', { note: '' }, true), - createRole(2, Roles.USER_OFFICER, 'User Officer', '', {}, true), - createRole( - 3, - Roles.INSTRUMENT_SCIENTIST, - 'Instrument Scientist', - '', - {}, - true - ), + { + ...createRole(1, Roles.USER, 'User', '', { note: '' }, true), + tags: [], + }, + { + ...createRole(2, Roles.USER_OFFICER, 'User Officer', '', {}, true), + tags: [], + }, + { + ...createRole( + 3, + Roles.INSTRUMENT_SCIENTIST, + 'Instrument Scientist', + '', + {}, + true + ), + tags: [], + }, ]) ); }); diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index 1cbb3cc100..3402bd5b27 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -1,5 +1,6 @@ import { logger } from '@user-office-software/duo-logger'; +import { createUOWSClient } from './UOWSClient'; import { BasicPersonDetailsDTO } from '../../../generated/models/BasicPersonDetailsDTO'; import { PermissionUserGroupDTO } from '../../../generated/models/PermissionUserGroupDTO'; import { RoleDTO } from '../../../generated/models/RoleDTO'; @@ -12,11 +13,12 @@ import { UpdateUserByIdArgs } from '../../resolvers/mutations/UpdateUserMutation import { UsersArgs } from '../../resolvers/queries/UsersQuery'; import { Cache } from '../../utils/Cache'; import { PaginationSortDirection } from '../../utils/pagination'; +import RoleDataSource from '../postgres/RoleDataSource'; import PostgresUserDataSource from '../postgres/UserDataSource'; import { UserDataSource } from '../UserDataSource'; -import { createUOWSClient } from './UOWSClient'; const postgresUserDataSource = new PostgresUserDataSource(); +const roleDataSource = new RoleDataSource(); const UOWSClient = createUOWSClient(); @@ -454,6 +456,7 @@ export class StfcUserDataSource implements UserDataSource { return stfcRawRolesRequest!; } + async getUserRoles(id: number): Promise { const cachedRoles = this.uopRolesCache.get(String(id)); if (cachedRoles) { @@ -477,6 +480,8 @@ export class StfcUserDataSource implements UserDataSource { return []; } + userRole.tags = await roleDataSource.getTagsByRoleId(userRole.id); + if (!stfcRoles || stfcRoles.length == 0) { return [userRole]; } @@ -490,6 +495,12 @@ export class StfcUserDataSource implements UserDataSource { const uniqueRoles: Role[] = [...new Set(combinedUserRoles)]; + for (let i = 0; i < uniqueRoles.length; i++) { + uniqueRoles[i].tags = await roleDataSource.getTagsByRoleId( + uniqueRoles[i].id + ); + } + uniqueRoles.sort((a, b) => a.id - b.id); const userRoles = [userRole, ...uniqueRoles]; diff --git a/apps/backend/src/middlewares/factory/xlsx.ts b/apps/backend/src/middlewares/factory/xlsx.ts index 64c9861f89..da38baf3c5 100644 --- a/apps/backend/src/middlewares/factory/xlsx.ts +++ b/apps/backend/src/middlewares/factory/xlsx.ts @@ -1,6 +1,7 @@ import express from 'express'; import { container } from 'tsyringe'; +import i18next from '../../../i18next'; import { UserAuthorization } from '../../auth/UserAuthorization'; import { Tokens } from '../../config/Tokens'; import callFactoryService, { @@ -165,13 +166,26 @@ router.get(`/${XLSXType.CALL_FAP}/:call_id`, async (req, res, next) => { }); router.get(`/${XLSXType.TECHNIQUE}/:proposal_pks`, async (req, res, next) => { + const userAuthorization = container.resolve( + Tokens.UserAuthorization + ); + + const userWithRole = { + ...res.locals.agent, + }; + + const roleTags = await userAuthorization.getCurrentRoleTags(userWithRole); + const translationForFirstRoleTag = !roleTags.length + ? '' + : roleTags[0].name + '.'; + const techniqueProposalDataColumns = [ 'Proposal ID', 'Title', 'Principal Investigator', 'PI Email', 'Date Submitted', - 'Technique', + i18next.t(`${translationForFirstRoleTag}Technique`), 'Instrument', 'Status', ]; @@ -181,19 +195,11 @@ router.get(`/${XLSXType.TECHNIQUE}/:proposal_pks`, async (req, res, next) => { throw new Error('Not authorized'); } - const userWithRole = { - ...res.locals.agent, - }; - const proposalPks: number[] = req.params.proposal_pks .split(',') .map((n: string) => parseInt(n)) .filter((id: number) => !isNaN(id)); - const userAuthorization = container.resolve( - Tokens.UserAuthorization - ); - if ( !userAuthorization.isUserOfficer(userWithRole) && !userAuthorization.isInstrumentScientist(userWithRole) diff --git a/apps/backend/src/models/Role.ts b/apps/backend/src/models/Role.ts index 651a693800..409d1589f0 100644 --- a/apps/backend/src/models/Role.ts +++ b/apps/backend/src/models/Role.ts @@ -1,3 +1,5 @@ +import { Tag } from './Tag'; + export enum Roles { USER = 'user', USER_OFFICER = 'user_officer', @@ -23,6 +25,7 @@ type RoleBase = { title: string; description: string; isRootRole: boolean; + tags?: Tag[]; }; export type Role = diff --git a/apps/frontend/src/components/common/proposalFilters/TechniqueFilter.tsx b/apps/frontend/src/components/common/proposalFilters/TechniqueFilter.tsx index 94cfecfa9d..74071207bb 100644 --- a/apps/frontend/src/components/common/proposalFilters/TechniqueFilter.tsx +++ b/apps/frontend/src/components/common/proposalFilters/TechniqueFilter.tsx @@ -5,10 +5,11 @@ import InputLabel from '@mui/material/InputLabel'; import ListSubheader from '@mui/material/ListSubheader'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; -import React, { Dispatch } from 'react'; +import React, { useContext, Dispatch } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; +import { UserContext } from 'context/UserContextProvider'; import { TechniqueMinimalFragment, TechniqueFilterInput } from 'generated/sdk'; export enum TechniqueFilterEnum { @@ -35,6 +36,7 @@ const TechniqueFilter = ({ shouldShowMultiple, showMultiTechniqueProposals, }: TechniqueFilterProps) => { + const { roles, currentRoleId } = useContext(UserContext); const [, setSearchParams] = useSearchParams(); const { t } = useTranslation(); @@ -42,11 +44,16 @@ const TechniqueFilter = ({ return null; } + const currentRoleTags = roles.find((r) => currentRoleId === r.id)!.tags; + const currentRoleFirstTagName = currentRoleTags?.length + ? currentRoleTags[0].name + : ''; + return ( <> - {t('Technique')} + {t(`${currentRoleFirstTagName}.Technique`)} {isLoading ? ( Loading... diff --git a/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx b/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx index 00ad4f2abc..843b1b740d 100644 --- a/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx +++ b/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx @@ -74,7 +74,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { tableRef.current?.onQueryChange({}); }, []); const [searchParams, setSearchParams] = useSearchParams({}); - const { currentRole } = useContext(UserContext); + const { currentRole, roles, currentRoleId } = useContext(UserContext); const [tableData, setTableData] = useState([]); const { @@ -190,7 +190,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const excludedStatusIds = proposalStatuses .filter((status) => !techPropStatusCodes.includes(status.id as StatusCode)) .map((status) => status.id); - console.log({ proposalStatusId }); + const [proposalFilter, setProposalFilter] = useState({ callId, instrumentFilter: { @@ -214,6 +214,11 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { excludeProposalStatusIds: excludedStatusIds, }); + const currentRoleTags = roles.find((r) => currentRoleId === r.id)!.tags; + const currentRoleFirstTagName = currentRoleTags?.length + ? currentRoleTags[0].name + : ''; + const lastProcessedCallId = useRef(null); useEffect(() => { if (callId !== lastProcessedCallId.current) { @@ -694,7 +699,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const techniquesColumns = () => [ { - title: 'Technique', + title: i18n.t(`${currentRoleFirstTagName}.Technique`), field: 'technique.name', sorting: false, render: (rowData: ProposalViewData) => diff --git a/package-lock.json b/package-lock.json index 79aa12258a..cb339dfc71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "ISC", "dependencies": { "concurrently": "^10.0.0", + "i18next-http-backend": "^4.0.0", "wait-on": "^9.0.1" }, "devDependencies": { @@ -485,6 +486,15 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next-http-backend": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz", + "integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/joi": { "version": "18.2.1", "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", @@ -1260,6 +1270,11 @@ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true }, + "i18next-http-backend": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz", + "integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==" + }, "joi": { "version": "18.2.1", "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", diff --git a/package.json b/package.json index 5dfd6ca90d..af76b2e086 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ ], "dependencies": { "concurrently": "^10.0.0", + "i18next-http-backend": "^4.0.0", "wait-on": "^9.0.1" }, "devDependencies": {