diff --git a/src/client.ts b/src/client.ts index 66b9b72db..209ae78ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -31,6 +31,15 @@ const cache = new InMemoryCache({ Collaborator: { keyFields: ["collaboratorID"], }, + PBACDefaults: { + keyFields: ["role"], + }, + Permission: { + keyFields: false, + }, + Notification: { + keyFields: false, + }, }, }); diff --git a/src/components/APITokenDialog/index.test.tsx b/src/components/APITokenDialog/index.test.tsx index 785fe9483..e7b71a830 100644 --- a/src/components/APITokenDialog/index.test.tsx +++ b/src/components/APITokenDialog/index.test.tsx @@ -26,7 +26,7 @@ const baseAuthCtx: AuthContextState = { user: null, }; -const baseUser: User = { +const baseUser: Omit = { _id: "", firstName: "", lastName: "", @@ -38,20 +38,27 @@ const baseUser: User = { dataCommons: [], createdAt: "", updateAt: "", + notifications: [], }; type ParentProps = { children: React.ReactNode; mocks?: MockedResponse[]; role?: UserRole; + permissions?: AuthPermissions[]; }; -const TestParent: FC = ({ role = "Submitter", mocks = [], children }) => { +const TestParent: FC = ({ + role = "Submitter", + permissions = ["data_submission:create"], + mocks = [], + children, +}) => { const authState = useMemo( () => ({ ...baseAuthCtx, isLoggedIn: true, - user: { ...baseUser, role }, + user: { ...baseUser, role, permissions }, }), [role] ); @@ -263,19 +270,4 @@ describe("Implementation Requirements", () => { expect(mockWriteText).not.toHaveBeenCalled(); }); - - it.each(["Admin", "Federal Lead", "Data Curator", "User", "fake user" as UserRole])( - "should show an error when the user role %s tries to generate a token", - async (role) => { - const { getByText } = render(, { - wrapper: (p) => , - }); - - userEvent.click(getByText(/Create Token/, { selector: "button" })); - - await waitFor(() => { - expect(getByText(/Token was unable to be created./)).toBeInTheDocument(); - }); - } - ); }); diff --git a/src/components/APITokenDialog/index.tsx b/src/components/APITokenDialog/index.tsx index 70e0d7ef5..e27a84f44 100644 --- a/src/components/APITokenDialog/index.tsx +++ b/src/components/APITokenDialog/index.tsx @@ -14,8 +14,6 @@ import { GRANT_TOKEN, GrantTokenResp } from "../../graphql"; import GenericAlert, { AlertState } from "../GenericAlert"; import { ReactComponent as CopyIconSvg } from "../../assets/icons/copy_icon.svg"; import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.svg"; -import { useAuthContext } from "../Contexts/AuthContext"; -import { GenerateApiTokenRoles } from "../../config/AuthRoles"; const StyledDialog = styled(Dialog)({ "& .MuiDialog-paper": { @@ -146,8 +144,6 @@ type Props = { } & Omit; const APITokenDialog: FC = ({ onClose, open, ...rest }) => { - const { user } = useAuthContext(); - const [tokens, setTokens] = useState([]); const [tokenIdx, setTokenIdx] = useState(null); const [changesAlert, setChangesAlert] = useState(null); @@ -166,11 +162,6 @@ const APITokenDialog: FC = ({ onClose, open, ...rest }) => { }; const generateToken = async () => { - if (!GenerateApiTokenRoles.includes(user?.role)) { - onGenerateTokenError(); - return; - } - try { const { data: d, errors } = await grantToken(); const tokens = d?.grantToken?.tokens; diff --git a/src/components/AccessRequest/FormDialog.test.tsx b/src/components/AccessRequest/FormDialog.test.tsx index 1d6e696fb..1082486cc 100644 --- a/src/components/AccessRequest/FormDialog.test.tsx +++ b/src/components/AccessRequest/FormDialog.test.tsx @@ -80,13 +80,14 @@ const mockUser: User = { lastName: "", email: "", role: "User", - organization: null, dataCommons: [], studies: [], IDP: "nih", userStatus: "Active", updateAt: "", createdAt: "", + permissions: ["access:request"], + notifications: [], }; type MockParentProps = { @@ -534,13 +535,6 @@ describe("Implementation Requirements", () => { const newUser: User = { ...mockUser, role: "Admin", // Technically not even able to see this dialog - organization: { - orgID: "123", - orgName: "NCI", - status: "Active", - createdAt: "", - updateAt: "", - }, }; const { getByTestId } = render(, { diff --git a/src/components/AccessRequest/FormDialog.tsx b/src/components/AccessRequest/FormDialog.tsx index 709bce8b4..987bc2477 100644 --- a/src/components/AccessRequest/FormDialog.tsx +++ b/src/components/AccessRequest/FormDialog.tsx @@ -71,7 +71,7 @@ type Props = { onClose: () => void; } & Omit; -const RoleOptions: UserRole[] = ["Submitter", "Organization Owner"]; +const RoleOptions: UserRole[] = ["Submitter"]; /** * Provides a dialog for users to request access to a specific role. diff --git a/src/components/AccessRequest/index.test.tsx b/src/components/AccessRequest/index.test.tsx index ba5835912..9ab2daf7b 100644 --- a/src/components/AccessRequest/index.test.tsx +++ b/src/components/AccessRequest/index.test.tsx @@ -15,18 +15,18 @@ import { ListApprovedStudiesResp, } from "../../graphql"; -const mockUser: Omit = { +const mockUser: Omit = { _id: "", firstName: "", lastName: "", email: "", - organization: null, dataCommons: [], studies: [], IDP: "nih", userStatus: "Active", updateAt: "", createdAt: "", + notifications: [], }; const mockListApprovedStudies: MockedResponse = { @@ -47,15 +47,16 @@ const mockListApprovedStudies: MockedResponse = ({ mocks, role, children }) => { +const MockParent: FC = ({ mocks, role, permissions, children }) => { const authValue: AuthContextState = useMemo( () => ({ isLoggedIn: true, status: AuthContextStatus.LOADED, - user: { ...mockUser, role }, + user: { ...mockUser, role, permissions }, }), [role] ); @@ -70,7 +71,7 @@ const MockParent: FC = ({ mocks, role, children }) => { describe("Accessibility", () => { it("should not have any violations", async () => { const { container } = render(, { - wrapper: (p) => , + wrapper: (p) => , }); expect(await axe(container)).toHaveNoViolations(); @@ -80,7 +81,14 @@ describe("Accessibility", () => { describe("Basic Functionality", () => { it("should open the dialog when the 'Request Access' button is clicked", async () => { const { getByTestId, getByRole, queryByRole } = render(, { - wrapper: (p) => , + wrapper: (p) => ( + + ), }); expect(queryByRole("dialog")).not.toBeInTheDocument(); @@ -94,34 +102,16 @@ describe("Basic Functionality", () => { describe("Implementation Requirements", () => { it("should have a button with the text content 'Request Access'", async () => { const { getByText } = render(, { - wrapper: (p) => , + wrapper: (p) => , }); expect(getByText("Request Access")).toBeInTheDocument(); expect(getByText("Request Access")).toBeEnabled(); }); - it.each(["User", "Submitter", "Organization Owner"])( - "should render the 'Request Access' button for the '%s' role", - (role) => { - const { getByTestId } = render(, { - wrapper: (p) => , - }); - - expect(getByTestId("request-access-button")).toBeInTheDocument(); - } - ); - - it.each([ - "Admin", - "Data Commons POC", - "Federal Lead", - "Federal Monitor", - "Data Curator", - "fake role" as UserRole, - ])("should not render the 'Request Access' button for the '%s' role", (role) => { + it("should not render the 'Request Access' button without the required permission", async () => { const { queryByTestId } = render(, { - wrapper: (p) => , + wrapper: (p) => , }); expect(queryByTestId("request-access-button")).not.toBeInTheDocument(); diff --git a/src/components/AccessRequest/index.tsx b/src/components/AccessRequest/index.tsx index d6e223334..e76af66ae 100644 --- a/src/components/AccessRequest/index.tsx +++ b/src/components/AccessRequest/index.tsx @@ -2,7 +2,7 @@ import { FC, memo, useState } from "react"; import { Button, styled } from "@mui/material"; import FormDialog from "./FormDialog"; import { useAuthContext } from "../Contexts/AuthContext"; -import { CanRequestRoleChange } from "../../config/AuthRoles"; +import { hasPermission } from "../../config/AuthPermissions"; const StyledButton = styled(Button)({ marginLeft: "42px", @@ -38,7 +38,7 @@ const AccessRequest: FC = (): React.ReactNode => { setDialogOpen(false); }; - if (!user?.role || !CanRequestRoleChange.includes(user.role)) { + if (!hasPermission(user, "access", "request")) { return null; } diff --git a/src/components/Collaborators/CollaboratorsDialog.test.tsx b/src/components/Collaborators/CollaboratorsDialog.test.tsx index 74b3e03bb..6d317d2be 100644 --- a/src/components/Collaborators/CollaboratorsDialog.test.tsx +++ b/src/components/Collaborators/CollaboratorsDialog.test.tsx @@ -32,19 +32,14 @@ const mockUser: User = { email: "user1@example.com", firstName: "John", lastName: "Doe", - organization: { - orgID: "org-1", - orgName: "Organization 1", - status: "Active", - createdAt: "", - updateAt: "", - }, dataCommons: [], studies: [], IDP: "nih", userStatus: "Active", updateAt: "", createdAt: "", + permissions: ["data_submission:view", "data_submission:create"], + notifications: [], }; const mockSubmission = { @@ -61,7 +56,7 @@ const mockCollaborators = [ { collaboratorID: "user-2", collaboratorName: "Jane Smith", - permission: "Can View", + permission: "Can Edit", Organization: { orgID: "org-2", orgName: "Organization 2", @@ -152,7 +147,7 @@ describe("CollaboratorsDialog Component", () => { "Data SubmissionCollaborators" // line break between "Submission" and "Collaborators" text ); expect(getByTestId("collaborators-dialog-description")).toHaveTextContent( - "Below is a list of collaborators who have been granted access to this data submission. Each collaborator can view or edit the submission based on the permissions assigned by the submission creator." + "Below is a list of collaborators who have been granted access to this data submission. Once added, each collaborator can contribute to the submission by uploading data, running validations, and submitting." ); }); @@ -182,10 +177,7 @@ describe("CollaboratorsDialog Component", () => { it("calls onClose when Close button is clicked", () => { mockUseAuthContext.mockReturnValue({ - user: { - ...mockUser, - role: "Admin", - }, + user: { ...mockUser, _id: "some-other-user" } as User, status: AuthStatus.LOADED, }); @@ -367,20 +359,12 @@ describe("CollaboratorsDialog Component", () => { }); }); - it.each([ - "User", - "Admin", - "Data Curator", - "Data Commons POC", - "Federal Lead", - "Federal Monitor", - "invalid-role" as UserRole, - ])("should disable inputs when user is role %s", (role) => { + it("should disable inputs when user does not have required permissions", async () => { mockUseAuthContext.mockReturnValue({ user: { ...mockUser, - role, - }, + permissions: ["data_submission:view"], + } as User, status: AuthStatus.LOADED, }); @@ -396,67 +380,15 @@ describe("CollaboratorsDialog Component", () => { expect(getByTestId("collaborators-dialog-close-button")).toBeInTheDocument(); }); - it.each(["Submitter", "Organization Owner"])( - "should enable inputs when user is role %s", - (role) => { - mockUseAuthContext.mockReturnValue({ - user: { - ...mockUser, - role, - }, - status: AuthStatus.LOADED, - }); - - const mockOnClose = jest.fn(); - const { getByTestId, queryByTestId } = render( - - - - ); - - expect(getByTestId("collaborators-dialog-save-button")).toBeInTheDocument(); - expect(getByTestId("collaborators-dialog-cancel-button")).toBeInTheDocument(); - expect(queryByTestId("collaborators-dialog-close-button")).not.toBeInTheDocument(); - } - ); - - it("allows modification when user is Organization Owner regardless of submitterID", () => { - mockUseAuthContext.mockReturnValue({ - user: { ...mockUser, role: "Organization Owner", _id: "user-99" }, - status: AuthStatus.LOADED, - }); - - mockUseSubmissionContext.mockReturnValue({ - data: { getSubmission: { ...mockSubmission, submitterID: "user-1" } }, - }); - - const mockOnClose = jest.fn(); - const { getByTestId, queryByTestId } = render( - - - - ); - - expect(getByTestId("collaborators-dialog-save-button")).toBeInTheDocument(); - expect(getByTestId("collaborators-dialog-cancel-button")).toBeInTheDocument(); - expect(queryByTestId("collaborators-dialog-close-button")).not.toBeInTheDocument(); - }); - - it("should not allow modification when user is Organization Owner of a different organization", () => { + it("should enable inputs when user has the required permissions", async () => { mockUseAuthContext.mockReturnValue({ user: { ...mockUser, - role: "Organization Owner", - _id: "user-99", - organization: { orgID: "some-other-org" }, + permissions: ["data_submission:view", "data_submission:create"], } as User, status: AuthStatus.LOADED, }); - mockUseSubmissionContext.mockReturnValue({ - data: { getSubmission: { ...mockSubmission, submitterID: "user-1" } }, - }); - const mockOnClose = jest.fn(); const { getByTestId, queryByTestId } = render( @@ -464,8 +396,8 @@ describe("CollaboratorsDialog Component", () => { ); - expect(queryByTestId("collaborators-dialog-save-button")).not.toBeInTheDocument(); - expect(queryByTestId("collaborators-dialog-cancel-button")).not.toBeInTheDocument(); - expect(getByTestId("collaborators-dialog-close-button")).toBeInTheDocument(); + expect(getByTestId("collaborators-dialog-save-button")).toBeInTheDocument(); + expect(getByTestId("collaborators-dialog-cancel-button")).toBeInTheDocument(); + expect(queryByTestId("collaborators-dialog-close-button")).not.toBeInTheDocument(); }); }); diff --git a/src/components/Collaborators/CollaboratorsDialog.tsx b/src/components/Collaborators/CollaboratorsDialog.tsx index 08217a983..aff712e2d 100644 --- a/src/components/Collaborators/CollaboratorsDialog.tsx +++ b/src/components/Collaborators/CollaboratorsDialog.tsx @@ -5,8 +5,8 @@ import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.sv import CollaboratorsTable from "./CollaboratorsTable"; import { useCollaboratorsContext } from "../Contexts/CollaboratorsContext"; import { useSubmissionContext } from "../Contexts/SubmissionContext"; -import { canModifyCollaboratorsRoles } from "../../config/AuthRoles"; import { Status as AuthStatus, useAuthContext } from "../Contexts/AuthContext"; +import { hasPermission } from "../../config/AuthPermissions"; const StyledDialog = styled(Dialog)({ "& .MuiDialog-paper": { @@ -111,11 +111,9 @@ const CollaboratorsDialog = ({ onClose, onSave, open, ...rest }: Props) => { const isLoading = collaboratorLoading || status === AuthStatus.LOADING; const canModifyCollaborators = useMemo( () => - canModifyCollaboratorsRoles.includes(user?.role) && - (submission?.getSubmission?.submitterID === user?._id || - (user?.role === "Organization Owner" && - user?.organization?.orgID === submission?.getSubmission?.organization?._id)), - [canModifyCollaboratorsRoles, user, submission?.getSubmission] + hasPermission(user, "data_submission", "create", null, true) && + submission?.getSubmission?.submitterID === user?._id, + [user, submission?.getSubmission] ); useEffect(() => { @@ -174,9 +172,9 @@ const CollaboratorsDialog = ({ onClose, onSave, open, ...rest }: Props) => { Collaborators - Below is a list of collaborators who have been granted access to this data submission. Each - collaborator can view or edit the submission based on the permissions assigned by the - submission creator. + Below is a list of collaborators who have been granted access to this data submission. Once + added, each collaborator can contribute to the submission by uploading data, running + validations, and submitting.
diff --git a/src/components/Collaborators/CollaboratorsTable.test.tsx b/src/components/Collaborators/CollaboratorsTable.test.tsx index 73b8caf18..db58f880d 100644 --- a/src/components/Collaborators/CollaboratorsTable.test.tsx +++ b/src/components/Collaborators/CollaboratorsTable.test.tsx @@ -38,19 +38,14 @@ const mockUser: User = { email: "user1@example.com", firstName: "John", lastName: "Doe", - organization: { - orgID: "org-1", - orgName: "Organization 1", - status: "Active", - createdAt: "", - updateAt: "", - }, dataCommons: [], studies: [], IDP: "nih", userStatus: "Active", updateAt: "", createdAt: "", + permissions: ["data_submission:create"], + notifications: [], }; const mockSubmission: Submission = { @@ -63,11 +58,7 @@ const mockCollaborators: Collaborator[] = [ { collaboratorID: "user-2", collaboratorName: "Jane Smith", - permission: "Can View", - Organization: { - orgID: "org-2", - orgName: "Organization 2", - }, + permission: "Can Edit", }, ]; @@ -75,20 +66,12 @@ const mockRemainingPotentialCollaborators: Collaborator[] = [ { collaboratorID: "user-3", collaboratorName: "Bob Johnson", - permission: "Can View", - Organization: { - orgID: "org-3", - orgName: "Organization 3", - }, + permission: "Can Edit", }, { collaboratorID: "user-4", collaboratorName: "Alice Williams", - permission: "Can View", - Organization: { - orgID: "org-4", - orgName: "Organization 4", - }, + permission: "Can Edit", }, ]; @@ -194,8 +177,6 @@ describe("CollaboratorsTable Component", () => { ); expect(getByTestId("header-collaborator")).toHaveTextContent("Collaborator"); - expect(getByTestId("header-organization")).toHaveTextContent("Collaborator Organization"); - expect(getByTestId("header-access")).toHaveTextContent("Access"); expect(getByTestId("header-remove")).toHaveTextContent("Remove"); }); @@ -213,12 +194,6 @@ describe("CollaboratorsTable Component", () => { expect(collaboratorSelect).toBeInTheDocument(); expect(collaboratorSelect).toHaveValue("user-2"); - const collaboratorOrg = getByTestId("collaborator-org-0"); - expect(collaboratorOrg).toHaveTextContent("Organizati..."); - - const collaboratorPermissions = getByTestId("collaborator-permissions-0"); - expect(collaboratorPermissions).toBeInTheDocument(); - const removeButton = getByTestId("remove-collaborator-button-0"); expect(removeButton).toBeInTheDocument(); }); @@ -321,49 +296,10 @@ describe("CollaboratorsTable Component", () => { expect(mockHandleUpdateCollaborator).toHaveBeenCalledWith(0, { collaboratorID: "user-3", - permission: "Can View", - }); - }); - - it("calls handleUpdateCollaborator when permission changes", () => { - const { getByTestId } = render( - - - - ); - - const radioGroup = getByTestId("collaborator-permissions-0"); - - fireEvent.click(within(radioGroup).getByDisplayValue("Can Edit")); - - expect(mockHandleUpdateCollaborator).toHaveBeenCalledWith(0, { - collaboratorID: "user-2", permission: "Can Edit", }); }); - it("renders permission tooltips correctly", async () => { - const { getByText, getByTestId } = render( - - - - ); - - const radioGroup = getByTestId("collaborator-permissions-0"); - - fireEvent.mouseOver(within(radioGroup).getByDisplayValue("Can View")); - - await waitFor(() => { - expect(getByText(TOOLTIP_TEXT.COLLABORATORS_DIALOG.PERMISSIONS.CAN_VIEW)).toBeInTheDocument(); - }); - - fireEvent.mouseOver(within(radioGroup).getByDisplayValue("Can Edit")); - - await waitFor(() => { - expect(getByText(TOOLTIP_TEXT.COLLABORATORS_DIALOG.PERMISSIONS.CAN_EDIT)).toBeInTheDocument(); - }); - }); - it("renders disabled add collaborator button tooltip correctly", async () => { const { getByText, getByTestId } = render( @@ -385,11 +321,7 @@ describe("CollaboratorsTable Component", () => { { collaboratorID: "", collaboratorName: "", - permission: "Can View", - Organization: { - orgID: "", - orgName: "", - }, + permission: "Can Edit", }, ]; @@ -433,10 +365,6 @@ describe("CollaboratorsTable Component", () => { collaboratorID: "user-5", collaboratorName: "Emily Davis", permission: "Can Edit", - Organization: { - orgID: "org-5", - orgName: "Organization 5", - }, }, ]; @@ -468,11 +396,7 @@ describe("CollaboratorsTable Component", () => { { collaboratorID: "", collaboratorName: "Jane Smith", - permission: "Can View", - Organization: { - orgID: "org-2", - orgName: "Organization 2", - }, + permission: "Can Edit", }, ]; @@ -502,11 +426,7 @@ describe("CollaboratorsTable Component", () => { { collaboratorID: "user-2", collaboratorName: null, - permission: "Can View", - Organization: { - orgID: "org-2", - orgName: "Organization 2", - }, + permission: "Can Edit", }, ]; @@ -527,25 +447,20 @@ describe("CollaboratorsTable Component", () => { ); const collaboratorSelect = getByTestId("collaborator-select-0"); - expect(within(collaboratorSelect).getByTestId("truncated-text-label").textContent).toEqual(" "); }); it("displays a space when collaboratorID is null", () => { - const mockCollaboratorsWithNullName = [ + const mockCollaboratorsWithNullID = [ { collaboratorID: null, collaboratorName: "user-name", - permission: "Can View", - Organization: { - orgID: "org-2", - orgName: "Organization 2", - }, + permission: "Can Edit", }, ]; mockUseCollaboratorsContext.mockReturnValue({ - currentCollaborators: mockCollaboratorsWithNullName, + currentCollaborators: mockCollaboratorsWithNullID, remainingPotentialCollaborators: mockRemainingPotentialCollaborators, maxCollaborators: 5, handleAddCollaborator: mockHandleAddCollaborator, @@ -561,20 +476,15 @@ describe("CollaboratorsTable Component", () => { ); const collaboratorSelect = getByTestId("collaborator-select-0-input"); - expect(collaboratorSelect).toHaveValue(""); }); - it("handles undefined collaborator permission by defaulting to empty string", () => { + it("handles undefined collaborator permission by defaulting to no selection (only 'Can Edit' is valid)", () => { const mockCollaboratorsWithUndefinedPermission = [ { collaboratorID: "user-2", collaboratorName: "Jane Smith", permission: undefined, - Organization: { - orgID: "org-2", - orgName: "Organization 2", - }, }, ]; @@ -594,12 +504,7 @@ describe("CollaboratorsTable Component", () => { ); - const radioGroup = getByTestId("collaborator-permissions-0"); - - const canViewRadio = within(radioGroup).getByDisplayValue("Can View") as HTMLInputElement; - const canEditRadio = within(radioGroup).getByDisplayValue("Can Edit") as HTMLInputElement; - - expect(canViewRadio.checked).toBe(false); - expect(canEditRadio.checked).toBe(false); + const collaboratorSelect = getByTestId("collaborator-select-0-input"); + expect(collaboratorSelect).toHaveValue("user-2"); }); }); diff --git a/src/components/Collaborators/CollaboratorsTable.tsx b/src/components/Collaborators/CollaboratorsTable.tsx index 1f3c97a5d..c1f6e3b26 100644 --- a/src/components/Collaborators/CollaboratorsTable.tsx +++ b/src/components/Collaborators/CollaboratorsTable.tsx @@ -1,9 +1,7 @@ import React from "react"; import { - FormControlLabel, IconButton, MenuItem, - RadioGroup, Stack, styled, Table, @@ -12,13 +10,10 @@ import { TableContainer, TableHead, TableRow, - TooltipProps, } from "@mui/material"; import AddCircleIcon from "@mui/icons-material/AddCircle"; import { isEqual } from "lodash"; -import StyledTooltip from "../StyledFormComponents/StyledTooltip"; import { TOOLTIP_TEXT } from "../../config/DashboardTooltips"; -import StyledFormRadioButton from "../Questionnaire/StyledRadioButton"; import { ReactComponent as RemoveIconSvg } from "../../assets/icons/remove_icon.svg"; import AddRemoveButton from "../AddRemoveButton"; import TruncatedText from "../TruncatedText"; @@ -100,47 +95,6 @@ const StyledNameCell = styled(StyledTableCell)({ }, }); -const StyledRadioControl = styled(FormControlLabel)({ - fontFamily: "Nunito", - fontSize: "16px", - fontWeight: "500", - lineHeight: "20px", - textAlign: "left", - color: "#083A50", - "&:last-child": { - marginRight: "0px", - minWidth: "unset", - }, -}); - -const StyledRadioGroup = styled(RadioGroup)({ - width: "100%", - justifyContent: "center", - alignItems: "center", - gap: "14px", - "& .MuiFormControlLabel-root": { - margin: 0, - "&.Mui-disabled": { - cursor: "not-allowed", - }, - }, - "& .MuiFormControlLabel-asterisk": { - display: "none", - }, - "& .MuiSelect-select .notranslate": { - display: "inline-block", - minHeight: "38px", - }, - "& .MuiRadio-root.Mui-disabled .radio-icon": { - background: "#FFF !important", - opacity: 0.4, - }, -}); - -const StyledRadioButton = styled(StyledFormRadioButton)({ - padding: "0 7px 0 0", -}); - const StyledRemoveButton = styled(IconButton)(({ theme }) => ({ color: "#C05239", padding: "5px", @@ -177,24 +131,6 @@ const StyledSelect = styled(StyledFormSelect)({ }, }); -const CustomTooltip = (props: TooltipProps) => ( - -); - type Props = { /** * Indicates whether the table will allow edititing of collaborators @@ -222,17 +158,6 @@ const CollaboratorsTable = ({ isEdit }: Props) => { Collaborator - - Collaborator
- Organization -
- - Access - {isEdit && ( { key={`collaborator_${idx}_${collaborator.collaboratorID}`} data-testid={`collaborator-row-${idx}`} > - + @@ -271,7 +196,7 @@ const CollaboratorsTable = ({ isEdit }: Props) => { renderValue={() => ( @@ -290,69 +215,8 @@ const CollaboratorsTable = ({ isEdit }: Props) => { ))} - - - - - - - handleUpdateCollaborator(idx, { - collaboratorID: collaborator?.collaboratorID, - permission: val, - }) - } - data-testid={`collaborator-permissions-${idx}`} - aria-labelledby="header-access" - row - > - - - } - label="Can View" - /> - - - - } - label="Can Edit" - /> - - - - {isEdit && ( - + handleRemoveCollaborator(idx)} diff --git a/src/components/Contexts/CollaboratorsContext.test.tsx b/src/components/Contexts/CollaboratorsContext.test.tsx index eaa041f07..0440a3c34 100644 --- a/src/components/Contexts/CollaboratorsContext.test.tsx +++ b/src/components/Contexts/CollaboratorsContext.test.tsx @@ -20,19 +20,11 @@ const dummySubmissionData = { collaboratorID: "user-1", permission: "Can Edit", collaboratorName: "Smith, Alice", - Organization: { - orgID: "org-1", - orgName: "Org 1", - }, }, { collaboratorID: "user-2", - permission: "Can View", + permission: "Can Edit", collaboratorName: "Johnson, Bob", - Organization: { - orgID: "org-2", - orgName: "Org 2", - }, }, ], } as Submission, @@ -51,13 +43,6 @@ const mockPotentialCollaborators: User[] = [ _id: "user-1", firstName: "Alice", lastName: "Smith", - organization: { - orgID: "org-1", - orgName: "Org 1", - status: "Active", - createdAt: "", - updateAt: "", - }, role: "User", email: "", dataCommons: [], @@ -66,18 +51,13 @@ const mockPotentialCollaborators: User[] = [ userStatus: "Active", updateAt: "", createdAt: "", + permissions: [], + notifications: [], }, { _id: "user-2", firstName: "Bob", lastName: "Johnson", - organization: { - orgID: "org-2", - orgName: "Org 2", - status: "Active", - createdAt: "", - updateAt: "", - }, role: "User", email: "", dataCommons: [], @@ -86,18 +66,13 @@ const mockPotentialCollaborators: User[] = [ userStatus: "Active", updateAt: "", createdAt: "", + permissions: [], + notifications: [], }, { _id: "user-3", firstName: "Charlie", lastName: "Brown", - organization: { - orgID: "org-3", - orgName: "Org 3", - status: "Active", - createdAt: "", - updateAt: "", - }, role: "User", email: "", dataCommons: [], @@ -106,6 +81,8 @@ const mockPotentialCollaborators: User[] = [ userStatus: "Active", updateAt: "", createdAt: "", + permissions: [], + notifications: [], }, ]; @@ -548,10 +525,6 @@ describe("CollaboratorsContext", () => { expect(currentCollaborators[0].collaboratorID).toBe("user-1"); expect(currentCollaborators[0].collaboratorName).toBe("Smith, Alice"); - expect(currentCollaborators[0].Organization).toEqual({ - orgID: "org-1", - orgName: "Org 1", - }); }); }); @@ -590,7 +563,6 @@ describe("CollaboratorsContext", () => { expect(currentCollaborators[0].collaboratorID).toBe("user-4"); expect(currentCollaborators[0].permission).toBe("Can Edit"); expect(currentCollaborators[0].collaboratorName).toBe("Doe, John"); - expect(currentCollaborators[0].Organization).toBeUndefined(); }); mockSubmissionData = dummySubmissionData; @@ -625,7 +597,6 @@ describe("CollaboratorsContext", () => { expect(currentCollaborators[0].collaboratorID).toBe("user-4"); expect(currentCollaborators[0].collaboratorName).toBeUndefined(); - expect(currentCollaborators[0].Organization).toBeUndefined(); }); mockSubmissionData = dummySubmissionData; @@ -698,7 +669,6 @@ describe("CollaboratorsContext", () => { expect(currentCollaborators[0].collaboratorID).toBe("user-3"); expect(currentCollaborators[0].permission).toBe("Can Edit"); expect(currentCollaborators[0].collaboratorName).toBeUndefined(); - expect(currentCollaborators[0].Organization).toBeUndefined(); }); it("should handle missing data in saveCollaborators", async () => { @@ -751,10 +721,6 @@ describe("CollaboratorsContext", () => { expect(currentCollaborators[0].permission).toBe("Can Edit"); expect(currentCollaborators[0].collaboratorName).toBe("Brown, Charlie"); - expect(currentCollaborators[0].Organization).toEqual({ - orgID: "org-3", - orgName: "Org 3", - }); }); it("should handle updating an existing collaborator when they're no longer a potential collaborator", async () => { @@ -799,20 +765,19 @@ describe("CollaboratorsContext", () => { act(() => { result.current.handleUpdateCollaborator(0, { collaboratorID: dummySubmissionData.getSubmission.collaborators[0].collaboratorID, - permission: "Can View", // current permission is Can Edit + permission: "Can Edit", }); }); await waitFor(() => { // Verify the update propagated - expect(result.current.currentCollaborators[0].permission).toBe("Can View"); + expect(result.current.currentCollaborators[0].permission).toBe("Can Edit"); }); // Verify the user's details are carried over from the existing collaborators expect(result.current.currentCollaborators[0].collaboratorName).toBe( existingCol.collaboratorName ); - expect(result.current.currentCollaborators[0].Organization).toBe(existingCol.Organization); act(() => { result.current.handleRemoveCollaborator(0); diff --git a/src/components/Contexts/CollaboratorsContext.tsx b/src/components/Contexts/CollaboratorsContext.tsx index 14dbfa658..9b82c96b8 100644 --- a/src/components/Contexts/CollaboratorsContext.tsx +++ b/src/components/Contexts/CollaboratorsContext.tsx @@ -65,7 +65,7 @@ export const useCollaboratorsContext = (): CollaboratorsCtxState => { */ const defaultEmptyCollaborator: Collaborator = { collaboratorID: "", - permission: "Can View", + permission: "Can Edit", } as Collaborator; type ProviderProps = { @@ -134,7 +134,7 @@ export const CollaboratorsProvider: FC = ({ children }) => { /** * Creates a new empty collaborator object * - * @returns {Collaborator} An empty Collaborator with "Can View" permission + * @returns {Collaborator} An empty Collaborator with "Can Edit" permission */ const createEmptyCollaborator = (): Collaborator => ({ ...defaultEmptyCollaborator }); @@ -227,10 +227,7 @@ export const CollaboratorsProvider: FC = ({ children }) => { collaboratorIdx: number, newCollaborator: CollaboratorInput ): void => { - if ( - isNaN(collaboratorIdx) || - (!newCollaborator?.collaboratorID && !newCollaborator?.permission) - ) { + if (isNaN(collaboratorIdx) || !newCollaborator?.collaboratorID) { return; } @@ -267,9 +264,9 @@ export const CollaboratorsProvider: FC = ({ children }) => { * @returns {Promise} Promise resolving to the updated list of collaborators */ const saveCollaborators = useCallback(async (): Promise => { - const collaboratorsToSave = currentCollaborators - .filter((c) => !!c.collaboratorID && !!c.permission) - .map((c) => ({ collaboratorID: c.collaboratorID, permission: c.permission })); + const collaboratorsToSave: CollaboratorInput[] = currentCollaborators + .filter((c) => !!c.collaboratorID) + .map((c) => ({ collaboratorID: c.collaboratorID, permission: "Can Edit" })); try { const { data, errors } = await editSubmissionCollaborators({ diff --git a/src/components/DataSubmissions/CreateDataSubmissionDialog.test.tsx b/src/components/DataSubmissions/CreateDataSubmissionDialog.test.tsx index 452077c43..d7f3910a7 100644 --- a/src/components/DataSubmissions/CreateDataSubmissionDialog.test.tsx +++ b/src/components/DataSubmissions/CreateDataSubmissionDialog.test.tsx @@ -10,7 +10,14 @@ import { ContextState as AuthCtxState, Status as AuthStatus, } from "../Contexts/AuthContext"; -import { CREATE_SUBMISSION, CreateSubmissionResp, GetMyUserResp } from "../../graphql"; +import { + CREATE_SUBMISSION, + CreateSubmissionResp, + GetMyUserResp, + LIST_APPROVED_STUDIES, + ListApprovedStudiesInput, + ListApprovedStudiesResp, +} from "../../graphql"; const baseStudies: GetMyUserResp["getMyUser"]["studies"] = [ { @@ -71,6 +78,8 @@ const baseUser: Omit = { dataCommons: [], createdAt: "", updateAt: "", + permissions: ["data_submission:create"], + notifications: [], }; const baseAuthCtx: AuthCtxState = { @@ -974,118 +983,118 @@ describe("Implementation Requirements", () => { }); // NOTE: We're just random-testing against the opposite of the RequiresStudiesAssigned variable - // it.each(["Data Curator", "Data Commons POC"])( - // "should fetch all of the studies if the user's role is %s", - // async (role) => { - // const mockMatcher = jest.fn().mockImplementation(() => true); - // const listApprovedStudiesMock: MockedResponse< - // ListApprovedStudiesResp, - // ListApprovedStudiesInput - // > = { - // request: { - // query: LIST_APPROVED_STUDIES, - // }, - // variableMatcher: mockMatcher, - // result: { - // data: { - // listApprovedStudies: { - // total: 1, - // studies: [ - // { - // _id: "study1", - // studyName: "study-1-from-api", - // studyAbbreviation: "study-1-from-api-abbr", - // dbGaPID: "", - // controlledAccess: false, - // }, - // { - // _id: "study2", - // studyName: "study-2-from-api", - // studyAbbreviation: "study-2-from-api-abbr", - // dbGaPID: "", - // controlledAccess: false, - // }, - // ] as ApprovedStudy[], - // }, - // }, - // }, - // }; - - // const { getByRole } = render(, { - // wrapper: (p) => ( - // - // ), - // }); - - // userEvent.click(getByRole("button", { name: "Create a Data Submission" })); - - // await waitFor(() => { - // expect(mockMatcher).toHaveBeenCalledTimes(1); // Ensure the listApprovedStudies query was called - // }); - // } - // ); - - // it("should fetch all of the studies if the user's assigned studies contains the 'All' study", async () => { - // const mockMatcher = jest.fn().mockImplementation(() => true); - // const listApprovedStudiesMock: MockedResponse< - // ListApprovedStudiesResp, - // ListApprovedStudiesInput - // > = { - // request: { - // query: LIST_APPROVED_STUDIES, - // }, - // variableMatcher: mockMatcher, - // result: { - // data: { - // listApprovedStudies: { - // total: 1, - // studies: [ - // { - // _id: "study1", - // studyName: "study-1-from-api", - // studyAbbreviation: "study-1-from-api-abbr", - // dbGaPID: "", - // controlledAccess: false, - // }, - // ] as ApprovedStudy[], - // }, - // }, - // }, - // }; - - // const { getByRole } = render(, { - // wrapper: (p) => ( - // - // ), - // }); - - // userEvent.click(getByRole("button", { name: "Create a Data Submission" })); - - // await waitFor(() => { - // expect(mockMatcher).toHaveBeenCalledTimes(1); // Ensure the listApprovedStudies query was called - // }); - // }); + it.each(["Data Commons Personnel"])( + "should fetch all of the studies if the user's role is %s", + async (role) => { + const mockMatcher = jest.fn().mockImplementation(() => true); + const listApprovedStudiesMock: MockedResponse< + ListApprovedStudiesResp, + ListApprovedStudiesInput + > = { + request: { + query: LIST_APPROVED_STUDIES, + }, + variableMatcher: mockMatcher, + result: { + data: { + listApprovedStudies: { + total: 1, + studies: [ + { + _id: "study1", + studyName: "study-1-from-api", + studyAbbreviation: "study-1-from-api-abbr", + dbGaPID: "", + controlledAccess: false, + }, + { + _id: "study2", + studyName: "study-2-from-api", + studyAbbreviation: "study-2-from-api-abbr", + dbGaPID: "", + controlledAccess: false, + }, + ] as ApprovedStudy[], + }, + }, + }, + }; + + const { getByRole } = render(, { + wrapper: (p) => ( + + ), + }); + + userEvent.click(getByRole("button", { name: "Create a Data Submission" })); + + await waitFor(() => { + expect(mockMatcher).toHaveBeenCalledTimes(1); // Ensure the listApprovedStudies query was called + }); + } + ); + + it("should fetch all of the studies if the user's assigned studies contains the 'All' study", async () => { + const mockMatcher = jest.fn().mockImplementation(() => true); + const listApprovedStudiesMock: MockedResponse< + ListApprovedStudiesResp, + ListApprovedStudiesInput + > = { + request: { + query: LIST_APPROVED_STUDIES, + }, + variableMatcher: mockMatcher, + result: { + data: { + listApprovedStudies: { + total: 1, + studies: [ + { + _id: "study1", + studyName: "study-1-from-api", + studyAbbreviation: "study-1-from-api-abbr", + dbGaPID: "", + controlledAccess: false, + }, + ] as ApprovedStudy[], + }, + }, + }, + }; + + const { getByRole } = render(, { + wrapper: (p) => ( + + ), + }); + + userEvent.click(getByRole("button", { name: "Create a Data Submission" })); + + await waitFor(() => { + expect(mockMatcher).toHaveBeenCalledTimes(1); // Ensure the listApprovedStudies query was called + }); + }); }); diff --git a/src/components/DataSubmissions/CreateDataSubmissionDialog.tsx b/src/components/DataSubmissions/CreateDataSubmissionDialog.tsx index 94b322ac1..5daaf0b51 100644 --- a/src/components/DataSubmissions/CreateDataSubmissionDialog.tsx +++ b/src/components/DataSubmissions/CreateDataSubmissionDialog.tsx @@ -34,6 +34,7 @@ import BaseStyledHelperText from "../StyledFormComponents/StyledHelperText"; import Tooltip from "../Tooltip"; import { Logger, validateEmoji } from "../../utils"; import { RequiresStudiesAssigned } from "../../config/AuthRoles"; +import { hasPermission } from "../../config/AuthPermissions"; const CreateSubmissionDialog = styled(Dialog)({ "& .MuiDialog-paper": { @@ -259,7 +260,6 @@ const CreateDataSubmissionDialog: FC = ({ onCreate }) => { ); }, [shouldFetchAllStudies, allStudies, user?.studies]); - const orgOwnerOrSubmitter = user?.role === "Organization Owner" || user?.role === "Submitter"; const intention = watch("intention"); const submissionTypeOptions: RadioOption[] = [ { @@ -574,7 +574,7 @@ const CreateDataSubmissionDialog: FC = ({ onCreate }) => { - {orgOwnerOrSubmitter && ( + {hasPermission(user, "data_submission", "create", null, true) && ( = { dataCommons: [], createdAt: "", updateAt: "", + permissions: ["data_submission:view", "data_submission:review"], + notifications: [], }; type ParentProps = { @@ -510,44 +512,46 @@ describe("Implementation Requirements", () => { } ); - it.each(["Data Curator", "Admin"])( - "should always render for the role %s with Other Submissions present", - (role) => { - const { getByTestId } = render( - - - - ); + it("should always render for user with the required permissions while other Submissions are present", () => { + const { getByTestId } = render( + + + + ); - expect(getByTestId("cross-validate-button")).toBeInTheDocument(); - } - ); + expect(getByTestId("cross-validate-button")).toBeInTheDocument(); + }); - it.each([ - "Submitter", - "Organization Owner", - "Federal Lead", - "Data Commons POC", - "fake role" as User["role"], - ])("should never render for the role %s", (role) => { + it("should never render for user without the required permissions while other Submissions are present", () => { const { getByTestId } = render( - + = ({ submission, ...props }) => { }, [crossSubmissionStatus]); if ( - !user?.role || - !CrossValidateRoles.includes(user.role) || + !hasPermission(user, "data_submission", "review", submission) || !hasOtherSubmissions || status !== "Submitted" ) { diff --git a/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx b/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx index bcfa8dbe3..e045ce83a 100644 --- a/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx +++ b/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx @@ -51,6 +51,8 @@ const TestParent: FC = ({ createdAt: "", updateAt: "", studies: null, + permissions: ["data_submission:create"], + notifications: [], }, }), [userRole] diff --git a/src/components/DataSubmissions/DataSubmissionSummary.test.tsx b/src/components/DataSubmissions/DataSubmissionSummary.test.tsx index b2d997b49..e8f67e1c6 100644 --- a/src/components/DataSubmissions/DataSubmissionSummary.test.tsx +++ b/src/components/DataSubmissions/DataSubmissionSummary.test.tsx @@ -36,6 +36,8 @@ const baseUser: User = { dataCommons: [], createdAt: "", updateAt: "", + permissions: ["data_submission:view"], + notifications: [], }; const baseSubmissionCtx: SubmissionCtxState = { @@ -118,20 +120,12 @@ describe("Basic Functionality", () => { { collaboratorID: "col-1", collaboratorName: "", - Organization: { - orgID: "", - orgName: "", - }, - permission: "Can View", + permission: "Can Edit", }, { collaboratorID: "col-2", collaboratorName: "", - Organization: { - orgID: "", - orgName: "", - }, - permission: "Can View", + permission: "Can Edit", }, ], studyAbbreviation: "AAAAAAAAAAAAAAAAA", @@ -182,29 +176,17 @@ describe("Basic Functionality", () => { { collaboratorID: "1", collaboratorName: "", - Organization: { - orgID: "", - orgName: "", - }, - permission: "Can View", + permission: "Can Edit", }, { collaboratorID: "2", collaboratorName: "", - Organization: { - orgID: "", - orgName: "", - }, - permission: "Can View", + permission: "Can Edit", }, { collaboratorID: "3", collaboratorName: "", - Organization: { - orgID: "", - orgName: "", - }, - permission: "Can View", + permission: "Can Edit", }, ], }; diff --git a/src/components/DataSubmissions/DataUpload.test.tsx b/src/components/DataSubmissions/DataUpload.test.tsx index b7f7efa82..faa5d90e6 100644 --- a/src/components/DataSubmissions/DataUpload.test.tsx +++ b/src/components/DataSubmissions/DataUpload.test.tsx @@ -26,7 +26,7 @@ jest.mock("../../utils", () => ({ const baseSubmission: Omit = { name: "", - submitterID: "", + submitterID: "current-user", submitterName: "", organization: undefined, dataCommons: "", @@ -59,7 +59,7 @@ const baseSubmission: Omit = { collaborators: [], }; -const baseUser: User = { +const baseUser: Omit = { _id: "current-user", firstName: "", lastName: "", @@ -71,20 +71,27 @@ const baseUser: User = { dataCommons: null, createdAt: "", updateAt: "", + notifications: [], }; type ParentProps = { mocks?: MockedResponse[]; - role?: User["role"]; + role?: UserRole; + permissions?: AuthPermissions[]; children: React.ReactNode; }; -const TestParent: FC = ({ mocks = [], role = "Submitter", children }) => { +const TestParent: FC = ({ + mocks = [], + role = "Submitter", + permissions = ["data_submission:view", "data_submission:create"], + children, +}) => { const authCtxState: AuthCtxState = useMemo( () => ({ status: AuthStatus.LOADED, isLoggedIn: true, - user: { ...baseUser, role }, + user: { ...baseUser, role, permissions }, }), [role] ); @@ -299,32 +306,7 @@ describe("Implementation Requirements", () => { expect(button).toBeVisible(); }); - it.each(["Submitter", "Organization Owner"])( - "should enable the Uploader CLI download button for '%s' role", - async (role) => { - const { getByTestId } = render( - , - { wrapper: (p) => } - ); - - expect(getByTestId("uploader-cli-config-button")).toBeEnabled(); - } - ); - - it.each([ - "Admin", - "Data Curator", - "Data Commons POC", - "Federal Lead", - "User", - "fake role" as User["role"], // NOTE: asserting that a whitelist of allowed roles is used instead of a blacklist - ])("should disable the Uploader CLI download button for '%s' role", async (role) => { + it("should enable the Uploader CLI download button when user has required permissions", async () => { const { getByTestId } = render( { dataType: "Metadata and Data Files", // NOTE: Required for the button to show }} />, - { wrapper: (p) => } + { + wrapper: (p) => ( + + ), + } ); - expect(getByTestId("uploader-cli-config-button")).toBeDisabled(); + expect(getByTestId("uploader-cli-config-button")).toBeEnabled(); }); - it("should disable the Uploader CLI download button when collaborator does not have 'Can Edit' permissions", async () => { + it("should disable the Uploader CLI download button when user is missing required permissions", async () => { const { getByTestId } = render( , - { wrapper: (p) => } + { wrapper: (p) => } ); expect(getByTestId("uploader-cli-config-button")).toBeDisabled(); }); - it("should enable the Uploader CLI download button when collaborator does have 'Can Edit' permissions", async () => { + it("should enable the Uploader CLI download button when user is a collaborator", async () => { const { getByTestId } = render( { { collaboratorID: "current-user", collaboratorName: "", - Organization: null, permission: "Can Edit", }, ], diff --git a/src/components/DataSubmissions/DataUpload.tsx b/src/components/DataSubmissions/DataUpload.tsx index 4b021f0fa..8d8d752dd 100644 --- a/src/components/DataSubmissions/DataUpload.tsx +++ b/src/components/DataSubmissions/DataUpload.tsx @@ -10,7 +10,7 @@ import FlowWrapper from "./FlowWrapper"; import UploaderToolDialog from "../UploaderToolDialog"; import UploaderConfigDialog, { InputForm } from "../UploaderConfigDialog"; import { useAuthContext } from "../Contexts/AuthContext"; -import { GenerateApiTokenRoles } from "../../config/AuthRoles"; +import { hasPermission } from "../../config/AuthPermissions"; export type Props = { /** @@ -73,8 +73,6 @@ export const DataUpload: FC = ({ submission }: Props) => { context: { clientName: "backend" }, }); - const collaborator = submission?.collaborators?.find((c) => c.collaboratorID === user?._id); - const handleConfigDownload = async ({ manifest, dataFolder }: InputForm) => { try { const { data, error } = await retrieveCLIConfig({ @@ -110,10 +108,7 @@ export const DataUpload: FC = ({ submission }: Props) => { return ( setConfigDialogOpen(true)} variant="contained" color="info" @@ -122,7 +117,7 @@ export const DataUpload: FC = ({ submission }: Props) => { Download Configuration File ); - }, [submission?.dataType, user?.role, collaborator]); + }, [submission, user]); return ( diff --git a/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx b/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx index 3822f333d..46ae4b314 100644 --- a/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx +++ b/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx @@ -71,6 +71,8 @@ const baseUser: User = { dataCommons: [], createdAt: "", updateAt: "", + permissions: ["data_submission:view", "data_submission:create"], + notifications: [], }; type TestParentProps = { @@ -269,32 +271,6 @@ describe("Basic Functionality", () => { expect(getByTestId("delete-node-data-button")).toBeDisabled(); }); - it("should be disabled when the collaborator does not have 'Can Edit' permissions", () => { - const { getByTestId } = render( - - + + ); + } + + return null; }) : null} - - -
- - setClickedTitle("")} - > - User Profile - - - - - - {(user?.role === "Admin" || user?.role === "Organization Owner") && ( - - setClickedTitle("")} - > - Manage Users - - - )} - {user?.role === "Admin" && ( - - setClickedTitle("")} - > - Manage Programs - - - )} - {user?.role === "Admin" && ( - - setClickedTitle("")} - > - Manage Studies - - - )} - {user?.role && GenerateApiTokenRoles.includes(user?.role) ? ( - - - - ) : null} - { - setClickedTitle(""); - handleLogout(); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - setClickedTitle(""); - handleLogout(); - } - }} - > - Logout - -
-
-
setOpenAPITokenDialog(false)} /> setUploaderToolOpen(false)} /> diff --git a/src/components/PermissionPanel/index.test.tsx b/src/components/PermissionPanel/index.test.tsx new file mode 100644 index 000000000..64d833704 --- /dev/null +++ b/src/components/PermissionPanel/index.test.tsx @@ -0,0 +1,1263 @@ +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import { act, render, waitFor, within } from "@testing-library/react"; +import { FormProvider, FormProviderProps } from "react-hook-form"; +import { axe } from "jest-axe"; +import { FC } from "react"; +import { GraphQLError } from "graphql"; +import userEvent from "@testing-library/user-event"; +import PermissionPanel from "./index"; +import { + RETRIEVE_PBAC_DEFAULTS, + RetrievePBACDefaultsInput, + RetrievePBACDefaultsResp, +} from "../../graphql"; + +type MockParentProps = { + children: React.ReactNode; + methods?: FormProviderProps; + mocks?: MockedResponse[]; +}; + +const MockParent: FC = ({ children, methods, mocks = [] }) => ( + + []) as FormProviderProps["watch"]} + setValue={jest.fn()} + {...methods} + > + {children} + + +); + +describe("Accessibility", () => { + it("should not have any accessibility violations (empty)", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [], + notifications: [], + }, + ], + }, + }, + }; + + const { container } = render(, { + wrapper: ({ children }) => {children}, + }); + + let result; + await act(async () => { + result = await axe(container); + }); + expect(result).toHaveNoViolations(); + }); + + it("should not have any accessibility violations (populated)", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "data_submission:view", + group: "Data Submission", + name: "View", + order: 0, + checked: true, + disabled: true, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "account:disabled", + group: "Account", + name: "Disabled", + order: 0, + checked: false, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const { container } = render(, { + wrapper: ({ children }) => {children}, + }); + + let result; + await act(async () => { + result = await axe(container); + }); + expect(result).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + it("should not crash when rendered", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [], + notifications: [], + }, + ], + }, + }, + }; + + render(, { + wrapper: ({ children }) => {children}, + }); + }); + + it("should cache the default PBAC data by default", async () => { + const mockMatcher = jest.fn().mockImplementation(() => true); + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + }, + variableMatcher: mockMatcher, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [], + notifications: [], + }, + ], + }, + }, + maxUsageCount: 999, + }; + + const { rerender } = render(, { + wrapper: ({ children }) => {children}, + }); + + expect(mockMatcher).toHaveBeenCalledTimes(1); + + rerender(); + + expect(mockMatcher).toHaveBeenCalledTimes(1); + }); + + it("should group the permissions by their pre-defined groups", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "data_submission:view", + group: "Data Submission", + name: "View", + order: 0, + checked: true, + disabled: true, + }, + { + _id: "program:manage", + group: "Admin", + name: "Manage Programs", + order: 0, + checked: false, + disabled: false, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "account:disabled", + group: "Account", + name: "Disabled", + order: 0, + checked: false, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const mockWatcher = jest.fn().mockImplementation((field) => { + // Return the selected role (e.g. watch("role")) + if (field === "role") { + return "Submitter"; + } + + // Return the selected permissions (e.g. watch("permissions")) + return []; + }); + + const { getByTestId } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("permissions-group-Submission Request")).toBeInTheDocument(); + }); + + const srGroup = getByTestId("permissions-group-Submission Request"); + expect(within(srGroup).getByTestId("permission-submission_request:create")).toBeInTheDocument(); + + const dsGroup = getByTestId("permissions-group-Data Submission"); + expect(dsGroup).toBeInTheDocument(); + expect(within(dsGroup).getByTestId("permission-data_submission:view")).toBeInTheDocument(); + + const adminGroup = getByTestId("permissions-group-Admin"); + expect(adminGroup).toBeInTheDocument(); + expect(within(adminGroup).getByTestId("permission-program:manage")).toBeInTheDocument(); + + const dsEmailGroup = getByTestId("notifications-group-Data Submissions"); + expect(dsEmailGroup).toBeInTheDocument(); + expect( + within(dsEmailGroup).getByTestId("notification-data_submission:cancelled") + ).toBeInTheDocument(); + + const accountGroup = getByTestId("notifications-group-Account"); + expect(accountGroup).toBeInTheDocument(); + expect(within(accountGroup).getByTestId("notification-account:disabled")).toBeInTheDocument(); + }); + + it("should utilize a maximum of 3 columns for the permissions", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Group1", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "submission_request:submit", + group: "Group1", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "submission_request:review", + group: "Group2", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "submission_request:submit", + group: "Group3", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "submission_request:view", + group: "Group4", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "data_submission:create", + group: "Group5", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "study:manage", + group: "Group6", + name: "Create", + order: 0, + checked: true, + disabled: false, + }, + ], + notifications: [], + }, + ], + }, + }, + }; + + const mockWatcher = jest.fn().mockImplementation((field) => { + if (field === "role") { + return "Submitter"; + } + + return []; + }); + + const { getByTestId, queryByTestId } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("permissions-column-0")).toBeInTheDocument(); + expect(getByTestId("permissions-column-1")).toBeInTheDocument(); + expect(getByTestId("permissions-column-2")).toBeInTheDocument(); + }); + + // Column 0-1 (has 1 group) + expect( + within(getByTestId("permissions-column-0")).getByTestId("permissions-group-Group1") + ).toBeInTheDocument(); + expect( + within(getByTestId("permissions-column-1")).getByTestId("permissions-group-Group2") + ).toBeInTheDocument(); + + // Column 2 (has remaining groups) + expect( + within(getByTestId("permissions-column-2")).getByTestId("permissions-group-Group3") + ).toBeInTheDocument(); + expect( + within(getByTestId("permissions-column-2")).getByTestId("permissions-group-Group4") + ).toBeInTheDocument(); + expect( + within(getByTestId("permissions-column-2")).getByTestId("permissions-group-Group5") + ).toBeInTheDocument(); + expect( + within(getByTestId("permissions-column-2")).getByTestId("permissions-group-Group6") + ).toBeInTheDocument(); + + // Sanity check + expect(queryByTestId("permissions-column-3")).not.toBeInTheDocument(); + }); + + it("should utilize a maximum of 3 columns for the notifications", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [], + notifications: [ + { + _id: "access:requested", + group: "Group1", + name: "Notification 1", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "data_submission:deleted", + group: "Group1", + name: "Notification 1-2", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "account:disabled", + group: "Group2", + name: "Notification 2", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "data_submission:cancelled", + group: "Group3", + name: "Notification 3", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "submission_request:to_be_reviewed", + group: "Group4", + name: "Notification 4", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "data_submission:withdrawn", + group: "Group5", + name: "Notification 5", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "data_submission:deleted", + group: "Group6", + name: "Notification 6", + order: 0, + checked: true, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const mockWatcher = jest.fn().mockImplementation((field) => { + if (field === "role") { + return "Submitter"; + } + + return []; + }); + + const { getByTestId, queryByTestId } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("notifications-column-0")).toBeInTheDocument(); + expect(getByTestId("notifications-column-1")).toBeInTheDocument(); + expect(getByTestId("notifications-column-2")).toBeInTheDocument(); + }); + + // Column 0-1 (has 1 group) + expect( + within(getByTestId("notifications-column-0")).getByTestId("notifications-group-Group1") + ).toBeInTheDocument(); + expect( + within(getByTestId("notifications-column-1")).getByTestId("notifications-group-Group2") + ).toBeInTheDocument(); + + // Column 2 (has remaining groups) + expect( + within(getByTestId("notifications-column-2")).getByTestId("notifications-group-Group3") + ).toBeInTheDocument(); + expect( + within(getByTestId("notifications-column-2")).getByTestId("notifications-group-Group4") + ).toBeInTheDocument(); + expect( + within(getByTestId("notifications-column-2")).getByTestId("notifications-group-Group5") + ).toBeInTheDocument(); + expect( + within(getByTestId("notifications-column-2")).getByTestId("notifications-group-Group6") + ).toBeInTheDocument(); + + // Sanity check + expect(queryByTestId("notifications-column-3")).not.toBeInTheDocument(); + }); + + it("should sort the permissions and notifications by their order property", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Group1", + name: "Create", + order: 1, + checked: true, + disabled: false, + }, + { + _id: "submission_request:submit", + group: "Group1", + name: "Submit", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "submission_request:review", + group: "Group1", + name: "Review", + order: 2, + checked: true, + disabled: false, + }, + ], + notifications: [ + { + _id: "access:requested", + group: "Group1", + name: "Notification 1", + order: 1, + checked: true, + disabled: false, + }, + { + _id: "data_submission:deleted", + group: "Group1", + name: "Notification 1-2", + order: 0, + checked: true, + disabled: false, + }, + { + _id: "account:disabled", + group: "Group1", + name: "Notification 2", + order: 2, + checked: true, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const mockWatcher = jest.fn().mockImplementation((field) => { + if (field === "role") { + return "Submitter"; + } + + return []; + }); + + const { getByTestId } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("permissions-group-Group1")).toBeInTheDocument(); + }); + + const permissionGroup = getByTestId("permissions-group-Group1"); + expect(permissionGroup.innerHTML.search("permission-submission_request:submit")).toBeLessThan( + permissionGroup.innerHTML.search("permission-submission_request:create") + ); + expect(permissionGroup.innerHTML.search("permission-submission_request:create")).toBeLessThan( + permissionGroup.innerHTML.search("permission-submission_request:review") + ); + + const notificationGroup = getByTestId("notifications-group-Group1"); + expect(notificationGroup.innerHTML.search("notification-data_submission:deleted")).toBeLessThan( + notificationGroup.innerHTML.search("notification-access:requested") + ); + expect(notificationGroup.innerHTML.search("notification-access:requested")).toBeLessThan( + notificationGroup.innerHTML.search("notification-account:disabled") + ); + }); + + it("should show an error when unable to retrieve the default PBAC details (GraphQL)", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + errors: [new GraphQLError("Mock Error")], + }, + }; + + render(, { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith(expect.any(String), { + variant: "error", + }); + }); + }); + + it("should show an error when unable to retrieve the default PBAC details (Network)", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + error: new Error("Network error"), + }; + + render(, { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith(expect.any(String), { + variant: "error", + }); + }); + }); +}); + +describe("Implementation Requirements", () => { + it("should utilize the initial form values to determine the checked state of each permission", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "data_submission:view", + group: "Data Submission", + name: "View", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "program:manage", + group: "Admin", + name: "Manage Programs", + order: 0, + checked: false, + disabled: false, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "account:disabled", + group: "Account", + name: "Disabled", + order: 0, + checked: false, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const mockWatcher = jest.fn().mockImplementation((field) => { + if (field === "role") { + return "Submitter"; + } + + if (field === "permissions") { + return ["submission_request:create", "program:manage"]; + } + + if (field === "notifications") { + return ["data_submission:cancelled"]; + } + + return []; + }); + + const { getByTestId } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("permission-submission_request:create")).toBeInTheDocument(); + }); + + // Checked permissions by default based on the initial form values + expect( + within(getByTestId("permission-submission_request:create")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + expect( + within(getByTestId("permission-program:manage")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + + // Unchecked permissions sanity check + expect( + within(getByTestId("permission-data_submission:view")).getByRole("checkbox", { + hidden: true, + }) + ).not.toBeChecked(); + + // Checked notifications by default based on the initial form values + expect( + within(getByTestId("notification-data_submission:cancelled")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + + // Unchecked notifications sanity check + expect( + within(getByTestId("notification-account:disabled")).getByRole("checkbox", { + hidden: true, + }) + ).not.toBeChecked(); + }); + + it("should reset the permissions to their default values when the role changes", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "data_submission:view", + group: "Data Submission", + name: "View", + order: 0, + checked: false, + disabled: false, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: false, + disabled: false, + }, + { + _id: "account:disabled", + group: "Account", + name: "Disabled", + order: 0, + checked: false, + disabled: false, + }, + ], + }, + { + role: "Federal Lead", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: false, // Original submitter had this checked + disabled: false, + }, + { + _id: "data_submission:view", + group: "Data Submission", + name: "View", + order: 0, + checked: true, + disabled: false, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: false, // Original submitter had this checked + disabled: false, + }, + { + _id: "account:disabled", + group: "Account", + name: "Disabled", + order: 0, + checked: true, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const formValues = { + role: "Submitter", + permissions: ["submission_request:create"], + notifications: ["data_submission:cancelled"], + }; + + const mockWatcher = jest.fn().mockImplementation((field) => formValues[field] ?? ""); + + const mockSetValue = jest.fn().mockImplementation((field, value) => { + formValues[field] = value; + }); + + const { getByTestId, rerender } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("permission-submission_request:create")).toBeInTheDocument(); + }); + + // Checked permissions by default based on the initial form values + expect( + within(getByTestId("permission-submission_request:create")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + + // Unchecked permissions sanity check + expect( + within(getByTestId("permission-data_submission:view")).getByRole("checkbox", { + hidden: true, + }) + ).not.toBeChecked(); + + // Checked notifications by default based on the initial form values + expect( + within(getByTestId("notification-data_submission:cancelled")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + + // Unchecked notifications sanity check + expect( + within(getByTestId("notification-account:disabled")).getByRole("checkbox", { + hidden: true, + }) + ).not.toBeChecked(); + + // Change the role + formValues.role = "Federal Lead"; + + rerender(); // The original role is "Submitter", nothing should change + rerender(); // This is a work-around to trigger the UI update + + // Checked permissions by default based on the NEW role + expect( + within(getByTestId("permission-data_submission:view")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + + // Unchecked permissions sanity check + expect( + within(getByTestId("permission-submission_request:create")).getByRole("checkbox", { + hidden: true, + }) + ).not.toBeChecked(); + + // Checked notifications by default based on the NEW role + expect( + within(getByTestId("notification-account:disabled")).getByRole("checkbox", { + hidden: true, + }) + ).toBeChecked(); + + // Unchecked notifications sanity check + expect( + within(getByTestId("notification-data_submission:cancelled")).getByRole("checkbox", { + hidden: true, + }) + ).not.toBeChecked(); + }); + + it("should allow disabled PBAC options to be checked by default", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: true, + disabled: true, + }, + { + _id: "data_submission:view", + group: "Data Submission", + name: "View", + order: 0, + checked: true, + disabled: false, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: true, + disabled: true, + }, + { + _id: "account:disabled", + group: "Account", + name: "Disabled", + order: 0, + checked: true, + disabled: false, + }, + ], + }, + ], + }, + }, + maxUsageCount: 999, + }; + + const formValues = { + role: "Federal Lead", + permissions: [], + notifications: [], + }; + + const mockWatcher = jest.fn().mockImplementation((field) => formValues[field] ?? ""); + + const mockSetValue = jest.fn().mockImplementation((field, value) => { + formValues[field] = value; + }); + + const { getByTestId, rerender } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Trigger role change + formValues.role = "Submitter"; + + rerender(); + + await waitFor(() => { + expect(getByTestId("permission-submission_request:create")).toBeInTheDocument(); + }); + + expect( + within(getByTestId("permission-submission_request:create")).getByRole("checkbox", { + hidden: true, + }) + ).toBeDisabled(); + + expect( + within(getByTestId("notification-data_submission:cancelled")).getByRole("checkbox", { + hidden: true, + }) + ).toBeDisabled(); + }); + + it("should be rendered as collapsed by default", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [], + }, + }, + }; + + const { getByTestId } = render(, { + wrapper: ({ children }) => {children}, + }); + + expect(within(getByTestId("permissions-accordion")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "false" + ); + + userEvent.click(within(getByTestId("permissions-accordion")).getByRole("button")); + + expect(within(getByTestId("permissions-accordion")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "true" + ); + + expect(within(getByTestId("notifications-accordion")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "false" + ); + + userEvent.click(within(getByTestId("notifications-accordion")).getByRole("button")); + + expect(within(getByTestId("notifications-accordion")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "true" + ); + }); + + it("should propagate the permission and notification selections to the parent form", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [ + { + role: "Submitter", + permissions: [ + { + _id: "submission_request:create", + group: "Submission Request", + name: "Create", + order: 0, + checked: false, + disabled: false, + }, + ], + notifications: [ + { + _id: "data_submission:cancelled", + group: "Data Submissions", + name: "Cancelled", + order: 0, + checked: false, + disabled: false, + }, + ], + }, + ], + }, + }, + }; + + const formValues = { + role: "Submitter", + permissions: [], + notifications: [], + }; + + const mockWatcher = jest.fn().mockImplementation((field) => formValues[field] ?? ""); + + const mockSetValue = jest.fn().mockImplementation((field, value) => { + formValues[field] = value; + }); + + const { getByTestId, rerender } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getByTestId("permission-submission_request:create")).toBeInTheDocument(); + }); + + userEvent.click( + within(getByTestId("permission-submission_request:create")).getByRole("checkbox", { + hidden: true, + }) + ); + + expect(mockSetValue).toHaveBeenCalledWith("permissions", ["submission_request:create"]); + + rerender(); // Force the watch() to be called again and update the form values + + userEvent.click( + within(getByTestId("permission-submission_request:create")).getByRole("checkbox", { + hidden: true, + }) + ); + + expect(mockSetValue).toHaveBeenCalledWith("permissions", []); + + userEvent.click( + within(getByTestId("notification-data_submission:cancelled")).getByRole("checkbox", { + hidden: true, + }) + ); + + expect(mockSetValue).toHaveBeenCalledWith("notifications", ["data_submission:cancelled"]); + + rerender(); // Force the watch() to be called again and update the form values + + userEvent.click( + within(getByTestId("notification-data_submission:cancelled")).getByRole("checkbox", { + hidden: true, + }) + ); + + expect(mockSetValue).toHaveBeenCalledWith("notifications", []); + }); + + it("should render a notice when there are no default PBAC details for a role", async () => { + const mock: MockedResponse = { + request: { + query: RETRIEVE_PBAC_DEFAULTS, + variables: { roles: ["All"] }, + }, + result: { + data: { + retrievePBACDefaults: [], + }, + }, + }; + + const { getByTestId } = render(, { + wrapper: ({ children }) => {children}, + }); + + expect(getByTestId("no-permissions-notice")).toBeInTheDocument(); + expect(getByTestId("no-permissions-notice")).toHaveTextContent( + /No permission options found for this role./i + ); + + expect(getByTestId("no-notifications-notice")).toBeInTheDocument(); + expect(getByTestId("no-notifications-notice")).toHaveTextContent( + /No notification options found for this role./i + ); + }); +}); diff --git a/src/components/PermissionPanel/index.tsx b/src/components/PermissionPanel/index.tsx new file mode 100644 index 000000000..97b5c408b --- /dev/null +++ b/src/components/PermissionPanel/index.tsx @@ -0,0 +1,273 @@ +import { useQuery } from "@apollo/client"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Checkbox, + FormControlLabel, + FormGroup, + styled, + Typography, + Unstable_Grid2 as Grid2, +} from "@mui/material"; +import { FC, memo, useEffect, useMemo, useRef } from "react"; +import { useFormContext } from "react-hook-form"; +import { useSnackbar } from "notistack"; +import { cloneDeep } from "lodash"; +import { + EditUserInput, + RetrievePBACDefaultsResp, + RetrievePBACDefaultsInput, + RETRIEVE_PBAC_DEFAULTS, +} from "../../graphql"; +import { ColumnizedPBACGroups, columnizePBACGroups, Logger } from "../../utils"; + +const StyledBox = styled(Box)({ + width: "957px", + transform: "translateX(-50%)", + marginLeft: "50%", + marginTop: "63px", +}); + +const StyledAccordion = styled(Accordion)({ + width: "100%", +}); + +const StyledAccordionSummary = styled(AccordionSummary)({ + borderBottom: "1px solid #6B7294", + minHeight: "unset !important", + "& .MuiAccordionSummary-content": { + margin: "9px 0", + }, + "& .MuiAccordionSummary-content.Mui-expanded": { + margin: "9px 0", + }, + "& .MuiAccordionSummary-expandIconWrapper": { + color: "#356AAD", + }, +}); + +const StyledAccordionHeader = styled(Typography)<{ component: React.ElementType }>({ + fontWeight: 700, + fontSize: "16px", + lineHeight: "19px", + color: "#356AAD", +}); + +const StyledGroupTitle = styled(Typography)({ + fontWeight: 600, + fontSize: "13px", + lineHeight: "20px", + color: "#187A90", + textTransform: "uppercase", + marginBottom: "7.5px", + marginTop: "13.5px", + userSelect: "none", +}); + +const StyledFormControlLabel = styled(FormControlLabel)({ + whiteSpace: "nowrap", + userSelect: "none", + "& .MuiTypography-root": { + fontWeight: 400, + fontSize: "16px", + color: "#083A50 !important", + }, + "& .MuiCheckbox-root": { + paddingTop: "5.5px", + paddingBottom: "5.5px", + color: "#005EA2 !important", + }, + "& .MuiTypography-root.Mui-disabled, & .MuiCheckbox-root.Mui-disabled": { + opacity: 0.6, + cursor: "not-allowed !important", + }, +}); + +const StyledNotice = styled(Typography)({ + marginTop: "29.5px", + textAlign: "center", + width: "100%", + color: "#6B7294", + userSelect: "none", +}); + +/** + * Provides a panel for managing permissions and notifications for a user role. + * + * @returns The PermissionPanel component. + */ +const PermissionPanel: FC = () => { + const { enqueueSnackbar } = useSnackbar(); + const { setValue, watch } = useFormContext(); + + const { data, loading } = useQuery( + RETRIEVE_PBAC_DEFAULTS, + { + variables: { roles: ["All"] }, + context: { clientName: "backend" }, + fetchPolicy: "cache-first", + onError: (error) => { + Logger.error("Failed to retrieve PBAC defaults", { error }); + enqueueSnackbar("Failed to retrieve PBAC defaults", { variant: "error" }); + }, + } + ); + + const selectedRole = watch("role"); + const permissionsValue = watch("permissions"); + const notificationsValue = watch("notifications"); + const roleRef = useRef(selectedRole); + + const permissionColumns = useMemo>(() => { + if (!data?.retrievePBACDefaults && loading) { + return []; + } + + const defaults = data?.retrievePBACDefaults?.find((pbac) => pbac.role === selectedRole); + if (!defaults || !defaults?.permissions) { + Logger.error("Role not found in PBAC defaults", { role: selectedRole, data }); + return []; + } + + const remappedPermissions: PBACDefault[] = cloneDeep(defaults.permissions).map( + (p) => ({ ...p, checked: permissionsValue.includes(p._id) }) + ); + + return columnizePBACGroups(remappedPermissions, 3); + }, [data, permissionsValue]); + + const notificationColumns = useMemo>(() => { + if (!data?.retrievePBACDefaults && loading) { + return []; + } + + const defaults = data?.retrievePBACDefaults?.find((pbac) => pbac.role === selectedRole); + if (!defaults || !defaults?.notifications) { + Logger.error("Role not found in PBAC defaults", { role: selectedRole, data }); + return []; + } + + const remappedNotifications: PBACDefault[] = cloneDeep( + defaults.notifications + ).map((n) => ({ ...n, checked: notificationsValue.includes(n._id) })); + + return columnizePBACGroups(remappedNotifications, 3); + }, [data, notificationsValue]); + + const handlePermissionChange = (_id: AuthPermissions) => { + if (permissionsValue.includes(_id)) { + setValue( + "permissions", + permissionsValue.filter((p) => p !== _id) + ); + } else { + setValue("permissions", [...permissionsValue, _id]); + } + }; + + const handleNotificationChange = (_id: AuthNotifications) => { + if (notificationsValue.includes(_id)) { + setValue( + "notifications", + notificationsValue.filter((n) => n !== _id) + ); + } else { + setValue("notifications", [...notificationsValue, _id]); + } + }; + + const handleRoleChange = (selectedRole: UserRole) => { + if (selectedRole === roleRef.current) { + return; + } + + const defaults = data?.retrievePBACDefaults?.find((pbac) => pbac.role === selectedRole); + setValue("permissions", defaults?.permissions?.filter((p) => p.checked).map((p) => p._id)); + setValue("notifications", defaults?.notifications?.filter((n) => n.checked).map((n) => n._id)); + roleRef.current = selectedRole; + }; + + useEffect(() => { + handleRoleChange(selectedRole); + }, [selectedRole]); + + return ( + + + }> + Permissions + + + + {permissionColumns.map((column, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {column.map(({ name, data }) => ( +
+ {name} + + {data.map(({ _id, checked, disabled, name }) => ( + handlePermissionChange(_id)} + control={} + data-testid={`permission-${_id}`} + /> + ))} + +
+ ))} +
+ ))} + {permissionColumns.length === 0 && ( + + No permission options found for this role. + + )} +
+
+
+ + }> + Email Notifications + + + + {notificationColumns.map((column, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {column.map(({ name, data }) => ( +
+ {name} + + {data.map(({ _id, checked, disabled, name }) => ( + handleNotificationChange(_id)} + control={} + data-testid={`notification-${_id}`} + /> + ))} + +
+ ))} +
+ ))} + {notificationColumns.length === 0 && ( + + No notification options found for this role. + + )} +
+
+
+
+ ); +}; + +export default memo(PermissionPanel); diff --git a/src/components/StyledFormComponents/StyledAsterisk.tsx b/src/components/StyledFormComponents/StyledAsterisk.tsx index 491575b6f..9a4c6fcbc 100644 --- a/src/components/StyledFormComponents/StyledAsterisk.tsx +++ b/src/components/StyledFormComponents/StyledAsterisk.tsx @@ -1,6 +1,7 @@ +import { forwardRef } from "react"; import { styled } from "@mui/material"; -const Asterisk = styled("span")(() => ({ +const StyledAsterisk = styled("span")(() => ({ color: "#C93F08", marginLeft: "2px", fontWeight: 700, @@ -9,6 +10,12 @@ const Asterisk = styled("span")(() => ({ lineHeight: "18.8px", })); -const StyledAsterisk = () => *; +const Asterisk = forwardRef>( + (props, ref) => ( + + * + + ) +); -export default StyledAsterisk; +export default Asterisk; diff --git a/src/config/AuthPermissions.test.ts b/src/config/AuthPermissions.test.ts new file mode 100644 index 000000000..94cd918f7 --- /dev/null +++ b/src/config/AuthPermissions.test.ts @@ -0,0 +1,396 @@ +import { hasPermission } from "./AuthPermissions"; + +const baseApplication: Application = { + _id: "", + status: "New", + createdAt: "", + updatedAt: "", + submittedDate: "", + history: [], + controlledAccess: false, + openAccess: false, + ORCID: "", + PI: "", + applicant: { + applicantID: "owner-123", + applicantName: "", + applicantEmail: "", + }, + questionnaireData: null, + programName: "", + studyAbbreviation: "", + conditional: false, + pendingConditions: [], + programAbbreviation: "", + programDescription: "", +}; + +const baseSubmission: Submission = { + _id: "submission-1", + dataCommons: "commons-1", + studyID: "study-1", + submitterID: "owner-123", + collaborators: [], + name: "", + submitterName: "", + organization: undefined, + modelVersion: "", + studyAbbreviation: "", + dbGaPID: "", + bucketName: "", + rootPath: "", + status: "New", + metadataValidationStatus: "New", + fileValidationStatus: "New", + crossSubmissionStatus: "New", + deletingData: false, + archived: false, + validationStarted: "", + validationEnded: "", + validationScope: "New", + validationType: [], + fileErrors: [], + history: [], + conciergeName: "", + conciergeEmail: "", + intention: "New/Update", + dataType: "Metadata Only", + otherSubmissions: "", + nodeCount: 0, + createdAt: "", + updatedAt: "", +}; + +const createUser = (role: UserRole, permissions: AuthPermissions[] = []): User => ({ + role, + permissions, + notifications: [], + _id: "user-1", + firstName: "Alice", + lastName: "Smith", + email: "alice@example.com", + dataCommons: [], + studies: [], + IDP: "nih", + userStatus: "Active", + updateAt: "", + createdAt: "", +}); + +describe("Basic Permissions", () => { + it.each([ + "Admin", + "Data Commons Personnel", + "Federal Lead", + "Submitter", + "User", + "Invalid-role" as UserRole, + ])( + "should allow a user to view the dashboard if they have permission, regardless of '%s' role", + (role) => { + const user = createUser(role, ["dashboard:view"]); + expect(hasPermission(user, "dashboard", "view")).toBe(true); + } + ); + + it.each([ + "Admin", + "Data Commons Personnel", + "Federal Lead", + "Submitter", + "User", + "Invalid-role" as UserRole, + ])( + "should deny a user to view the dashboard if they do not have permission, regardless of '%s' role", + (role) => { + const user = createUser(role, []); + expect(hasPermission(user, "dashboard", "view")).toBe(false); + } + ); + + it("should check only the permission key if the onlyKey param is set to true", () => { + const user = createUser("Submitter", ["dashboard:view"]); + expect(hasPermission(user, "dashboard", "view", null, true)).toBe(true); + expect(hasPermission(user, "access", "request", null, true)).toBe(false); + }); +}); + +describe("Edge Cases", () => { + it("should deny permission if the user role is invalid", () => { + const user = createUser("InvalidRole" as UserRole); + expect(hasPermission(user, "dashboard", "view")).toBe(false); + }); + + it("should deny permission if the user role is null or undefined", () => { + const user = createUser(undefined); + expect(hasPermission(user, "dashboard", "view")).toBe(false); + }); + + it("should deny permission if the action is invalid", () => { + const user = createUser("Admin"); + expect(hasPermission(user, "dashboard", "invalid_action" as never)).toBe(false); + }); + + it("should deny permission if the resource is invalid", () => { + const user = createUser("Admin"); + expect(hasPermission(user, "invalid_resource" as never, "view" as never)).toBe(false); + }); +}); + +describe("submission_request:submit Permission", () => { + const validStatuses: ApplicationStatus[] = ["In Progress", "Inquired"]; + const invalidStatuses: ApplicationStatus[] = [ + "Approved", + "In Review", + "New", + "Rejected", + "Submitted", + ]; + + it.each(validStatuses)( + "should allow the form owner to submit if the status is '%s' without the permission", + (status) => { + const user = createUser("Submitter", []); + + const application: Application = { + ...baseApplication, + status, + applicant: { + ...baseApplication.applicant, + applicantID: "user-1", + }, + }; + + expect(hasPermission(user, "submission_request", "submit", application)).toBe(true); + } + ); + + it.each(validStatuses)( + "should allow a user with 'submission_request:submit' permission key if the status is '%s', even if not owner", + (status) => { + const user = createUser("Submitter", ["submission_request:submit"]); + const application: Application = { ...baseApplication, status }; + + expect(hasPermission(user, "submission_request", "submit", application)).toBe(true); + } + ); + + it("should deny submission if the user is not the owner AND lacks the 'submission_request:submit' permission key", () => { + const user = createUser("Submitter", []); + const application: Application = { ...baseApplication, status: "In Progress" }; + + expect(hasPermission(user, "submission_request", "submit", application)).toBe(false); + }); + + it.each(invalidStatuses)("should deny submission if the application status is '%s'", (status) => { + const user = createUser("Submitter", ["submission_request:submit"]); + const application: Application = { ...baseApplication, status }; + + expect(hasPermission(user, "submission_request", "submit", application)).toBe(false); + }); + + it("should return false if application is missing or undefined", () => { + const user = createUser("Submitter", ["submission_request:submit"]); + + expect(hasPermission(user, "submission_request", "submit", undefined)).toBe(false); + expect(hasPermission(user, "submission_request", "submit", null)).toBe(false); + }); +}); + +describe("data_submission:create Permission", () => { + const createSubmission = { + ...baseSubmission, + _id: "submission-1", + studyID: "study-1", + dataCommons: "commons-1", + }; + + it("should allow a collaborator (no permission key needed)", () => { + const user = createUser("User", []); + const submission: Submission = { + ...createSubmission, + collaborators: [ + { + collaboratorID: user._id, + collaboratorName: "", + permission: null, + }, + ], + }; + expect(hasPermission(user, "data_submission", "create", submission)).toBe(true); + }); + + it("should allow a submitter who is the submission owner WITH 'data_submission:create' key", () => { + const user = createUser("Submitter", ["data_submission:create"]); + user._id = "owner-123"; + expect(hasPermission(user, "data_submission", "create", createSubmission)).toBe(true); + }); + + it("should allow a 'Federal Lead' who is the submission owner WITH 'data_submission:create' key", () => { + const user = createUser("Federal Lead", ["data_submission:create"]); + user._id = "owner-123"; + expect(hasPermission(user, "data_submission", "create", createSubmission)).toBe(true); + }); + + it("should allow a 'Data Commons Personnel' who is the submission owner WITH 'data_submission:create' key", () => { + const user = createUser("Data Commons Personnel", ["data_submission:create"]); + user._id = "owner-123"; + expect(hasPermission(user, "data_submission", "create", createSubmission)).toBe(true); + }); + + it("should allow 'Admin' who is the submission owner WITH 'data_submission:create' key", () => { + const user = createUser("Admin", ["data_submission:create"]); + user._id = "owner-123"; + expect(hasPermission(user, "data_submission", "create", createSubmission)).toBe(true); + }); + + it("should deny if user doesn't meet any condition", () => { + const user = createUser("User", []); + expect(hasPermission(user, "data_submission", "create", createSubmission)).toBe(false); + }); + + it("should return false if submission is missing or undefined", () => { + const user = createUser("Admin", ["data_submission:create"]); + expect(hasPermission(user, "data_submission", "create", undefined)).toBe(false); + expect(hasPermission(user, "data_submission", "create", null)).toBe(false); + }); +}); + +describe("data_submission:review Permission", () => { + const reviewSubmission = { + ...baseSubmission, + _id: "submission-3", + studyID: "study-3", + dataCommons: "commons-3", + }; + + it("should allow a 'Federal Lead' with 'data_submission:review' key if they have the matching study", () => { + const user = createUser("Federal Lead", ["data_submission:review"]); + user.studies = [{ _id: "study-3" }]; + expect(hasPermission(user, "data_submission", "review", reviewSubmission)).toBe(true); + }); + + it("should allow a 'Federal Lead' with 'data_submission:review' key if they have the 'All' study", () => { + const user = createUser("Federal Lead", ["data_submission:review"]); + user.studies = [{ _id: "All" }]; + expect(hasPermission(user, "data_submission", "review", reviewSubmission)).toBe(true); + }); + + it("should allow 'Data Commons Personnel' with 'data_submission:review' key if they have the matching dataCommons", () => { + const user = createUser("Data Commons Personnel", ["data_submission:review"]); + user.dataCommons = ["commons-3"]; + expect(hasPermission(user, "data_submission", "review", reviewSubmission)).toBe(true); + }); + + it("should allow 'Admin' with 'data_submission:review' key", () => { + const user = createUser("Admin", ["data_submission:review"]); + expect(hasPermission(user, "data_submission", "review", reviewSubmission)).toBe(true); + }); + + it("should deny if user doesn't meet any condition", () => { + const user = createUser("Submitter", ["data_submission:review"]); + expect(hasPermission(user, "data_submission", "review", reviewSubmission)).toBe(false); + }); + + it("should return false if submission is missing or undefined", () => { + const user = createUser("Admin", ["data_submission:review"]); + expect(hasPermission(user, "data_submission", "review", undefined)).toBe(false); + expect(hasPermission(user, "data_submission", "review", null)).toBe(false); + }); +}); + +describe("data_submission:admin_submit Permission", () => { + const adminSubmitSubmission = { + ...baseSubmission, + _id: "submission-4", + studyID: "study-4", + dataCommons: "commons-4", + }; + + it("should allow a 'Federal Lead' with 'data_submission:admin_submit' key if they have the matching study", () => { + const user = createUser("Federal Lead", ["data_submission:admin_submit"]); + user.studies = [{ _id: "study-4" }]; + expect(hasPermission(user, "data_submission", "admin_submit", adminSubmitSubmission)).toBe( + true + ); + }); + + it("should allow a 'Federal Lead' with 'data_submission:admin_submit' key if they have the 'All' study", () => { + const user = createUser("Federal Lead", ["data_submission:admin_submit"]); + user.studies = [{ _id: "All" }]; + expect(hasPermission(user, "data_submission", "admin_submit", adminSubmitSubmission)).toBe( + true + ); + }); + + it("should allow 'Data Commons Personnel' with 'data_submission:admin_submit' key if they have the matching dataCommons", () => { + const user = createUser("Data Commons Personnel", ["data_submission:admin_submit"]); + user.dataCommons = ["commons-4"]; + expect(hasPermission(user, "data_submission", "admin_submit", adminSubmitSubmission)).toBe( + true + ); + }); + + it("should allow 'Admin' with 'data_submission:admin_submit' key", () => { + const user = createUser("Admin", ["data_submission:admin_submit"]); + expect(hasPermission(user, "data_submission", "admin_submit", adminSubmitSubmission)).toBe( + true + ); + }); + + it("should deny if user doesn't meet any condition", () => { + const user = createUser("User", []); + expect(hasPermission(user, "data_submission", "admin_submit", adminSubmitSubmission)).toBe( + false + ); + }); + + it("should return false if submission is missing or undefined", () => { + const user = createUser("Admin", ["data_submission:admin_submit"]); + expect(hasPermission(user, "data_submission", "admin_submit", undefined)).toBe(false); + expect(hasPermission(user, "data_submission", "admin_submit", null)).toBe(false); + }); +}); + +describe("data_submission:confirm Permission", () => { + const confirmSubmission = { + ...baseSubmission, + _id: "submission-5", + studyID: "study-5", + dataCommons: "commons-5", + }; + + it("should allow a 'Federal Lead' with 'data_submission:confirm' key if they have the matching study", () => { + const user = createUser("Federal Lead", ["data_submission:confirm"]); + user.studies = [{ _id: "study-5" }]; + expect(hasPermission(user, "data_submission", "confirm", confirmSubmission)).toBe(true); + }); + + it("should allow a 'Federal Lead' with 'data_submission:confirm' key if they have the 'All' study", () => { + const user = createUser("Federal Lead", ["data_submission:confirm"]); + user.studies = [{ _id: "All" }]; + expect(hasPermission(user, "data_submission", "confirm", confirmSubmission)).toBe(true); + }); + + it("should allow 'Data Commons Personnel' with 'data_submission:confirm' key if they have the matching dataCommons", () => { + const user = createUser("Data Commons Personnel", ["data_submission:confirm"]); + user.dataCommons = ["commons-5"]; + expect(hasPermission(user, "data_submission", "confirm", confirmSubmission)).toBe(true); + }); + + it("should allow 'Admin' with 'data_submission:confirm' key", () => { + const user = createUser("Admin", ["data_submission:confirm"]); + expect(hasPermission(user, "data_submission", "confirm", confirmSubmission)).toBe(true); + }); + + it("should deny if user doesn't meet any condition", () => { + const user = createUser("Submitter", ["data_submission:confirm"]); + expect(hasPermission(user, "data_submission", "confirm", confirmSubmission)).toBe(false); + }); + + it("should return false if submission is missing or undefined", () => { + const user = createUser("Admin", ["data_submission:confirm"]); + expect(hasPermission(user, "data_submission", "confirm", undefined)).toBe(false); + expect(hasPermission(user, "data_submission", "confirm", null)).toBe(false); + }); +}); diff --git a/src/config/AuthPermissions.ts b/src/config/AuthPermissions.ts new file mode 100644 index 000000000..8fa161ea2 --- /dev/null +++ b/src/config/AuthPermissions.ts @@ -0,0 +1,192 @@ +const NO_CONDITIONS = "NO CONDITIONS"; + +type PermissionCheck = + | typeof NO_CONDITIONS + | ((user: User, data: Permissions[Key]["dataType"]) => boolean); + +type PermissionMap = { + [Key in keyof Permissions]: Partial<{ + [Action in Permissions[Key]["action"]]: PermissionCheck; + }>; +}; + +type Permissions = { + access: { + dataType: null; + action: "request"; + }; + dashboard: { + dataType: null; + action: "view"; + }; + submission_request: { + dataType: Application; + action: "view" | "create" | "submit" | "review"; + }; + data_submission: { + dataType: Submission; + action: "view" | "create" | "review" | "admin_submit" | "confirm"; + }; + user: { + dataType: null; + action: "manage"; + }; + program: { + dataType: null; + action: "manage"; + }; + study: { + dataType: null; + action: "manage"; + }; +}; + +export const PERMISSION_MAP = { + submission_request: { + view: NO_CONDITIONS, + create: NO_CONDITIONS, + submit: (user, application) => { + const isFormOwner = application?.applicant?.applicantID === user?._id; + const hasPermissionKey = user?.permissions?.includes("submission_request:submit"); + const submitStatuses: ApplicationStatus[] = ["In Progress", "Inquired"]; + + // Check for implicit permission as well as for the permission key + if (!isFormOwner && !hasPermissionKey) { + return false; + } + if (!submitStatuses?.includes(application?.status)) { + return false; + } + + return true; + }, + review: NO_CONDITIONS, + }, + dashboard: { + view: NO_CONDITIONS, + }, + data_submission: { + view: NO_CONDITIONS, + create: (user, submission) => { + const hasPermissionKey = user?.permissions?.includes("data_submission:create"); + const isSubmissionOwner = submission?.submitterID === user?._id; + const isCollaborator = submission?.collaborators?.some((c) => c.collaboratorID === user?._id); + + if (isCollaborator) { + return true; + } + if (isSubmissionOwner && hasPermissionKey) { + return true; + } + + return false; + }, + review: (user, submission) => { + const { role, dataCommons, studies } = user; + const hasPermissionKey = user?.permissions?.includes("data_submission:review"); + + if (role === "Federal Lead" && hasPermissionKey) { + return studies?.some((s) => s._id === submission.studyID || s._id === "All"); + } + if (role === "Data Commons Personnel" && hasPermissionKey) { + return dataCommons?.some((dc) => dc === submission?.dataCommons); + } + if (role === "Admin" && hasPermissionKey) { + return true; + } + + return false; + }, + admin_submit: (user, submission) => { + const { role, dataCommons, studies } = user; + const hasPermissionKey = user?.permissions?.includes("data_submission:admin_submit"); + + if (role === "Federal Lead" && hasPermissionKey) { + return studies?.some((s) => s._id === submission.studyID || s._id === "All"); + } + if (role === "Data Commons Personnel" && hasPermissionKey) { + return dataCommons?.some((dc) => dc === submission?.dataCommons); + } + if (role === "Admin" && hasPermissionKey) { + return true; + } + + return false; + }, + confirm: (user, submission) => { + const { role, dataCommons, studies } = user; + const hasPermissionKey = user?.permissions?.includes("data_submission:confirm"); + + if (role === "Federal Lead" && hasPermissionKey) { + return studies?.some((s) => s._id === submission.studyID || s._id === "All"); + } + if (role === "Data Commons Personnel" && hasPermissionKey) { + return dataCommons?.some((dc) => dc === submission?.dataCommons); + } + if (role === "Admin" && hasPermissionKey) { + return true; + } + + return false; + }, + }, + access: { + request: NO_CONDITIONS, + }, + user: { + manage: NO_CONDITIONS, + }, + program: { + manage: NO_CONDITIONS, + }, + study: { + manage: NO_CONDITIONS, + }, +} as const satisfies PermissionMap; + +/** + * Determines if a user has the necessary permission to perform a specific action on a resource. + * + * @template Resource - A key of the `Permissions` type representing the resource to check. + * @param {User} user - The user object, which contains the user's role and permissions. + * @param {Resource} resource - The resource on which the action is being performed. + * @param {Permissions[Resource]["action"]} action - The action to check permission for. + * @param {Permissions[Resource]["dataType"]} [data] - Optional additional data needed for dynamic permission checks. + * @param {boolean} onlyKey - Optional flag for checking ONLY if the user has the permission key. + * @returns {boolean} - `true` if the user has permission, otherwise `false`. + * + * @example + * // Basic permission check without additional data + * const canCreate = hasPermission(user, "submission_request", "create"); + * + * @example + * // Permission check with additional data + * const canSubmit = hasPermission(user, "submission_request", "submit", applicationData); + */ +export const hasPermission = ( + user: User, + resource: Resource, + action: Permissions[Resource]["action"], + data?: Permissions[Resource]["dataType"], + onlyKey?: boolean +): boolean => { + if (!user?.role) { + return false; + } + + const permission = (PERMISSION_MAP as PermissionMap)?.[resource]?.[action]; + const permissionKey = `${resource}:${action}`; + + // If no conditions need to be checked, just check if user has permission key + if (onlyKey || permission === NO_CONDITIONS) { + return user.permissions?.includes(permissionKey as AuthPermissions); + } + + // If permission not defined, then deny permission + if (permission == null) { + return false; + } + + // Check conditions + return !!data && permission(user, data); +}; diff --git a/src/config/AuthRoles.ts b/src/config/AuthRoles.ts index 7a1fe285b..fad965682 100644 --- a/src/config/AuthRoles.ts +++ b/src/config/AuthRoles.ts @@ -1,115 +1,18 @@ /** * Defines a list of valid user roles that can be assigned to a user. * + * @note This list is rendered in the UI exactly as ordered here, without additional sorting. * @see {@link UserRole} */ export const Roles: UserRole[] = [ "User", "Submitter", - "Organization Owner", - "Federal Monitor", + "Data Commons Personnel", "Federal Lead", - "Data Curator", - "Data Commons POC", "Admin", ]; -/** - * Defines a list of roles that are allowed to interact with regular Validation. - */ -export const ValidateRoles: UserRole[] = [ - "Submitter", - "Organization Owner", - "Data Curator", - "Admin", -]; - -/** - * Defines a list of roles that are allowed to interact with Cross Validation. - */ -export const CrossValidateRoles: UserRole[] = ["Admin", "Data Curator"]; - -/** - * Defines a list of roles that can, at a minimum, view profiles of other users. - */ -export const CanManageUsers: UserRole[] = ["Admin", "Organization Owner"]; - -/** - * Defines a list of roles that are allowed to generate an API token. - * - * @note This also directly defines the roles that are allowed to generate a CLI config. - */ -export const GenerateApiTokenRoles: User["role"][] = ["Organization Owner", "Submitter"]; - -/** - * Defines a list of roles that are allowed to interact with the Operation Dashboard. - */ -export const DashboardRoles: UserRole[] = [ - "Admin", - "Data Curator", - "Federal Lead", - "Federal Monitor", -]; - -/** - * Defines a list of roles that are allowed to submit a Data Submission - */ -export const SubmitDataSubmissionRoles: UserRole[] = [ - "Submitter", - "Organization Owner", - "Data Curator", - "Admin", -]; - -/** - * Defines a list of roles that are allowed to view Data Submissions - * outside of their organization - */ -export const canViewOtherOrgRoles: UserRole[] = [ - "Admin", - "Data Commons POC", - "Data Curator", - "Federal Lead", - "Federal Monitor", -]; - -/** - * Defines a list of roles that can modify collaborators in a Data Submission - */ -export const canModifyCollaboratorsRoles: UserRole[] = ["Submitter", "Organization Owner"]; - -/** - * The users with permission to delete data nodes from a submission. - * - */ -export const canDeleteDataNodesRoles: UserRole[] = [ - "Submitter", - "Organization Owner", - "Data Curator", - "Admin", -]; - -/** - * Defines a list of roles that can upload metadata to a Data Submission - */ -export const canUploadMetadataRoles: UserRole[] = ["Submitter", "Organization Owner"]; - -/** - * A set of user roles that are allowed to request a role change from their profile. - */ -export const CanRequestRoleChange: UserRole[] = ["User", "Submitter", "Organization Owner"]; - -/** - * A set of user roles that are allowed to create a Submission Request form. - */ -export const CanCreateSubmissionRequest: UserRole[] = ["User", "Submitter", "Organization Owner"]; - -/** - * A set of user roles that are allowed to Submit a Submission Request. - */ -export const CanSubmitSubmissionRequestRoles: UserRole[] = ["User", "Submitter", "Federal Lead"]; - /** * A set of roles that are constrained to a set of studies. */ -export const RequiresStudiesAssigned: UserRole[] = ["Submitter", "Federal Monitor", "Federal Lead"]; +export const RequiresStudiesAssigned: UserRole[] = ["Submitter", "Federal Lead"]; diff --git a/src/config/HeaderConfig.ts b/src/config/HeaderConfig.ts index a8d9e963b..2d2c61849 100644 --- a/src/config/HeaderConfig.ts +++ b/src/config/HeaderConfig.ts @@ -3,7 +3,6 @@ import LogoSmall from "../assets/header/Portal_Logo_Small.svg"; import usaFlagSmall from "../assets/header/us_flag_small.svg"; import { DataCommons } from "./DataCommons"; import ApiInstructions from "../assets/pdf/CRDC_Data_Submission_API_Instructions.pdf"; -import { DashboardRoles } from "./AuthRoles"; export const DataSubmissionInstructionsLink = "https://datacommons.cancer.gov/data-submission-instructions"; @@ -19,7 +18,7 @@ export const headerData = { usaFlagSmallAltText: "usaFlagSmall", }; -export const navMobileList: NavBarItem[] = [ +export const HeaderLinks: NavBarItem[] = [ { name: "Back to CRDC", link: "https://datacommons.cancer.gov/submit", @@ -55,11 +54,11 @@ export const navMobileList: NavBarItem[] = [ link: "/operation-dashboard", id: "navbar-dropdown-operation-dashboard", className: "navMobileItem", - roles: DashboardRoles, + permissions: ["dashboard:view"], }, ]; -export const navbarSublists: Record = { +export const HeaderSubLinks: Record = { "Model Navigator": DataCommons.map((dc) => ({ id: `model-navigator-${dc.name}`, name: `${dc.name}${dc.name.indexOf("Model") === -1 ? " Model" : ""}`, diff --git a/src/content/OperationDashboard/Controller.test.tsx b/src/content/OperationDashboard/Controller.test.tsx index c95d6259c..3703f4bd1 100644 --- a/src/content/OperationDashboard/Controller.test.tsx +++ b/src/content/OperationDashboard/Controller.test.tsx @@ -19,7 +19,7 @@ jest.mock("../../hooks/usePageTitle", () => ({ })); // NOTE: Omitting fields depended on by the component -const baseUser: Omit = { +const baseUser: Omit = { _id: "", firstName: "", lastName: "", @@ -30,10 +30,12 @@ const baseUser: Omit = { createdAt: "", updateAt: "", studies: null, + notifications: [], }; type ParentProps = { - role: User["role"]; + role: UserRole; + permissions?: AuthPermissions[]; initialEntry?: string; mocks?: MockedResponse[]; ctxStatus?: AuthContextStatus; @@ -42,6 +44,7 @@ type ParentProps = { const TestParent: FC = ({ role, + permissions = ["dashboard:view"], initialEntry = "/dashboard", mocks = [], ctxStatus = AuthContextStatus.LOADED, @@ -51,7 +54,7 @@ const TestParent: FC = ({ () => ({ status: ctxStatus, isLoggedIn: role !== null, - user: { ...baseUser, role }, + user: { ...baseUser, role, permissions }, }), [role, ctxStatus] ); @@ -193,15 +196,9 @@ describe("Basic Functionality", () => { }); }); - it.each([ - "User", - "Submitter", - "Organization Owner", - "Data Commons POC", - "fake role" as User["role"], // Asserting that a whitelist is used instead of a blacklist - ])("should redirect the user role %p to the home page", (role) => { + it("should redirect the user with missing permissions to the home page", async () => { const { getByText } = render(, { - wrapper: (p) => , + wrapper: (p) => , }); expect(getByText("Root Page")).toBeInTheDocument(); diff --git a/src/content/OperationDashboard/Controller.tsx b/src/content/OperationDashboard/Controller.tsx index 86a7d2bf6..dcd3ae42c 100644 --- a/src/content/OperationDashboard/Controller.tsx +++ b/src/content/OperationDashboard/Controller.tsx @@ -6,13 +6,13 @@ import { GET_DASHBOARD_URL, GetDashboardURLInput, GetDashboardURLResp } from ".. import usePageTitle from "../../hooks/usePageTitle"; import { Status, useAuthContext } from "../../components/Contexts/AuthContext"; import SuspenseLoader from "../../components/SuspenseLoader"; -import { DashboardRoles } from "../../config/AuthRoles"; import DashboardView from "./DashboardView"; +import { hasPermission } from "../../config/AuthPermissions"; /** * Handles the logic for the OperationDashboard component. * - * @returns {JSX.Element} The OperationDashboard component. + * @returns The DashboardController component */ const DashboardController = () => { usePageTitle("Operation Dashboard"); @@ -21,16 +21,16 @@ const DashboardController = () => { const { enqueueSnackbar } = useSnackbar(); const [searchParams] = useSearchParams({ type: "Submission" }); - const canAccessPage = useMemo( - () => authStatus === Status.LOADED && user?.role && DashboardRoles.includes(user.role), - [authStatus, user?.role] + const canManage = useMemo( + () => authStatus === Status.LOADED && hasPermission(user, "dashboard", "view"), + [authStatus, user] ); const { data, error, loading } = useQuery( GET_DASHBOARD_URL, { variables: { type: searchParams.get("type") }, - skip: !canAccessPage || !searchParams.get("type"), + skip: !canManage || !searchParams.get("type"), onError: (e) => enqueueSnackbar(e?.message, { variant: "error", @@ -43,7 +43,7 @@ const DashboardController = () => { return ; } - if (!canAccessPage) { + if (!canManage) { return ; } diff --git a/src/content/OperationDashboard/DashboardView.test.tsx b/src/content/OperationDashboard/DashboardView.test.tsx index fc2197b39..e11aaa548 100644 --- a/src/content/OperationDashboard/DashboardView.test.tsx +++ b/src/content/OperationDashboard/DashboardView.test.tsx @@ -9,7 +9,7 @@ import { } from "../../components/Contexts/AuthContext"; import DashboardView from "./DashboardView"; -const baseUser: Omit = { +const baseUser: Omit = { _id: "", firstName: "", lastName: "", @@ -20,17 +20,25 @@ const baseUser: Omit = { createdAt: "", updateAt: "", studies: null, + notifications: [], }; -const MockParent: FC<{ role?: UserRole; children: React.ReactElement }> = ({ +type ParentProps = { + role?: UserRole; + permissions?: AuthPermissions[]; + children: React.ReactElement; +}; + +const MockParent: FC = ({ role = "Admin", + permissions = ["dashboard:view"], children, }) => { const baseAuthCtx: AuthContextState = useMemo( () => ({ status: AuthContextStatus.LOADED, isLoggedIn: role !== null, - user: { ...baseUser, role }, + user: { ...baseUser, role, permissions }, }), [role] ); diff --git a/src/content/OperationDashboard/DashboardView.tsx b/src/content/OperationDashboard/DashboardView.tsx index 5cbfde313..4b4c760ca 100644 --- a/src/content/OperationDashboard/DashboardView.tsx +++ b/src/content/OperationDashboard/DashboardView.tsx @@ -14,7 +14,8 @@ import StyledLabel from "../../components/StyledFormComponents/StyledLabel"; import SuspenseLoader from "../../components/SuspenseLoader"; import bannerSvg from "../../assets/banner/submission_banner.png"; import { useAuthContext } from "../../components/Contexts/AuthContext"; -import { Logger } from "../../utils"; +import { addDataCommonsParameter, addStudiesParameter } from "../../utils"; +import { RequiresStudiesAssigned } from "../../config/AuthRoles"; export type DashboardViewProps = { url: string; @@ -87,32 +88,28 @@ const DashboardView: FC = ({ const dashboardElementRef = useRef(null); const contentParameters = useMemo(() => { - const { role, studies, dataCommons } = user || {}; - const params: DashboardContentOptions["parameters"] = []; - - if (role === "Federal Monitor" && Array.isArray(studies) && studies.length > 0) { - params.push({ - Name: "studiesParameter", - Values: studies?.map((study: ApprovedStudy) => study?._id), - }); - } else if (role === "Federal Monitor") { - Logger.error("This role requires studies to be set but none were found.", studies); - params.push({ Name: "studiesParameter", Values: ["NO-CONTENT"] }); + if (!user?.role) { + return []; } - if (role === "Data Curator" && Array.isArray(dataCommons) && dataCommons.length > 0) { - params.push({ Name: "dataCommonsParameter", Values: dataCommons }); - } else if (role === "Data Curator") { - Logger.error("This role requires dataCommons to be set but none were found.", dataCommons); - params.push({ Name: "dataCommonsParameter", Values: ["NO-CONTENT"] }); + const { role } = user; + + if (RequiresStudiesAssigned.includes(role)) { + return addStudiesParameter(user); + } + + if (role === "Data Commons Personnel") { + return addDataCommonsParameter(user); } - return params; + return []; }, [user]); const handleDashboardChange = (e: SelectChangeEvent) => { setSearchParams({ type: e.target.value }); - dashboardElementRef.current.innerHTML = ""; + if (dashboardElementRef.current) { + dashboardElementRef.current.innerHTML = ""; + } setEmbeddedDashboard(null); }; diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index 76fe8e91f..9017ea92c 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -28,9 +28,9 @@ import { useSearchParamsContext } from "../../components/Contexts/SearchParamsCo import { useSubmissionContext } from "../../components/Contexts/SubmissionContext"; import DataActivity, { DataActivityRef } from "./DataActivity"; import CrossValidation from "./CrossValidation"; -import { CrossValidateRoles, SubmitDataSubmissionRoles } from "../../config/AuthRoles"; import CopyAdornment from "../../components/DataSubmissions/CopyAdornment"; import { Logger } from "../../utils"; +import { hasPermission } from "../../config/AuthPermissions"; const StyledBanner = styled("div")(({ bannerSrc }: { bannerSrc: string }) => ({ background: `url(${bannerSrc})`, @@ -168,7 +168,6 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.UPLOAD_ACTIVITY const dataSubmissionListPageUrl = `/data-submissions${ lastSearchParams?.["/data-submissions"] ?? "" }`; - const isValidTab = tab && Object.values(URLTabs).includes(tab); const activityRef = useRef(null); const hasUploadingBatches = useMemo( () => data?.batchStatusList?.batches?.some((b) => b.status === "Uploading"), @@ -176,21 +175,22 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.UPLOAD_ACTIVITY ); const crossValidationVisible: boolean = useMemo( () => - CrossValidateRoles.includes(user?.role) && + hasPermission(user, "data_submission", "review", data?.getSubmission) && data?.getSubmission?.crossSubmissionStatus !== null, - [user?.role, data?.getSubmission?.crossSubmissionStatus] + [user, data?.getSubmission] ); + const isValidTab = + tab && + Object.values(URLTabs).includes(tab) && + (tab !== URLTabs.CROSS_VALIDATION_RESULTS || crossValidationVisible); const submitInfo: SubmitButtonResult = useMemo(() => { - if (!data?.getSubmission?._id || !SubmitDataSubmissionRoles.includes(user?.role)) { - return { enabled: false }; - } - if (hasUploadingBatches) { + if (!data?.getSubmission?._id || hasUploadingBatches) { return { enabled: false }; } - return shouldEnableSubmit(data.getSubmission, qcData?.submissionQCResults?.results, user?.role); - }, [data?.getSubmission, user, hasUploadingBatches, SubmitDataSubmissionRoles]); + return shouldEnableSubmit(data.getSubmission, qcData?.submissionQCResults?.results, user); + }, [data?.getSubmission, qcData?.submissionQCResults?.results, user, hasUploadingBatches]); const releaseInfo: ReleaseInfo = useMemo( () => shouldDisableRelease(data?.getSubmission), [data?.getSubmission?.crossSubmissionStatus, data?.getSubmission?.otherSubmissions] @@ -261,6 +261,12 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.UPLOAD_ACTIVITY } }, [error, qcError]); + useEffect(() => { + if (!isValidTab) { + navigate(`/data-submission/${submissionId}/${URLTabs.UPLOAD_ACTIVITY}`, { replace: true }); + } + }, [isValidTab]); + return ( @@ -317,7 +323,9 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.UPLOAD_ACTIVITY {/* Primary Tab Content */} {tab === URLTabs.UPLOAD_ACTIVITY && } {tab === URLTabs.VALIDATION_RESULTS && } - {tab === URLTabs.CROSS_VALIDATION_RESULTS && } + {tab === URLTabs.CROSS_VALIDATION_RESULTS && crossValidationVisible && ( + + )} {tab === URLTabs.SUBMITTED_DATA && } {/* Return to Data Submission List Button */} diff --git a/src/content/dataSubmissions/DataSubmissionActions.tsx b/src/content/dataSubmissions/DataSubmissionActions.tsx index 6de43916e..c2a076147 100644 --- a/src/content/dataSubmissions/DataSubmissionActions.tsx +++ b/src/content/dataSubmissions/DataSubmissionActions.tsx @@ -7,6 +7,7 @@ import CustomDialog from "../../components/Shared/Dialog"; import { ReleaseInfo } from "../../utils"; import Tooltip from "../../components/Tooltip"; import { TOOLTIP_TEXT } from "../../config/DashboardTooltips"; +import { hasPermission } from "../../config/AuthPermissions"; const StyledActionWrapper = styled(Stack)(() => ({ justifyContent: "center", @@ -87,12 +88,13 @@ export type ActiveDialog = | "Cancel"; type ActionConfig = { - roles: User["role"][]; + hasPermission: (user: User, submission: Submission) => boolean; statuses: SubmissionStatus[]; }; type ActionKey = | "Submit" + | "AdminSubmit" | "Release" | "Withdraw" | "SubmittedReject" @@ -102,31 +104,43 @@ type ActionKey = const actionConfig: Record = { Submit: { - roles: ["Submitter", "Organization Owner", "Data Curator", "Admin"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "create", submission), + statuses: ["In Progress", "Withdrawn", "Rejected"], + }, + AdminSubmit: { + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "admin_submit", submission), statuses: ["In Progress", "Withdrawn", "Rejected"], }, Release: { - roles: ["Data Curator", "Admin"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "review", submission), statuses: ["Submitted"], }, Withdraw: { - roles: ["Submitter", "Organization Owner"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "create", submission), statuses: ["Submitted"], }, SubmittedReject: { - roles: ["Data Curator", "Admin"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "review", submission), statuses: ["Submitted"], }, ReleasedReject: { - roles: ["Data Commons POC", "Admin"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "confirm", submission), statuses: ["Released"], }, Complete: { - roles: ["Data Curator", "Admin", "Data Commons POC"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "confirm", submission), statuses: ["Released"], }, Cancel: { - roles: ["Submitter", "Organization Owner", "Data Curator", "Admin"], + hasPermission: (user, submission) => + hasPermission(user, "data_submission", "create", submission), statuses: ["New", "In Progress", "Rejected"], }, }; @@ -152,8 +166,6 @@ const DataSubmissionActions = ({ const [action, setAction] = useState(null); const [reviewComment, setReviewComment] = useState(""); - const collaborator = submission?.collaborators?.find((c) => c.collaboratorID === user?._id); - const handleOnAction = async (action: SubmissionAction) => { if (currentDialog) { setCurrentDialog(null); @@ -178,7 +190,9 @@ const DataSubmissionActions = ({ const canShowAction = (actionKey: ActionKey) => { const config = actionConfig[actionKey]; - return config?.statuses?.includes(submission?.status) && config?.roles?.includes(user?.role); + return ( + config?.statuses?.includes(submission?.status) && config?.hasPermission(user, submission) + ); }; const handleCommentChange = (event: React.ChangeEvent) => { @@ -189,7 +203,8 @@ const DataSubmissionActions = ({ return ( {/* Action Buttons */} - {canShowAction("Submit") ? ( + {canShowAction("Submit") || + (canShowAction("AdminSubmit") && submitActionButton?.isAdminOverride) ? ( onOpenDialog("Submit")} loading={action === "Submit"} - disabled={ - (collaborator && collaborator.permission !== "Can Edit") || - !submitActionButton?.enabled || - (action && action !== "Submit") - } + disabled={!submitActionButton?.enabled || (action && action !== "Submit")} > {submitActionButton?.isAdminOverride ? "Admin Submit" : "Submit"} @@ -254,10 +265,7 @@ const DataSubmissionActions = ({ color="error" onClick={() => onOpenDialog("Withdraw")} loading={action === "Withdraw"} - disabled={ - (collaborator && collaborator.permission !== "Can Edit") || - (action && action !== "Withdraw") - } + disabled={action && action !== "Withdraw"} > Withdraw @@ -279,10 +287,7 @@ const DataSubmissionActions = ({ color="error" onClick={() => onOpenDialog("Cancel")} loading={action === "Cancel"} - disabled={ - (collaborator && collaborator.permission !== "Can Edit") || - (action && action !== "Cancel") - } + disabled={action && action !== "Cancel"} > Cancel diff --git a/src/content/dataSubmissions/SubmittedData.test.tsx b/src/content/dataSubmissions/SubmittedData.test.tsx index d6e815c9d..cd9ea9d76 100644 --- a/src/content/dataSubmissions/SubmittedData.test.tsx +++ b/src/content/dataSubmissions/SubmittedData.test.tsx @@ -38,6 +38,8 @@ const baseUser: User = { dataCommons: [], createdAt: "", updateAt: "", + permissions: ["data_submission:create"], + notifications: [], }; const baseAuthCtx: AuthContextState = { @@ -752,7 +754,7 @@ describe("SubmittedData > Table", () => { }); }); - it("should disable the checkboxes when collaborator does not have 'Can Edit' permissions", async () => { + it("should enable the checkboxes when user is a collaborator", async () => { const getNodesMock: MockedResponse = { maxUsageCount: 2, // initial query + orderBy bug request: { @@ -788,66 +790,6 @@ describe("SubmittedData > Table", () => { { collaboratorID: baseUser._id, collaboratorName: "", - Organization: baseUser.organization, - permission: "Can View", - }, - ]} - > - - - ); - - await waitFor(() => { - const headerCheckbox = within(getByTestId("header-checkbox")).getByRole("checkbox"); - const rowCheckbox = getAllByTestId("row-checkbox"); - - expect(headerCheckbox).toBeDisabled(); - rowCheckbox.forEach((checkbox) => - expect(within(checkbox).getByRole("checkbox")).toBeDisabled() - ); - - const checkboxes = getAllByRole("checkbox"); - checkboxes.forEach((checkbox) => expect(checkbox).toBeDisabled()); - }); - }); - - it("should enable the checkboxes when collaborator has 'Can Edit' permissions", async () => { - const getNodesMock: MockedResponse = { - maxUsageCount: 2, // initial query + orderBy bug - request: { - query: GET_SUBMISSION_NODES, - }, - variableMatcher: () => true, - result: { - data: { - getSubmissionNodes: { - total: 200, - properties: ["col-xyz"], - IDPropName: "col-xyz", - nodes: Array(20).fill({ - nodeType: "example-node", - nodeID: "example-node-id", - props: JSON.stringify({ - "col-xyz": "value-for-column-xyz", - }), - status: "New", - }), - }, - }, - }, - }; - - const { getAllByRole, getAllByTestId, getByTestId } = render( - ( + + + +); /** * Renders the correct view based on the URL and permissions-tier * - * @param {void} props - React props - * @returns {FC} - React component + * @returns The Organization Controller component */ const OrganizationController = () => { const { orgId } = useParams<{ orgId?: string }>(); - const { user } = useAuthContext(); - const isAdministrative = user?.role === "Admin"; + const { user, status: authStatus } = useAuthContext(); - if (!isAdministrative) { - return ; + if (authStatus === Status.LOADING) { + return ; } - if (orgId) { - return ; + if (!hasPermission(user, "program", "manage")) { + return ; } - return ( - - - - ); + return orgId ? : ; }; export default OrganizationController; diff --git a/src/content/questionnaire/FormView.tsx b/src/content/questionnaire/FormView.tsx index b6c4836fb..1516e2601 100644 --- a/src/content/questionnaire/FormView.tsx +++ b/src/content/questionnaire/FormView.tsx @@ -28,8 +28,8 @@ import bannerPng from "../../assets/banner/submission_banner.png"; import { Status as AuthStatus, useAuthContext } from "../../components/Contexts/AuthContext"; import usePageTitle from "../../hooks/usePageTitle"; import ExportRequestButton from "../../components/ExportRequestButton"; -import { CanSubmitSubmissionRequestRoles } from "../../config/AuthRoles"; import { Logger } from "../../utils"; +import { hasPermission } from "../../config/AuthPermissions"; const StyledContainer = styled(Container)(() => ({ "&.MuiContainer-root": { @@ -393,50 +393,43 @@ const FormView: FC = ({ section }: Props) => { newData.sections.push({ name: activeSection, status: newStatus }); } - // Skip state update if there are no changes - if (!isEqual(data.questionnaireData, newData)) { - const res = await setData(newData); - if (res?.status === "failed" && !!res?.errorMessage) { - enqueueSnackbar( - `An error occurred while saving the ${map[activeSection].title} section. ${res.errorMessage}`, - { - variant: "error", - } - ); - } else { - enqueueSnackbar( - `Your changes for the ${map[activeSection].title} section have been successfully saved.`, - { - variant: "success", - } - ); - } + const saveResult = await setData(newData); + if (saveResult?.status === "failed" && !!saveResult?.errorMessage) { + enqueueSnackbar( + `An error occurred while saving the ${map[activeSection].title} section. ${saveResult.errorMessage}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar( + `Your changes for the ${map[activeSection].title} section have been successfully saved.`, + { + variant: "success", + } + ); + } - if ( - !blockedNavigate && - res?.status === "success" && - data["_id"] === "new" && - res.id !== data?.["_id"] - ) { - // NOTE: This currently triggers a form data refetch, which is not ideal - navigate(`/submission/${res.id}/${activeSection}`, { replace: true }); - } + if ( + !blockedNavigate && + saveResult?.status === "success" && + data["_id"] === "new" && + saveResult.id !== data?.["_id"] + ) { + // NOTE: This currently triggers a form data refetch, which is not ideal + navigate(`/submission/${saveResult.id}/${activeSection}`, { replace: true }); + } - if (res?.status === "success") { - return { - status: "success", - id: res.id, - }; - } + if (saveResult?.status === "success") { return { - status: "failed", - errorMessage: res?.errorMessage, + status: "success", + id: saveResult.id, }; } return { - status: "success", - id: data?.["_id"], + status: "failed", + errorMessage: saveResult?.errorMessage, }; }; @@ -538,15 +531,8 @@ const FormView: FC = ({ section }: Props) => { }; const handleSubmitForm = () => { - if ( - !CanSubmitSubmissionRequestRoles.includes(user?.role) || - (data?.status !== "In Progress" && - (data?.status !== "Inquired" || user?.role !== "Federal Lead")) - ) { - Logger.error("Invalid request to submit Submission Request form.", { - userRole: user?.role, - submissionStatus: data?.status, - }); + if (!hasPermission(user, "submission_request", "submit", data)) { + Logger.error("Invalid request to submit Submission Request form."); return; } setOpenSubmitDialog(true); @@ -627,15 +613,6 @@ const FormView: FC = ({ section }: Props) => { }; }); - useEffect(() => { - const formLoaded = status === FormStatus.LOADED && authStatus === AuthStatus.LOADED && data; - const invalidFormAuth = formMode === "Unauthorized" || authStatus === AuthStatus.ERROR || !user; - - if (formLoaded && invalidFormAuth) { - navigate("/"); - } - }, [formMode, navigate, status, authStatus, user, data]); - useEffect(() => { const isComplete = isAllSectionsComplete(); setAllSectionsComplete(isComplete); @@ -730,9 +707,7 @@ const FormView: FC = ({ section }: Props) => { )} {activeSection === "REVIEW" && - CanSubmitSubmissionRequestRoles.includes(user?.role) && - (data?.status === "In Progress" || - (data?.status === "Inquired" && user?.role === "Federal Lead")) && ( + hasPermission(user, "submission_request", "submit", data) && ( ({ useNavigate: () => mockNavigate, })); -const baseUser: Omit = { +const baseUser: Omit = { _id: "user-id", firstName: "", lastName: "", @@ -44,6 +44,7 @@ const baseUser: Omit = { createdAt: "", updateAt: "", studies: null, + notifications: [], }; const defaultMocks: MockedResponse[] = [ @@ -73,6 +74,7 @@ type ParentProps = { mocks?: MockedResponse[]; initialEntries?: MemoryRouterProps["initialEntries"]; role?: UserRole; + permissions?: AuthPermissions[]; children: React.ReactNode; }; @@ -80,13 +82,18 @@ const TestParent: FC = ({ mocks = defaultMocks, initialEntries = ["/"], role = "Submitter", + permissions = [ + "submission_request:view", + "submission_request:create", + "submission_request:submit", + ], children, }: ParentProps) => { const baseAuthCtx: AuthContextState = useMemo( () => ({ status: AuthStatus.LOADED, isLoggedIn: role !== null, - user: { ...baseUser, role }, + user: { ...baseUser, role, permissions }, }), [role] ); @@ -141,28 +148,21 @@ describe("ListView Component", () => { expect(mockUsePageTitle).toHaveBeenCalledWith("Submission Request List"); }); - it.each(["User", "Submitter", "Organization Owner"])( - "shows the 'Start a Submission Request' button for '%s' role", - (role) => { - const { getByText } = render( - - - - ); - expect(getByText("Start a Submission Request")).toBeInTheDocument(); - } - ); + it("shows the 'Start a Submission Request' button for users with the required permissions", () => { + const { getByText } = render( + + + + ); + expect(getByText("Start a Submission Request")).toBeInTheDocument(); + }); - it.each([ - "Admin", - "Data Commons POC", - "Data Curator", - "Federal Lead", - "Federal Monitor", - "fake-role" as UserRole, - ])("should not show the 'Start a Submission Request' button for '%s' role", (role) => { + it("hides the 'Start a Submission Request' button for users missing the required permissions", () => { const { queryByText } = render( - + ); @@ -522,7 +522,15 @@ describe("ListView Component", () => { }; const { getByText } = render( - + ); diff --git a/src/content/questionnaire/ListView.tsx b/src/content/questionnaire/ListView.tsx index 8287ef6df..b9a275d3d 100644 --- a/src/content/questionnaire/ListView.tsx +++ b/src/content/questionnaire/ListView.tsx @@ -32,9 +32,9 @@ import usePageTitle from "../../hooks/usePageTitle"; import GenericTable, { Column } from "../../components/GenericTable"; import QuestionnaireContext from "./Contexts/QuestionnaireContext"; import TruncatedText from "../../components/TruncatedText"; -import { CanCreateSubmissionRequest } from "../../config/AuthRoles"; import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip"; import Tooltip from "../../components/Tooltip"; +import { hasPermission } from "../../config/AuthPermissions"; type T = ListApplicationsResp["listApplications"]["applications"][number]; @@ -210,10 +210,8 @@ const columns: Column[] = [ renderValue: (a) => ( {({ user, handleOnReviewClick }) => { - const role = user?.role; - if ( - CanCreateSubmissionRequest.includes(role) && + hasPermission(user, "submission_request", "create") && a.applicant?.applicantID === user._id && ["New", "In Progress", "Inquired"].includes(a.status) ) { @@ -225,7 +223,10 @@ const columns: Column[] = [ ); } - if (role === "Federal Lead" && ["Submitted", "In Review"].includes(a.status)) { + if ( + hasPermission(user, "submission_request", "review") && + ["Submitted", "In Review"].includes(a.status) + ) { return ( handleOnReviewClick(a)} @@ -394,7 +395,7 @@ const ListingView: FC = () => { padding="57px 0 0 25px" body={ - {CanCreateSubmissionRequest.includes(user?.role) && ( + {hasPermission(user, "submission_request", "create") && ( Start a Submission Request diff --git a/src/content/studies/Controller.test.tsx b/src/content/studies/Controller.test.tsx index dd09182f6..36098c458 100644 --- a/src/content/studies/Controller.test.tsx +++ b/src/content/studies/Controller.test.tsx @@ -19,7 +19,7 @@ import { } from "../../graphql"; // NOTE: Omitting fields depended on by the component -const baseUser: Omit = { +const baseUser: Omit = { _id: "", firstName: "", lastName: "", @@ -30,10 +30,12 @@ const baseUser: Omit = { createdAt: "", updateAt: "", studies: null, + notifications: [], }; type ParentProps = { - role: User["role"]; + role: UserRole; + permissions?: AuthPermissions[]; initialEntry?: string; mocks?: MockedResponse[]; ctxStatus?: AuthContextStatus; @@ -42,6 +44,7 @@ type ParentProps = { const TestParent: FC = ({ role, + permissions = ["study:manage"], initialEntry = "/studies", mocks = [], ctxStatus = AuthContextStatus.LOADED, @@ -51,7 +54,7 @@ const TestParent: FC = ({ () => ({ status: ctxStatus, isLoggedIn: role !== null, - user: { ...baseUser, role }, + user: { ...baseUser, role, permissions }, }), [role, ctxStatus] ); @@ -135,15 +138,9 @@ describe("StudiesController", () => { }); }); - it.each([ - "Data Curator", - "Data Commons POC", - "Federal Lead", - "User", - "fake role" as User["role"], - ])("should redirect the user role %p to the home page", (role) => { + it("should redirect the user missing the required permissions to the home page", async () => { const { getByText } = render( - + ); diff --git a/src/content/studies/Controller.tsx b/src/content/studies/Controller.tsx index 193e579cd..9b2d5b7da 100644 --- a/src/content/studies/Controller.tsx +++ b/src/content/studies/Controller.tsx @@ -4,31 +4,26 @@ import { Status, useAuthContext } from "../../components/Contexts/AuthContext"; import ListView from "./ListView"; import StudyView from "./StudyView"; import SuspenseLoader from "../../components/SuspenseLoader"; +import { hasPermission } from "../../config/AuthPermissions"; /** * Renders the correct view based on the URL and permissions-tier * - * @param {void} props - React props - * @returns {FC} - React component + * @returns The StudiesController component */ const StudiesController: FC = () => { const { studyId } = useParams<{ studyId?: string }>(); const { user, status: authStatus } = useAuthContext(); - const isAdministrative = user?.role === "Admin"; if (authStatus === Status.LOADING) { return ; } - if (!isAdministrative) { + if (!hasPermission(user, "study", "manage")) { return ; } - if (studyId) { - return ; - } - - return ; + return studyId ? : ; }; export default StudiesController; diff --git a/src/content/studies/ListView.tsx b/src/content/studies/ListView.tsx index bc3c51bad..06749d422 100644 --- a/src/content/studies/ListView.tsx +++ b/src/content/studies/ListView.tsx @@ -12,7 +12,6 @@ import { } from "../../graphql"; import { FormatDate } from "../../utils"; import { formatAccessTypes } from "../../utils/studyUtils"; -import { useAuthContext, Status as AuthStatus } from "../../components/Contexts/AuthContext"; import ApprovedStudyFilters from "../../components/AdminPortal/Studies/ApprovedStudyFilters"; import TruncatedText from "../../components/TruncatedText"; import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip"; @@ -176,7 +175,6 @@ const ListView = () => { usePageTitle("Manage Studies"); const { state } = useLocation(); - const { status: authStatus } = useAuthContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); @@ -274,7 +272,7 @@ const ListView = () => { columns={columns} data={data || []} total={count || 0} - loading={loading || authStatus === AuthStatus.LOADING} + loading={loading} disableUrlParams={false} defaultRowsPerPage={20} defaultOrder="asc" diff --git a/src/content/users/Controller.tsx b/src/content/users/Controller.tsx index 9ef0fd803..0c519bc80 100644 --- a/src/content/users/Controller.tsx +++ b/src/content/users/Controller.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Navigate, useParams } from "react-router-dom"; -import { useAuthContext } from "../../components/Contexts/AuthContext"; +import { Status, useAuthContext } from "../../components/Contexts/AuthContext"; import ListView from "./ListView"; import ProfileView from "./ProfileView"; -import { CanManageUsers } from "../../config/AuthRoles"; +import { hasPermission } from "../../config/AuthPermissions"; +import SuspenseLoader from "../../components/SuspenseLoader"; type Props = { type: "users" | "profile"; @@ -29,27 +30,27 @@ type Props = { * which is shown to Admins and Org Owners, and allows them to see * the list of users. * - * @param {Props} props - React props - * @returns {FC} - React component + * @param type The type of view to render + * @returns The UserController component */ const UserController = ({ type }: Props) => { const { userId } = useParams(); - const { user } = useAuthContext(); - const { _id, role } = user || {}; - const isAdministrative = role && CanManageUsers.includes(role); + const { user, status: authStatus } = useAuthContext(); + const { _id } = user || {}; - // Accounts can only view their own "profile", redirect to it - if ((type === "profile" && userId !== _id) || (type === "users" && !isAdministrative)) { - return ; + if (authStatus === Status.LOADING) { + return ; } - // Show list of users to Admin or Org Owner - if (!userId && isAdministrative) { - return ; + // Accounts can only view their own "profile", redirect to it + if ( + (type === "profile" && userId !== _id) || + (type === "users" && !hasPermission(user, "user", "manage")) + ) { + return ; } - // Admin or Org Owner viewing a user's "Edit User" page or their own "Edit User" page - return ; + return userId ? : ; }; export default UserController; diff --git a/src/content/users/ListView.tsx b/src/content/users/ListView.tsx index f7e527824..72f3e6018 100644 --- a/src/content/users/ListView.tsx +++ b/src/content/users/ListView.tsx @@ -19,7 +19,6 @@ import PageBanner from "../../components/PageBanner"; import { Roles } from "../../config/AuthRoles"; import { LIST_USERS, ListUsersResp } from "../../graphql"; import { compareStrings, formatIDP, sortData } from "../../utils"; -import { useAuthContext, Status as AuthStatus } from "../../components/Contexts/AuthContext"; import usePageTitle from "../../hooks/usePageTitle"; import GenericTable, { Column } from "../../components/GenericTable"; import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext"; @@ -215,7 +214,6 @@ const columns: Column[] = [ const ListingView: FC = () => { usePageTitle("Manage Users"); - const { status: authStatus } = useAuthContext(); const { state } = useLocation(); const { searchParams, setSearchParams } = useSearchParamsContext(); const [dataset, setDataset] = useState([]); @@ -378,7 +376,7 @@ const ListingView: FC = () => { columns={columns} data={dataset || []} total={count || 0} - loading={loading || authStatus === AuthStatus.LOADING} + loading={loading} disableUrlParams={false} defaultRowsPerPage={20} defaultOrder="asc" diff --git a/src/content/users/ProfileView.tsx b/src/content/users/ProfileView.tsx index 4ce4bfca7..9fd2ea26d 100644 --- a/src/content/users/ProfileView.tsx +++ b/src/content/users/ProfileView.tsx @@ -2,7 +2,13 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; import { LoadingButton } from "@mui/lab"; import { Box, Container, MenuItem, Stack, TextField, Typography, styled } from "@mui/material"; -import { Controller, ControllerRenderProps, SubmitHandler, useForm } from "react-hook-form"; +import { + Controller, + ControllerRenderProps, + FormProvider, + SubmitHandler, + useForm, +} from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { useSnackbar } from "notistack"; import bannerSvg from "../../assets/banner/profile_banner.png"; @@ -24,22 +30,24 @@ import { UpdateMyUserInput, UpdateMyUserResp, } from "../../graphql"; -import { formatFullStudyName, formatIDP, formatStudySelectionValue } from "../../utils"; +import { formatFullStudyName, formatIDP, formatStudySelectionValue, Logger } from "../../utils"; import { DataCommons } from "../../config/DataCommons"; import usePageTitle from "../../hooks/usePageTitle"; import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext"; import BaseSelect from "../../components/StyledFormComponents/StyledSelect"; import BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput"; import BaseAutocomplete from "../../components/StyledFormComponents/StyledAutocomplete"; +import BaseAsterisk from "../../components/StyledFormComponents/StyledAsterisk"; import useProfileFields, { VisibleFieldState } from "../../hooks/useProfileFields"; import AccessRequest from "../../components/AccessRequest"; +import PermissionPanel from "../../components/PermissionPanel"; type Props = { _id: User["_id"]; viewType: "users" | "profile"; }; -type FormInput = UpdateMyUserInput["userInfo"] | EditUserInput; +export type FormInput = UpdateMyUserInput["userInfo"] | EditUserInput; const StyledContainer = styled(Container)({ marginBottom: "90px", @@ -88,6 +96,10 @@ const StyledHeaderText = styled(Typography)({ fontWeight: 700, }); +const StyledForm = styled("form")({ + width: "532px", +}); + const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" })<{ visible?: boolean; }>(({ visible = true }) => ({ @@ -102,8 +114,8 @@ const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" }) const StyledLabel = styled("span")({ color: "#356AAD", fontWeight: "700", - marginRight: "40px", - minWidth: "127px", + marginRight: "30px", + minWidth: "137px", }); const BaseInputStyling = { @@ -118,17 +130,12 @@ const StyledButtonStack = styled(Stack)({ marginTop: "50px", }); -const StyledButton = styled(LoadingButton)(({ txt, border }: { txt: string; border: string }) => ({ - borderRadius: "8px", - border: `2px solid ${border}`, - color: `${txt} !important`, - width: "101px", - height: "51px", - textTransform: "none", - fontWeight: 700, - fontSize: "17px", - padding: "6px 8px", -})); +const StyledButton = styled(LoadingButton)({ + minWidth: "120px", + fontSize: "16px", + padding: "10px", + lineHeight: "24px", +}); const StyledContentStack = styled(Stack)({ marginLeft: "2px !important", @@ -145,6 +152,12 @@ const StyledTag = styled("div")({ paddingLeft: "12px", }); +const StyledAsterisk = styled(BaseAsterisk, { shouldForwardProp: (p) => p !== "visible" })<{ + visible?: boolean; +}>(({ visible = true }) => ({ + display: visible ? undefined : "none", +})); + /** * User Profile View Component * @@ -158,7 +171,7 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { user: currentUser, setData, logout, status: authStatus } = useAuthContext(); const { lastSearchParams } = useSearchParamsContext(); - const { handleSubmit, register, reset, watch, setValue, control } = useForm(); + const methods = useForm(); const ALL_STUDIES_OPTION = "All"; const manageUsersPageUrl = `/users${lastSearchParams?.["/users"] ?? ""}`; @@ -169,6 +182,7 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { const [saving, setSaving] = useState(false); const [studyOptions, setStudyOptions] = useState([]); + const { handleSubmit, register, reset, watch, setValue, control } = methods; const roleField = watch("role"); const prevRoleRef = useRef(roleField); const studiesField = watch("studies"); @@ -244,7 +258,8 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { setSaving(false); if (errors || !d?.updateMyUser) { - enqueueSnackbar(errors || "Unable to save profile changes", { variant: "error" }); + Logger.error("ProfileView: Error from API", errors); + enqueueSnackbar("Unable to save profile changes", { variant: "error" }); return; } @@ -258,12 +273,15 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { userStatus: data.userStatus, studies: fieldset.studies !== "HIDDEN" ? data.studies : null, dataCommons: fieldset.dataCommons !== "HIDDEN" ? data.dataCommons : null, + permissions: data.permissions, + notifications: data.notifications, }, }).catch((e) => ({ errors: e?.message, data: null })); setSaving(false); if (errors || !d?.editUser) { - enqueueSnackbar(errors || "Unable to save user profile changes", { variant: "error" }); + Logger.error("ProfileView: Error from API", errors); + enqueueSnackbar("Unable to save user profile changes", { variant: "error" }); return; } @@ -386,7 +404,6 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { profile icon - = ({ _id, viewType }: Props) => { {user.email} - - - - Account Type - {formatIDP(user.IDP)} - - - Email - {user.email} - - - First name - {VisibleFieldState.includes(fieldset.firstName) ? ( - v?.trim(), - })} - inputProps={{ "aria-labelledby": "firstNameLabel", maxLength: 30 }} - size="small" - required - /> - ) : ( - user.firstName - )} - - - Last name - {VisibleFieldState.includes(fieldset.lastName) ? ( - v?.trim(), - })} - inputProps={{ "aria-labelledby": "lastNameLabel", maxLength: 30 }} - size="small" - required - /> - ) : ( - user.lastName - )} - - - Role - {VisibleFieldState.includes(fieldset.role) ? ( - ( - handleRoleChange(field, e?.target?.value as UserRole)} - MenuProps={{ disablePortal: true }} - inputProps={{ "aria-labelledby": "userRoleLabel" }} - > - {Roles.map((role) => ( - - {role} - - ))} - - )} - /> - ) : ( - <> - {user?.role} - {canRequestRole && } - - )} - - - Studies - {VisibleFieldState.includes(fieldset.studies) ? ( - ( - ( - 0 ? undefined : "Select studies"} - inputProps={{ "aria-labelledby": "userStudies", ...inputProps }} - onBlur={sortStudyOptions} - /> - )} - renderTags={(value: string[], _, state) => { - if (value?.length === 0 || state.focused) { - return null; - } - - return ( - - {formatStudySelectionValue(value, formattedStudyMap)} - - ); - }} - options={studyOptions} - getOptionLabel={(option: string) => formattedStudyMap[option]} - onChange={(_, data: string[]) => handleStudyChange(field, data)} - disabled={fieldset.studies === "DISABLED"} - loading={approvedStudiesLoading} - disableCloseOnSelect - multiple - /> - )} - /> - ) : null} - - - Account Status - {VisibleFieldState.includes(fieldset.userStatus) ? ( - ( - - Active - Inactive - - )} - /> - ) : ( - user.userStatus - )} - - - Data Commons - {VisibleFieldState.includes(fieldset.dataCommons) ? ( - ( - - {DataCommons.map((dc) => ( - - {dc.name} - - ))} - - )} - /> - ) : ( - user.dataCommons?.join(", ") - )} - - - - {Object.values(fieldset).some((fieldState) => fieldState === "UNLOCKED") && ( - - Save - - )} - {viewType === "users" && ( - navigate(manageUsersPageUrl)} - txt="#666666" - border="#828282" - > - Cancel - - )} - - + + + + Account Type + {formatIDP(user.IDP)} + + + Email + {user.email} + + + + First name + + + {VisibleFieldState.includes(fieldset.firstName) ? ( + v?.trim(), + })} + inputProps={{ "aria-labelledby": "firstNameLabel", maxLength: 30 }} + size="small" + required + /> + ) : ( + user.firstName + )} + + + + Last name + + + {VisibleFieldState.includes(fieldset.lastName) ? ( + v?.trim(), + })} + inputProps={{ "aria-labelledby": "lastNameLabel", maxLength: 30 }} + size="small" + required + /> + ) : ( + user.lastName + )} + + + + Role + + + {VisibleFieldState.includes(fieldset.role) ? ( + ( + handleRoleChange(field, e?.target?.value as UserRole)} + MenuProps={{ disablePortal: true }} + inputProps={{ "aria-labelledby": "userRoleLabel" }} + > + {Roles.map((role) => ( + + {role} + + ))} + + )} + /> + ) : ( + <> + {user?.role} + {canRequestRole && } + + )} + + + + Studies + + + {VisibleFieldState.includes(fieldset.studies) ? ( + ( + ( + 0 ? undefined : "Select studies"} + inputProps={{ + "aria-labelledby": "userStudies", + required: studiesField.length === 0, + ...inputProps, + }} + onBlur={sortStudyOptions} + /> + )} + renderTags={(value: string[], _, state) => { + if (value?.length === 0 || state.focused) { + return null; + } + + return ( + + {formatStudySelectionValue(value, formattedStudyMap)} + + ); + }} + options={studyOptions} + getOptionLabel={(option: string) => formattedStudyMap[option]} + onChange={(_, data: string[]) => handleStudyChange(field, data)} + disabled={fieldset.studies === "DISABLED"} + loading={approvedStudiesLoading} + disableCloseOnSelect + multiple + /> + )} + /> + ) : null} + + + + Account Status + + + {VisibleFieldState.includes(fieldset.userStatus) ? ( + ( + + Active + Inactive + + )} + /> + ) : ( + user.userStatus + )} + + + + Data Commons + + + {VisibleFieldState.includes(fieldset.dataCommons) ? ( + ( + + {DataCommons.map((dc) => ( + + {dc.name} + + ))} + + )} + /> + ) : ( + user.dataCommons?.join(", ") + )} + + {VisibleFieldState.includes(fieldset.permissions) && + VisibleFieldState.includes(fieldset.notifications) && } + + {Object.values(fieldset).some((fieldState) => fieldState === "UNLOCKED") && ( + + Save + + )} + {viewType === "users" && ( + navigate(manageUsersPageUrl)} + variant="contained" + color="info" + > + Cancel + + )} + + +
diff --git a/src/graphql/editSubmissionCollaborators.ts b/src/graphql/editSubmissionCollaborators.ts index a4ebdb297..6fe06b5ff 100644 --- a/src/graphql/editSubmissionCollaborators.ts +++ b/src/graphql/editSubmissionCollaborators.ts @@ -8,10 +8,6 @@ export const mutation = gql` collaboratorID collaboratorName permission - Organization { - orgID - orgName - } } } } @@ -23,5 +19,5 @@ export type Input = { }; export type Response = { - editSubmissionCollaborators: Submission; + editSubmissionCollaborators: Pick; }; diff --git a/src/graphql/editUser.ts b/src/graphql/editUser.ts index bb5d1ae8a..0ca66f8f7 100644 --- a/src/graphql/editUser.ts +++ b/src/graphql/editUser.ts @@ -7,6 +7,8 @@ export const mutation = gql` $role: String $studies: [String] $dataCommons: [String] + $permissions: [String] + $notifications: [String] ) { editUser( userID: $userID @@ -14,6 +16,8 @@ export const mutation = gql` role: $role studies: $studies dataCommons: $dataCommons + permissions: $permissions + notifications: $notifications ) { userStatus role @@ -25,6 +29,8 @@ export const mutation = gql` dbGaPID controlledAccess } + permissions + notifications } } `; @@ -38,10 +44,10 @@ export type Input = { * An array of studyIDs to assign to the user */ studies: string[]; -} & Pick; +} & Pick; export type Response = { - editUser: Pick & { + editUser: Pick & { studies: Pick< ApprovedStudy, "_id" | "studyName" | "studyAbbreviation" | "dbGaPID" | "controlledAccess" diff --git a/src/graphql/getMyUser.ts b/src/graphql/getMyUser.ts index 1691eed40..9a737ca20 100644 --- a/src/graphql/getMyUser.ts +++ b/src/graphql/getMyUser.ts @@ -18,6 +18,8 @@ export const query = gql` dbGaPID controlledAccess } + permissions + notifications createdAt updateAt } diff --git a/src/graphql/getSubmission.ts b/src/graphql/getSubmission.ts index e3bd41aa7..78f7ac1e6 100644 --- a/src/graphql/getSubmission.ts +++ b/src/graphql/getSubmission.ts @@ -62,10 +62,6 @@ export const query = gql` collaborators { collaboratorID collaboratorName - Organization { - orgID - orgName - } permission } createdAt diff --git a/src/graphql/getUser.ts b/src/graphql/getUser.ts index 12347d5da..8146ac8d3 100644 --- a/src/graphql/getUser.ts +++ b/src/graphql/getUser.ts @@ -18,6 +18,8 @@ export const query = gql` studyName studyAbbreviation } + permissions + notifications } } `; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index ae3f10207..13f1cbf49 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -149,6 +149,12 @@ export type { Input as EditUserInput, Response as EditUserResp } from "./editUse export { mutation as REQUEST_ACCESS } from "./requestAccess"; export type { Input as RequestAccessInput, Response as RequestAccessResp } from "./requestAccess"; +export { query as RETRIEVE_PBAC_DEFAULTS } from "./retrievePBACDefaults"; +export type { + Input as RetrievePBACDefaultsInput, + Response as RetrievePBACDefaultsResp, +} from "./retrievePBACDefaults"; + // Organizations export { query as LIST_ORGS } from "./listOrganizations"; export type { Response as ListOrgsResp } from "./listOrganizations"; diff --git a/src/graphql/listPotentialCollaborators.ts b/src/graphql/listPotentialCollaborators.ts index 5de3a705f..09295ba28 100644 --- a/src/graphql/listPotentialCollaborators.ts +++ b/src/graphql/listPotentialCollaborators.ts @@ -6,10 +6,6 @@ export const query = gql` _id firstName lastName - organization { - orgID - orgName - } } } `; @@ -19,5 +15,5 @@ export type Input = { }; export type Response = { - listPotentialCollaborators: Pick[]; + listPotentialCollaborators: Pick[]; }; diff --git a/src/graphql/retrievePBACDefaults.ts b/src/graphql/retrievePBACDefaults.ts new file mode 100644 index 000000000..e210c8dd8 --- /dev/null +++ b/src/graphql/retrievePBACDefaults.ts @@ -0,0 +1,46 @@ +import gql from "graphql-tag"; + +export const query = gql` + query retrievePBACDefaults($roles: [String!]!) { + retrievePBACDefaults(roles: $roles) { + role + permissions { + _id + group + name + order + checked + disabled + } + notifications { + _id + group + name + order + checked + disabled + } + } + } +`; + +export type Input = { + roles: Array; +}; + +export type Response = { + retrievePBACDefaults: Array<{ + /** + * The role that the defaults apply to. + */ + role: UserRole; + /** + * The default permissions for the role. + */ + permissions: PBACDefault[]; + /** + * The default notifications for the role. + */ + notifications: PBACDefault[]; + }>; +}; diff --git a/src/hooks/useFormMode.ts b/src/hooks/useFormMode.ts index 0676895bb..a0fba6290 100644 --- a/src/hooks/useFormMode.ts +++ b/src/hooks/useFormMode.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Status as AuthStatus, useAuthContext } from "../components/Contexts/AuthContext"; import { Status as FormStatus, useFormContext } from "../components/Contexts/FormContext"; -import { FormMode, FormModes, getFormMode } from "../utils"; +import { FormMode, FormModes, getFormMode, Logger } from "../utils"; const useFormMode = () => { const { user, status: authStatus } = useAuthContext(); @@ -15,10 +15,15 @@ const useFormMode = () => { } const updatedFormMode: FormMode = getFormMode(user, data); + if (updatedFormMode === FormModes.UNAUTHORIZED) { + Logger.error("useFormMode: User is unauthorized to view this Submission Request.", { + user, + data, + }); + } + setFormMode(updatedFormMode); - setReadOnlyInputs( - updatedFormMode === FormModes.VIEW_ONLY || updatedFormMode === FormModes.REVIEW - ); + setReadOnlyInputs(updatedFormMode !== FormModes.EDIT); }, [user, data]); return { formMode, readOnlyInputs }; diff --git a/src/hooks/useProfileFields.test.ts b/src/hooks/useProfileFields.test.ts index 1eecad487..a051e5109 100644 --- a/src/hooks/useProfileFields.test.ts +++ b/src/hooks/useProfileFields.test.ts @@ -7,20 +7,26 @@ describe("Users View", () => { jest.clearAllMocks(); }); - it("should return UNLOCKED for role and userStatus when viewing users as an Admin", () => { - const user = { _id: "User-A", role: "Admin" } as User; - const profileOf: Pick = { _id: "I-Am-User-B", role: "Submitter" }; + // NOTE: This is mostly a sanity check to ensure we're ignoring the signed-in user's role + it.each(["Admin", "Data Commons Personnel", "Federal Lead", "Submitter", "User"])( + "should return UNLOCKED for role, status, and PBAC when viewing users with management permission (%s)", + (role) => { + const user = { _id: "User-A", role, permissions: ["user:manage"] } as User; + const profileOf: Pick = { _id: "I-Am-User-B", role: "Submitter" }; - jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); + jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); - const { result } = renderHook(() => useProfileFields(profileOf, "users")); + const { result } = renderHook(() => useProfileFields(profileOf, "users")); - expect(result.current.role).toBe("UNLOCKED"); - expect(result.current.userStatus).toBe("UNLOCKED"); - }); + expect(result.current.role).toBe("UNLOCKED"); + expect(result.current.userStatus).toBe("UNLOCKED"); + expect(result.current.permissions).toBe("UNLOCKED"); + expect(result.current.notifications).toBe("UNLOCKED"); + } + ); - it("should return READ_ONLY for all standard fields when a Organization Owner views the page", () => { - const user = { _id: "User-A", role: "Organization Owner" } as User; + it("should return READ_ONLY for all standard fields when a Submitter views the page", () => { + const user = { _id: "User-A", role: "Submitter" } as User; const profileOf: Pick = { _id: "I-Am-User-B", role: "Submitter" }; jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); @@ -35,17 +41,14 @@ describe("Users View", () => { it.each<[FieldState, UserRole]>([ ["HIDDEN", "User"], - ["HIDDEN", "Organization Owner"], - ["HIDDEN", "Data Curator"], - ["HIDDEN", "Data Commons POC"], + ["HIDDEN", "Data Commons Personnel"], ["HIDDEN", "Admin"], ["HIDDEN", "fake role" as UserRole], // NOTE: All of the following are assigned to studies ["UNLOCKED", "Federal Lead"], ["UNLOCKED", "Submitter"], - ["UNLOCKED", "Federal Monitor"], ])("should return %s for the studies field on the users page for role %s", (state, role) => { - const user = { _id: "User-A", role: "Admin" } as User; + const user = { _id: "User-A", role: "Admin", permissions: ["user:manage"] } as User; const profileOf: Pick = { _id: "I-Am-User-B", role }; jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); @@ -58,15 +61,13 @@ describe("Users View", () => { it.each<[FieldState, UserRole]>([ ["HIDDEN", "User"], ["HIDDEN", "Submitter"], - ["HIDDEN", "Organization Owner"], ["HIDDEN", "Federal Lead"], - ["UNLOCKED", "Data Curator"], // NOTE: accepts Data Commons + ["UNLOCKED", "Data Commons Personnel"], // NOTE: accepts Data Commons ["HIDDEN", "Admin"], ["HIDDEN", "fake role" as UserRole], - ["HIDDEN", "Federal Monitor"], - ["UNLOCKED", "Data Commons POC"], // NOTE: accepts Data Commons + ["UNLOCKED", "Data Commons Personnel"], // NOTE: accepts Data Commons ])("should return %s for the dataCommons field on the users page for role %s", (state, role) => { - const user = { _id: "User-A", role: "Admin" } as User; + const user = { _id: "User-A", role: "Admin", permissions: ["user:manage"] } as User; const profileOf: Pick = { _id: "I-Am-User-B", role }; jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); @@ -121,9 +122,8 @@ describe("Profile View", () => { it.each([ "User", "Submitter", - "Federal Monitor", "Federal Lead", - "Data Commons POC", + "Data Commons Personnel", "fake role" as UserRole, ])("should return HIDDEN for the studies field on the profile page for role %s", (role) => { const user = { _id: "User-A", role } as User; @@ -136,16 +136,34 @@ describe("Profile View", () => { expect(result.current.studies).toBe("HIDDEN"); }); + it.each([ + "User", + "Submitter", + "Federal Lead", + "Data Commons Personnel", + "fake role" as UserRole, + ])( + "should return HIDDEN for the permissions and notifications panel on the profile page for role %s", + (role) => { + const user = { _id: "User-A", role } as User; + const profileOf: Pick = { _id: "User-A", role }; + + jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); + + const { result } = renderHook(() => useProfileFields(profileOf, "profile")); + + expect(result.current.permissions).toBe("HIDDEN"); + expect(result.current.notifications).toBe("HIDDEN"); + } + ); + it.each<[state: FieldState, role: UserRole]>([ ["HIDDEN", "User"], ["HIDDEN", "Submitter"], - ["HIDDEN", "Organization Owner"], ["HIDDEN", "Federal Lead"], - ["READ_ONLY", "Data Curator"], // NOTE: Data Commons visible but read-only + ["READ_ONLY", "Data Commons Personnel"], // NOTE: Data Commons visible but read-only ["HIDDEN", "Admin"], ["HIDDEN", "fake role" as UserRole], - ["HIDDEN", "Federal Monitor"], - ["READ_ONLY", "Data Commons POC"], // NOTE: Data Commons visible but read-only ])("should return %s for the dataCommons field for the role %s", (state, role) => { const user = { _id: "User-A", role } as User; const profileOf: Pick = { _id: "User-A", role }; @@ -160,11 +178,8 @@ describe("Profile View", () => { // NOTE: This is a fallback case that should never be reached in the current implementation it.each([ "Admin", - "Data Commons POC", - "Data Curator", + "Data Commons Personnel", "Federal Lead", - "Federal Monitor", - "Organization Owner", "Submitter", "User", "fake role" as UserRole, @@ -184,6 +199,8 @@ describe("Profile View", () => { expect(result.current.userStatus).toBe("READ_ONLY"); expect(result.current.dataCommons).toBe("HIDDEN"); expect(result.current.studies).toBe("HIDDEN"); + expect(result.current.permissions).toBe("HIDDEN"); + expect(result.current.notifications).toBe("HIDDEN"); } ); @@ -191,7 +208,10 @@ describe("Profile View", () => { // to an Admin and refreshes their profile page, this field would appear unlocked it("should return READ_ONLY for an Admin viewing a Data Commons POC profile", () => { const user = { _id: "User-A", role: "Admin" } as User; - const profileOf: Pick = { _id: "Not-User-B", role: "Data Commons POC" }; + const profileOf: Pick = { + _id: "Not-User-B", + role: "Data Commons Personnel", + }; jest.spyOn(Auth, "useAuthContext").mockReturnValue({ user } as Auth.ContextState); diff --git a/src/hooks/useProfileFields.ts b/src/hooks/useProfileFields.ts index 5456e552f..63dae67f5 100644 --- a/src/hooks/useProfileFields.ts +++ b/src/hooks/useProfileFields.ts @@ -1,11 +1,20 @@ import { useAuthContext } from "../components/Contexts/AuthContext"; +import { hasPermission } from "../config/AuthPermissions"; import { RequiresStudiesAssigned } from "../config/AuthRoles"; + /** * Constrains the fields that this hook supports generating states for */ type EditableFields = Extends< keyof User, - "firstName" | "lastName" | "role" | "userStatus" | "studies" | "dataCommons" + | "firstName" + | "lastName" + | "role" + | "userStatus" + | "studies" + | "dataCommons" + | "permissions" + | "notifications" >; /** @@ -40,6 +49,9 @@ const useProfileFields = ( viewType: "users" | "profile" ): Readonly> => { const { user } = useAuthContext(); + const canManage = hasPermission(user, "user", "manage"); + + const isSelf: boolean = user?._id === profileOf?._id; const fields: ProfileFields = { firstName: "READ_ONLY", lastName: "READ_ONLY", @@ -47,8 +59,9 @@ const useProfileFields = ( userStatus: "READ_ONLY", dataCommons: "HIDDEN", studies: "HIDDEN", + permissions: "HIDDEN", + notifications: "HIDDEN", }; - const isSelf: boolean = user?._id === profileOf?._id; // Editable for the current user viewing their own profile if (isSelf && viewType === "profile") { @@ -56,18 +69,20 @@ const useProfileFields = ( fields.lastName = "UNLOCKED"; } - // Editable for Admin viewing Manage Users - if (user?.role === "Admin" && viewType === "users") { + // Editable for user with permission to Manage Users + if (canManage && viewType === "users") { fields.role = "UNLOCKED"; fields.userStatus = "UNLOCKED"; + fields.permissions = "UNLOCKED"; + fields.notifications = "UNLOCKED"; // Editable for Admin viewing certain roles, otherwise hidden (even for a user viewing their own profile) fields.studies = RequiresStudiesAssigned.includes(profileOf?.role) ? "UNLOCKED" : "HIDDEN"; } - // Only applies to Data Commons POC - if (profileOf?.role === "Data Commons POC" || profileOf?.role === "Data Curator") { - fields.dataCommons = user?.role === "Admin" && viewType === "users" ? "UNLOCKED" : "READ_ONLY"; + // Only applies to Data Commons Personnel + if (profileOf?.role === "Data Commons Personnel") { + fields.dataCommons = canManage && viewType === "users" ? "UNLOCKED" : "READ_ONLY"; } else { fields.dataCommons = "HIDDEN"; } diff --git a/src/types/Auth.d.ts b/src/types/Auth.d.ts index 8f4fe6178..7c1860888 100644 --- a/src/types/Auth.d.ts +++ b/src/types/Auth.d.ts @@ -21,13 +21,6 @@ type User = { * The user's email address */ email: string; - /** - * The user's organization if assigned, null otherwise - * - * @see {@link OrgInfo} - * @deprecated This field is deprecated and NOT populated by all APIs. Remove ASAP. - */ - organization?: OrgInfo | null; /** * List of data commons that the user has access to */ @@ -47,6 +40,14 @@ type User = { * The current account status for the user */ userStatus: "Active" | "Inactive" | "Disabled"; + /** + * The list of permissions granted to the user + */ + permissions: AuthPermissions[]; + /** + * The list of notifications the user will receive + */ + notifications: AuthNotifications[]; /** * The last update date of the user object * @@ -61,15 +62,7 @@ type User = { createdAt: string; }; -type UserRole = - | "User" - | "Submitter" - | "Organization Owner" - | "Federal Monitor" - | "Federal Lead" - | "Data Curator" - | "Data Commons POC" - | "Admin"; +type UserRole = "User" | "Admin" | "Data Commons Personnel" | "Federal Lead" | "Submitter"; type OrgInfo = { orgID: string; diff --git a/src/types/Navigation.d.ts b/src/types/Navigation.d.ts index ed0708b3a..de400c53f 100644 --- a/src/types/Navigation.d.ts +++ b/src/types/Navigation.d.ts @@ -22,12 +22,13 @@ type NavBarItem = { */ className: "navMobileItem" | "navMobileItem clickable"; /** - * Defines RBAC for the Navigation Item + * Defines a list of permissions necessary for the Navigation Item to be shown * * Guide: - * - If the value is an array, the current user must be in one of the roles to see the item + * - Provide a list of permission the user must have to access the content + * otherwise it will be hidden */ - roles?: User["role"][]; + permissions?: AuthPermissions[]; }; type NavBarSubItem = { @@ -68,6 +69,14 @@ type NavBarSubItem = { * @note Only works if the className is `navMobileSubItem action` */ onClick?: () => void; + /** + * Defines a list of permissions necessary for the Navigation Sub Item to be shown + * + * Guide: + * - Provide a list of permission the user must have to access the content + * otherwise it will be hidden + */ + permissions?: AuthPermissions[]; }; type FooterConfiguration = { diff --git a/src/types/PBAC.d.ts b/src/types/PBAC.d.ts new file mode 100644 index 000000000..798f802ba --- /dev/null +++ b/src/types/PBAC.d.ts @@ -0,0 +1,92 @@ +type SubmissionRequestPermissions = + | "submission_request:view" + | "submission_request:create" + | "submission_request:review" + | "submission_request:submit"; + +type DataSubmissionPermissions = + | "data_submission:view" + | "data_submission:create" + | "data_submission:review" + | "data_submission:admin_submit" + | "data_submission:confirm"; + +type DashboardPermissions = "dashboard:view"; +type AccessPermissions = "access:request"; +type UserPermissions = "user:manage"; +type ProgramPermissions = "program:manage"; +type StudyPermissions = "study:manage"; + +type AuthPermissions = + | SubmissionRequestPermissions + | DataSubmissionPermissions + | DashboardPermissions + | AccessPermissions + | UserPermissions + | ProgramPermissions + | StudyPermissions; + +type SubmissionRequestNotifications = + | "submission_request:submitted" + | "submission_request:to_be_reviewed" + | "submission_request:reviewed" + | "submission_request:deleted" + | "submission_request:expiring"; + +type DataSubmissionNotifications = + | "data_submission:submitted" + | "data_submission:cancelled" + | "data_submission:withdrawn" + | "data_submission:released" + | "data_submission:completed" + | "data_submission:deleted" + | "data_submission:expiring"; + +type MiscNotifications = + | "access:requested" + | "account:inactivated" + | "account:users_inactivated" + | "account:disabled"; + +type AuthNotifications = + | SubmissionRequestNotifications + | DataSubmissionNotifications + | MiscNotifications; + +/** + * Defines the default structure of a PBAC object. + * + * e.g. Permission or Notification + */ +type PBACDefault = { + /** + * The unique identifier of the PBAC object. + * + * @example "manage:users" + */ + _id: T; + /** + * The group the PBAC object belongs to. + * + * @example "User Management" + */ + group: string; + /** + * The name of the individual PBAC setting. + * + * @example "Manage Users" + */ + name: string; + /** + * The sort order of the PBAC object within its group. + */ + order: number; + /** + * Whether the PBAC object is checked for the role. + */ + checked: boolean; + /** + * Whether the PBAC object is disabled for the role. + */ + disabled: boolean; +}; diff --git a/src/types/Submissions.d.ts b/src/types/Submissions.d.ts index cfa76ef4f..125132db4 100644 --- a/src/types/Submissions.d.ts +++ b/src/types/Submissions.d.ts @@ -64,7 +64,7 @@ type Submission = { */ nodeCount: number; /** - * A list of additional submitters who can view and/or edit the submission + * A list of additional submitters who can view and edit the submission */ collaborators: Collaborator[]; createdAt: string; // ISO 8601 date time format with UTC or offset e.g., 2023-05-01T09:23:30Z @@ -373,15 +373,14 @@ type SubmitButtonResult = { /** * Represents the permissions a collaborator can have in a submission */ -type CollaboratorPermissions = "Can View" | "Can Edit"; +type CollaboratorPermissions = "Can Edit"; /** - * Represents a submitter that can view/edit another submitter's submission + * Represents a submitter that can view and edit another submitter's submission */ type Collaborator = { collaboratorID: string; collaboratorName: string; - Organization: Pick; permission: CollaboratorPermissions; }; diff --git a/src/utils/dashboardUtils.test.ts b/src/utils/dashboardUtils.test.ts new file mode 100644 index 000000000..6188e80c9 --- /dev/null +++ b/src/utils/dashboardUtils.test.ts @@ -0,0 +1,177 @@ +import { DashboardContentOptions } from "amazon-quicksight-embedding-sdk"; +import { addStudiesParameter, addDataCommonsParameter } from "./dashboardUtils"; +import { Logger } from "./logger"; + +jest.mock("./logger", () => ({ + Logger: { + error: jest.fn(), + }, +})); + +describe("addStudiesParameter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return an empty array if user has 'All' as the first study", () => { + const user = { + studies: [{ _id: "All" }, { _id: "AnotherStudy" }], + } as unknown as User; + + const result = addStudiesParameter(user); + expect(result).toEqual([]); + expect(Logger.error).not.toHaveBeenCalled(); + }); + + it("should return an array with studiesParameter if user has valid studies", () => { + const user = { + studies: [{ _id: "StudyA" }, { _id: "StudyB" }], + } as unknown as User; + + const result = addStudiesParameter(user); + expect(result).toEqual([ + { + Name: "studiesParameter", + Values: ["StudyA", "StudyB"], + }, + ]); + expect(Logger.error).not.toHaveBeenCalled(); + }); + + it("should return NO-CONTENT if user has an empty studies array", () => { + const user = { + studies: [], + } as unknown as User; + + const result = addStudiesParameter(user); + expect(result).toEqual([ + { + Name: "studiesParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + expect(Logger.error).toHaveBeenCalledWith( + "Federal Lead requires studies to be set but none or invalid values were found.", + [] + ); + }); + + it("should return NO-CONTENT if user studies is undefined or null", () => { + const user = { + studies: null, + } as unknown as User; + + const result = addStudiesParameter(user); + expect(result).toEqual([ + { + Name: "studiesParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + }); + + it("should handle a null user gracefully", () => { + const user = null as unknown as User; + const result = addStudiesParameter(user); + expect(result).toEqual([ + { + Name: "studiesParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + }); + + it("should handle an undefined user gracefully", () => { + const user = undefined as unknown as User; + const result = addStudiesParameter(user); + expect(result).toEqual([ + { + Name: "studiesParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + }); +}); + +describe("addDataCommonsParameter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return an array with dataCommonsParameter if user has valid dataCommons", () => { + const user = { + dataCommons: ["CommonsA", "CommonsB"], + } as unknown as User; + + const result = addDataCommonsParameter(user); + expect(result).toEqual([ + { + Name: "dataCommonsParameter", + Values: ["CommonsA", "CommonsB"], + }, + ]); + expect(Logger.error).not.toHaveBeenCalled(); + }); + + it("should return NO-CONTENT if user dataCommons is an empty array", () => { + const user = { + dataCommons: [], + } as unknown as User; + + const result = addDataCommonsParameter(user); + expect(result).toEqual([ + { + Name: "dataCommonsParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + expect(Logger.error).toHaveBeenCalledWith( + "Data Commons Personnel requires dataCommons to be set but none were found.", + [] + ); + }); + + it("should return NO-CONTENT if user dataCommons is null or undefined", () => { + const user = { + dataCommons: null, + } as unknown as User; + + const result = addDataCommonsParameter(user); + expect(result).toEqual([ + { + Name: "dataCommonsParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + }); + + it("should handle a null user gracefully", () => { + const user = null as unknown as User; + const result = addDataCommonsParameter(user); + expect(result).toEqual([ + { + Name: "dataCommonsParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + }); + + it("should handle an undefined user gracefully", () => { + const user = undefined as unknown as User; + const result = addDataCommonsParameter(user); + expect(result).toEqual([ + { + Name: "dataCommonsParameter", + Values: ["NO-CONTENT"], + }, + ]); + expect(Logger.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/dashboardUtils.ts b/src/utils/dashboardUtils.ts new file mode 100644 index 000000000..add5b8965 --- /dev/null +++ b/src/utils/dashboardUtils.ts @@ -0,0 +1,66 @@ +import { DashboardContentOptions } from "amazon-quicksight-embedding-sdk"; +import { Logger } from "./logger"; + +/** + * Constructs and returns an array of QuickSight parameter objects for a user's studies. + * + * - If the user's first study is `All`, the function returns an empty array (allowing QuickSight to display all data). + * - If the user has a valid array of studies, it creates a `studiesParameter` whose values are the `_id` fields of each study. + * - Otherwise, it logs an error and returns a parameter array with `["NO-CONTENT"]`. + * + * + * @param {User} user - The current user + * @returns {DashboardContentOptions["parameters"]} The updated dashboard parameters + */ +export const addStudiesParameter = (user: User): DashboardContentOptions["parameters"] => { + const params: DashboardContentOptions["parameters"] = []; + const { studies } = user || {}; + + // If user contains the "All" study, do NOT push the "studiesParameter" + if ((studies || [])?.findIndex((s) => s?._id === "All") !== -1) { + return params; + } + + // Otherwise, push a real or fallback param + if (Array.isArray(studies) && studies.length > 0) { + params.push({ + Name: "studiesParameter", + Values: studies.map((s) => s._id), + }); + return params; + } + + Logger.error( + "Federal Lead requires studies to be set but none or invalid values were found.", + studies + ); + params.push({ Name: "studiesParameter", Values: ["NO-CONTENT"] }); + return params; +}; + +/** + * Constructs and returns an array of QuickSight parameter objects for a user's data commons. + * + * - If the user has a valid array of data commons, it creates a `dataCommonsParameter` whose values are the array elements. + * - Otherwise, it logs an error and returns a parameter array with `["NO-CONTENT"]`. + * + * + * @param {User} user - The current user + * @returns {DashboardContentOptions["parameters"]} The updated dashboard parameters + */ +export const addDataCommonsParameter = (user: User): DashboardContentOptions["parameters"] => { + const params: DashboardContentOptions["parameters"] = []; + const { dataCommons } = user || {}; + + if (Array.isArray(dataCommons) && dataCommons.length > 0) { + params.push({ Name: "dataCommonsParameter", Values: dataCommons }); + return params; + } + + Logger.error( + "Data Commons Personnel requires dataCommons to be set but none were found.", + dataCommons + ); + params.push({ Name: "dataCommonsParameter", Values: ["NO-CONTENT"] }); + return params; +}; diff --git a/src/utils/dataSubmissionUtils.test.ts b/src/utils/dataSubmissionUtils.test.ts index 486f1fa74..40a54edf7 100644 --- a/src/utils/dataSubmissionUtils.test.ts +++ b/src/utils/dataSubmissionUtils.test.ts @@ -38,16 +38,32 @@ const baseSubmission: Submission = { collaborators: [], }; +const baseUser: User = { + _id: "current-user", + firstName: "", + lastName: "", + userStatus: "Active", + role: "Submitter", + IDP: "nih", + email: "", + studies: null, + dataCommons: [], + createdAt: "", + updateAt: "", + permissions: ["data_submission:create"], + notifications: [], +}; + const baseQCResults: QCResult[] = []; describe("General Submit", () => { - it("should disable submit without isAdminOverride when user role is not Admin but there are validation errors", () => { + it("should disable submit without isAdminOverride when user does not have the admin submit permission but there are validation errors", () => { const submission: Submission = { ...baseSubmission, metadataValidationStatus: "Error", fileValidationStatus: "Error", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -58,7 +74,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "Error", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -69,7 +85,7 @@ describe("General Submit", () => { metadataValidationStatus: "Error", fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -80,7 +96,7 @@ describe("General Submit", () => { metadataValidationStatus: "Warning", fileValidationStatus: "Error", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -91,7 +107,7 @@ describe("General Submit", () => { metadataValidationStatus: "Error", fileValidationStatus: "Warning", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -103,7 +119,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: null, }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); @@ -114,29 +130,18 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); - it("should disable submit when user role is undefined", () => { - const submission: Submission = { - ...baseSubmission, - metadataValidationStatus: "Passed", - fileValidationStatus: "Passed", - }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, undefined); - expect(result.enabled).toBe(false); - expect(result.isAdminOverride).toBe(false); - }); - it("should disable submit when metadata validation is null", () => { const submission: Submission = { ...baseSubmission, metadataValidationStatus: null, fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -147,7 +152,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: null, }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -160,7 +165,7 @@ describe("General Submit", () => { fileValidationStatus: null, intention: "Delete", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); @@ -172,7 +177,7 @@ describe("General Submit", () => { fileValidationStatus: null, intention: "New/Update", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -184,7 +189,7 @@ describe("General Submit", () => { fileValidationStatus: "Passed", intention: "Delete", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -196,7 +201,7 @@ describe("General Submit", () => { fileValidationStatus: "Error", intention: "Delete", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -207,7 +212,7 @@ describe("General Submit", () => { metadataValidationStatus: null, fileValidationStatus: null, }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -218,7 +223,7 @@ describe("General Submit", () => { metadataValidationStatus: "Validating", fileValidationStatus: "Validating", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -229,7 +234,7 @@ describe("General Submit", () => { metadataValidationStatus: "Validating", fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -240,7 +245,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "Validating", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -251,7 +256,7 @@ describe("General Submit", () => { metadataValidationStatus: "New", fileValidationStatus: "New", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -262,7 +267,7 @@ describe("General Submit", () => { metadataValidationStatus: "New", fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -273,7 +278,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "New", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -284,7 +289,7 @@ describe("General Submit", () => { metadataValidationStatus: "Warning", fileValidationStatus: "Warning", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); @@ -295,7 +300,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); @@ -306,7 +311,7 @@ describe("General Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "Warning", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); @@ -317,13 +322,13 @@ describe("General Submit", () => { metadataValidationStatus: "Warning", fileValidationStatus: "Warning", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); it("should disable submit when submission is null", () => { - const result = utils.shouldEnableSubmit(null, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(null, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -336,7 +341,12 @@ describe("Admin Submit", () => { metadataValidationStatus: "Error", fileValidationStatus: "Error", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + role: "Admin", + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(true); }); @@ -347,7 +357,11 @@ describe("Admin Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "Passed", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(false); }); @@ -358,7 +372,11 @@ describe("Admin Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: null, }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -369,7 +387,11 @@ describe("Admin Submit", () => { metadataValidationStatus: null, fileValidationStatus: null, }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(false); }); @@ -381,7 +403,12 @@ describe("Admin Submit", () => { fileValidationStatus: null, intention: "Delete", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + role: "Admin", + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(true); }); @@ -407,7 +434,12 @@ describe("Admin Submit", () => { }, ], }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + role: "Admin", + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -436,7 +468,7 @@ describe("Admin Submit", () => { const qcResults: Pick[] = [ { errors: [{ title: "Orphaned file found", description: "" }] }, ]; - const result = utils.shouldEnableSubmit(submission, qcResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, qcResults, baseUser); expect(result._identifier).toBe("Submission should not have orphaned files"); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); @@ -450,7 +482,12 @@ describe("Admin Submit", () => { fileValidationStatus: null, intention: "Delete", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + role: "Admin", + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(true); expect(result.isAdminOverride).toBe(true); }); @@ -462,7 +499,11 @@ describe("Admin Submit", () => { metadataValidationStatus: "Passed", fileValidationStatus: "New", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Admin"); + const user: User = { + ...baseUser, + permissions: ["data_submission:view", "data_submission:admin_submit"], + }; + const result = utils.shouldEnableSubmit(submission, baseQCResults, user); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); @@ -476,7 +517,7 @@ describe("Submit > Submission Type/Intention", () => { metadataValidationStatus: "Error", fileValidationStatus: "Error", }; - const result = utils.shouldEnableSubmit(submission, baseQCResults, "Submitter"); + const result = utils.shouldEnableSubmit(submission, baseQCResults, baseUser); expect(result.enabled).toBe(false); expect(result.isAdminOverride).toBe(false); }); diff --git a/src/utils/dataSubmissionUtils.ts b/src/utils/dataSubmissionUtils.ts index 8edfdf0ba..4915d1b57 100644 --- a/src/utils/dataSubmissionUtils.ts +++ b/src/utils/dataSubmissionUtils.ts @@ -1,3 +1,4 @@ +import { hasPermission } from "../config/AuthPermissions"; import { ADMIN_OVERRIDE_CONDITIONS, SUBMIT_BUTTON_CONDITIONS } from "../config/SubmitButtonConfig"; import { safeParse } from "./jsonUtils"; @@ -7,22 +8,23 @@ import { safeParse } from "./jsonUtils"; * to determine if the submission can be enabled. * * @param {Submission} submission - The submission object to evaluate. - * @param {UserRole} userRole - The role of the user (e.g., Admin, Submitter). + * @param {QCResult[]} qcResults - The QC results for the submission. + * @param {User} user - The current user. * @returns {SubmitButtonResult} - Returns an object indicating whether the submit button is enabled, * whether the admin override is in effect, and an optional tooltip explaining why it is disabled. */ export const shouldEnableSubmit = ( submission: Submission, qcResults: Pick[], - userRole: UserRole + user: User ): SubmitButtonResult => { - if (!submission || !userRole) { + if (!submission || !user) { return { enabled: false, isAdminOverride: false }; } // Check for potential Admin override - const isAdmin = userRole === "Admin"; - if (isAdmin) { + const canAdminOverride = hasPermission(user, "data_submission", "admin_submit", submission); + if (canAdminOverride) { const adminOverrideResult = shouldAllowAdminOverride(submission, qcResults); if (adminOverrideResult.enabled) { return { ...adminOverrideResult }; diff --git a/src/utils/dataValidationUtils.ts b/src/utils/dataValidationUtils.ts index d06cca06f..044c81306 100644 --- a/src/utils/dataValidationUtils.ts +++ b/src/utils/dataValidationUtils.ts @@ -1,3 +1,5 @@ +import { hasPermission } from "../config/AuthPermissions"; + /** * Translates the Validation Type radio to an array of types to validate. * @@ -25,15 +27,13 @@ export const getValidationTypes = (validationType: ValidationType | "All"): Vali */ export const getDefaultValidationType = ( dataSubmission: Submission, - user: User, - permissionMap: Partial> + user: User ): ValidationType | "All" => { - const { role } = user || {}; const { status, metadataValidationStatus, fileValidationStatus } = dataSubmission || {}; if ( status === "Submitted" && - permissionMap["Submitted"]?.includes(role) && + hasPermission(user, "data_submission", "review", dataSubmission) && metadataValidationStatus && fileValidationStatus ) { @@ -58,13 +58,11 @@ export const getDefaultValidationType = ( */ export const getDefaultValidationTarget = ( dataSubmission: Submission, - user: User, - permissionMap: Partial> + user: User ): ValidationTarget => { - const { role } = user || {}; const { status } = dataSubmission || {}; - if (status === "Submitted" && permissionMap["Submitted"]?.includes(role)) { + if (status === "Submitted" && hasPermission(user, "data_submission", "review", dataSubmission)) { return "All"; } diff --git a/src/utils/formModeUtils.test.ts b/src/utils/formModeUtils.test.ts index 2350dcdd5..b4a4d5a6a 100644 --- a/src/utils/formModeUtils.test.ts +++ b/src/utils/formModeUtils.test.ts @@ -13,6 +13,8 @@ describe("getFormMode tests based on provided requirements", () => { updateAt: "2023-05-02T09:23:30Z", studies: null, dataCommons: [], + permissions: [], + notifications: [], }; // submission created by baseUser and part of the same org @@ -32,7 +34,11 @@ describe("getFormMode tests based on provided requirements", () => { // User Tests describe("getFormMode > User tests", () => { - const user: User = { ...baseUser, role: "User" }; + const user: User = { + ...baseUser, + role: "Submitter", + permissions: ["submission_request:create", "submission_request:submit"], + }; it("should allow User to edit when form status is New", () => { expect(utils.getFormMode(user, baseSubmission)).toBe(utils.FormModes.EDIT); @@ -72,7 +78,11 @@ describe("getFormMode tests based on provided requirements", () => { // Submitter Tests describe("getFormMode > Submitter tests", () => { - const user: User = { ...baseUser, role: "Submitter" }; + const user: User = { + ...baseUser, + role: "Submitter", + permissions: ["submission_request:create", "submission_request:submit"], + }; it("should allow Submitter to edit when form status is New", () => { expect(utils.getFormMode(user, baseSubmission)).toBe(utils.FormModes.EDIT); @@ -99,7 +109,15 @@ describe("getFormMode tests based on provided requirements", () => { // Federal Lead Tests describe("getFormMode > Fed Lead tests", () => { - const user: User = { ...baseUser, role: "Federal Lead" }; + const user: User = { + ...baseUser, + role: "Federal Lead", + permissions: [ + "submission_request:view", + "submission_request:submit", + "submission_request:review", + ], + }; it("should set Review mode for Fed Lead when status is 'In Review'", () => { expect(utils.getFormMode(user, { ...baseSubmission, status: "In Review" })).toBe( @@ -138,45 +156,9 @@ describe("getFormMode tests based on provided requirements", () => { }); }); - // Org Owner Tests - describe("getFormMode > Org Owner tests", () => { - const user: User = { ...baseUser, role: "Organization Owner" }; - - it("should allow Org Owner to edit their own unsubmitted or inquired forms", () => { - const statuses: ApplicationStatus[] = ["New", "In Progress", "Inquired"]; - - statuses.forEach((status) => { - expect(utils.getFormMode(user, { ...baseSubmission, status })).toBe(utils.FormModes.EDIT); - }); - }); - - it("should set View Only for Org Owner when form is Submitted, In Review, Approved, or Rejected", () => { - const statuses: ApplicationStatus[] = ["Submitted", "In Review", "Approved", "Rejected"]; - - statuses.forEach((status) => { - expect(utils.getFormMode(user, { ...baseSubmission, status })).toBe( - utils.FormModes.VIEW_ONLY - ); - }); - }); - - it("should be View Only mode for any Submission when Org Owner does not own the Submission", () => { - const submission: Application = { - ...baseSubmission, - status: "In Progress", - applicant: { - ...baseSubmission.applicant, - applicantID: "user-456-another-user", - }, - }; - - expect(utils.getFormMode(user, submission)).toBe(utils.FormModes.VIEW_ONLY); - }); - }); - // Admin Tests describe("getFormMode > Admin tests", () => { - const user: User = { ...baseUser, role: "Admin" }; + const user: User = { ...baseUser, role: "Admin", permissions: ["submission_request:view"] }; it("should always set View Only for Admin", () => { const statuses: ApplicationStatus[] = [ @@ -212,12 +194,7 @@ describe("getFormMode tests based on provided requirements", () => { // Other role Tests describe("getFormMode > Other roles tests", () => { - it("should always set View Only for all other roles", () => { - const roles: User["role"][] = [ - "Data Commons POC", - "Some other role", - "This role doesn't exist", - ] as unknown as User["role"][]; + it("should always set View Only for all other roles with the required view permissions", () => { const statuses: ApplicationStatus[] = [ "New", "In Progress", @@ -228,13 +205,15 @@ describe("getFormMode tests based on provided requirements", () => { "Inquired", ]; - roles.forEach((role) => { - const user: User = { ...baseUser, role }; - statuses.forEach((status) => { - expect(utils.getFormMode(user, { ...baseSubmission, status })).toBe( - utils.FormModes.VIEW_ONLY - ); - }); + const user: User = { + ...baseUser, + role: "Data Commons Personnel", + permissions: ["submission_request:view"], + }; + statuses.forEach((status) => { + expect(utils.getFormMode(user, { ...baseSubmission, status })).toBe( + utils.FormModes.VIEW_ONLY + ); }); }); }); @@ -242,15 +221,26 @@ describe("getFormMode tests based on provided requirements", () => { // New Rejected status tests describe("getFormMode > New Rejected status test", () => { it("rejected is a final state, no role should be able to do anything past rejected", () => { - const roles: User["role"][] = [ - "Data Commons POC", - "Some other role", - "This role doesn't exist", - ] as unknown as User["role"][]; + const roles: UserRole[] = [ + "Data Commons Personnel", + "Admin", + "Federal Lead", + "Submitter", + "User", + ]; const status: ApplicationStatus = "Rejected"; roles.forEach((role) => { - const user: User = { ...baseUser, role }; + const user: User = { + ...baseUser, + role, + permissions: [ + "submission_request:create", + "submission_request:view", + "submission_request:submit", + "submission_request:review", + ], + }; expect(utils.getFormMode(user, { ...baseSubmission, status })).toBe( utils.FormModes.VIEW_ONLY ); @@ -269,21 +259,24 @@ describe("getFormMode tests based on provided requirements", () => { applicantID: "user-456-another-user", }, }; - const fedLead: User = { ...baseUser, role: "Organization Owner" }; - const submitterOwner: User = { + const fedLead: User = { ...baseUser, - role: "Submitter", - _id: "user-456-another-user", + role: "Federal Lead", + permissions: [ + "submission_request:view", + "submission_request:submit", + "submission_request:review", + ], }; - const orgOwnerSubmissionOwner: User = { + const submitterOwner: User = { ...baseUser, - role: "Organization Owner", + role: "Submitter", + permissions: ["submission_request:create", "submission_request:submit"], _id: "user-456-another-user", }; expect(utils.getFormMode(fedLead, submission)).toBe(utils.FormModes.VIEW_ONLY); expect(utils.getFormMode(submitterOwner, submission)).toBe(utils.FormModes.EDIT); - expect(utils.getFormMode(orgOwnerSubmissionOwner, submission)).toBe(utils.FormModes.EDIT); }); }); @@ -304,20 +297,24 @@ describe("getFormMode tests based on provided requirements", () => { expect(utils.getFormMode(null, null)).toBe(utils.FormModes.UNAUTHORIZED); }); - it("should set Unauthorized form if user role is undefined", () => { - const user: User = { ...baseUser, role: undefined }; + it("should set Unauthorized form if user does not have the required permissions and is not submission owner", () => { + const user: User = { ...baseUser, role: undefined, permissions: [] }; + const submission: Application = { + ...baseSubmission, + applicant: { ...baseSubmission.applicant, applicantID: "some-other-user" }, + }; - expect(utils.getFormMode(user, baseSubmission)).toBe(utils.FormModes.UNAUTHORIZED); + expect(utils.getFormMode(user, submission)).toBe(utils.FormModes.UNAUTHORIZED); }); - it("should set Unauthorized if form status is unknown or not defined", () => { - const user: User = { ...baseUser, role: "User" }; + it("should set 'View Only' if form status is unknown or not defined", () => { + const user: User = { ...baseUser, role: "User", permissions: ["submission_request:view"] }; const submission: Application = { ...baseSubmission, status: undefined, }; - expect(utils.getFormMode(user, submission)).toBe(utils.FormModes.UNAUTHORIZED); + expect(utils.getFormMode(user, submission)).toBe(utils.FormModes.VIEW_ONLY); }); }); }); diff --git a/src/utils/formModeUtils.ts b/src/utils/formModeUtils.ts index 860a41080..ae8e056a2 100644 --- a/src/utils/formModeUtils.ts +++ b/src/utils/formModeUtils.ts @@ -1,5 +1,8 @@ +import { hasPermission } from "../config/AuthPermissions"; + export type FormMode = "Unauthorized" | "Edit" | "View Only" | "Review"; +export const ViewOnlyStatuses = ["Submitted", "In Review", "Approved", "Rejected"]; export const EditStatuses = ["New", "In Progress", "Inquired"]; export const ReviewStatuses = ["In Review"]; export const FormModes = { @@ -10,101 +13,45 @@ export const FormModes = { } as const; /** - * Calculate the form mode for a user - * NOTE: - * - This is a private helper function for getFormMode + * Get updated form mode based on user permissions and form status * * @param {User} user - The current user * @param {Application} data - The current application/submission - * @returns {FormMode} - Form mode corresponding to the given form status and user. + * @returns {FormMode} - Updated form mode based on role, organization role, and form status */ -const _getFormModeForUser = (user: User, data: Application): FormMode => { - const { status: formStatus } = data || {}; - const formBelongsToUser = data?.applicant?.applicantID === user?.["_id"]; - const isStatusViewOnlyForUser = ["Submitted", "In Review", "Approved", "Rejected"].includes( - formStatus - ); - - if (formStatus !== "New" && !formBelongsToUser) { +export const getFormMode = (user: User, data: Application): FormMode => { + if (!data) { return FormModes.UNAUTHORIZED; } - if (isStatusViewOnlyForUser) { - return FormModes.VIEW_ONLY; - } - if (EditStatuses.includes(formStatus)) { - return FormModes.EDIT; + const isFormOwner = user?._id === data.applicant?.applicantID; + if (!hasPermission(user, "submission_request", "view") && !isFormOwner) { + return FormModes.UNAUTHORIZED; } - return FormModes.UNAUTHORIZED; -}; -/** - * Calculate the form mode for a Federal Lead - * NOTE: - * - This is a private helper function for getFormMode - * - * @param {Application} data - The current application/submission - * @returns {FormMode} - Form mode corresponding to the given form status for a Federal Lead. - */ -const _getFormModeForFederalLead = (data: Application): FormMode => { - const { status: formStatus } = data || {}; + if ( + !isFormOwner && + !hasPermission(user, "submission_request", "view") && + !hasPermission(user, "submission_request", "create") && + !hasPermission(user, "submission_request", "review") + ) { + return FormModes.UNAUTHORIZED; + } - if (ReviewStatuses.includes(formStatus)) { + if ( + hasPermission(user, "submission_request", "review") && + ReviewStatuses.includes(data?.status) + ) { return FormModes.REVIEW; } - return FormModes.VIEW_ONLY; -}; - -/** - * Calculate the form mode for an Organization Owner - * NOTE: - * - This is a private helper function for getFormMode - * - * @param {User} user - The current user - * @param {Application} data - The current application/submission - * @returns {FormMode} - Form mode corresponding to the given form status and organization owner. - */ -const _getFormModeForOrgOwner = (user: User, data: Application): FormMode => { - const { status: formStatus } = data || {}; - const formBelongsToUser = data?.applicant?.applicantID === user?.["_id"]; - - if (!formBelongsToUser) { - return FormModes.VIEW_ONLY; - } - if (EditStatuses.includes(formStatus)) { + // User is only allowed to edit their own Submission Request + if ( + isFormOwner && + hasPermission(user, "submission_request", "create") && + EditStatuses.includes(data?.status) + ) { return FormModes.EDIT; } - return FormModes.VIEW_ONLY; -}; -/** - * Get updated form mode based on role, organization role, and form status - * NOTE: - * - Depends on the following private helper functions: - * _getFormModeForUser, - * _getFormModeForFederalLead, - * _getFormModeForOrgOwner - * - * @param {User} user - The current user - * @param {Application} data - The current application/submission - * @returns {FormMode} - Updated form mode based on role, organization role, and form status - */ -export const getFormMode = (user: User, data: Application): FormMode => { - if (!user?.role || !data) { - return FormModes.UNAUTHORIZED; - } - - switch (user.role) { - case "Federal Lead": - return _getFormModeForFederalLead(data); - case "Admin": - return FormModes.VIEW_ONLY; - case "Organization Owner": - return _getFormModeForOrgOwner(user, data); - case "User": - case "Submitter": - return _getFormModeForUser(user, data); - default: - return FormModes.VIEW_ONLY; - } + return FormModes.VIEW_ONLY; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 740ade86a..8be715d00 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,3 +15,4 @@ export * from "./searchParamUtils"; export * from "./envUtils"; export * from "./logger"; export * from "./fetchUtils"; +export * from "./dashboardUtils"; diff --git a/src/utils/profileUtils.test.ts b/src/utils/profileUtils.test.ts index 8958c3050..251806548 100644 --- a/src/utils/profileUtils.test.ts +++ b/src/utils/profileUtils.test.ts @@ -1,11 +1,4 @@ import * as utils from "./profileUtils"; -import { formatName } from "./stringUtils"; - -jest.mock("./stringUtils", () => ({ - formatName: jest.fn(), -})); - -const mockFormatName = formatName as jest.Mock; describe("formatIDP cases", () => { it("should format NIH IDP", () => { @@ -41,30 +34,15 @@ describe("userToCollaborator cases", () => { _id: "user-1", firstName: "John", lastName: "Doe", - organization: { - orgID: "org-1", - orgName: "Organization 1", - status: "Active", - createdAt: "", - updateAt: "", - }, }; - mockFormatName.mockReturnValue("John Doe"); - const collaborator = utils.userToCollaborator(user); expect(collaborator).toEqual({ collaboratorID: "user-1", - collaboratorName: "John Doe", - permission: "Can View", - Organization: { - orgID: "org-1", - orgName: "Organization 1", - }, + collaboratorName: "Doe, John", + permission: "Can Edit", }); - - expect(mockFormatName).toHaveBeenCalledWith("John", "Doe"); }); it("should use provided permission", () => { @@ -74,8 +52,6 @@ describe("userToCollaborator cases", () => { lastName: "Doe", }; - mockFormatName.mockReturnValue("John Doe"); - const collaborator = utils.userToCollaborator(user, "Can Edit"); expect(collaborator.permission).toBe("Can Edit"); @@ -87,12 +63,9 @@ describe("userToCollaborator cases", () => { lastName: "Doe", }; - mockFormatName.mockReturnValue("Doe"); - const collaborator = utils.userToCollaborator(user); - expect(collaborator.collaboratorName).toBe("Doe"); - expect(mockFormatName).toHaveBeenCalledWith(undefined, "Doe"); + expect(collaborator.collaboratorName).toBe("Doe, "); }); it("should handle missing lastName", () => { @@ -101,53 +74,9 @@ describe("userToCollaborator cases", () => { firstName: "John", }; - mockFormatName.mockReturnValue("John"); - - const collaborator = utils.userToCollaborator(user); - - expect(collaborator.collaboratorName).toBe("John"); - expect(mockFormatName).toHaveBeenCalledWith("John", undefined); - }); - - it("should handle missing organization", () => { - const user: Partial = { - _id: "user-1", - firstName: "John", - lastName: "Doe", - }; - - mockFormatName.mockReturnValue("John Doe"); - - const collaborator = utils.userToCollaborator(user); - - expect(collaborator.Organization).toEqual({ - orgID: undefined, - orgName: undefined, - }); - }); - - it("should handle missing organization orgID and orgName", () => { - const user: Partial = { - _id: "user-1", - firstName: "John", - lastName: "Doe", - organization: { - orgID: "", - orgName: "", - status: "Active", - createdAt: "", - updateAt: "", - }, - }; - - mockFormatName.mockReturnValue("John Doe"); - const collaborator = utils.userToCollaborator(user); - expect(collaborator.Organization).toEqual({ - orgID: "", - orgName: "", - }); + expect(collaborator.collaboratorName).toBe(", John"); }); it("should handle missing _id", () => { @@ -156,8 +85,6 @@ describe("userToCollaborator cases", () => { lastName: "Doe", }; - mockFormatName.mockReturnValue("John Doe"); - const collaborator = utils.userToCollaborator(user); expect(collaborator.collaboratorID).toBeUndefined(); @@ -168,15 +95,9 @@ describe("userToCollaborator cases", () => { expect(collaborator).toEqual({ collaboratorID: undefined, - collaboratorName: formatName(undefined, undefined), - permission: "Can View", - Organization: { - orgID: undefined, - orgName: undefined, - }, + collaboratorName: ", ", + permission: "Can Edit", }); - - expect(mockFormatName).toHaveBeenCalledWith(undefined, undefined); }); it("should handle undefined user", () => { @@ -184,15 +105,9 @@ describe("userToCollaborator cases", () => { expect(collaborator).toEqual({ collaboratorID: undefined, - collaboratorName: formatName(undefined, undefined), - permission: "Can View", - Organization: { - orgID: undefined, - orgName: undefined, - }, + collaboratorName: ", ", + permission: "Can Edit", }); - - expect(mockFormatName).toHaveBeenCalledWith(undefined, undefined); }); it("should handle user with empty properties", () => { @@ -200,30 +115,15 @@ describe("userToCollaborator cases", () => { _id: "", firstName: "", lastName: "", - organization: { - orgID: "", - orgName: "", - status: "Active", - createdAt: "", - updateAt: "", - }, }; - mockFormatName.mockReturnValue(""); - const collaborator = utils.userToCollaborator(user); expect(collaborator).toEqual({ collaboratorID: "", - collaboratorName: "", - permission: "Can View", - Organization: { - orgID: "", - orgName: "", - }, + collaboratorName: ", ", + permission: "Can Edit", }); - - expect(mockFormatName).toHaveBeenCalledWith("", ""); }); it("should handle user with additional properties", () => { @@ -233,29 +133,201 @@ describe("userToCollaborator cases", () => { lastName: "Doe", email: "john.doe@example.com", role: "Admin", - organization: { - orgID: "org-1", - orgName: "Organization 1", - status: "Active", - createdAt: "", - updateAt: "", - }, }; - mockFormatName.mockReturnValue("John Doe"); - const collaborator = utils.userToCollaborator(user); expect(collaborator).toEqual({ collaboratorID: "user-1", - collaboratorName: "John Doe", - permission: "Can View", - Organization: { - orgID: "org-1", - orgName: "Organization 1", - }, + collaboratorName: "Doe, John", + permission: "Can Edit", }); + }); +}); + +describe("columnizePBACGroups cases", () => { + const baseDefault: PBACDefault = { + _id: "access:request", // The _id field is not actually used by the util + name: "", + group: "", + order: 0, + checked: false, + disabled: false, + }; + + it("should return empty array for invalid input", () => { + expect(utils.columnizePBACGroups([])).toEqual([]); + expect(utils.columnizePBACGroups(null)).toEqual([]); + expect(utils.columnizePBACGroups(undefined)).toEqual([]); + }); + + it("should group PBACDefaults into columns using the default colCount", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "1", group: "A" }, + { ...baseDefault, name: "2", group: "A" }, + { ...baseDefault, name: "3", group: "B" }, + { ...baseDefault, name: "4", group: "B" }, + { ...baseDefault, name: "5", group: "C" }, + { ...baseDefault, name: "6", group: "C" }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults); + + expect(columnized).toHaveLength(3); + expect(columnized[0]).toHaveLength(1); + expect(columnized[1]).toHaveLength(1); + expect(columnized[2]).toHaveLength(1); + + expect(columnized[0][0].data).toEqual([ + { ...baseDefault, name: "1", group: "A" }, + { ...baseDefault, name: "2", group: "A" }, + ]); + + expect(columnized[1][0].data).toEqual([ + { ...baseDefault, name: "3", group: "B" }, + { ...baseDefault, name: "4", group: "B" }, + ]); + + expect(columnized[2][0].data).toEqual([ + { ...baseDefault, name: "5", group: "C" }, + { ...baseDefault, name: "6", group: "C" }, + ]); + }); + + it("should group PBACDefaults into columns using a custom colCount", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "1", group: "A" }, + { ...baseDefault, name: "2", group: "B" }, + { ...baseDefault, name: "3", group: "C" }, + { ...baseDefault, name: "4", group: "D" }, + { ...baseDefault, name: "5", group: "E" }, + { ...baseDefault, name: "6", group: "F" }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults, 2); + + expect(columnized).toHaveLength(2); + expect(columnized[0]).toHaveLength(1); + expect(columnized[1]).toHaveLength(5); + }); + + it("should handle a higher colCount than the number of groups", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "1", group: "A" }, + { ...baseDefault, name: "2", group: "B" }, + { ...baseDefault, name: "3", group: "C" }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults, 10); + + expect(columnized).toHaveLength(3); + expect(columnized[0]).toHaveLength(1); + expect(columnized[1]).toHaveLength(1); + expect(columnized[2]).toHaveLength(1); + }); + + it("should handle PBACDefaults with no group", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "1", group: "A" }, + { ...baseDefault, name: "2", group: "B" }, + { ...baseDefault, name: "3", group: "" }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults); + + expect(columnized).toHaveLength(3); + expect(columnized[0]).toHaveLength(1); + expect(columnized[1]).toHaveLength(1); + expect(columnized[2]).toHaveLength(1); + + expect(columnized[2][0].data).toEqual([{ ...baseDefault, name: "3", group: "" }]); + }); + + it("should fallback to an empty group name if the PBACDefault has an invalid group name", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "1", group: "valid" }, + { ...baseDefault, name: "2", group: undefined }, + { ...baseDefault, name: "3", group: null }, + { ...baseDefault, name: "4", group: 3 as unknown as string }, + { ...baseDefault, name: "5", group: { Obj: "yes" } as unknown as string }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults, 10); // Set to 10 to ensure all groups COULD go to their own column + + expect(columnized).toHaveLength(2); + expect(columnized[0]).toHaveLength(1); + expect(columnized[1]).toHaveLength(1); // 1 Group for all invalid + + expect(columnized[0][0].data).toEqual([{ ...baseDefault, name: "1", group: "valid" }]); + expect(columnized[1][0].name).toBe(""); + expect(columnized[1][0].data).toHaveLength(4); // All invalid groups are together + expect(columnized[1][0].data).toEqual([ + pbacDefaults[1], + pbacDefaults[2], + pbacDefaults[3], + pbacDefaults[4], + ]); + }); + + it("should sort the groups in the order: Submission Request, Data Submission, Admin, Miscellaneous", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "6", group: "Random Group 1" }, // 5 + { ...baseDefault, name: "1", group: "Data Submission" }, // 2 + { ...baseDefault, name: "3", group: "Miscellaneous" }, // 4 + { ...baseDefault, name: "2", group: "Admin" }, // 3 + { ...baseDefault, name: "4", group: "Submission Request" }, // 1 + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults, 4); + + expect(columnized).toHaveLength(4); + expect(columnized[0]).toHaveLength(1); + expect(columnized[1]).toHaveLength(1); + expect(columnized[2]).toHaveLength(1); + expect(columnized[3]).toHaveLength(2); + + expect(columnized[0][0].data).toEqual([ + { ...baseDefault, name: "4", group: "Submission Request" }, + ]); + expect(columnized[1][0].data).toEqual([ + { ...baseDefault, name: "1", group: "Data Submission" }, + ]); + expect(columnized[2][0].data).toEqual([{ ...baseDefault, name: "2", group: "Admin" }]); + expect(columnized[3][0].data).toEqual([{ ...baseDefault, name: "3", group: "Miscellaneous" }]); + expect(columnized[3][1].data).toEqual([{ ...baseDefault, name: "6", group: "Random Group 1" }]); + }); + + it("should sort the options within each group by their order", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "entry 1", group: "Group01", order: 5 }, + { ...baseDefault, name: "entry 2", group: "Group01", order: 1 }, + { ...baseDefault, name: "entry 3", group: "Group01", order: 3 }, + { ...baseDefault, name: "entry 4", group: "Group01", order: 2 }, + { ...baseDefault, name: "entry 5", group: "Group01", order: 4 }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults); + expect(columnized[0][0].data).toEqual([ + { ...baseDefault, name: "entry 2", group: "Group01", order: 1 }, + { ...baseDefault, name: "entry 4", group: "Group01", order: 2 }, + { ...baseDefault, name: "entry 3", group: "Group01", order: 3 }, + { ...baseDefault, name: "entry 5", group: "Group01", order: 4 }, + { ...baseDefault, name: "entry 1", group: "Group01", order: 5 }, + ]); + }); - expect(mockFormatName).toHaveBeenCalledWith("John", "Doe"); + it("should leave the order unchanged if 'order' is null", () => { + const pbacDefaults: PBACDefault[] = [ + { ...baseDefault, name: "entry 1", group: "Group01", order: null }, + { ...baseDefault, name: "entry 2", group: "Group01", order: null }, + { ...baseDefault, name: "entry 3", group: "Group01", order: null }, + ]; + + const columnized = utils.columnizePBACGroups(pbacDefaults); + expect(columnized[0][0].data).toEqual([ + { ...baseDefault, name: "entry 1", group: "Group01", order: null }, + { ...baseDefault, name: "entry 2", group: "Group01", order: null }, + { ...baseDefault, name: "entry 3", group: "Group01", order: null }, + ]); }); }); diff --git a/src/utils/profileUtils.ts b/src/utils/profileUtils.ts index e4ffb981b..302a924da 100644 --- a/src/utils/profileUtils.ts +++ b/src/utils/profileUtils.ts @@ -1,5 +1,3 @@ -import { formatName } from "./stringUtils"; - /** * Formats a Authentication IDP for visual display * @@ -30,13 +28,117 @@ export const formatIDP = (idp: User["IDP"]): string => { */ export const userToCollaborator = ( user: Partial, - permission: CollaboratorPermissions = "Can View" + permission: CollaboratorPermissions = "Can Edit" ): Collaborator => ({ collaboratorID: user?._id, - collaboratorName: formatName(user?.firstName, user?.lastName), + collaboratorName: `${user?.lastName || ""}, ${user?.firstName || ""}`, permission, - Organization: { - orgID: user?.organization?.orgID, - orgName: user?.organization?.orgName, - }, }); + +/** + * The data structure for a columized PBAC group + */ +export type ColumnizedPBACGroups = { + /** + * The name of the group of PBACDefaults + * + * All PBACDefaults in the group will have the same group name + */ + name: string; + /** + * The PBACDefaults for the group + */ + data: PBACDefault[]; +}[][]; + +/** + * A utility function to group an array of PBACDefaults into columns + * based on the group name. + * + * If the number of unique groups exceeds `colCount`, the function will + * aggregate the remaining groups into the last column. + * + * Data Structure: Array of Columns -> Array of Groups -> PBAC Defaults for the group + * + * @see {@link ColumnizedPBACGroups} + * @param data The array of PBACDefaults to columnize + * @param colCount The number of columns to create + * @returns An array of columns containing an array of PBACDefaults + */ +export const columnizePBACGroups = ( + data: PBACDefault[], + colCount = 3 +): ColumnizedPBACGroups => { + if (!data || !Array.isArray(data) || data.length === 0) { + return []; + } + + // Group the PBACDefaults by their group name + const groupedData: Record[]> = {}; + data.forEach((item) => { + const groupName = typeof item?.group === "string" ? item.group : ""; + if (!groupedData[groupName]) { + groupedData[groupName] = []; + } + + groupedData[groupName].push(item); + }); + + // Sort the PBACDefaults within each group + Object.values(groupedData).forEach((options: PBACDefault[]) => { + options.sort((a: PBACDefault, b: PBACDefault) => (a?.order || 0) - (b?.order || 0)); + }); + + // Sort the groups by their partial group name + const sortedGroups = Object.entries(groupedData); + sortedGroups.sort(([a], [b]) => orderPBACGroups(a, b)); + + // Columnize the groups + const columns: ColumnizedPBACGroups = []; + sortedGroups.forEach(([group, data], index) => { + const groupIndex = index > colCount - 1 ? colCount - 1 : index; + if (!columns[groupIndex]) { + columns[groupIndex] = []; + } + + columns[groupIndex].push({ name: group, data }); + }); + + return columns; +}; + +/** + * A utility function to order PBAC Groups by their partial group name in the following order: + * + * 1. Submission Request + * 2. Data Submission + * 3. Admin + * 4. Miscellaneous + * 5. All other groups + * + * If the name does not contain any of the above groups, it will be pushed to the end, + * but will not be sorted against other unlisted groups. + * + * @param groups The groups to order + * @returns The ordered groups in the format of Record + */ +export const orderPBACGroups = (a: string, b: string): number => { + const SORT_PRIORITY = ["Submission Request", "Data Submission", "Admin", "Miscellaneous"]; + + const aIndex = SORT_PRIORITY.findIndex((group) => a.includes(group)); + const bIndex = SORT_PRIORITY.findIndex((group) => b.includes(group)); + + if (aIndex === -1 && bIndex === -1) { + return 0; + } + + if (aIndex === -1) { + return 1; + } + + if (bIndex === -1) { + return -1; + } + + return aIndex - bIndex; +}; diff --git a/src/utils/stringUtils.test.ts b/src/utils/stringUtils.test.ts index 62e928439..6921cddfc 100644 --- a/src/utils/stringUtils.test.ts +++ b/src/utils/stringUtils.test.ts @@ -420,69 +420,3 @@ describe("isStringLengthBetween utility function", () => { expect(result).toBe(false); }); }); - -describe("formatName utility function", () => { - it("should format full name as 'lastName, firstName' when both names are provided", () => { - expect(utils.formatName("John", "Doe")).toBe("Doe, John"); - }); - - it("should return lastName when firstName is missing", () => { - expect(utils.formatName(undefined, "Doe")).toBe("Doe"); - expect(utils.formatName(null, "Doe")).toBe("Doe"); - expect(utils.formatName("", "Doe")).toBe("Doe"); - }); - - it("should return firstName when lastName is missing", () => { - expect(utils.formatName("John", undefined)).toBe("John"); - expect(utils.formatName("John", null)).toBe("John"); - expect(utils.formatName("John", "")).toBe("John"); - }); - - it("should return empty string when both firstName and lastName are missing", () => { - expect(utils.formatName(undefined, undefined)).toBe(""); - expect(utils.formatName(null, null)).toBe(""); - expect(utils.formatName("", "")).toBe(""); - }); - - it("should trim whitespace from names", () => { - expect(utils.formatName(" John ", " Doe ")).toBe("Doe, John"); - expect(utils.formatName(" John ", undefined)).toBe("John"); - expect(utils.formatName(undefined, " Doe ")).toBe("Doe"); - }); - - it("should handle names with special characters", () => { - expect(utils.formatName("Jean-Luc", "Picard")).toBe("Picard, Jean-Luc"); - expect(utils.formatName("Mary Jane", "Watson-Parker")).toBe("Watson-Parker, Mary Jane"); - }); - - it("should handle names with leading/trailing whitespace", () => { - expect(utils.formatName(" John", "Doe ")).toBe("Doe, John"); - }); - - it("should handle names that are only whitespace", () => { - expect(utils.formatName(" ", " ")).toBe(""); - expect(utils.formatName(" ", "Doe")).toBe("Doe"); - expect(utils.formatName("John", " ")).toBe("John"); - }); - - it("should handle numeric strings as names", () => { - expect(utils.formatName("123", "456")).toBe("456, 123"); - }); - - it("should handle names with unicode characters", () => { - expect(utils.formatName("Renée", "Élodie")).toBe("Élodie, Renée"); - }); - - it("should handle names with apostrophes", () => { - expect(utils.formatName("O'Connor", "Shaun")).toBe("Shaun, O'Connor"); - }); - - it("should handle null and undefined values gracefully", () => { - expect(utils.formatName(null, undefined)).toBe(""); - expect(utils.formatName(undefined, null)).toBe(""); - }); - - it("should handle non-string types when cast to string", () => { - expect(utils.formatName(123 as unknown as string, 456 as unknown as string)).toBe(""); - }); -}); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index 864714508..1604cbeef 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -196,27 +196,3 @@ export const isStringLengthBetween = ( return str?.length > minLength && str?.length < maxLength; }; - -/** - * Formats a person's name as "lastName, firstName" or returns the available name if one is missing. - * If both names are missing, returns an empty string. - * - * @param firstName - The person's first name. - * @param lastName - The person's last name. - * @returns The formatted name. - */ -export const formatName = (firstName?: string, lastName?: string): string => { - const trimmedFirstName = typeof firstName === "string" ? firstName?.trim() : ""; - const trimmedLastName = typeof lastName === "string" ? lastName?.trim() : ""; - - if (trimmedFirstName && trimmedLastName) { - return `${trimmedLastName}, ${trimmedFirstName}`; - } - if (trimmedLastName) { - return trimmedLastName; - } - if (trimmedFirstName) { - return trimmedFirstName; - } - return ""; -};