From 86ae41e00cc46e7760826c652066a42a5090395f Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 14 Jul 2026 15:24:34 -0400 Subject: [PATCH 01/24] MPDX-9771 - Plumb MPD supervisor admin flag and impersonation scope into session Co-Authored-By: Claude Fable 5 --- __tests__/fixtures/session.ts | 1 + pages/api/auth/[...nextauth].page.ts | 12 +++ pages/api/auth/helpers.test.ts | 65 ++++++++++++- pages/api/auth/helpers.ts | 25 ++++- .../impersonate/impersonateHelper.test.ts | 91 +++++++++++++++++++ .../api/auth/impersonate/impersonateHelper.ts | 11 +++ .../MultiPageMenu/MultiPageMenuItems.graphql | 1 + 7 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 pages/api/auth/impersonate/impersonateHelper.test.ts diff --git a/__tests__/fixtures/session.ts b/__tests__/fixtures/session.ts index b9702b3305..fa148073ab 100644 --- a/__tests__/fixtures/session.ts +++ b/__tests__/fixtures/session.ts @@ -9,6 +9,7 @@ export const session: Session = { userID: 'user-1', admin: false, developer: false, + mpdSupervisorAdmin: false, impersonating: false, }, }; diff --git a/pages/api/auth/[...nextauth].page.ts b/pages/api/auth/[...nextauth].page.ts index 1a5efabe24..5f50585bee 100644 --- a/pages/api/auth/[...nextauth].page.ts +++ b/pages/api/auth/[...nextauth].page.ts @@ -27,11 +27,13 @@ declare module 'next-auth' { email: string; admin: boolean; developer: boolean; + mpdSupervisorAdmin: boolean; apiToken: string; userID: string; impersonating?: boolean; impersonatorApiToken?: string; isImpersonatorDeveloper?: boolean; + impersonationScope?: string; }; } @@ -41,6 +43,7 @@ declare module 'next-auth' { impersonating?: boolean; impersonatorApiToken?: string; isImpersonatorDeveloper?: boolean; + impersonationScope?: string; } } @@ -48,11 +51,13 @@ declare module 'next-auth/jwt' { interface JWT { admin: boolean; developer: boolean; + mpdSupervisorAdmin: boolean; apiToken: string; userID?: string; impersonating?: boolean; impersonatorApiToken?: string; isImpersonatorDeveloper?: boolean; + impersonationScope?: string; } } @@ -178,6 +183,7 @@ const Auth = (req: NextApiRequest, res: NextApiResponse): Promise => { user.impersonating = userInfo.impersonating; user.impersonatorApiToken = userInfo.impersonatorApiToken; user.isImpersonatorDeveloper = userInfo.isImpersonatorDeveloper; + user.impersonationScope = userInfo.impersonationScope; if (cookies) { res.setHeader('Set-Cookie', cookies); @@ -241,11 +247,13 @@ const Auth = (req: NextApiRequest, res: NextApiResponse): Promise => { ...token, admin: data.user.admin, developer: data.user.developer, + mpdSupervisorAdmin: data.user.mpdSupervisorAdmin, apiToken: user.apiToken, userID: user.userID, impersonating: user.impersonating, impersonatorApiToken: user.impersonatorApiToken, isImpersonatorDeveloper: user.isImpersonatorDeveloper, + impersonationScope: user.impersonationScope, }; } else { return token; @@ -255,10 +263,12 @@ const Auth = (req: NextApiRequest, res: NextApiResponse): Promise => { const { admin, developer, + mpdSupervisorAdmin, apiToken, userID, impersonating, isImpersonatorDeveloper, + impersonationScope, } = token; // Check the expiration of the API token JWT without verifying its signature @@ -273,10 +283,12 @@ const Auth = (req: NextApiRequest, res: NextApiResponse): Promise => { ...session.user, admin, developer, + mpdSupervisorAdmin, apiToken, userID, impersonating, isImpersonatorDeveloper, + impersonationScope, }, }; }, diff --git a/pages/api/auth/helpers.test.ts b/pages/api/auth/helpers.test.ts index 6f183465d7..9787818778 100644 --- a/pages/api/auth/helpers.test.ts +++ b/pages/api/auth/helpers.test.ts @@ -1,4 +1,9 @@ -import { isJwtExpired, signValue, verifySignedValue } from './helpers'; +import { + isJwtExpired, + setUserInfo, + signValue, + verifySignedValue, +} from './helpers'; describe('isJwtExpired', () => { it('returns true for expired JWTs', () => { @@ -28,6 +33,64 @@ describe('isJwtExpired', () => { }); }); +describe('setUserInfo', () => { + const accessToken = 'access-token'; + const userId = 'user-1'; + + it('sets impersonationScope when impersonating with a validly signed cookie', () => { + const reqCookies = [ + 'mpdx-handoff.impersonate=impersonate-jwt', + `mpdx-handoff.impersonationScope=${signValue('mpd_leader')}`, + ].join('; '); + + const { user, cookies } = setUserInfo(accessToken, userId, reqCookies); + + expect(user.impersonationScope).toBe('mpd_leader'); + expect(cookies).toContain( + 'mpdx-handoff.impersonationScope=; HttpOnly; Secure; path=/; Max-Age=0', + ); + }); + + it('ignores impersonationScope when not impersonating', () => { + const reqCookies = `mpdx-handoff.impersonationScope=${signValue( + 'mpd_leader', + )}`; + + const { user, cookies } = setUserInfo(accessToken, userId, reqCookies); + + expect(user.impersonationScope).toBeUndefined(); + // The cookie is still expired even though it was ignored + expect(cookies).toContain( + 'mpdx-handoff.impersonationScope=; HttpOnly; Secure; path=/; Max-Age=0', + ); + }); + + it('ignores impersonationScope when the signature is invalid', () => { + const signedScope = signValue('mpd_leader'); + const parts = signedScope.split('.'); + const tamperedScope = `admin.${parts[1]}.${parts[2]}`; + const reqCookies = [ + 'mpdx-handoff.impersonate=impersonate-jwt', + `mpdx-handoff.impersonationScope=${tamperedScope}`, + ].join('; '); + + const { user } = setUserInfo(accessToken, userId, reqCookies); + + expect(user.impersonationScope).toBeUndefined(); + }); + + it('does not set impersonationScope when the cookie is absent', () => { + const reqCookies = 'mpdx-handoff.impersonate=impersonate-jwt'; + + const { user, cookies } = setUserInfo(accessToken, userId, reqCookies); + + expect(user.impersonationScope).toBeUndefined(); + expect(cookies).not.toContain( + 'mpdx-handoff.impersonationScope=; HttpOnly; Secure; path=/; Max-Age=0', + ); + }); +}); + describe('signValue and verifySignedValue', () => { it('signs and verifies a boolean value correctly', () => { const signedTrue = signValue(true, 100); diff --git a/pages/api/auth/helpers.ts b/pages/api/auth/helpers.ts index d0a71bfe00..d2c93d13ef 100644 --- a/pages/api/auth/helpers.ts +++ b/pages/api/auth/helpers.ts @@ -9,6 +9,7 @@ interface User { impersonating?: boolean; impersonatorApiToken?: string; isImpersonatorDeveloper?: boolean; + impersonationScope?: string; } interface SetUserInfoReturn { user: User; @@ -46,6 +47,18 @@ export const setUserInfo = ( user.isImpersonatorDeveloper = unsignedDeveloperValue === 'true'; } + const impersonationScopeSigned = extractCookie( + reqCookies, + 'mpdx-handoff.impersonationScope', + ); + + if (impersonateJWT && impersonationScopeSigned) { + const unsignedScopeValue = verifySignedValue(impersonationScopeSigned); + if (unsignedScopeValue) { + user.impersonationScope = unsignedScopeValue; + } + } + const cookies: string[] = []; if (impersonateJWT) { cookies.push(`mpdx-handoff.impersonate=; ${expireCookieDefaultInfo}`); @@ -60,6 +73,11 @@ export const setUserInfo = ( `mpdx-handoff.isImpersonatorDeveloper=; ${expireCookieDefaultInfo}`, ); } + if (impersonationScopeSigned) { + cookies.push( + `mpdx-handoff.impersonationScope=; ${expireCookieDefaultInfo}`, + ); + } if (token) { cookies.push(`mpdx-handoff.token=; ${expireCookieDefaultInfo}`); } @@ -69,8 +87,11 @@ export const setUserInfo = ( }; }; -/* Sign a boolean with an expiration time */ -export function signValue(input: boolean, expiresInSeconds = 300): string { +/* Sign a boolean or string value with an expiration time */ +export function signValue( + input: boolean | string, + expiresInSeconds = 300, +): string { const value = input.toString(); const signatureExpiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds; const payload = `${value}.${signatureExpiresAt}`; diff --git a/pages/api/auth/impersonate/impersonateHelper.test.ts b/pages/api/auth/impersonate/impersonateHelper.test.ts new file mode 100644 index 0000000000..99271b896d --- /dev/null +++ b/pages/api/auth/impersonate/impersonateHelper.test.ts @@ -0,0 +1,91 @@ +import { NextApiRequest } from 'next'; +import { getToken } from 'next-auth/jwt'; +import { createMocks } from 'node-mocks-http'; +import { verifySignedValue } from '../helpers'; +import { ImpersonationTypeEnum, impersonate } from './impersonateHelper'; + +jest.mock('next-auth/jwt', () => ({ + getToken: jest.fn(), +})); + +const impersonationScopeCookiePrefix = 'mpdx-handoff.impersonationScope='; + +const makeRequest = (): NextApiRequest => { + const { req } = createMocks({ method: 'POST' }); + const request = req as unknown as NextApiRequest; + // The impersonate helper expects a JSON string body, but createMocks only accepts objects + request.body = JSON.stringify({ + user: 'impersonated.user@cru.org', + reason: 'Helpscout Ticket', + }); + return request; +}; + +const mockFetch = (attributes: Record) => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + json: jest.fn().mockResolvedValue({ + data: { + type: 'impersonation', + attributes: { + created_at: '2024-01-01T00:00:00Z', + json_web_token: 'impersonation-jwt', + updated_at: '2024-01-01T00:00:00Z', + updated_in_db_at: '2024-01-01T00:00:00Z', + ...attributes, + }, + }, + }), + }); +}; + +describe('impersonate', () => { + beforeEach(() => { + process.env.REST_API_URL = 'https://api.stage.mpdx.org/api/v2/'; + (getToken as jest.Mock).mockResolvedValue({ + apiToken: 'api-token', + userID: 'user-1', + developer: false, + }); + }); + + it('sets the impersonationScope cookie when the API response includes impersonation_scope', async () => { + mockFetch({ impersonation_scope: 'mpd_leader' }); + + const { status, cookies } = await impersonate( + makeRequest(), + ImpersonationTypeEnum.USER, + ); + + expect(status).toBe(200); + const scopeCookie = cookies.find((cookie) => + cookie.startsWith(impersonationScopeCookiePrefix), + ); + expect(scopeCookie).toBeDefined(); + const signedScope = (scopeCookie as string) + .slice(impersonationScopeCookiePrefix.length) + .split(';')[0]; + expect(verifySignedValue(signedScope)).toBe('mpd_leader'); + expect(scopeCookie).toContain('HttpOnly; Secure; path=/; Max-Age=300'); + }); + + it('does not set the impersonationScope cookie when impersonation_scope is absent', async () => { + mockFetch({}); + + const { status, cookies } = await impersonate( + makeRequest(), + ImpersonationTypeEnum.USER, + ); + + expect(status).toBe(200); + expect( + cookies.some((cookie) => + cookie.startsWith(impersonationScopeCookiePrefix), + ), + ).toBe(false); + // The other impersonation cookies are still set + expect( + cookies.some((cookie) => cookie.startsWith('mpdx-handoff.impersonate=')), + ).toBe(true); + }); +}); diff --git a/pages/api/auth/impersonate/impersonateHelper.ts b/pages/api/auth/impersonate/impersonateHelper.ts index 1e8b7567ff..6e53156f9a 100644 --- a/pages/api/auth/impersonate/impersonateHelper.ts +++ b/pages/api/auth/impersonate/impersonateHelper.ts @@ -27,6 +27,7 @@ type FetchTokenForOrganizationType = { attributes: { created_at: string; json_web_token: string; + impersonation_scope?: string; updated_at: string; updated_in_db_at: string; }; @@ -122,6 +123,16 @@ export const impersonate = async ( `mpdx-handoff.redirect-url=/; ${cookieDefaultInfo}`, `mpdx-handoff.token=${apiToken}; ${cookieDefaultInfo}`, ]; + + const impersonationScope = fetchRes?.data?.attributes?.impersonation_scope; + if (impersonationScope) { + cookies.push( + `mpdx-handoff.impersonationScope=${signValue( + impersonationScope, + )}; ${cookieDefaultInfo}`, + ); + } + return { status: fetchToken.status, errors, diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql index 8ead8a2f2c..13bda80598 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql @@ -3,5 +3,6 @@ query GetUserAccess { id admin developer + mpdSupervisorAdmin } } From 07f5eed35ebf0fa0a8ea5831006527bfc8cfad8a Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 14 Jul 2026 15:34:50 -0400 Subject: [PATCH 02/24] MPDX-9771 - Block contacts, tasks, and tools routes during restricted impersonation Co-Authored-By: Claude Fable 5 --- .../contacts/[[...contactId]].page.tsx | 4 +- .../contacts/flows/setup.page.tsx | 4 +- .../[accountListId]/settings/admin.page.tsx | 4 +- .../tasks/[[...contactId]].page.tsx | 4 +- .../appeals/appeal/[[...appealId]].page.tsx | 4 +- .../tools/appeals/index.page.tsx | 4 +- .../commitmentInfo/[[...contactId]].page.tsx | 4 +- .../emailAddresses/[[...contactId]].page.tsx | 4 +- .../[[...contactId]].page.tsx | 4 +- .../phoneNumbers/[[...contactId]].page.tsx | 4 +- .../sendNewsletter/[[...contactId]].page.tsx | 4 +- .../[accountListId]/tools/import/csv.page.tsx | 4 +- .../tools/import/google.page.tsx | 4 +- .../[accountListId]/tools/import/tnt.page.tsx | 4 +- .../merge/contacts/[[...contactId]].page.tsx | 4 +- .../merge/people/[[...contactId]].page.tsx | 4 +- pages/api/utils/pagePropsHelpers.test.ts | 123 ++++++++++++++++++ pages/api/utils/pagePropsHelpers.ts | 74 ++++++++++- 18 files changed, 227 insertions(+), 34 deletions(-) diff --git a/pages/accountLists/[accountListId]/contacts/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/contacts/[[...contactId]].page.tsx index 1f71986da6..0d1eb8dbd1 100644 --- a/pages/accountLists/[accountListId]/contacts/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/contacts/[[...contactId]].page.tsx @@ -1,7 +1,7 @@ import Head from 'next/head'; import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import { ContactsContext, ContactsType, @@ -66,4 +66,4 @@ const ContactsPage: React.FC = () => ( export default ContactsPage; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; diff --git a/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx b/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx index 0495d8973f..988ba3100a 100644 --- a/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx +++ b/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx @@ -7,7 +7,7 @@ import { useSnackbar } from 'notistack'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import { colorMap } from 'src/components/Contacts/ContactFlow/ContactFlow'; import { ContactFlowSetupColumn } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/Column/ContactFlowSetupColumn'; import { UnusedStatusesColumn } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/Column/UnusedStatusesColumn'; @@ -227,6 +227,6 @@ const ContactFlowSetupPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default ContactFlowSetupPage; diff --git a/pages/accountLists/[accountListId]/settings/admin.page.tsx b/pages/accountLists/[accountListId]/settings/admin.page.tsx index d6b3b61079..5d37d82174 100644 --- a/pages/accountLists/[accountListId]/settings/admin.page.tsx +++ b/pages/accountLists/[accountListId]/settings/admin.page.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; import React, { ReactElement, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { enforceAdmin } from 'pages/api/utils/pagePropsHelpers'; +import { enforceAdminOrMpdLeader } from 'pages/api/utils/pagePropsHelpers'; import { ImpersonateUserAccordion } from 'src/components/Settings/Admin/ImpersonateUser/ImpersonateUserAccordion'; import { ResetAccountAccordion } from 'src/components/Settings/Admin/ResetAccount/ResetAccountAccordion'; import { AdminAccordion } from 'src/components/Shared/Forms/Accordions/AccordionEnum'; @@ -41,6 +41,6 @@ const Admin = (): ReactElement => { ); }; -export const getServerSideProps = enforceAdmin; +export const getServerSideProps = enforceAdminOrMpdLeader; export default Admin; diff --git a/pages/accountLists/[accountListId]/tasks/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tasks/[[...contactId]].page.tsx index cdf82d4534..39a3f7f035 100644 --- a/pages/accountLists/[accountListId]/tasks/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tasks/[[...contactId]].page.tsx @@ -7,7 +7,7 @@ import { Box, Button, ButtonGroup } from '@mui/material'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import { DynamicContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/DynamicContactsRightPanel'; import { InfiniteList } from 'src/components/InfiniteList/InfiniteList'; import { navBarHeight } from 'src/components/Layouts/Primary/Primary'; @@ -382,6 +382,6 @@ const TasksPage: React.FC = () => ( ); -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default TasksPage; diff --git a/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx b/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx index 6ba7d50a25..3a741fad41 100644 --- a/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx @@ -1,7 +1,7 @@ import Head from 'next/head'; import React, { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import AppealsDetailsPage from 'src/components/Tool/Appeal/AppealDetails/AppealsDetailsPage'; import { getAppName } from 'src/lib/getAppName'; import { AppealsWrapper } from '../AppealsWrapper'; @@ -29,4 +29,4 @@ const AppealsPage: React.FC = () => ( export default AppealsPage; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; diff --git a/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx b/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx index db865809fc..c100eb897e 100644 --- a/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx +++ b/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx @@ -1,6 +1,6 @@ import React, { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import AppealsInitialPage from 'src/components/Tool/Appeal/InitialPage/AppealsInitialPage'; import { ToolsWrapper } from '../ToolsWrapper'; @@ -16,4 +16,4 @@ const AppealsPage = (): ReactElement => { export default AppealsPage; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; diff --git a/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx index b7ab065789..86c090c6b5 100644 --- a/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import FixCommitmentInfo from 'src/components/Tool/FixCommitmentInfo/FixCommitmentInfo'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -19,6 +19,6 @@ const FixCommitmentInfoPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default FixCommitmentInfoPage; diff --git a/pages/accountLists/[accountListId]/tools/fix/emailAddresses/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/fix/emailAddresses/[[...contactId]].page.tsx index 91920cb500..f2f1a4c3d8 100644 --- a/pages/accountLists/[accountListId]/tools/fix/emailAddresses/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/fix/emailAddresses/[[...contactId]].page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import { FixEmailAddresses } from 'src/components/Tool/FixEmailAddresses/FixEmailAddresses'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -19,6 +19,6 @@ const FixEmailAddressesPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default FixEmailAddressesPage; diff --git a/pages/accountLists/[accountListId]/tools/fix/mailingAddresses/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/fix/mailingAddresses/[[...contactId]].page.tsx index 7346c21e37..d2b4d10903 100644 --- a/pages/accountLists/[accountListId]/tools/fix/mailingAddresses/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/fix/mailingAddresses/[[...contactId]].page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import FixMailingAddresses from 'src/components/Tool/FixMailingAddresses/FixMailingAddresses'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -19,6 +19,6 @@ const FixMailingAddressesPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default FixMailingAddressesPage; diff --git a/pages/accountLists/[accountListId]/tools/fix/phoneNumbers/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/fix/phoneNumbers/[[...contactId]].page.tsx index 32804b3eb6..1ca0d1330c 100644 --- a/pages/accountLists/[accountListId]/tools/fix/phoneNumbers/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/fix/phoneNumbers/[[...contactId]].page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import FixPhoneNumbers from 'src/components/Tool/FixPhoneNumbers/FixPhoneNumbers'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -19,6 +19,6 @@ const FixPhoneNumbersPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default FixPhoneNumbersPage; diff --git a/pages/accountLists/[accountListId]/tools/fix/sendNewsletter/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/fix/sendNewsletter/[[...contactId]].page.tsx index 6f5e86b789..e87d124ecb 100644 --- a/pages/accountLists/[accountListId]/tools/fix/sendNewsletter/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/fix/sendNewsletter/[[...contactId]].page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import FixSendNewsletter from 'src/components/Tool/FixSendNewsletter/FixSendNewsletter'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -19,6 +19,6 @@ const FixSendNewsletterPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default FixSendNewsletterPage; diff --git a/pages/accountLists/[accountListId]/tools/import/csv.page.tsx b/pages/accountLists/[accountListId]/tools/import/csv.page.tsx index 757470b016..6ac68e5906 100644 --- a/pages/accountLists/[accountListId]/tools/import/csv.page.tsx +++ b/pages/accountLists/[accountListId]/tools/import/csv.page.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import { CsvImportProvider, CsvImportViewStepEnum, @@ -73,6 +73,6 @@ const CsvHome: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default CsvHome; diff --git a/pages/accountLists/[accountListId]/tools/import/google.page.tsx b/pages/accountLists/[accountListId]/tools/import/google.page.tsx index 381bb041e9..820a4af819 100644 --- a/pages/accountLists/[accountListId]/tools/import/google.page.tsx +++ b/pages/accountLists/[accountListId]/tools/import/google.page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import Loading from 'src/components/Loading'; import GoogleImport from 'src/components/Tool/GoogleImport/GoogleImport'; import { useAccountListId } from 'src/hooks/useAccountListId'; @@ -24,6 +24,6 @@ const GoogleImportPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default GoogleImportPage; diff --git a/pages/accountLists/[accountListId]/tools/import/tnt.page.tsx b/pages/accountLists/[accountListId]/tools/import/tnt.page.tsx index 9870785a0b..71915eb928 100644 --- a/pages/accountLists/[accountListId]/tools/import/tnt.page.tsx +++ b/pages/accountLists/[accountListId]/tools/import/tnt.page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import Loading from 'src/components/Loading'; import TntConnect from 'src/components/Tool/TntConnect/TntConnect'; import { useAccountListId } from 'src/hooks/useAccountListId'; @@ -24,6 +24,6 @@ const TntConnectPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default TntConnectPage; diff --git a/pages/accountLists/[accountListId]/tools/merge/contacts/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/merge/contacts/[[...contactId]].page.tsx index 88feea479f..d787a50573 100644 --- a/pages/accountLists/[accountListId]/tools/merge/contacts/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/merge/contacts/[[...contactId]].page.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import MergeContacts from 'src/components/Tool/MergeContacts/MergeContacts'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -26,6 +26,6 @@ const MergeContactsPage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default MergeContactsPage; diff --git a/pages/accountLists/[accountListId]/tools/merge/people/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/merge/people/[[...contactId]].page.tsx index 765c4a37d0..efa92cc885 100644 --- a/pages/accountLists/[accountListId]/tools/merge/people/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/merge/people/[[...contactId]].page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import MergePeople from 'src/components/Tool/MergePeople/MergePeople'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { ToolsWrapper } from '../../ToolsWrapper'; @@ -16,6 +16,6 @@ const MergePeoplePage: React.FC = () => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default MergePeoplePage; diff --git a/pages/api/utils/pagePropsHelpers.test.ts b/pages/api/utils/pagePropsHelpers.test.ts index 00f529ba2a..82f0d17a01 100644 --- a/pages/api/utils/pagePropsHelpers.test.ts +++ b/pages/api/utils/pagePropsHelpers.test.ts @@ -5,8 +5,10 @@ import { RedirectReason } from 'pages/api/auth/redirectReasonEnum'; import makeSsrClient from 'src/lib/apollo/ssrClient'; import { blockImpersonatingNonDevelopers, + blockRestrictedImpersonation, dashboardRedirect, enforceAdmin, + enforceAdminOrMpdLeader, ensureSessionAndAccountList, loginRedirect, makeGetServerSideProps, @@ -75,6 +77,63 @@ describe('pagePropsHelpers', () => { }); }); + describe('enforceAdminOrMpdLeader', () => { + it('redirects to the login page if the user is not logged in', async () => { + (getSession as jest.Mock).mockResolvedValue(null); + + await expect(enforceAdminOrMpdLeader(context)).resolves.toMatchObject({ + redirect: { + destination: '/login?redirect=%2Fpage%3Fparam%3Dvalue', + }, + }); + }); + + it('does not return a redirect if the user is an admin', async () => { + const user = { + apiToken: 'token', + admin: true, + mpdSupervisorAdmin: false, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrMpdLeader(context)).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); + + it('does not return a redirect if the user is an MPD supervisor admin', async () => { + const user = { + apiToken: 'token', + admin: false, + mpdSupervisorAdmin: true, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrMpdLeader(context)).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); + + it('returns a redirect if the user is neither an admin nor an MPD supervisor admin', async () => { + const user = { + apiToken: 'token', + admin: false, + mpdSupervisorAdmin: false, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrMpdLeader(context)).resolves.toMatchObject({ + redirect: { + destination: '/accountLists/account-list-1?redirect=unauthorized', + }, + }); + }); + }); + describe('ensureSessionAndAccountList', () => { it('does not return a redirect if the user is logged in', async () => { const user = { apiToken: 'token' }; @@ -199,6 +258,70 @@ describe('pagePropsHelpers', () => { }, }); }); + + it('allows access if the impersonation session has a restricted scope', async () => { + const user = { + apiToken: 'token', + impersonating: true, + isImpersonatorDeveloper: false, + impersonationScope: 'mpd_leader', + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect( + blockImpersonatingNonDevelopers(context), + ).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); + }); + + describe('blockRestrictedImpersonation', () => { + it('redirects to the login page if the user is not logged in', async () => { + (getSession as jest.Mock).mockResolvedValue(null); + + await expect( + blockRestrictedImpersonation(context), + ).resolves.toMatchObject({ + redirect: { + destination: '/login?redirect=%2Fpage%3Fparam%3Dvalue', + }, + }); + }); + + it('redirects to the dashboard if the impersonation session has a restricted scope', async () => { + const user = { + apiToken: 'token', + impersonating: true, + impersonationScope: 'mpd_leader', + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect( + blockRestrictedImpersonation(context), + ).resolves.toMatchObject({ + redirect: { + destination: + '/accountLists/account-list-1?redirect=impersonation-blocked', + permanent: false, + }, + }); + }); + + it('allows access if the session has no impersonation scope', async () => { + const user = { apiToken: 'token', impersonating: true }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect( + blockRestrictedImpersonation(context), + ).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); }); describe('makeGetServerSideProps', () => { diff --git a/pages/api/utils/pagePropsHelpers.ts b/pages/api/utils/pagePropsHelpers.ts index f7365f0a34..29174198e9 100644 --- a/pages/api/utils/pagePropsHelpers.ts +++ b/pages/api/utils/pagePropsHelpers.ts @@ -44,6 +44,9 @@ export const dashboardRedirect = ( /* Block non-developers who are impersonating, allow everyone else * Use case: Pages that regular users can access on their own account, * but should be restricted when admins impersonate (e.g., staff expense data) + * Exception: restricted-scope impersonation sessions (e.g. MPD leaders, where + * impersonationScope is set) are allowed through because their access is + * limited to the sections their scope permits */ export const blockImpersonatingNonDevelopers: GetServerSideProps< PagePropsWithSession @@ -54,8 +57,12 @@ export const blockImpersonatingNonDevelopers: GetServerSideProps< return loginRedirect(context); } - // Check if the impersonator is a developer - if (session.user.impersonating && !session.user.isImpersonatorDeveloper) { + // Check if the impersonator is a developer or has a restricted scope + if ( + session.user.impersonating && + !session.user.isImpersonatorDeveloper && + !session.user.impersonationScope + ) { return dashboardRedirect(context, RedirectReason.ImpersonationBlocked); } @@ -103,6 +110,69 @@ export const enforceAdmin: GetServerSideProps = async ( }; }; +// Redirect back to the dashboard unless the user is an admin or an MPD +// supervisor admin (MPD leader) +export const enforceAdminOrMpdLeader: GetServerSideProps< + PagePropsWithSession +> = async (context) => { + const session = await getSession(context); + + if (!session?.user.apiToken) { + return loginRedirect(context); + } + + if (!session.user.admin && !session.user.mpdSupervisorAdmin) { + return dashboardRedirect(context, RedirectReason.Unauthorized); + } + + const underscoreRedirect = await handleUnderscoreAccountListRedirect( + session, + context.resolvedUrl, + ); + if (underscoreRedirect) { + return underscoreRedirect; + } + + return { + props: { + session, + }, + }; +}; + +/* Block restricted-scope impersonation sessions (e.g. MPD leaders, where + * impersonationScope is set), allow everyone else + * Use case: Sections like Contacts, Tasks, and Tools that must be unreachable + * during a restricted impersonation session + */ +export const blockRestrictedImpersonation: GetServerSideProps< + PagePropsWithSession +> = async (context) => { + const session = await getSession(context); + + if (!session?.user.apiToken) { + return loginRedirect(context); + } + + if (session.user.impersonationScope) { + return dashboardRedirect(context, RedirectReason.ImpersonationBlocked); + } + + const underscoreRedirect = await handleUnderscoreAccountListRedirect( + session, + context.resolvedUrl, + ); + if (underscoreRedirect) { + return underscoreRedirect; + } + + return { + props: { + session, + }, + }; +}; + // Redirect back to login screen if user isn't logged in export const ensureSessionAndAccountList: GetServerSideProps< PagePropsWithSession From 229ab99e9c5fe8c71e5e71804f0124c61a7a83d7 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 14 Jul 2026 16:05:02 -0400 Subject: [PATCH 03/24] MPDX-9771 - Hide contacts, tasks, and tools nav and expose Admin Console to MPD leaders Co-Authored-By: Claude Fable 5 --- .../settings/admin.page.test.tsx | 14 ++++ .../[accountListId]/settings/admin.page.tsx | 12 ++- .../MultiPageMenu/MultiPageMenu.test.tsx | 35 ++++++++ .../MultiPageMenu/MultiPageMenu.tsx | 6 ++ src/hooks/useNavPages.test.tsx | 82 +++++++++++++++++++ src/hooks/useNavPages.tsx | 10 +++ src/hooks/useSettingsNavItems.ts | 2 +- 7 files changed, 156 insertions(+), 5 deletions(-) diff --git a/pages/accountLists/[accountListId]/settings/admin.page.test.tsx b/pages/accountLists/[accountListId]/settings/admin.page.test.tsx index e4ddb4456a..144dc7a572 100644 --- a/pages/accountLists/[accountListId]/settings/admin.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/admin.page.test.tsx @@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { mockSession } from '__tests__/util/mockSession'; import { AdminAccordion } from 'src/components/Shared/Forms/Accordions/AccordionEnum'; import theme from 'src/theme'; import Admin from './admin.page'; @@ -36,6 +37,8 @@ const Components: React.FC = ({ selectedTab }) => ( describe('Admin', () => { it('should keep impersonate user accordion close', async () => { + mockSession({ admin: true }); + const { getAllByText } = render(); await waitFor(() => { expect(getAllByText('Impersonate User')).toHaveLength(3); @@ -44,6 +47,8 @@ describe('Admin', () => { }); it('should open impersonate user accordion', async () => { + mockSession({ admin: true }); + const { getAllByText } = render( , ); @@ -52,4 +57,13 @@ describe('Admin', () => { expect(getAllByText('Reset Account').length).toEqual(3); }); }); + + it('should hide the reset account accordion from MPD supervisor admins who are not admins', async () => { + mockSession({ admin: false, mpdSupervisorAdmin: true }); + + const { findAllByText, queryByText } = render(); + + expect(await findAllByText('Impersonate User')).toHaveLength(3); + expect(queryByText('Reset Account')).not.toBeInTheDocument(); + }); }); diff --git a/pages/accountLists/[accountListId]/settings/admin.page.tsx b/pages/accountLists/[accountListId]/settings/admin.page.tsx index 5d37d82174..490d643f30 100644 --- a/pages/accountLists/[accountListId]/settings/admin.page.tsx +++ b/pages/accountLists/[accountListId]/settings/admin.page.tsx @@ -6,6 +6,7 @@ import { ImpersonateUserAccordion } from 'src/components/Settings/Admin/Imperson import { ResetAccountAccordion } from 'src/components/Settings/Admin/ResetAccount/ResetAccountAccordion'; import { AdminAccordion } from 'src/components/Shared/Forms/Accordions/AccordionEnum'; import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; +import { useRequiredSession } from 'src/hooks/useRequiredSession'; import { SettingsWrapper } from './Wrapper'; export const suggestedArticles = 'HS_SETTINGS_SERVICES_SUGGESTIONS'; @@ -13,6 +14,7 @@ export const suggestedArticles = 'HS_SETTINGS_SERVICES_SUGGESTIONS'; const Admin = (): ReactElement => { const { t } = useTranslation(); const { query } = useRouter(); + const user = useRequiredSession(); const [expandedAccordion, setExpandedAccordion] = useState( typeof query.selectedTab === 'string' @@ -32,10 +34,12 @@ const Admin = (): ReactElement => { expandedAccordion={expandedAccordion} /> - + {user.admin && ( + + )} ); diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx index 19d66729cf..3bf21d611f 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx @@ -464,6 +464,41 @@ describe('MultiPageMenu', () => { }); }); + it('shows the admin console but not admin-only or developer-only tools for MPD supervisor admins', async () => { + mockSession({ admin: false, developer: false, mpdSupervisorAdmin: true }); + + const mutationSpy = jest.fn(); + const { findByText, queryByText } = render( + + + + mocks={{ + ManageOrganizationsAccess: noOrganizationsAccessMock, + }} + onCall={mutationSpy} + > + {}} + designationAccounts={[]} + setDesignationAccounts={jest.fn()} + navType={NavTypeEnum.Settings} + /> + + + , + ); + + expect(await findByText('Admin Console')).toBeInTheDocument(); + + expect(queryByText('Manage Organizations')).not.toBeInTheDocument(); + expect(queryByText('Backend Admin')).not.toBeInTheDocument(); + expect(queryByText('Sidekiq')).not.toBeInTheDocument(); + }); + describe('HR Tools Menu', () => { it('shows hr tools for senior staff', async () => { const { findByText, getByText, queryByText } = render( diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx index 41148e6852..49bd532490 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx @@ -80,6 +80,12 @@ const showMenuItem = ({ if (item.grantedAccess.indexOf('developer') !== -1 && user.developer) { return true; } + if ( + item.grantedAccess.indexOf('mpdSupervisorAdmin') !== -1 && + user.mpdSupervisorAdmin + ) { + return true; + } } else { return true; } diff --git a/src/hooks/useNavPages.test.tsx b/src/hooks/useNavPages.test.tsx index 0d058cd8db..cdcf0820c7 100644 --- a/src/hooks/useNavPages.test.tsx +++ b/src/hooks/useNavPages.test.tsx @@ -106,4 +106,86 @@ describe('useNavPages', () => { 'hr-tools-page', ); }); + + describe('restricted impersonation', () => { + it('hides the Contacts, Tasks, and MPDX Tools tabs when the impersonation scope is set', async () => { + mockSession({ developer: false, impersonationScope: 'mpd_supervisor' }); + + const { result, waitForNextUpdate } = renderHook( + () => useNavPages(false), + { wrapper: Wrapper }, + ); + await waitForNextUpdate(); + + const navPageIds = result.current.navPages.map((page) => page.id); + expect(navPageIds).not.toContain('contacts-page'); + expect(navPageIds).not.toContain('tasks-page'); + expect(navPageIds).not.toContain('mpdx-tools-page'); + expect(navPageIds).toContain('dashboard-page'); + expect(navPageIds).toContain('reports-page'); + }); + + it('keeps the HR Tools tab visible when the impersonation scope is set', async () => { + process.env.DEVELOPMENT_ENV = 'true'; + mockSession({ developer: true, impersonationScope: 'mpd_supervisor' }); + + const { result, waitForNextUpdate } = renderHook( + () => useNavPages(false), + { wrapper: Wrapper }, + ); + await waitForNextUpdate(); + + const navPageIds = result.current.navPages.map((page) => page.id); + expect(navPageIds).toContain('hr-tools-page'); + expect(navPageIds).not.toContain('contacts-page'); + expect(navPageIds).not.toContain('tasks-page'); + expect(navPageIds).not.toContain('mpdx-tools-page'); + }); + + it('excludes the hidden pages and their items from the search dialog when the impersonation scope is set', async () => { + mockSession({ developer: false, impersonationScope: 'mpd_supervisor' }); + + const { result, waitForNextUpdate } = renderHook( + () => useNavPages(false, true), + { wrapper: Wrapper }, + ); + await waitForNextUpdate(); + + const searchTitles = result.current.searchDialogPages.map( + (page) => page.title, + ); + expect(searchTitles).not.toContain('Contacts'); + expect(searchTitles).not.toContain('Tasks'); + expect(searchTitles).not.toContain('MPDX Tools'); + expect( + searchTitles.filter((title) => title.startsWith('MPDX Tools - ')), + ).toEqual([]); + }); + + it('keeps the Contacts, Tasks, and MPDX Tools tabs for a normal session', async () => { + mockSession({ developer: false }); + + const { result, waitForNextUpdate } = renderHook( + () => useNavPages(false, true), + { wrapper: Wrapper }, + ); + await waitForNextUpdate(); + + const navPageIds = result.current.navPages.map((page) => page.id); + expect(navPageIds).toContain('contacts-page'); + expect(navPageIds).toContain('tasks-page'); + expect(navPageIds).toContain('mpdx-tools-page'); + + const searchTitles = result.current.searchDialogPages.map( + (page) => page.title, + ); + expect(searchTitles).toContain('Contacts'); + expect(searchTitles).toContain('Tasks'); + expect(searchTitles).toContain('MPDX Tools'); + expect( + searchTitles.filter((title) => title.startsWith('MPDX Tools - ')) + .length, + ).toBeGreaterThan(0); + }); + }); }); diff --git a/src/hooks/useNavPages.tsx b/src/hooks/useNavPages.tsx index 0bb2c5641a..60c3a79d6e 100644 --- a/src/hooks/useNavPages.tsx +++ b/src/hooks/useNavPages.tsx @@ -9,6 +9,7 @@ import { useDeveloperBypass } from './useDeveloperBypass'; import { useHrToolsNavItems } from './useHrToolsNavItems'; import { useReportNavItems } from './useReportNavItems'; import { useReportsDisabled } from './useReportsDisabled'; +import { useRequiredSession } from './useRequiredSession'; import { useSettingsNavItems } from './useSettingsNavItems'; import { useToolsNavItems } from './useToolsNavItems'; @@ -59,6 +60,11 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { const developerBypass = useDeveloperBypass(); const canSeeHrTools = userType === UserTypeEnum.UsStaff || developerBypass; + // During a restricted impersonation session the impersonator may only + // access MPD leader tools, so hide the contacts, tasks, and tools pages + const { impersonationScope } = useRequiredSession(); + const isRestrictedImpersonation = !!impersonationScope; + const reportItems = useReportNavItems(); const toolsItems = useToolsNavItems(); const settingsItems = useSettingsNavItems(); @@ -83,6 +89,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { showInNav: true, isDropdown: false, showInSearchDialog: true, + hideTab: isRestrictedImpersonation, }, { id: 'tasks-page', @@ -93,6 +100,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { showInNav: true, isDropdown: false, showInSearchDialog: true, + hideTab: isRestrictedImpersonation, }, { id: 'reports-page', @@ -146,6 +154,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { ), showInNav: true, showInSearchDialog: true, + hideTab: isRestrictedImpersonation, }, { id: 'settings-page', @@ -197,6 +206,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { canSeeHrTools, reportsDisabled, data, + isRestrictedImpersonation, ]); const navPages = useMemo( diff --git a/src/hooks/useSettingsNavItems.ts b/src/hooks/useSettingsNavItems.ts index 208894544b..e735f2d73a 100644 --- a/src/hooks/useSettingsNavItems.ts +++ b/src/hooks/useSettingsNavItems.ts @@ -59,7 +59,7 @@ export function useSettingsNavItems(): NavItems[] { { id: 'admin', title: t('Admin Console'), - grantedAccess: ['admin', 'developer'], + grantedAccess: ['admin', 'developer', 'mpdSupervisorAdmin'], }, { id: '/auth/user/admin', From c3b232d65c3d0155a8e29a72705915cda86801d3 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 14 Jul 2026 16:17:33 -0400 Subject: [PATCH 04/24] MPDX-9771 - Add impersonate action to MPD Goal Admin rows Co-Authored-By: Claude Fable 5 --- .../GoalsTable/GoalsTable.test.tsx | 62 +++++- .../MpdGoalAdmin/GoalsTable/GoalsTable.tsx | 31 +++ .../DynamicImpersonateStaffModal.tsx | 14 ++ .../ImpersonateStaffModal.test.tsx | 183 ++++++++++++++++++ .../ImpersonateStaffModal.tsx | 141 ++++++++++++++ 5 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/DynamicImpersonateStaffModal.tsx create mode 100644 src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.test.tsx create mode 100644 src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.tsx diff --git a/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.test.tsx b/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.test.tsx index 1728cb1205..f8090cea00 100644 --- a/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.test.tsx +++ b/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.test.tsx @@ -1,6 +1,9 @@ import { ThemeProvider } from '@mui/material/styles'; import { act, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { mockSession } from '__tests__/util/mockSession'; import theme from 'src/theme'; import { MpdGoalAdminProvider, useMpdGoalAdmin } from '../MpdGoalAdminContext'; import { mockCohorts } from '../mockData'; @@ -18,11 +21,15 @@ const Capture: React.FC<{ rows: (typeof mockCohorts)[0]['rows'] }> = ({ const renderTable = (data = rows) => render( - - - - - , + + + + + + + + + , ); describe('GoalsTable', () => { @@ -164,4 +171,49 @@ describe('GoalsTable', () => { queryByText(`Person ${DEFAULT_ROWS_PER_PAGE}`), ).not.toBeInTheDocument(); }); + + describe('Impersonate action', () => { + it('shows an Impersonate action per row for an MPD supervisor admin', () => { + mockSession({ mpdSupervisorAdmin: true, admin: false }); + + const { getAllByRole } = renderTable(); + expect(getAllByRole('button', { name: 'Impersonate' })).toHaveLength( + Math.min(rows.length, DEFAULT_ROWS_PER_PAGE), + ); + }); + + it('shows an Impersonate action per row for an admin', () => { + mockSession({ mpdSupervisorAdmin: false, admin: true }); + + const { getAllByRole } = renderTable(); + expect(getAllByRole('button', { name: 'Impersonate' })).toHaveLength( + Math.min(rows.length, DEFAULT_ROWS_PER_PAGE), + ); + }); + + it('hides the Impersonate action for users without admin access', () => { + mockSession({ mpdSupervisorAdmin: false, admin: false }); + + const { queryByRole } = renderTable(); + expect( + queryByRole('button', { name: 'Impersonate' }), + ).not.toBeInTheDocument(); + }); + + it('opens the impersonate modal for the selected staff member', async () => { + mockSession({ mpdSupervisorAdmin: true, admin: false }); + + const { getAllByRole, findByText } = renderTable(); + // The first Impersonate action belongs to the first row on the page. + userEvent.click(getAllByRole('button', { name: 'Impersonate' })[0]); + + // The modal is loaded dynamically, so it appears asynchronously. + expect(await findByText('Impersonate John & Jane Doe')).toBeVisible(); + expect( + await findByText( + 'You are about to impersonate John & Jane Doe (john.doe@example.com). You will be logged in on their behalf and will see what they see. Provide a reason for this impersonation.', + ), + ).toBeVisible(); + }); + }); }); diff --git a/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.tsx b/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.tsx index 21d31e8f68..9ba7cf5da6 100644 --- a/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.tsx +++ b/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.tsx @@ -20,11 +20,16 @@ import { import { visuallyHidden } from '@mui/utils'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; +import { useRequiredSession } from 'src/hooks/useRequiredSession'; import { currencyFormat } from 'src/lib/intlFormat'; import { AssignCoachModal, AssignCoachOption, } from '../AssignCoachModal/AssignCoachModal'; +import { + DynamicImpersonateStaffModal, + preloadImpersonateStaffModal, +} from '../ImpersonateStaffModal/DynamicImpersonateStaffModal'; import { useMpdGoalAdmin } from '../MpdGoalAdminContext'; import { StaffGoalRow, isSendable } from '../mpdGoalAdminHelpers'; @@ -37,12 +42,19 @@ export const DEFAULT_ROWS_PER_PAGE = 5; export const GoalsTable: React.FC = ({ rows }) => { const { t } = useTranslation(); const locale = useLocale(); + const { admin, mpdSupervisorAdmin } = useRequiredSession(); const { selectedRowIds, toggleRow, toggleRows, search, selectedCohortId } = useMpdGoalAdmin(); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE); // The staff row whose coach is being assigned; null when the modal is closed. const [coachRow, setCoachRow] = useState(null); + // The staff row being impersonated; null when the modal is closed. + const [impersonateRow, setImpersonateRow] = useState( + null, + ); + + const canImpersonate = admin || mpdSupervisorAdmin; // TODO(MPDX-9699): populate from the assignable-coaches query once the // backend field exists. Empty for now so the modal renders a UI-only dropdown. @@ -166,6 +178,18 @@ export const GoalsTable: React.FC = ({ rows }) => { > {t('View/Edit')} + {canImpersonate && ( + setImpersonateRow(row)} + sx={{ ml: 1 }} + > + {t('Impersonate')} + + )} {/* Disabled until wired up so assistive tech announces the @@ -203,6 +227,13 @@ export const GoalsTable: React.FC = ({ rows }) => { handleClose={() => setCoachRow(null)} /> )} + {impersonateRow && ( + setImpersonateRow(null)} + /> + )} ); }; diff --git a/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/DynamicImpersonateStaffModal.tsx b/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/DynamicImpersonateStaffModal.tsx new file mode 100644 index 0000000000..c4ed9ae9df --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/DynamicImpersonateStaffModal.tsx @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic'; +import { DynamicModalPlaceholder } from 'src/components/DynamicPlaceholders/DynamicModalPlaceholder'; + +export const preloadImpersonateStaffModal = () => + import( + /* webpackChunkName: "ImpersonateStaffModal" */ './ImpersonateStaffModal' + ).then(({ ImpersonateStaffModal }) => ImpersonateStaffModal); + +export const DynamicImpersonateStaffModal = dynamic( + preloadImpersonateStaffModal, + { + loading: DynamicModalPlaceholder, + }, +); diff --git a/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.test.tsx b/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.test.tsx new file mode 100644 index 0000000000..0994cf94da --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.test.tsx @@ -0,0 +1,183 @@ +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import theme from 'src/theme'; +import { ImpersonateStaffModal } from './ImpersonateStaffModal'; + +const staffName = 'John & Jane Doe'; +const staffEmail = 'john.doe@example.com'; + +const push = jest.fn(); +const router = { + push, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleClose = jest.fn(); + +const Components = () => ( + + + + + + + +); + +describe('ImpersonateStaffModal', () => { + it('shows who will be impersonated', () => { + const { getByText } = render(); + + expect(getByText(`Impersonate ${staffName}`)).toBeInTheDocument(); + expect( + getByText( + `You are about to impersonate ${staffName} (${staffEmail}). You will be logged in on their behalf and will see what they see. Provide a reason for this impersonation.`, + ), + ).toBeInTheDocument(); + }); + + it('requires a reason before submitting', async () => { + const fetch = jest.fn(); + window.fetch = fetch; + + const { getByRole, findByText } = render(); + + userEvent.click(getByRole('button', { name: 'Impersonate' })); + + expect(await findByText('Reason is required')).toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('impersonates the staff member and redirects on success', async () => { + const fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve({ errors: [] }), + status: 200, + }); + window.fetch = fetch; + + const { getByRole } = render(); + + userEvent.type( + getByRole('textbox', { name: /reason \/ helpscout ticket link/i }), + 'HS-1234', + ); + userEvent.click(getByRole('button', { name: 'Impersonate' })); + + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + '/api/auth/impersonate/impersonateUser', + { + method: 'POST', + body: JSON.stringify({ + user: staffEmail, + reason: 'HS-1234', + }), + }, + ), + ); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + 'Redirecting you to home screen to impersonate user...', + { + variant: 'success', + }, + ), + ); + expect(push).toHaveBeenCalledWith('/login'); + }); + + it('disables the buttons while submitting', async () => { + // A fetch that never resolves keeps the form in the submitting state + const fetch = jest.fn().mockReturnValue(new Promise(() => {})); + window.fetch = fetch; + + const { getByRole } = render(); + + userEvent.type( + getByRole('textbox', { name: /reason \/ helpscout ticket link/i }), + 'HS-1234', + ); + userEvent.click(getByRole('button', { name: 'Impersonate' })); + + await waitFor(() => + expect(getByRole('button', { name: 'Impersonate' })).toBeDisabled(), + ); + expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + it('shows the API errors without redirecting', async () => { + const fetch = jest.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + errors: [{ detail: 'Error1' }, { detail: 'Error2' }], + }), + status: 400, + }); + window.fetch = fetch; + + const { getByRole } = render(); + + userEvent.type( + getByRole('textbox', { name: /reason \/ helpscout ticket link/i }), + 'HS-1234', + ); + userEvent.click(getByRole('button', { name: 'Impersonate' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith('Error1', { variant: 'error' }); + expect(mockEnqueue).toHaveBeenCalledWith('Error2', { variant: 'error' }); + }); + expect(push).not.toHaveBeenCalled(); + }); + + it('shows unknown errors without redirecting', async () => { + const fetch = jest.fn().mockRejectedValue(new Error('Unknown Error')); + window.fetch = fetch; + + const { getByRole } = render(); + + userEvent.type( + getByRole('textbox', { name: /reason \/ helpscout ticket link/i }), + 'HS-1234', + ); + userEvent.click(getByRole('button', { name: 'Impersonate' })); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Unknown Error', { + variant: 'error', + }), + ); + expect(push).not.toHaveBeenCalled(); + }); + + it('closes without submitting via the cancel button', async () => { + const fetch = jest.fn(); + window.fetch = fetch; + + const { getByRole } = render(); + + userEvent.click(getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(handleClose).toHaveBeenCalled()); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.tsx b/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.tsx new file mode 100644 index 0000000000..50f7d008d9 --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/ImpersonateStaffModal/ImpersonateStaffModal.tsx @@ -0,0 +1,141 @@ +import { useRouter } from 'next/router'; +import React, { ReactElement } from 'react'; +import { + DialogActions, + DialogContent, + TextField, + Typography, +} from '@mui/material'; +import { Formik } from 'formik'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; +import { + CancelButton, + SubmitButton, +} from 'src/components/Shared/Modal/ActionButtons/ActionButtons'; +import Modal from 'src/components/Shared/Modal/Modal'; +import { getErrorMessage } from 'src/lib/getErrorFromCatch'; + +interface ImpersonateStaffModalProps { + /** Name of the staff member to impersonate, shown in the modal title. */ + staffName: string; + /** Email the impersonation session is started for. */ + staffEmail: string; + handleClose: () => void; +} + +interface ImpersonateStaffFormValues { + reason: string; +} + +export const ImpersonateStaffModal: React.FC = ({ + staffName, + staffEmail, + handleClose, +}) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const { push } = useRouter(); + + const impersonateStaffSchema: yup.ObjectSchema = + yup.object({ + reason: yup.string().required(t('Reason is required')), + }); + + // Mirrors the impersonation flow in + // src/components/Settings/Admin/ImpersonateUser/ImpersonateUserAccordion.tsx + const onSubmit = async ({ reason }: ImpersonateStaffFormValues) => { + try { + const setupImpersonate = await fetch( + '/api/auth/impersonate/impersonateUser', + { + method: 'POST', + body: JSON.stringify({ + user: staffEmail, + reason, + }), + }, + ); + const setupImpersonateJson: { errors?: Array<{ detail: string }> } = + await setupImpersonate.json(); + + if (setupImpersonate.status === 200) { + enqueueSnackbar( + t('Redirecting you to home screen to impersonate user...'), + { + variant: 'success', + }, + ); + push('/login'); + } else { + setupImpersonateJson.errors?.forEach((error) => { + enqueueSnackbar(error.detail, { + variant: 'error', + }); + }); + } + } catch (err) { + enqueueSnackbar(getErrorMessage(err), { + variant: 'error', + }); + } + }; + + return ( + + + {({ + values: { reason }, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + errors, + touched, + }): ReactElement => ( +
+ + + {t( + 'You are about to impersonate {{name}} ({{email}}). You will be logged in on their behalf and will see what they see. Provide a reason for this impersonation.', + { name: staffName, email: staffEmail }, + )} + + + + + + + {t('Impersonate')} + + +
+ )} +
+
+ ); +}; From 065d1591ee1c5bd04dc57365e4f2a304e4894a1e Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 14 Jul 2026 16:47:52 -0400 Subject: [PATCH 05/24] MPDX-9771 - Render non-editable HR forms read-only during restricted impersonation Co-Authored-By: Claude Fable 5 --- .../RequestPage/RequestPage.test.tsx | 31 ++++ .../RequestPage/RequestPage.tsx | 31 +++- .../Shared/AdditionalSalaryRequestContext.tsx | 16 +- .../Shared/AutoSave/useSaveField.test.tsx | 16 ++ .../Shared/AutoSave/useSaveField.ts | 16 +- .../Shared/useAdditionalSalaryRequestForm.ts | 7 +- .../Categories/Autosave/useSaveField.test.tsx | 19 +++ .../Categories/Autosave/useSaveField.ts | 14 +- .../GoalCalculator/GoalCard/MpdGoalCard.tsx | 3 + .../GoalsList/GoalsList.test.tsx | 9 + .../GoalCalculator/GoalsList/GoalsList.tsx | 35 +++- .../GoalCard/PdsGoalCard.tsx | 3 + .../GoalsList/PdsGoalsList.test.tsx | 16 ++ .../GoalsList/PdsGoalsList.tsx | 31 +++- .../HoursPerWeekGrid/HoursPerWeekGrid.tsx | 56 ++++-- .../Shared/Autosave/useSaveField.test.tsx | 19 +++ .../Shared/Autosave/useSaveField.ts | 7 +- .../BalanceCard/BalanceCard.test.tsx | 13 ++ .../BalanceCard/BalanceCard.tsx | 28 ++- .../Table/Row/createTableRow.tsx | 159 ++++++++++-------- .../Table/TransfersTable.tsx | 3 + .../DirectionButtons/DirectionButtons.tsx | 22 ++- .../Reports/Shared/GoalCard/GoalCard.tsx | 25 ++- src/hooks/useRestrictedImpersonation.test.ts | 19 +++ src/hooks/useRestrictedImpersonation.ts | 9 + 25 files changed, 476 insertions(+), 131 deletions(-) create mode 100644 src/hooks/useRestrictedImpersonation.test.ts create mode 100644 src/hooks/useRestrictedImpersonation.ts diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.test.tsx index 02e7b40327..23f3e3cc30 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.test.tsx @@ -7,6 +7,7 @@ import { SnackbarProvider } from 'notistack'; import * as yup from 'yup'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { mockSession } from '__tests__/util/mockSession'; import { AsrStatusEnum, ElectionType403bEnum, @@ -814,4 +815,34 @@ describe('RequestPage', () => { ).not.toBeInTheDocument(); }); }); + + describe('restricted impersonation', () => { + it('disables the Continue button on the first page when continuing would create a request', () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + mockUseAdditionalSalaryRequest.mockReturnValue({ + ...defaultMockContextValue, + requestData: { latestAdditionalSalaryRequest: null }, + } as unknown as ReturnType); + + const { getByRole } = render(); + + expect(getByRole('button', { name: /continue/i })).toBeDisabled(); + }); + + it('disables the Submit button and hides Discard on the last form page', () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + mockUseAdditionalSalaryRequest.mockReturnValue({ + ...defaultMockContextValue, + currentIndex: 1, + currentStep: AdditionalSalaryRequestSectionEnum.CompleteForm, + } as unknown as ReturnType); + + const { getByRole, queryByRole } = render(); + + expect(getByRole('button', { name: /submit/i })).toBeDisabled(); + expect( + queryByRole('button', { name: /discard/i }), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.tsx index dd194ba16e..283ec53beb 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/RequestPage.tsx @@ -10,6 +10,7 @@ import { import Loading from 'src/components/Loading/Loading'; import { LimitedAccess } from 'src/components/Shared/LimitedAccess/LimitedAccess'; import { AsrStatusEnum } from 'src/graphql/types.generated'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import theme from 'src/theme'; import { PanelLayout } from '../../Shared/CalculationReports/PanelLayout/PanelLayout'; import { useIconPanelItems } from '../../Shared/CalculationReports/PanelLayout/useIconPanelItems'; @@ -55,6 +56,8 @@ const MainContent: React.FC = () => { const latestRequest = requestData?.latestAdditionalSalaryRequest; const reason = latestRequest?.progressiveApprovalTierReason ?? null; + const restrictedImpersonation = useRestrictedImpersonation(); + const [createRequest, { loading: creatingRequest }] = useCreateAdditionalSalaryRequestMutation(); @@ -68,11 +71,18 @@ const MainContent: React.FC = () => { errors, } = useFormikContext(); + const wouldCreateRequest = + latestRequest === null || + latestRequest?.status === AsrStatusEnum.ApprovedAndPaid; + const handleContinue = async () => { - if ( - latestRequest === null || - latestRequest?.status === AsrStatusEnum.ApprovedAndPaid - ) { + if (wouldCreateRequest) { + // The tool is read-only during restricted impersonation, so continuing + // must not create a new request + if (restrictedImpersonation) { + return; + } + await trackMutation( createRequest({ variables: { @@ -158,7 +168,9 @@ const MainContent: React.FC = () => { handlePreviousStep={handlePreviousStep} showBackButton={!isFirstFormPage} handleDiscard={ - requestId && isFormPage ? handleDiscard : undefined + requestId && isFormPage && !restrictedImpersonation + ? handleDiscard + : undefined } isSubmission={isLastFormPage} submitForm={submitForm} @@ -174,6 +186,15 @@ const MainContent: React.FC = () => { (splitAsr && !!errors.additionalInfo) || (exceedsCap && !!errors.additionalInfo) } + disableNext={ + restrictedImpersonation && + ((isFirstFormPage && wouldCreateRequest) || isLastFormPage) + } + disabledNextTooltip={ + restrictedImpersonation + ? t('Read-only while impersonating') + : undefined + } overrideNext={isFirstFormPage ? handleContinue : undefined} /> diff --git a/src/components/HrTools/AdditionalSalaryRequest/Shared/AdditionalSalaryRequestContext.tsx b/src/components/HrTools/AdditionalSalaryRequest/Shared/AdditionalSalaryRequestContext.tsx index cc6fd22080..0966031e27 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/Shared/AdditionalSalaryRequestContext.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/Shared/AdditionalSalaryRequestContext.tsx @@ -18,6 +18,7 @@ import { AdditionalSalaryRequestCalculations, AsrStatusEnum, } from 'src/graphql/types.generated'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { useStepList } from 'src/hooks/useStepList'; import { useTrackMutation } from 'src/hooks/useTrackMutation'; import { Steps } from '../../Shared/CalculationReports/StepsList/StepsList'; @@ -200,6 +201,7 @@ export const AdditionalSalaryRequestProvider: React.FC = ({ const [deleteAdditionalSalaryRequest] = useDeleteAdditionalSalaryRequestMutation(); + const restrictedImpersonation = useRestrictedImpersonation(); const currentStep = sections[currentIndex]; @@ -213,6 +215,12 @@ export const AdditionalSalaryRequestProvider: React.FC = ({ const handleDeleteRequest = useCallback( async (id: string, isCancel: boolean) => { + // The tool is read-only during restricted impersonation, so deleting + // must not fire mutations that the API would reject + if (restrictedImpersonation) { + return; + } + await deleteAdditionalSalaryRequest({ variables: { id }, refetchQueries: ['AdditionalSalaryRequest'], @@ -235,7 +243,13 @@ export const AdditionalSalaryRequestProvider: React.FC = ({ }, }); }, - [deleteAdditionalSalaryRequest, enqueueSnackbar, resetSteps, t], + [ + deleteAdditionalSalaryRequest, + restrictedImpersonation, + enqueueSnackbar, + resetSteps, + t, + ], ); const [isDrawerOpen, setIsDrawerOpen] = useState(true); diff --git a/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.test.tsx index f7af3551cd..c5ae0873d9 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.test.tsx @@ -1,6 +1,7 @@ import { ThemeProvider } from '@mui/material/styles'; import { act, renderHook, waitFor } from '@testing-library/react'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { mockSession } from '__tests__/util/mockSession'; import { PageEnum } from 'src/components/HrTools/Shared/CalculationReports/Shared/sharedTypes'; import { ElectionType403bEnum } from 'src/graphql/types.generated'; import { createCache } from 'src/lib/apollo/cache'; @@ -238,4 +239,19 @@ describe('useSaveField', () => { await waitFor(() => expect(mockTrackMutation).toHaveBeenCalled()); }); + + it('should not call mutation during restricted impersonation', async () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + + const { result } = renderHook( + () => useSaveField({ formValues: defaultFormValues }), + { + wrapper: TestComponent, + }, + ); + + await result.current({ currentYearSalaryNotReceived: 200 }); + + expect(mutationSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.ts b/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.ts index bba98f8945..b9b4248eb3 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.ts +++ b/src/components/HrTools/AdditionalSalaryRequest/Shared/AutoSave/useSaveField.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { AdditionalSalaryRequestAttributesInput } from 'src/graphql/types.generated'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { CompleteFormValues } from '../../AdditionalSalaryRequest'; import { useUpdateAdditionalSalaryRequestMutation } from '../../AdditionalSalaryRequest.generated'; import { useAdditionalSalaryRequest } from '../AdditionalSalaryRequestContext'; @@ -11,6 +12,7 @@ interface UseSaveFieldOptions { export const useSaveField = ({ formValues }: UseSaveFieldOptions) => { const { requestData, trackMutation } = useAdditionalSalaryRequest(); + const restrictedImpersonation = useRestrictedImpersonation(); const [updateAdditionalSalaryRequest] = useUpdateAdditionalSalaryRequestMutation({ refetchQueries: ['AdditionalSalaryRequest'], @@ -20,6 +22,12 @@ export const useSaveField = ({ formValues }: UseSaveFieldOptions) => { async ( attributes: Partial, ): Promise => { + // The tool is read-only during restricted impersonation, so autosave + // must not fire mutations that the API would reject + if (restrictedImpersonation) { + return; + } + const request = requestData?.latestAdditionalSalaryRequest; const requestId = request?.id; if (!requestId) { @@ -48,7 +56,13 @@ export const useSaveField = ({ formValues }: UseSaveFieldOptions) => { }), ); }, - [formValues, updateAdditionalSalaryRequest, requestData, trackMutation], + [ + formValues, + restrictedImpersonation, + updateAdditionalSalaryRequest, + requestData, + trackMutation, + ], ); return saveField; diff --git a/src/components/HrTools/AdditionalSalaryRequest/Shared/useAdditionalSalaryRequestForm.ts b/src/components/HrTools/AdditionalSalaryRequest/Shared/useAdditionalSalaryRequestForm.ts index d704ade127..18409b7337 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/Shared/useAdditionalSalaryRequestForm.ts +++ b/src/components/HrTools/AdditionalSalaryRequest/Shared/useAdditionalSalaryRequestForm.ts @@ -8,6 +8,7 @@ import { ProgressiveApprovalTierReasonEnum, } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import i18n from 'src/lib/i18n'; import { currencyFormat } from 'src/lib/intlFormat'; import { amount, phoneNumber } from 'src/lib/yupHelpers'; @@ -37,6 +38,7 @@ export const useAdditionalSalaryRequestForm = ( } = useAdditionalSalaryRequest(); const { primaryAccountBalance } = useFormUserInfo(); + const restrictedImpersonation = useRestrictedImpersonation(); const { data: requestData } = useAdditionalSalaryRequestQuery({ variables: { isSpouse }, @@ -202,7 +204,9 @@ export const useAdditionalSalaryRequestForm = ( const onSubmit = useCallback( async (values: CompleteFormValues) => { - if (!requestId) { + // The tool is read-only during restricted impersonation, so submitting + // must not fire mutations that the API would reject + if (restrictedImpersonation || !requestId) { return; } @@ -238,6 +242,7 @@ export const useAdditionalSalaryRequestForm = ( }, [ requestId, + restrictedImpersonation, updateAdditionalSalaryRequest, submitAdditionalSalaryRequest, handleNextStep, diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.test.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.test.tsx index f48eeb989c..4facead466 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.test.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.test.tsx @@ -1,4 +1,5 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { mockSession } from '__tests__/util/mockSession'; import { GoalCalculatorTestWrapper } from '../../../GoalCalculatorTestWrapper'; import { useSaveField } from './useSaveField'; @@ -49,4 +50,22 @@ describe('useSaveField', () => { expect(mutationSpy).not.toHaveGraphqlOperation('UpdateGoalCalculation'), ); }); + + it('should not update goal calculation during restricted impersonation', async () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + + const { result } = renderHook(useSaveField, { wrapper: Wrapper }); + + // Wait for the goal calculation to load + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('GoalCalculation'), + ); + + result.current({ name: 'New Name' }); + + await Promise.resolve(); + await waitFor(() => + expect(mutationSpy).not.toHaveGraphqlOperation('UpdateGoalCalculation'), + ); + }); }); diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.ts b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.ts index e46927096c..8bbc7283fa 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.ts +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useSaveField.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { GoalCalculationUpdateInput } from 'src/graphql/types.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { useGoalCalculator } from '../../../Shared/GoalCalculatorContext'; import { useUpdateGoalCalculationMutation } from '../SettingsCategory/GoalCalculation.generated'; @@ -12,10 +13,13 @@ export const useSaveField = () => { } = useGoalCalculator(); const goalCalculation = data?.goalCalculation; const [updateGoalCalculation] = useUpdateGoalCalculationMutation(); + const restrictedImpersonation = useRestrictedImpersonation(); const saveField = useCallback( async (attributes: Partial) => { - if (!goalCalculation) { + // The tool is read-only during restricted impersonation, so autosave + // must not fire mutations that the API would reject + if (restrictedImpersonation || !goalCalculation) { return; } @@ -49,7 +53,13 @@ export const useSaveField = () => { }), ); }, - [accountListId, goalCalculation, trackMutation, updateGoalCalculation], + [ + accountListId, + goalCalculation, + restrictedImpersonation, + trackMutation, + updateGoalCalculation, + ], ); return saveField; diff --git a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx index 91bcfb151c..28b6736401 100644 --- a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx +++ b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { GoalCard } from 'src/components/Reports/Shared/GoalCard/GoalCard'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { ListGoalCalculationFragment, useDeleteGoalCalculationMutation, @@ -15,6 +16,7 @@ export interface MpdGoalCardProps { export const MpdGoalCard: React.FC = ({ goal }) => { const accountListId = useAccountListId(); const constants = useGoalCalculatorConstants(); + const restrictedImpersonation = useRestrictedImpersonation(); const [deleteGoalCalculation] = useDeleteGoalCalculationMutation(); const overallTotal = useMemo( @@ -44,6 +46,7 @@ export const MpdGoalCard: React.FC = ({ goal }) => { updatedAt={goal.updatedAt} viewHref={`/accountLists/${accountListId}/hrTools/goalCalculator/${goal.id}`} onDelete={handleDelete} + deleteDisabled={restrictedImpersonation} /> ); }; diff --git a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx index 317f5b7669..7d2b769341 100644 --- a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx +++ b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx @@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { mockSession } from '__tests__/util/mockSession'; import { GoalCalculationsQuery } from './GoalCalculations.generated'; import { GoalsList } from './GoalsList'; @@ -114,4 +115,12 @@ describe('GoalsList', () => { }), ); }); + + it('disables the create button during restricted impersonation', async () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + + const { getByRole } = render(); + + expect(getByRole('button', { name: 'Create a New Goal' })).toBeDisabled(); + }); }); diff --git a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx index d9123cb787..09eadbfadd 100644 --- a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx +++ b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx @@ -1,10 +1,18 @@ import { useRouter } from 'next/router'; import React from 'react'; -import { Box, Button, CircularProgress, Stack, styled } from '@mui/material'; +import { + Box, + Button, + CircularProgress, + Stack, + Tooltip, + styled, +} from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useFetchAllPages } from 'src/hooks/useFetchAllPages'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import illustration6graybg from 'src/images/drawkit/grape/drawkit-grape-pack-illustration-6-gray-bg.svg'; import { MpdGoalCard } from '../GoalCard/MpdGoalCard'; import { @@ -27,6 +35,7 @@ export const GoalsList: React.FC = () => { const { t } = useTranslation(); const router = useRouter(); const accountListId = useAccountListId(); + const restrictedImpersonation = useRestrictedImpersonation(); const { data, error, fetchMore } = useGoalCalculationsQuery({ variables: { accountListId }, }); @@ -61,14 +70,24 @@ export const GoalsList: React.FC = () => { - + + + + + + + + = ({ const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); const { calculation, trackMutation } = usePdsGoalCalculator(); + const restrictedImpersonation = useRestrictedImpersonation(); const [createHoursItem] = useCreateDesignationSupportHoursItemMutation(); const [updateHoursItem] = useUpdateDesignationSupportHoursItemMutation(); const [deleteHoursItem] = useDeleteDesignationSupportHoursItemMutation(); @@ -128,7 +131,8 @@ export const HoursPerWeekGrid: React.FC = ({ const saveHoursItem = useCallback( async (entry: HoursPerWeekEntry, currentEntries: HoursPerWeekEntry[]) => { - if (!calculation) { + // Read-only during restricted impersonation + if (restrictedImpersonation || !calculation) { return; } @@ -183,6 +187,7 @@ export const HoursPerWeekGrid: React.FC = ({ }, [ calculation, + restrictedImpersonation, createHoursItem, updateHoursItem, trackMutation, @@ -203,7 +208,8 @@ export const HoursPerWeekGrid: React.FC = ({ ); const addEntry = useCallback(async () => { - if (!calculation) { + // Read-only during restricted impersonation + if (restrictedImpersonation || !calculation) { return; } @@ -253,6 +259,7 @@ export const HoursPerWeekGrid: React.FC = ({ }, [ t, calculation, + restrictedImpersonation, createHoursItem, trackMutation, entries.length, @@ -261,6 +268,11 @@ export const HoursPerWeekGrid: React.FC = ({ const deleteEntry = useCallback( async (id: string | number) => { + // Read-only during restricted impersonation + if (restrictedImpersonation) { + return; + } + const entryId = id.toString(); const previousEntries = entries; const remainingEntries = entries.filter((entry) => entry.id !== entryId); @@ -299,7 +311,15 @@ export const HoursPerWeekGrid: React.FC = ({ }); } }, - [entries, deleteHoursItem, trackMutation, saveField, enqueueSnackbar, t], + [ + entries, + restrictedImpersonation, + deleteHoursItem, + trackMutation, + saveField, + enqueueSnackbar, + t, + ], ); const processRowUpdate = useCallback( @@ -439,12 +459,13 @@ export const HoursPerWeekGrid: React.FC = ({ label={t('Delete')} onClick={() => deleteEntry(params.id)} showInMenu={false} + disabled={restrictedImpersonation} />, ]; }, }, ], - [t, totalHours, deleteEntry], + [t, totalHours, deleteEntry, restrictedImpersonation], ); return ( @@ -457,14 +478,23 @@ export const HoursPerWeekGrid: React.FC = ({ - + + + + = ({ }); }} isCellEditable={(params) => { + // Read-only during restricted impersonation + if (restrictedImpersonation) { + return false; + } if (params.id === 'total') { return false; } diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx index 0c38e9bc45..fe919f990a 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; +import { mockSession } from '__tests__/util/mockSession'; import { UpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalculations.generated'; import { PdsGoalCalculatorTestWrapper, @@ -93,4 +94,22 @@ describe('useSaveField', () => { ), ); }); + + it('does not update the calculation during restricted impersonation', async () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + + const { result } = renderHook(useSaveField, { wrapper: Wrapper }); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('PdsGoalCalculation'), + ); + + await result.current({ name: 'New Name' }); + + await waitFor(() => + expect(mutationSpy).not.toHaveGraphqlOperation( + 'UpdatePdsGoalCalculation', + ), + ); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts index b5b23fafa3..77c6ca5c21 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { DesignationSupportCalculationUpdateInput } from 'src/graphql/types.generated'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { useUpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalculations.generated'; import { usePdsGoalCalculator } from '../PdsGoalCalculatorContext'; @@ -10,10 +11,13 @@ export const useSaveField = () => { const [updatePdsGoalCalculation] = useUpdatePdsGoalCalculationMutation(); const { enqueueSnackbar } = useSnackbar(); const { t } = useTranslation(); + const restrictedImpersonation = useRestrictedImpersonation(); const saveField = useCallback( async (attributes: Partial) => { - if (!calculation) { + // The tool is read-only during restricted impersonation, so autosave + // must not fire mutations that the API would reject + if (restrictedImpersonation || !calculation) { return; } @@ -57,6 +61,7 @@ export const useSaveField = () => { }, [ calculation, + restrictedImpersonation, trackFieldMutation, updatePdsGoalCalculation, enqueueSnackbar, diff --git a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx index 3daf029066..029067d7eb 100644 --- a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx +++ b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { mockSession } from '__tests__/util/mockSession'; import theme from 'src/theme'; import { FundFieldsFragment } from '../ReportsSavingsFund.generated'; import { FundTypeEnum } from '../mockData'; @@ -205,4 +206,16 @@ describe('BalanceCard', () => { expect(transferFromButton).toBeDisabled(); }); + + it('should disable transfer from button during restricted impersonation', async () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + + const { findByRole } = render(); + + const transferFromButton = await findByRole('button', { + name: /transfer from/i, + }); + + expect(transferFromButton).toBeDisabled(); + }); }); diff --git a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx index 58b06f88f8..c2101c6a87 100644 --- a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx +++ b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { Diversity1, Outbox, Savings, Wallet } from '@mui/icons-material'; -import { Box, Button, Card, Typography } from '@mui/material'; +import { Box, Button, Card, Tooltip, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { SimpleScreenOnly } from 'src/components/Reports/styledComponents'; import { useLocale } from 'src/hooks/useLocale'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { currencyFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; import { FundFieldsFragment } from '../ReportsSavingsFund.generated'; @@ -22,6 +23,7 @@ export const BalanceCard: React.FC = ({ }) => { const { t } = useTranslation(); const locale = useLocale(); + const restrictedImpersonation = useRestrictedImpersonation(); const title = t('{{ name }} Account Balance', { name: fund.fundType }); const Icon = @@ -129,14 +131,24 @@ export const BalanceCard: React.FC = ({ mt: 'auto', }} > - + + + + ); diff --git a/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx b/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx index 3edab0ef8a..42d51a3bc5 100644 --- a/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx +++ b/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx @@ -31,6 +31,7 @@ type Options = { handleFailedTransferOpen: (transfer: Transfers) => void; t: TFunction; locale: string; + restrictedImpersonation?: boolean; }; export function populateTransferRows(options: Options) { @@ -42,6 +43,7 @@ export function populateTransferRows(options: Options) { handleFailedTransferOpen, t, locale, + restrictedImpersonation = false, } = options; useEffect(() => { @@ -209,82 +211,95 @@ export function populateTransferRows(options: Options) { const actions: RenderCell = ({ row }) => { if (row.actions === 'edit-delete') { - return type === TableTypeEnum.Upcoming ? ( - <> - - handleEditModalOpen(row)} - /> - - - { - handleDeleteModalOpen(row); - }} - sx={{ color: 'error.main' }} - /> - - - ) : row.endDate ? ( - <> - { - event.stopPropagation(); - handleCalendarOpen(row); - }} - > - + + handleEditModalOpen(row)} + /> + + + { + handleDeleteModalOpen(row); + }} + sx={{ color: 'error.main' }} + /> + + + ) : row.endDate ? ( + <> + { + event.stopPropagation(); + handleCalendarOpen(row); }} > - edit_calendar - - - - { - handleDeleteModalOpen(row); - }} - /> - - - ) : ( - <> - { - event.stopPropagation(); - handleCalendarOpen(row); - }} - > - + edit_calendar + + + + { + handleDeleteModalOpen(row); + }} + /> + + + ) : ( + <> + { + event.stopPropagation(); + handleCalendarOpen(row); }} > - calendar_add_on - - - - { - handleDeleteModalOpen(row); - }} - /> - - + + calendar_add_on + + + + { + handleDeleteModalOpen(row); + }} + /> + + + ); + + return ( + + {actionButtons} + ); } diff --git a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx index fa7d3f959f..149e6fa7e6 100644 --- a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx +++ b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx @@ -13,6 +13,7 @@ import { LoadingIndicator, } from 'src/components/Shared/styledComponents/LoadingStyling'; import { useLocale } from 'src/hooks/useLocale'; +import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { CustomEditCalendar } from '../CustomEditCalendar/CustomEditCalendar'; import { DynamicDeleteTransferModal } from '../DeleteTransferModal/DynamicDeleteTransferModal'; import { DynamicFailedTransferModal } from '../FailedTransferModal/DynamicFailedTransferModal'; @@ -76,6 +77,7 @@ export const TransfersTable: React.FC = ({ const { t } = useTranslation(); const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); + const restrictedImpersonation = useRestrictedImpersonation(); const [updateRecurringTransfer] = useUpdateRecurringTransferMutation({ refetchQueries: ['ReportsSavingsFundTransfer', 'FundBalances'], @@ -186,6 +188,7 @@ export const TransfersTable: React.FC = ({ handleFailedTransferOpen, t, locale, + restrictedImpersonation, }); const transferRows = history.map(CreateTransferRows); diff --git a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx index 32d1d9aa91..6c6168805e 100644 --- a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx +++ b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx @@ -139,15 +139,19 @@ export const DirectionButtons: React.FC = ({ {handleDiscard && backButton} {!hideNextButton && (isSubmission ? ( - + + + + + ) : ( Promise; badge?: React.ReactNode; + deleteDisabled?: boolean; } export const GoalCard: React.FC = ({ @@ -74,6 +75,7 @@ export const GoalCard: React.FC = ({ viewHref, onDelete, badge, + deleteDisabled = false, }) => { const { t } = useTranslation(); const locale = useLocale(); @@ -158,11 +160,24 @@ export const GoalCard: React.FC = ({ - + + + + + - - + {t('Create a New Goal')} + - - + {t('Create a New Goal')} + { // Default Regular Week 40*48/48 = 40.00 remains expect(getByText('40.00')).toBeInTheDocument(); }); - - describe('restricted impersonation', () => { - // Includes a custom (deletable) entry so the delete action renders - const restrictedCalculationMock: PdsGoalCalculationMock = { - ...defaultCalculationMock, - designationSupportHoursItems: [ - ...(defaultCalculationMock.designationSupportHoursItems ?? []), - { - id: 'item-custom', - label: 'Custom Entry', - hoursPerWeek: 5, - numberOfWeeks: 2, - name: 'custom', - position: 3, - predefined: false, - }, - ], - }; - - it('disables the Add Entry button', async () => { - mockSession({ impersonationScope: 'mpd_supervisor' }); - - const { findByText, getByRole } = render( - - - , - ); - - await waitForDataToLoad(); - await findByText('Regular Week'); - - expect(getByRole('button', { name: 'Add Entry' })).toBeDisabled(); - }); - - it('disables the delete button on custom entries', async () => { - mockSession({ impersonationScope: 'mpd_supervisor' }); - - const { findByText, getByLabelText } = render( - - - , - ); - - await waitForDataToLoad(); - await findByText('Custom Entry'); - - expect(getByLabelText('Delete')).toBeDisabled(); - }); - - it('does not open the cell editor or fire mutations when editing a cell', async () => { - mockSession({ impersonationScope: 'mpd_supervisor' }); - - const { findByText, queryByDisplayValue } = render( - - - , - ); - - await waitForDataToLoad(); - - // Attempt to edit Regular Week hours; isCellEditable blocks the editor - const regularRow = (await findByText('Regular Week')).closest( - '[role="row"]', - ); - const hoursCell = regularRow?.querySelector( - '[data-field="hoursPerWeek"]', - ); - userEvent.dblClick(hoursCell!); - - expect(queryByDisplayValue('40')).not.toBeInTheDocument(); - expect(mutationSpy).not.toHaveGraphqlOperation( - 'UpdateDesignationSupportHoursItem', - ); - expect(mutationSpy).not.toHaveGraphqlOperation( - 'UpdatePdsGoalCalculation', - ); - }); - }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx index 6941286bb9..1283f005eb 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx @@ -13,7 +13,6 @@ import { Button, Card, Divider, - Tooltip, Typography, styled, } from '@mui/material'; @@ -26,7 +25,6 @@ import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { BaseGrid } from 'src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/BaseGrid'; import { useLocale } from 'src/hooks/useLocale'; -import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { numberFormat } from 'src/lib/intlFormat'; import { useCreateDesignationSupportHoursItemMutation, @@ -71,7 +69,6 @@ export const HoursPerWeekGrid: React.FC = ({ const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); const { calculation, trackMutation } = usePdsGoalCalculator(); - const restrictedImpersonation = useRestrictedImpersonation(); const [createHoursItem] = useCreateDesignationSupportHoursItemMutation(); const [updateHoursItem] = useUpdateDesignationSupportHoursItemMutation(); const [deleteHoursItem] = useDeleteDesignationSupportHoursItemMutation(); @@ -131,8 +128,7 @@ export const HoursPerWeekGrid: React.FC = ({ const saveHoursItem = useCallback( async (entry: HoursPerWeekEntry, currentEntries: HoursPerWeekEntry[]) => { - // Read-only during restricted impersonation - if (restrictedImpersonation || !calculation) { + if (!calculation) { return; } @@ -187,7 +183,6 @@ export const HoursPerWeekGrid: React.FC = ({ }, [ calculation, - restrictedImpersonation, createHoursItem, updateHoursItem, trackMutation, @@ -208,8 +203,7 @@ export const HoursPerWeekGrid: React.FC = ({ ); const addEntry = useCallback(async () => { - // Read-only during restricted impersonation - if (restrictedImpersonation || !calculation) { + if (!calculation) { return; } @@ -259,7 +253,6 @@ export const HoursPerWeekGrid: React.FC = ({ }, [ t, calculation, - restrictedImpersonation, createHoursItem, trackMutation, entries.length, @@ -268,11 +261,6 @@ export const HoursPerWeekGrid: React.FC = ({ const deleteEntry = useCallback( async (id: string | number) => { - // Read-only during restricted impersonation - if (restrictedImpersonation) { - return; - } - const entryId = id.toString(); const previousEntries = entries; const remainingEntries = entries.filter((entry) => entry.id !== entryId); @@ -311,15 +299,7 @@ export const HoursPerWeekGrid: React.FC = ({ }); } }, - [ - entries, - restrictedImpersonation, - deleteHoursItem, - trackMutation, - saveField, - enqueueSnackbar, - t, - ], + [entries, deleteHoursItem, trackMutation, saveField, enqueueSnackbar, t], ); const processRowUpdate = useCallback( @@ -459,13 +439,12 @@ export const HoursPerWeekGrid: React.FC = ({ label={t('Delete')} onClick={() => deleteEntry(params.id)} showInMenu={false} - disabled={restrictedImpersonation} />, ]; }, }, ], - [t, totalHours, deleteEntry, restrictedImpersonation], + [t, totalHours, deleteEntry], ); return ( @@ -478,23 +457,14 @@ export const HoursPerWeekGrid: React.FC = ({ - } > - - - - + {t('Add Entry')} + = ({ }); }} isCellEditable={(params) => { - // Read-only during restricted impersonation - if (restrictedImpersonation) { - return false; - } if (params.id === 'total') { return false; } diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx index fe919f990a..0c38e9bc45 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { mockSession } from '__tests__/util/mockSession'; import { UpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalculations.generated'; import { PdsGoalCalculatorTestWrapper, @@ -94,22 +93,4 @@ describe('useSaveField', () => { ), ); }); - - it('does not update the calculation during restricted impersonation', async () => { - mockSession({ impersonationScope: 'mpd_supervisor' }); - - const { result } = renderHook(useSaveField, { wrapper: Wrapper }); - - await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('PdsGoalCalculation'), - ); - - await result.current({ name: 'New Name' }); - - await waitFor(() => - expect(mutationSpy).not.toHaveGraphqlOperation( - 'UpdatePdsGoalCalculation', - ), - ); - }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts index 77c6ca5c21..b5b23fafa3 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/useSaveField.ts @@ -2,7 +2,6 @@ import { useCallback } from 'react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { DesignationSupportCalculationUpdateInput } from 'src/graphql/types.generated'; -import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { useUpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalculations.generated'; import { usePdsGoalCalculator } from '../PdsGoalCalculatorContext'; @@ -11,13 +10,10 @@ export const useSaveField = () => { const [updatePdsGoalCalculation] = useUpdatePdsGoalCalculationMutation(); const { enqueueSnackbar } = useSnackbar(); const { t } = useTranslation(); - const restrictedImpersonation = useRestrictedImpersonation(); const saveField = useCallback( async (attributes: Partial) => { - // The tool is read-only during restricted impersonation, so autosave - // must not fire mutations that the API would reject - if (restrictedImpersonation || !calculation) { + if (!calculation) { return; } @@ -61,7 +57,6 @@ export const useSaveField = () => { }, [ calculation, - restrictedImpersonation, trackFieldMutation, updatePdsGoalCalculation, enqueueSnackbar, diff --git a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx index 029067d7eb..3daf029066 100644 --- a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx +++ b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { mockSession } from '__tests__/util/mockSession'; import theme from 'src/theme'; import { FundFieldsFragment } from '../ReportsSavingsFund.generated'; import { FundTypeEnum } from '../mockData'; @@ -206,16 +205,4 @@ describe('BalanceCard', () => { expect(transferFromButton).toBeDisabled(); }); - - it('should disable transfer from button during restricted impersonation', async () => { - mockSession({ impersonationScope: 'mpd_supervisor' }); - - const { findByRole } = render(); - - const transferFromButton = await findByRole('button', { - name: /transfer from/i, - }); - - expect(transferFromButton).toBeDisabled(); - }); }); diff --git a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx index c2101c6a87..58b06f88f8 100644 --- a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx +++ b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx @@ -1,10 +1,9 @@ import React from 'react'; import { Diversity1, Outbox, Savings, Wallet } from '@mui/icons-material'; -import { Box, Button, Card, Tooltip, Typography } from '@mui/material'; +import { Box, Button, Card, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { SimpleScreenOnly } from 'src/components/Reports/styledComponents'; import { useLocale } from 'src/hooks/useLocale'; -import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { currencyFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; import { FundFieldsFragment } from '../ReportsSavingsFund.generated'; @@ -23,7 +22,6 @@ export const BalanceCard: React.FC = ({ }) => { const { t } = useTranslation(); const locale = useLocale(); - const restrictedImpersonation = useRestrictedImpersonation(); const title = t('{{ name }} Account Balance', { name: fund.fundType }); const Icon = @@ -131,24 +129,14 @@ export const BalanceCard: React.FC = ({ mt: 'auto', }} > - - - - - + + {t('TRANSFER FROM')} + ); diff --git a/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx b/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx index 42d51a3bc5..3edab0ef8a 100644 --- a/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx +++ b/src/components/HrTools/SavingsFundTransfer/Table/Row/createTableRow.tsx @@ -31,7 +31,6 @@ type Options = { handleFailedTransferOpen: (transfer: Transfers) => void; t: TFunction; locale: string; - restrictedImpersonation?: boolean; }; export function populateTransferRows(options: Options) { @@ -43,7 +42,6 @@ export function populateTransferRows(options: Options) { handleFailedTransferOpen, t, locale, - restrictedImpersonation = false, } = options; useEffect(() => { @@ -211,95 +209,82 @@ export function populateTransferRows(options: Options) { const actions: RenderCell = ({ row }) => { if (row.actions === 'edit-delete') { - const actionButtons = - type === TableTypeEnum.Upcoming ? ( - <> - - handleEditModalOpen(row)} - /> - - - { - handleDeleteModalOpen(row); - }} - sx={{ color: 'error.main' }} - /> - - - ) : row.endDate ? ( - <> - { - event.stopPropagation(); - handleCalendarOpen(row); + return type === TableTypeEnum.Upcoming ? ( + <> + + handleEditModalOpen(row)} + /> + + + { + handleDeleteModalOpen(row); + }} + sx={{ color: 'error.main' }} + /> + + + ) : row.endDate ? ( + <> + { + event.stopPropagation(); + handleCalendarOpen(row); + }} + > + - - edit_calendar - - - - { - handleDeleteModalOpen(row); - }} - /> - - - ) : ( - <> - { - event.stopPropagation(); - handleCalendarOpen(row); + edit_calendar + + + + { + handleDeleteModalOpen(row); + }} + /> + + + ) : ( + <> + { + event.stopPropagation(); + handleCalendarOpen(row); + }} + > + - - calendar_add_on - - - - { - handleDeleteModalOpen(row); - }} - /> - - - ); - - return ( - - {actionButtons} - + calendar_add_on + + + + { + handleDeleteModalOpen(row); + }} + /> + + ); } diff --git a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx index 351ef08aac..50eaceb3e2 100644 --- a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx +++ b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx @@ -6,7 +6,6 @@ import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { mockSession } from '__tests__/util/mockSession'; import theme from 'src/theme'; import { TableTypeEnum, Transfers, mockData } from '../mockData'; import { TransfersTable } from './TransfersTable'; @@ -291,22 +290,4 @@ describe('TransferHistoryTable', () => { variant: 'success', }); }); - - it('disables the row action buttons during restricted impersonation', async () => { - mockSession({ impersonationScope: 'mpd_supervisor' }); - - const { findByRole } = render(); - - const grid = await findByRole('grid'); - - expect( - within(grid).getByRole('button', { name: 'Add End Date' }), - ).toBeDisabled(); - expect( - within(grid).getByRole('button', { name: 'Edit End Date' }), - ).toBeDisabled(); - within(grid) - .getAllByRole('button', { name: 'Stop Transfer' }) - .forEach((stopButton) => expect(stopButton).toBeDisabled()); - }); }); diff --git a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx index 42f1321be9..fa7d3f959f 100644 --- a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx +++ b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx @@ -13,7 +13,6 @@ import { LoadingIndicator, } from 'src/components/Shared/styledComponents/LoadingStyling'; import { useLocale } from 'src/hooks/useLocale'; -import { useRestrictedImpersonation } from 'src/hooks/useRestrictedImpersonation'; import { CustomEditCalendar } from '../CustomEditCalendar/CustomEditCalendar'; import { DynamicDeleteTransferModal } from '../DeleteTransferModal/DynamicDeleteTransferModal'; import { DynamicFailedTransferModal } from '../FailedTransferModal/DynamicFailedTransferModal'; @@ -77,7 +76,6 @@ export const TransfersTable: React.FC = ({ const { t } = useTranslation(); const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); - const restrictedImpersonation = useRestrictedImpersonation(); const [updateRecurringTransfer] = useUpdateRecurringTransferMutation({ refetchQueries: ['ReportsSavingsFundTransfer', 'FundBalances'], @@ -113,10 +111,6 @@ export const TransfersTable: React.FC = ({ }; const handleDeleteModalOpen = (transfer: Transfers) => { - // Read-only during restricted impersonation - if (restrictedImpersonation) { - return; - } setOpenDeleteModal(transfer); }; @@ -146,10 +140,6 @@ export const TransfersTable: React.FC = ({ date: DateTime | null, actionType: ActionTypeEnum, ) => { - // Read-only during restricted impersonation - if (restrictedImpersonation) { - return; - } const successMessage = actionType === ActionTypeEnum.Edit ? t('End date updated successfully') @@ -196,7 +186,6 @@ export const TransfersTable: React.FC = ({ handleFailedTransferOpen, t, locale, - restrictedImpersonation, }); const transferRows = history.map(CreateTransferRows); diff --git a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx index 0cae143ea6..4e8e84be2d 100644 --- a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx +++ b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx @@ -178,25 +178,6 @@ describe('DirectionButtons', () => { ).not.toBeInTheDocument(); }); - it('disables the Submit button and shows the disabledNextTooltip when disableNext is true during submission', async () => { - const { findByRole, findByText } = render( - , - ); - - const submitButton = await findByRole('button', { name: /submit/i }); - expect(submitButton).toBeDisabled(); - - userEvent.hover(submitButton.parentElement!); - - expect( - await findByText('Complete all fields to submit'), - ).toBeInTheDocument(); - }); - it('shows the loadingNextTitle with an in-button spinner and disables the button while loadingNext is true', async () => { const { findByRole } = render( = ({ {handleDiscard && backButton} {!hideNextButton && (isSubmission ? ( - - - - - + ) : ( { expect(mutationSpy).not.toHaveBeenCalled(); }); - it('disables the Delete button when deleteDisabled is true', () => { - const { getByRole } = renderCard({ deleteDisabled: true }); - - expect(getByRole('button', { name: 'Delete' })).toBeDisabled(); - }); - - it('enables the Delete button by default', () => { - const { getByRole } = renderCard(); - - expect(getByRole('button', { name: 'Delete' })).toBeEnabled(); - }); - it('renders the badge when provided', () => { const { getByText } = renderCard({ badge: Default, diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.tsx index f2e9413a98..91fac0967d 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.tsx @@ -63,7 +63,6 @@ export interface GoalCardProps { viewHref: string; onDelete: () => Promise; badge?: React.ReactNode; - deleteDisabled?: boolean; } export const GoalCard: React.FC = ({ @@ -75,7 +74,6 @@ export const GoalCard: React.FC = ({ viewHref, onDelete, badge, - deleteDisabled = false, }) => { const { t } = useTranslation(); const locale = useLocale(); @@ -160,24 +158,11 @@ export const GoalCard: React.FC = ({ - - - - - +