diff --git a/__tests__/fixtures/session.ts b/__tests__/fixtures/session.ts index b9702b3305..4e51380697 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, + coach: false, impersonating: false, }, }; 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.test.tsx b/pages/accountLists/[accountListId]/settings/admin.page.test.tsx index e4ddb4456a..b4de0b5cd6 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, coach: 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 d6b3b61079..e271e1888a 100644 --- a/pages/accountLists/[accountListId]/settings/admin.page.tsx +++ b/pages/accountLists/[accountListId]/settings/admin.page.tsx @@ -1,11 +1,12 @@ import { useRouter } from 'next/router'; import React, { ReactElement, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { enforceAdmin } from 'pages/api/utils/pagePropsHelpers'; +import { enforceAdminOrCoach } 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'; 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,15 +34,17 @@ const Admin = (): ReactElement => { expandedAccordion={expandedAccordion} /> - + {user.admin && ( + + )} ); }; -export const getServerSideProps = enforceAdmin; +export const getServerSideProps = enforceAdminOrCoach; 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.page.test.tsx b/pages/accountLists/[accountListId]/tools.page.test.tsx index f380e8413e..096392f8f3 100644 --- a/pages/accountLists/[accountListId]/tools.page.test.tsx +++ b/pages/accountLists/[accountListId]/tools.page.test.tsx @@ -4,8 +4,9 @@ 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 { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import theme from 'src/theme'; -import ToolsPage from './tools.page'; +import ToolsPage, { getServerSideProps } from './tools.page'; const accountListId = 'account-list-1'; @@ -34,6 +35,10 @@ const MocksProviders: React.FC = ({ children, setup }) => ( ); describe('Tools Page', () => { + it('blocks restricted impersonation in getServerSideProps', () => { + expect(getServerSideProps).toBe(blockRestrictedImpersonation); + }); + it('should render page', async () => { const { findByText } = render( diff --git a/pages/accountLists/[accountListId]/tools.page.tsx b/pages/accountLists/[accountListId]/tools.page.tsx index 5d37fe7dbc..449050a9a8 100644 --- a/pages/accountLists/[accountListId]/tools.page.tsx +++ b/pages/accountLists/[accountListId]/tools.page.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router'; import React, { ReactElement } from 'react'; import { Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { blockRestrictedImpersonation } from 'pages/api/utils/pagePropsHelpers'; import { SetupBanner } from 'src/components/Settings/preferences/SetupBanner'; import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import ToolsHome from 'src/components/Tool/Home/ToolsHome'; @@ -43,6 +43,6 @@ const ToolsPage = (): ReactElement => { ); }; -export const getServerSideProps = ensureSessionAndAccountList; +export const getServerSideProps = blockRestrictedImpersonation; export default ToolsPage; 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/auth/[...nextauth].page.ts b/pages/api/auth/[...nextauth].page.ts index 1a5efabe24..3f1c9ecd6e 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; + coach: 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; + coach: 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, + coach: data.coachingAccountLists.totalCount > 0, 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, + coach, apiToken, userID, impersonating, isImpersonatorDeveloper, + impersonationScope, } = token; // Check the expiration of the API token JWT without verifying its signature @@ -273,10 +283,13 @@ const Auth = (req: NextApiRequest, res: NextApiResponse): Promise => { ...session.user, admin, developer, + // Default to false for JWTs minted before coach existed + coach: coach ?? false, 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/pages/api/utils/pagePropsHelpers.test.ts b/pages/api/utils/pagePropsHelpers.test.ts index 00f529ba2a..1e8e808280 100644 --- a/pages/api/utils/pagePropsHelpers.test.ts +++ b/pages/api/utils/pagePropsHelpers.test.ts @@ -3,10 +3,13 @@ import { getSession } from 'next-auth/react'; import { session } from '__tests__/fixtures/session'; import { RedirectReason } from 'pages/api/auth/redirectReasonEnum'; import makeSsrClient from 'src/lib/apollo/ssrClient'; +import { MPD_LEADER_SCOPE } from 'src/lib/restrictedImpersonation'; import { blockImpersonatingNonDevelopers, + blockRestrictedImpersonation, dashboardRedirect, enforceAdmin, + enforceAdminOrCoach, ensureSessionAndAccountList, loginRedirect, makeGetServerSideProps, @@ -75,6 +78,80 @@ describe('pagePropsHelpers', () => { }); }); + describe('enforceAdminOrCoach', () => { + it('redirects to the login page if the user is not logged in', async () => { + (getSession as jest.Mock).mockResolvedValue(null); + + await expect(enforceAdminOrCoach(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, + coach: false, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrCoach(context)).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); + + it('does not return a redirect if the user is a coach', async () => { + const user = { + apiToken: 'token', + admin: false, + coach: true, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrCoach(context)).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); + + it('does not return a redirect if the user is a developer', async () => { + const user = { + apiToken: 'token', + admin: false, + developer: true, + coach: false, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrCoach(context)).resolves.toMatchObject({ + props: { + session: { user }, + }, + }); + }); + + it('returns a redirect if the user is not an admin, developer, or coach', async () => { + const user = { + apiToken: 'token', + admin: false, + developer: false, + coach: false, + }; + (getSession as jest.Mock).mockResolvedValue({ user }); + + await expect(enforceAdminOrCoach(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 +276,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_SCOPE, + }; + (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_SCOPE, + }; + (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..4c7922248a 100644 --- a/pages/api/utils/pagePropsHelpers.ts +++ b/pages/api/utils/pagePropsHelpers.ts @@ -8,6 +8,7 @@ import { Session } from 'next-auth'; import { getSession } from 'next-auth/react'; import { RedirectReason } from 'pages/api/auth/redirectReasonEnum'; import makeSsrClient from 'src/lib/apollo/ssrClient'; +import { isRestrictedImpersonation } from 'src/lib/restrictedImpersonation'; import { GetDefaultAccountDocument, GetDefaultAccountQuery, @@ -44,6 +45,10 @@ 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 — see the access matrix + * documented on isRestrictedImpersonation */ export const blockImpersonatingNonDevelopers: GetServerSideProps< PagePropsWithSession @@ -54,8 +59,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 && + !isRestrictedImpersonation(session.user) + ) { return dashboardRedirect(context, RedirectReason.ImpersonationBlocked); } @@ -103,6 +112,70 @@ export const enforceAdmin: GetServerSideProps = async ( }; }; +// Redirect back to the dashboard unless the user is an admin, a developer, or +// a coach (MPD leader) +export const enforceAdminOrCoach: GetServerSideProps< + PagePropsWithSession +> = async (context) => { + const session = await getSession(context); + + if (!session?.user.apiToken) { + return loginRedirect(context); + } + + if (!session.user.admin && !session.user.developer && !session.user.coach) { + 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 — see the access matrix + * documented on isRestrictedImpersonation + */ +export const blockRestrictedImpersonation: GetServerSideProps< + PagePropsWithSession +> = async (context) => { + const session = await getSession(context); + + if (!session?.user.apiToken) { + return loginRedirect(context); + } + + if (isRestrictedImpersonation(session.user)) { + 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 diff --git a/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.test.tsx b/src/components/HrTools/MpdGoalAdmin/GoalsTable/GoalsTable.test.tsx index 1728cb1205..74851b4fa6 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,69 @@ describe('GoalsTable', () => { queryByText(`Person ${DEFAULT_ROWS_PER_PAGE}`), ).not.toBeInTheDocument(); }); + + describe('Impersonate action', () => { + it('shows an Impersonate action per row for a coach', () => { + mockSession({ coach: 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({ coach: 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({ coach: false, admin: false }); + + const { queryByRole } = renderTable(); + expect( + queryByRole('button', { name: 'Impersonate' }), + ).not.toBeInTheDocument(); + }); + + it('hides the Impersonate action while already impersonating', () => { + mockSession({ + coach: true, + admin: true, + impersonating: true, + }); + + const { getAllByText, queryByRole } = renderTable(); + expect( + queryByRole('button', { name: 'Impersonate' }), + ).not.toBeInTheDocument(); + // The Actions column isn't left empty: the disabled View/Edit link + // still renders on every row. + const viewEditLinks = getAllByText('View/Edit'); + expect(viewEditLinks).toHaveLength( + Math.min(rows.length, DEFAULT_ROWS_PER_PAGE), + ); + expect(viewEditLinks[0]).toBeDisabled(); + }); + + it('opens the impersonate modal for the selected staff member', async () => { + mockSession({ coach: 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..e54168165b 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,21 @@ export const DEFAULT_ROWS_PER_PAGE = 5; export const GoalsTable: React.FC = ({ rows }) => { const { t } = useTranslation(); const locale = useLocale(); + const { admin, coach, impersonating } = 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, + ); + + // Hide the action mid-impersonation to avoid offering nested impersonation; + // the server independently enforces the authorization. + const canImpersonate = (admin || coach) && !impersonating; // 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 +180,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 +229,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')} + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx index 64f5600dab..d7427ec184 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx @@ -458,10 +458,10 @@ describe('MultiPageMenu', () => { await waitFor(() => { expect(queryByText('Manage Organizations')).not.toBeInTheDocument(); + expect(getByText('Admin Console')).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText('Admin Console')).toBeInTheDocument(); expect(getByText('Backend Admin')).toBeInTheDocument(); expect(getByText('Sidekiq')).toBeInTheDocument(); }); @@ -508,6 +508,41 @@ describe('MultiPageMenu', () => { }); }); + it('shows the admin console but not admin-only or developer-only tools for coaches', async () => { + mockSession({ admin: false, developer: false, coach: 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..7ecd6d2882 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx @@ -80,6 +80,9 @@ const showMenuItem = ({ if (item.grantedAccess.indexOf('developer') !== -1 && user.developer) { return true; } + if (item.grantedAccess.indexOf('coach') !== -1 && user.coach) { + return true; + } } else { return true; } diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql index 8ead8a2f2c..88664edaa4 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.graphql @@ -4,4 +4,7 @@ query GetUserAccess { admin developer } + coachingAccountLists(first: 1) { + totalCount + } } diff --git a/src/hooks/useNavPages.test.tsx b/src/hooks/useNavPages.test.tsx index 40c4a4f566..080f6d4cdb 100644 --- a/src/hooks/useNavPages.test.tsx +++ b/src/hooks/useNavPages.test.tsx @@ -150,4 +150,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 ae3eeae0be..3a035c72fa 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 { useRestrictedImpersonation } from './useRestrictedImpersonation'; import { useSettingsNavItems } from './useSettingsNavItems'; import { useToolsNavItems } from './useToolsNavItems'; @@ -62,6 +63,10 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { userType === UserTypeEnum.HybridStaff || developerBypass; + // During a restricted impersonation session the impersonator may only + // access MPD leader tools, so hide the contacts, tasks, and tools pages + const isRestrictedImpersonation = useRestrictedImpersonation(); + const reportItems = useReportNavItems(); const toolsItems = useToolsNavItems(); const settingsItems = useSettingsNavItems(); @@ -86,6 +91,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { showInNav: true, isDropdown: false, showInSearchDialog: true, + hideTab: isRestrictedImpersonation, }, { id: 'tasks-page', @@ -96,6 +102,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { showInNav: true, isDropdown: false, showInSearchDialog: true, + hideTab: isRestrictedImpersonation, }, { id: 'reports-page', @@ -149,6 +156,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { ), showInNav: true, showInSearchDialog: true, + hideTab: isRestrictedImpersonation, }, { id: 'settings-page', @@ -200,6 +208,7 @@ export function useNavPages(coachingAccountCount: boolean, isSearch = false) { canSeeHrTools, reportsDisabled, data, + isRestrictedImpersonation, ]); const navPages = useMemo( diff --git a/src/hooks/useRestrictedImpersonation.test.ts b/src/hooks/useRestrictedImpersonation.test.ts new file mode 100644 index 0000000000..00bc95495b --- /dev/null +++ b/src/hooks/useRestrictedImpersonation.test.ts @@ -0,0 +1,19 @@ +import { renderHook } from '@testing-library/react'; +import { mockSession } from '__tests__/util/mockSession'; +import { useRestrictedImpersonation } from './useRestrictedImpersonation'; + +describe('useRestrictedImpersonation', () => { + it('returns false when the session has no impersonation scope', () => { + const { result } = renderHook(useRestrictedImpersonation); + + expect(result.current).toBe(false); + }); + + it('returns true when the session has an impersonation scope', () => { + mockSession({ impersonationScope: 'mpd_supervisor' }); + + const { result } = renderHook(useRestrictedImpersonation); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/useRestrictedImpersonation.ts b/src/hooks/useRestrictedImpersonation.ts new file mode 100644 index 0000000000..d9b77df8c3 --- /dev/null +++ b/src/hooks/useRestrictedImpersonation.ts @@ -0,0 +1,12 @@ +import { isRestrictedImpersonation } from 'src/lib/restrictedImpersonation'; +import { useRequiredSession } from './useRequiredSession'; + +// During a restricted MPD-leader impersonation session the API rejects all +// mutations except the ones powering the editable MPD leader tools, so the +// remaining HR tools must render read-only and never fire mutations. +// Shares its definition of "restricted" with the server-side page guards via +// isRestrictedImpersonation — see the access matrix documented there. +export const useRestrictedImpersonation = (): boolean => { + const user = useRequiredSession(); + return isRestrictedImpersonation(user); +}; diff --git a/src/hooks/useSettingsNavItems.ts b/src/hooks/useSettingsNavItems.ts index 208894544b..c2dc618fe9 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', 'coach'], }, { id: '/auth/user/admin', diff --git a/src/lib/restrictedImpersonation.test.ts b/src/lib/restrictedImpersonation.test.ts new file mode 100644 index 0000000000..cd92e1cf0b --- /dev/null +++ b/src/lib/restrictedImpersonation.test.ts @@ -0,0 +1,19 @@ +import { + MPD_LEADER_SCOPE, + isRestrictedImpersonation, +} from './restrictedImpersonation'; + +describe('isRestrictedImpersonation', () => { + it('returns true when the session has an impersonation scope', () => { + expect( + isRestrictedImpersonation({ impersonationScope: MPD_LEADER_SCOPE }), + ).toBe(true); + }); + + it('returns false when the session has no impersonation scope', () => { + expect(isRestrictedImpersonation({})).toBe(false); + expect(isRestrictedImpersonation({ impersonationScope: undefined })).toBe( + false, + ); + }); +}); diff --git a/src/lib/restrictedImpersonation.ts b/src/lib/restrictedImpersonation.ts new file mode 100644 index 0000000000..864511e837 --- /dev/null +++ b/src/lib/restrictedImpersonation.ts @@ -0,0 +1,44 @@ +import { Session } from 'next-auth'; + +/** + * The impersonation scope values the API currently mints. As of now the only + * value produced by the impersonation handoff is 'mpd_leader' (see + * pages/api/auth/impersonate/impersonateHelper.ts, which reads + * `impersonation_scope` from the API response). The session field itself is + * typed as `string` in pages/api/auth/[...nextauth].page.ts because the value + * arrives from a signed cookie minted by the API, but any code that needs to + * branch on a specific scope should compare against this union so that new + * scope values are added here deliberately. + */ +export type ImpersonationScope = 'mpd_leader'; + +/** + * The scope minted when an MPD leader impersonates one of the staff they + * supervise — currently the only `ImpersonationScope` value. Compare against + * this constant (rather than a string literal) so the wire value stays pinned + * to the union at compile time. + */ +export const MPD_LEADER_SCOPE: ImpersonationScope = 'mpd_leader'; + +/** + * Returns whether the session is a restricted-scope impersonation session + * (e.g. an MPD leader impersonating one of the staff they supervise). + * + * Access matrix when this returns true: + * - Contacts, Tasks, and Tools sections are BLOCKED — + * `blockRestrictedImpersonation` in pages/api/utils/pagePropsHelpers.ts + * redirects away from them. + * - HR tools / staff-expense pages are ALLOWED, rendered read-only — + * `blockImpersonatingNonDevelopers` lets the session through, and the API + * rejects all mutations except the ones powering the editable MPD leader + * tools (see useRestrictedImpersonation). + * + * Note that ANY truthy scope is treated as restricted: today that is safe + * because 'mpd_leader' is the only scope the API mints, but a future scope + * value would silently inherit this exact access matrix. Before introducing a + * new scope, add it to `ImpersonationScope` and revisit whether this + * all-scopes predicate needs to become per-scope. + */ +export const isRestrictedImpersonation = ( + user: Pick, +): boolean => !!user.impersonationScope;