diff --git a/src/advanced-settings/AdvancedSettings.test.tsx b/src/advanced-settings/AdvancedSettings.test.tsx index 9ed3f144aa..0332aafa1f 100644 --- a/src/advanced-settings/AdvancedSettings.test.tsx +++ b/src/advanced-settings/AdvancedSettings.test.tsx @@ -52,6 +52,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: false, + canViewAdvancedSettings: true, canManageAdvancedSettings: true, } as ReturnType); }); @@ -175,6 +176,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, + canViewAdvancedSettings: true, canManageAdvancedSettings: true, } as ReturnType); render(); @@ -192,7 +194,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: false, + canViewAdvancedSettings: false, } as ReturnType); render(); expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); @@ -203,7 +205,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: false, + canViewAdvancedSettings: false, } as ReturnType); axiosMock .onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`) @@ -212,4 +214,21 @@ describe('', () => { expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); expect(screen.queryByText(/Under Construction/i)).not.toBeInTheDocument(); }); + + it('should show view-only alert and disable editing when user has view but not manage permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewAdvancedSettings: true, + canManageAdvancedSettings: false, + } as ReturnType); + render(); + expect(await screen.findByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + const textarea = screen.getByLabelText(/Advanced Module List/i); + expect(textarea).toBeDisabled(); + expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByText(messages.buttonCancelText.defaultMessage)).not.toBeInTheDocument(); + }); }); diff --git a/src/advanced-settings/AdvancedSettings.tsx b/src/advanced-settings/AdvancedSettings.tsx index 997d6ffaee..3eae6ead40 100644 --- a/src/advanced-settings/AdvancedSettings.tsx +++ b/src/advanced-settings/AdvancedSettings.tsx @@ -13,6 +13,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useCourseUserPermissions } from '@src/authz/hooks'; import { getAdvancedSettingsPermissions } from '@src/authz/permissionHelpers'; import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import AlertProctoringError from '@src/generic/AlertProctoringError'; import { LoadingSpinner } from '@src/generic/Loading'; import InternetConnectionAlert from '@src/generic/internet-connection-alert'; @@ -45,6 +46,7 @@ const AdvancedSettings = () => { const { isLoading: isLoadingUserPermissions, + canViewAdvancedSettings, canManageAdvancedSettings, } = useCourseUserPermissions(courseId, getAdvancedSettingsPermissions(courseId)); @@ -102,7 +104,7 @@ const AdvancedSettings = () => { ); } - if (!canManageAdvancedSettings) { + if (!canViewAdvancedSettings) { return ; } @@ -201,6 +203,7 @@ const AdvancedSettings = () => { subtitle={intl.formatMessage(messages.headingSubtitle)} title={intl.formatMessage(messages.headingTitle)} contentTitle={intl.formatMessage(messages.policy)} + banner={!canManageAdvancedSettings ? : null} />
@@ -246,6 +249,7 @@ const AdvancedSettings = () => { handleBlur={handleSettingBlur} isEditableState={isEditableState} setIsEditableState={setIsEditableState} + disabled={!canManageAdvancedSettings} /> ); })} diff --git a/src/advanced-settings/setting-card/SettingCard.test.jsx b/src/advanced-settings/setting-card/SettingCard.test.tsx similarity index 51% rename from src/advanced-settings/setting-card/SettingCard.test.jsx rename to src/advanced-settings/setting-card/SettingCard.test.tsx index cb2fd25aba..78316e6156 100644 --- a/src/advanced-settings/setting-card/SettingCard.test.jsx +++ b/src/advanced-settings/setting-card/SettingCard.test.tsx @@ -1,8 +1,13 @@ -import { fireEvent, render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { + fireEvent, + initializeMocks, + render, + screen, + userEvent, + waitFor, +} from '@src/testUtils'; -import SettingCard from './SettingCard'; +import SettingCard, { type SettingCardProps } from './SettingCard'; import messages from './messages'; const setEdited = jest.fn(); @@ -25,10 +30,9 @@ jest.mock('react-textarea-autosize', () => /> ))); -const RootWrapper = () => ( - +const renderComponent = (props: Partial = {}) => + render( ( handleBlur={handleBlur} isEditableState saveSettingsPrompt={false} - /> - -); + {...props} + />, + ); describe('', () => { - afterEach(() => jest.clearAllMocks()); + beforeEach(() => { + initializeMocks(); + }); + it('renders the setting card with the provided data', () => { - const { getByText, getByLabelText } = render(); - const cardTitle = getByText(/Setting Name/i); - const input = getByLabelText(/Setting Name/i); + renderComponent(); + const cardTitle = screen.getByText(/Setting Name/i); + const input = screen.getByLabelText(/Setting Name/i); expect(cardTitle).toBeInTheDocument(); expect(input).toBeInTheDocument(); - expect(input.value).toBe(JSON.stringify(settingData.value, null, 4)); + expect(input).toHaveValue(JSON.stringify(settingData.value, null, 4)); }); + it('displays the deprecated status when the setting is deprecated', () => { - const deprecatedSettingData = { ...settingData, deprecated: true }; - const { getByText } = render( - - - , - ); - const deprecatedStatus = getByText(messages.deprecated.defaultMessage); - expect(deprecatedStatus).toBeInTheDocument(); + renderComponent({ + settingData: { ...settingData, deprecated: true }, + isEditableState: false, + saveSettingsPrompt: true, + }); + expect(screen.getByText(messages.deprecated.defaultMessage)).toBeInTheDocument(); }); + it('does not display the deprecated status when the setting is not deprecated', () => { - const { queryByText } = render(); - expect(queryByText(messages.deprecated.defaultMessage)).toBeNull(); + renderComponent(); + expect(screen.queryByText(messages.deprecated.defaultMessage)).toBeNull(); }); + it('calls setEdited on blur', async () => { const user = userEvent.setup(); - const { getByLabelText } = render(); - const inputBox = getByLabelText(/Setting Name/i); + renderComponent(); + const inputBox = screen.getByLabelText(/Setting Name/i); fireEvent.focus(inputBox); await user.clear(inputBox); await user.type(inputBox, '3, 2, 1'); @@ -91,4 +89,9 @@ describe('', () => { expect(handleBlur).toHaveBeenCalled(); }); }); + + it('disables the setting input when `disabled` is true', () => { + renderComponent({ disabled: true }); + expect(screen.getByLabelText(/Setting Name/i)).toBeDisabled(); + }); }); diff --git a/src/advanced-settings/setting-card/SettingCard.tsx b/src/advanced-settings/setting-card/SettingCard.tsx index e7dedd7df4..3d93cde881 100644 --- a/src/advanced-settings/setting-card/SettingCard.tsx +++ b/src/advanced-settings/setting-card/SettingCard.tsx @@ -9,13 +9,29 @@ import { useToggle, } from '@openedx/paragon'; import { InfoOutline, Warning } from '@openedx/paragon/icons'; -import PropTypes from 'prop-types'; import { capitalize } from 'lodash'; import { useIntl } from '@edx/frontend-platform/i18n'; import TextareaAutosize from 'react-textarea-autosize'; import messages from './messages'; +export interface SettingCardProps { + name: string; + settingData: { + deprecated?: boolean; + help?: string; + displayName?: string; + value?: unknown; + }; + handleBlur: () => void; + setEdited: React.Dispatch>>; + showSaveSettingsPrompt: (show: boolean) => void; + saveSettingsPrompt: boolean; + isEditableState: boolean; + setIsEditableState: (isEditable: boolean) => void; + disabled?: boolean; +} + const SettingCard = ({ name, settingData, @@ -25,7 +41,8 @@ const SettingCard = ({ saveSettingsPrompt, isEditableState, setIsEditableState, -}) => { + disabled = false, +}: SettingCardProps) => { const intl = useIntl(); const { deprecated, help, displayName } = settingData; const initialValue = JSON.stringify(settingData.value, null, 4); @@ -83,7 +100,7 @@ const SettingCard = ({
@@ -99,6 +116,7 @@ const SettingCard = ({ onChange={handleSettingChange} aria-label={displayName} onBlur={handleCardBlur} + disabled={disabled} /> @@ -113,26 +131,4 @@ const SettingCard = ({ ); }; -SettingCard.propTypes = { - settingData: PropTypes.shape({ - deprecated: PropTypes.bool, - help: PropTypes.string, - displayName: PropTypes.string, - value: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.number, - PropTypes.object, - PropTypes.array, - ]), - }).isRequired, - setEdited: PropTypes.func.isRequired, - showSaveSettingsPrompt: PropTypes.func.isRequired, - name: PropTypes.string.isRequired, - handleBlur: PropTypes.func.isRequired, - saveSettingsPrompt: PropTypes.bool.isRequired, - isEditableState: PropTypes.bool.isRequired, - setIsEditableState: PropTypes.func.isRequired, -}; - export default SettingCard; diff --git a/src/authz/constants.ts b/src/authz/constants.ts index 028a395c3d..d056c7393d 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -19,6 +19,7 @@ export const COURSE_PERMISSIONS = { VIEW_COURSE: 'courses.view_course', EDIT_COURSE_CONTENT: 'courses.edit_course_content', + VIEW_ADVANCED_SETTINGS: 'courses.view_advanced_settings', MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', VIEW_GRADING_SETTINGS: 'courses.view_grading_settings', @@ -42,6 +43,8 @@ export const COURSE_PERMISSIONS = { VIEW_COURSE_TEAM: 'courses.view_course_team', MANAGE_GROUP_CONFIGURATIONS: 'courses.manage_group_configurations', + + VIEW_CERTIFICATES: 'courses.view_certificates', MANAGE_CERTIFICATES: 'courses.manage_certificates', VIEW_CHECKLISTS: 'courses.view_checklists', diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts index 7798b9d5ca..014780df16 100644 --- a/src/authz/permissionHelpers.test.ts +++ b/src/authz/permissionHelpers.test.ts @@ -93,17 +93,26 @@ describe('permissionHelpers', () => { }); describe('getAdvancedSettingsPermissions', () => { - it('returns MANAGE permission with the correct action and scope', () => { + it('returns VIEW and MANAGE permissions with the correct actions and scope', () => { const result = getAdvancedSettingsPermissions(courseId); - expect(result.canManageAdvancedSettings.action).toBe(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS); - expect(result.canManageAdvancedSettings.scope).toBe(courseId); + expect(result).toEqual({ + canViewAdvancedSettings: { + action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS, + scope: courseId, + }, + canManageAdvancedSettings: { + action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, + scope: courseId, + }, + }); }); it('uses the provided courseId as scope', () => { const otherId = 'course-v1:another+test+run'; const result = getAdvancedSettingsPermissions(otherId); + expect(result.canViewAdvancedSettings.scope).toBe(otherId); expect(result.canManageAdvancedSettings.scope).toBe(otherId); }); }); @@ -208,10 +217,14 @@ describe('permissionHelpers', () => { }); describe('getCertificatesPermissions', () => { - it('returns MANAGE_CERTIFICATES permission with the correct action and scope', () => { + it('returns VIEW and MANAGE permissions with the correct actions and scope', () => { const result = getCertificatesPermissions(courseId); expect(result).toEqual({ + canViewCertificates: { + action: COURSE_PERMISSIONS.VIEW_CERTIFICATES, + scope: courseId, + }, canManageCertificates: { action: COURSE_PERMISSIONS.MANAGE_CERTIFICATES, scope: courseId, diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts index 3520e80f10..2685beab9e 100644 --- a/src/authz/permissionHelpers.ts +++ b/src/authz/permissionHelpers.ts @@ -61,6 +61,10 @@ export const getPagesAndResourcesPermissions = (courseId: string) => ({ }); export const getAdvancedSettingsPermissions = (courseId: string) => ({ + canViewAdvancedSettings: { + action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS, + scope: courseId, + }, canManageAdvancedSettings: { action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, scope: courseId, @@ -99,6 +103,10 @@ export const getGroupConfigurationsPermissions = (courseId: string) => ({ }); export const getCertificatesPermissions = (courseId: string) => ({ + canViewCertificates: { + action: COURSE_PERMISSIONS.VIEW_CERTIFICATES, + scope: courseId, + }, canManageCertificates: { action: COURSE_PERMISSIONS.MANAGE_CERTIFICATES, scope: courseId, diff --git a/src/certificates/Certificates.test.tsx b/src/certificates/Certificates.test.tsx index 5c7a622635..f4ac8dd7d0 100644 --- a/src/certificates/Certificates.test.tsx +++ b/src/certificates/Certificates.test.tsx @@ -18,6 +18,7 @@ const mockPermissions = (overrides = {}) => jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, + canViewCertificates: true, canManageCertificates: true, ...overrides, } as ReturnType); @@ -38,7 +39,7 @@ describe('Certificates', () => { }); it('shows PermissionDeniedAlert when user lacks manage certificates permission', async () => { - mockPermissions({ canManageCertificates: false }); + mockPermissions({ canViewCertificates: false }); axiosMock .onGet(getCertificatesApiUrl(courseId)) .reply(200, certificatesDataMock); @@ -47,6 +48,16 @@ describe('Certificates', () => { expect(screen.queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument(); }); + it('renders content in view-only mode when user can view but not manage', async () => { + mockPermissions({ canViewCertificates: true, canManageCertificates: false }); + axiosMock + .onGet(getCertificatesApiUrl(courseId)) + .reply(200, certificatesDataMock); + renderComponent(); + expect(await screen.findByTestId('certificates-list')).toBeInTheDocument(); + expect(screen.getByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + }); + it('renders WithoutModes when there are certificates but no certificate modes', async () => { const noModesMock = { ...certificatesDataMock, diff --git a/src/certificates/Certificates.tsx b/src/certificates/Certificates.tsx index 1d5a16642a..a8ddef281a 100644 --- a/src/certificates/Certificates.tsx +++ b/src/certificates/Certificates.tsx @@ -1,8 +1,5 @@ import { Helmet } from 'react-helmet'; -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useCourseUserPermissions } from '@src/authz/hooks'; -import { getCertificatesPermissions } from '@src/authz/permissionHelpers'; import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; import Placeholder from '../editors/Placeholder'; import Loading from '../generic/Loading'; @@ -14,6 +11,7 @@ import CertificateCreateForm from './certificate-create-form/CertificateCreateFo import CertificateEditForm from './certificate-edit-form/CertificateEditForm'; import { MODE_STATES } from './data/constants'; import MainLayout from './layout/MainLayout'; +import { useCertificatesContext } from './context'; const MODE_COMPONENTS = { [MODE_STATES.noModes]: CertificateWithoutModes, @@ -24,7 +22,6 @@ const MODE_COMPONENTS = { }; const Certificates = () => { - const { courseId } = useCourseAuthoringContext(); const { certificates, componentMode, @@ -34,12 +31,9 @@ const Certificates = () => { hasCertificateModes, } = useCertificatesData(); - const { - isLoading: isLoadingUserPermissions, - canManageCertificates, - } = useCourseUserPermissions(courseId, getCertificatesPermissions(courseId)); + const { canViewCertificates } = useCertificatesContext(); - if (!isLoadingUserPermissions && !canManageCertificates) { + if (!canViewCertificates) { return ; } diff --git a/src/certificates/certificate-details/CertificateDetails.test.tsx b/src/certificates/certificate-details/CertificateDetails.test.tsx index 281d4007b4..70cc773332 100644 --- a/src/certificates/certificate-details/CertificateDetails.test.tsx +++ b/src/certificates/certificate-details/CertificateDetails.test.tsx @@ -8,6 +8,8 @@ import { } from '@src/testUtils'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { CertificatesProvider } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api'; import { certificatesDataMock } from '../__mocks__'; @@ -25,6 +27,19 @@ const defaultProps: CertificateDetialsProps = { detailsCourseNumber: certificatesDataMock.courseNumber, }; +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderComponent = (props = defaultProps) => render( @@ -37,6 +52,8 @@ const renderComponent = (props = defaultProps) => describe('CertificateDetails', () => { beforeEach(() => { ({ axiosMock } = initializeMocks()); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); axiosMock .onGet(getCertificatesApiUrl(courseId)) .reply(200, certificatesDataMock); @@ -88,4 +105,15 @@ describe('CertificateDetails', () => { await screen.findByText(messages.detailsSectionTitle.defaultMessage); expect(screen.getByText(courseTitleOverride)).toBeInTheDocument(); }); + + it('hides edit and delete buttons in view-only mode', async () => { + mockPermissions({ canManageCertificates: false }); + renderComponent(); + + await screen.findByText(messages.detailsSectionTitle.defaultMessage); + expect(screen.queryByRole('button', { name: commonMessages.editTooltip.defaultMessage })).not + .toBeInTheDocument(); + expect(screen.queryByRole('button', { name: commonMessages.deleteTooltip.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/certificate-details/CertificateDetails.tsx b/src/certificates/certificate-details/CertificateDetails.tsx index 5a68dbdb83..03cd9e95d2 100644 --- a/src/certificates/certificate-details/CertificateDetails.tsx +++ b/src/certificates/certificate-details/CertificateDetails.tsx @@ -14,6 +14,7 @@ import ModalNotification from '../../generic/modal-notification'; import commonMessages from '../messages'; import messages from './messages'; import useCertificateDetails from './hooks/useCertificateDetails'; +import { useCertificatesContext } from '@src/certificates/context'; export interface CertificateDetialsProps { certificateId: number; @@ -31,6 +32,7 @@ const CertificateDetails = ({ courseNumberOverride, }: CertificateDetialsProps) => { const intl = useIntl(); + const { canManageCertificates } = useCertificatesContext(); const { isConfirmOpen, confirmOpen, @@ -48,24 +50,26 @@ const CertificateDetails = ({ title={intl.formatMessage(messages.detailsSectionTitle)} className="certificate-details" data-testid="certificate-details" - actions={ - - {intl.formatMessage(commonMessages.editTooltip)}
} - alt={intl.formatMessage(commonMessages.editTooltip)} - onClick={isCertificateActive ? editModalOpen : handleEditAll} - /> - {intl.formatMessage(commonMessages.deleteTooltip)}
} - alt={intl.formatMessage(commonMessages.deleteTooltip)} - onClick={confirmOpen} - /> - - } + actions={canManageCertificates ? + ( + + {intl.formatMessage(commonMessages.editTooltip)}} + alt={intl.formatMessage(commonMessages.editTooltip)} + onClick={isCertificateActive ? editModalOpen : handleEditAll} + /> + {intl.formatMessage(commonMessages.deleteTooltip)}} + alt={intl.formatMessage(commonMessages.deleteTooltip)} + onClick={confirmOpen} + /> + + ) : + undefined} > diff --git a/src/certificates/certificate-signatories/CertificateSignatories.test.tsx b/src/certificates/certificate-signatories/CertificateSignatories.test.tsx index 6fab2b4a26..965769380b 100644 --- a/src/certificates/certificate-signatories/CertificateSignatories.test.tsx +++ b/src/certificates/certificate-signatories/CertificateSignatories.test.tsx @@ -1,4 +1,7 @@ import { initializeMocks, render, screen, within, userEvent } from '@src/testUtils'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { CertificatesProvider } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { signatoriesMock } from '../__mocks__'; import commonMessages from '../messages'; @@ -20,7 +23,11 @@ const mockArrayHelpers = { const renderComponent = (props) => render( - , + + + + + , ); const defaultProps = { @@ -38,6 +45,7 @@ const defaultProps = { describe('CertificateSignatories', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); mockUseEditSignatory.mockReturnValue({ toggleEditSignatory: jest.fn(), @@ -72,6 +80,7 @@ describe('CertificateSignatories', () => { describe('CertificateSignatories - real useEditSignatory', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); // Use the real implementation so handleDeleteSignatory actually calls arrayHelpers.remove const realUseEditSignatory = jest.requireActual('./hooks/useEditSignatory').default; diff --git a/src/certificates/certificate-signatories/signatory/Signatory.test.tsx b/src/certificates/certificate-signatories/signatory/Signatory.test.tsx index 9df5c3e4d4..1264f03aa4 100644 --- a/src/certificates/certificate-signatories/signatory/Signatory.test.tsx +++ b/src/certificates/certificate-signatories/signatory/Signatory.test.tsx @@ -1,4 +1,8 @@ import { initializeMocks, render, screen, userEvent } from '@src/testUtils'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { CertificatesProvider } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { signatoriesMock } from '../../__mocks__'; import commonMessages from '../../messages'; @@ -7,9 +11,26 @@ import Signatory from './Signatory'; const mockHandleEdit = jest.fn(); +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderSignatory = (props) => render( - , + + + + + , ); const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: 0 }; @@ -17,6 +38,8 @@ const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: describe('Signatory Component', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); }); it('renders signatory data in view mode', () => { @@ -45,4 +68,12 @@ describe('Signatory Component', () => { expect(mockHandleEdit).toHaveBeenCalled(); }); + + it('hides edit button in view-only mode', () => { + mockPermissions({ canManageCertificates: false }); + renderSignatory(defaultProps); + + expect(screen.queryByRole('button', { name: commonMessages.editTooltip.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/certificate-signatories/signatory/Signatory.tsx b/src/certificates/certificate-signatories/signatory/Signatory.tsx index 7a26dd7bed..c98e363b1b 100644 --- a/src/certificates/certificate-signatories/signatory/Signatory.tsx +++ b/src/certificates/certificate-signatories/signatory/Signatory.tsx @@ -12,6 +12,7 @@ import { getConfig } from '@edx/frontend-platform'; import commonMessages from '../../messages'; import messages from '../messages'; +import { useCertificatesContext } from '@src/certificates/context'; interface SignatoryProps { name: string; @@ -31,6 +32,7 @@ const Signatory = ({ handleEdit, }: SignatoryProps) => { const intl = useIntl(); + const { canManageCertificates } = useCertificatesContext(); return (
@@ -49,14 +51,16 @@ const Signatory = ({ - {intl.formatMessage(commonMessages.editTooltip)}
} - onClick={handleEdit} - /> + {canManageCertificates && ( + {intl.formatMessage(commonMessages.editTooltip)}} + onClick={handleEdit} + /> + )}
{signatureImagePath && ( void; savingIsSuccess: boolean; savingErrorMessage?: string; + canViewCertificates: boolean; + canManageCertificates: boolean; activationStatusMutation: UseMutationResult; updateCertificateMutation: UseMutationResult; deleteCertificateMutation: UseMutationResult; @@ -30,6 +34,12 @@ const CertificatesContext = createContext(u export const CertificatesProvider = ({ children }) => { const { courseId } = useCourseAuthoringContext(); const [componentMode, setComponentMode] = useState(MODE_STATES.noModes); + + const { + canViewCertificates, + canManageCertificates, + } = useCourseUserPermissions(courseId, getCertificatesPermissions(courseId)); + const activationStatusMutation = useUpdateCertificateActiveStatus(courseId); const updateCertificateMutation = useUpdateCertificate(courseId); const deleteCertificateMutation = useDeleteCertificate(courseId); @@ -49,6 +59,8 @@ export const CertificatesProvider = ({ children }) => { const contextValue = { componentMode, setComponentMode, + canViewCertificates, + canManageCertificates, activationStatusMutation, updateCertificateMutation, deleteCertificateMutation, @@ -61,6 +73,8 @@ export const CertificatesProvider = ({ children }) => { }, [ componentMode, setComponentMode, + canViewCertificates, + canManageCertificates, activationStatusMutation, updateCertificateMutation, ]); diff --git a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx index e8a332b0f8..dd5f2b006a 100644 --- a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx +++ b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx @@ -5,6 +5,8 @@ import { userEvent, } from '@src/testUtils'; import { CertificatesProvider, useCertificatesContext } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { MODE_STATES } from '../data/constants'; import messages from '../messages'; import EmptyCertificatesWithModes from './EmptyCertificatesWithModes'; @@ -15,6 +17,19 @@ const ComponentModeDisplay = () => { return
{componentMode}
; }; +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderComponent = () => render( @@ -28,6 +43,8 @@ const renderComponent = () => describe('EmptyCertificatesWithModes', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); }); it('renders correctly', () => { @@ -47,4 +64,13 @@ describe('EmptyCertificatesWithModes', () => { expect(screen.getByTestId('component-mode')).toHaveTextContent(MODE_STATES.create); }); + + it('hides add button in view-only mode', () => { + mockPermissions({ canManageCertificates: false }); + renderComponent(); + + expect(screen.getByText(messages.noCertificatesText.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.setupCertificateBtn.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx index e748ee25df..f238d542b7 100644 --- a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx +++ b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx @@ -8,7 +8,7 @@ import { useCertificatesContext } from '../context'; const EmptyCertificatesWithModes = () => { const intl = useIntl(); - const { setComponentMode } = useCertificatesContext(); + const { setComponentMode, canManageCertificates } = useCertificatesContext(); const handleCreateMode = () => { setComponentMode(MODE_STATES.create); }; @@ -19,12 +19,14 @@ const EmptyCertificatesWithModes = () => { {intl.formatMessage(messages.noCertificatesText)} - + {canManageCertificates && ( + + )} diff --git a/src/certificates/layout/MainLayout.tsx b/src/certificates/layout/MainLayout.tsx index a6c4563075..227fae49e9 100644 --- a/src/certificates/layout/MainLayout.tsx +++ b/src/certificates/layout/MainLayout.tsx @@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { SavingErrorAlert } from '@src/generic/saving-error-alert'; import SubHeader from '@src/generic/sub-header/SubHeader'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import messages from '../messages'; import CertificatesSidebar from './certificates-sidebar/CertificatesSidebar'; import HeaderButtons from './header-buttons/HeaderButtons'; @@ -20,6 +21,7 @@ const MainLayout = ({ showHeaderButtons = false, children }: MainLayoutProps) => const { savingIsSuccess, savingErrorMessage, + canManageCertificates, } = useCertificatesContext(); useEffect(() => { @@ -39,6 +41,7 @@ const MainLayout = ({ showHeaderButtons = false, children }: MainLayoutProps) => headerActions={showHeaderButtons ? : null} + banner={!canManageCertificates ? : null} />
({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderComponent = () => render( @@ -29,6 +44,8 @@ describe('HeaderButtons Component', () => { beforeEach(() => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); axiosMock .onGet(getCertificatesApiUrl(courseId)) .reply(200, certificatesDataMock); @@ -106,4 +123,16 @@ describe('HeaderButtons Component', () => { expect(screen.queryByRole('button', { name: messages.headingActionsDeactivate.defaultMessage })).not .toBeInTheDocument(); }); + + it('hides activate button in view-only mode', async () => { + mockPermissions({ canManageCertificates: false }); + renderComponent(); + + await waitFor(() => screen.getByRole('link', { name: messages.headingActionsPreview.defaultMessage })); + + expect(screen.queryByRole('button', { name: messages.headingActionsActivate.defaultMessage })).not + .toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.headingActionsDeactivate.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/layout/header-buttons/HeaderButtons.tsx b/src/certificates/layout/header-buttons/HeaderButtons.tsx index 6dd15b15ad..2930486444 100644 --- a/src/certificates/layout/header-buttons/HeaderButtons.tsx +++ b/src/certificates/layout/header-buttons/HeaderButtons.tsx @@ -8,16 +8,18 @@ import { import messages from '../../messages'; import useHeaderButtons from './hooks/useHeaderButtons'; +import { useCertificatesContext } from '@src/certificates/context'; const HeaderButtons = () => { const intl = useIntl(); + const { canManageCertificates } = useCertificatesContext(); const { previewUrl, courseModes, dropdowmItem, - isCertificateActive, setDropdowmItem, handleActivationStatus, + isCertificateActive, } = useHeaderButtons(); return ( @@ -34,14 +36,16 @@ const HeaderButtons = () => { > {intl.formatMessage(messages.headingActionsPreview)} - + {canManageCertificates && ( + + )} ); }; diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index 9bcb1b3c3d..adb46b6195 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -141,7 +141,7 @@ const SettingsTab = () => { isNewPage /> )} - {perms.canManageAdvancedSettings && ( + {perms.canViewAdvancedSettings && ( ); }; @@ -91,7 +91,7 @@ describe('HelpSidebar', () => { ['canViewScheduleAndDetails', messages.sidebarLinkToScheduleAndDetails], ['canViewGradingSettings', messages.sidebarLinkToGrading], ['canManageGroupConfigurations', messages.sidebarLinkToGroupConfigurations], - ['canManageAdvancedSettings', messages.sidebarLinkToAdvancedSettings], + ['canViewAdvancedSettings', messages.sidebarLinkToAdvancedSettings], ])('renders the %s link only when the permission is granted', async (permission, message) => { mockCoursePermissions({ [permission]: true }); const { queryByText, unmount } = renderHelpSidebar(props); @@ -130,7 +130,7 @@ describe('HelpSidebar', () => { canViewGradingSettings: false, canViewCourseTeam: false, canManageGroupConfigurations: false, - canManageAdvancedSettings: false, + canViewAdvancedSettings: false, }, { isLoading: true }); const { queryByText } = renderHelpSidebar(props); diff --git a/src/generic/help-sidebar/HelpSidebar.tsx b/src/generic/help-sidebar/HelpSidebar.tsx index 99c019c1eb..480a955546 100644 --- a/src/generic/help-sidebar/HelpSidebar.tsx +++ b/src/generic/help-sidebar/HelpSidebar.tsx @@ -109,7 +109,7 @@ const HelpSidebar = ({ isNewPage /> )} - {showOtherLink(advancedSettings) && perms.canManageAdvancedSettings && ( + {showOtherLink(advancedSettings) && perms.canViewAdvancedSettings && ( { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: false, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewGradingSettings: true, canViewScheduleAndDetails: true, canViewCourseTeam: true, canManageGroupConfigurations: true, - canManageCertificates: true, + canViewCertificates: true, } as ReturnType); }); @@ -314,7 +314,7 @@ describe('header utils', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewGradingSettings: true, canViewScheduleAndDetails: true, } as ReturnType); @@ -329,7 +329,7 @@ describe('header utils', () => { mockWaffleFlags({ enableAuthzCourseAuthoring: false }); jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewCourseTeam: true, } as any); const actualItems = @@ -352,7 +352,7 @@ describe('header utils', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewCourseTeam: true, } as any); const courseIdWithSpecialChars = 'course-v1:org+course+run'; @@ -369,7 +369,7 @@ describe('header utils', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: false, + canViewAdvancedSettings: false, canViewGradingSettings: true, canViewScheduleAndDetails: true, } as ReturnType); @@ -399,7 +399,7 @@ describe('header utils', () => { isLoading: false, isAuthzEnabled: true, canViewScheduleAndDetails: true, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewGradingSettings: true, } as ReturnType); const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }); @@ -415,7 +415,7 @@ describe('header utils', () => { isLoading: false, isAuthzEnabled: true, canViewScheduleAndDetails: false, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewGradingSettings: true, } as ReturnType); const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }); @@ -431,7 +431,7 @@ describe('header utils', () => { isLoading: false, isAuthzEnabled: true, canViewGradingSettings: true, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewScheduleAndDetails: true, } as ReturnType); const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }); @@ -447,7 +447,7 @@ describe('header utils', () => { isLoading: false, isAuthzEnabled: true, canViewGradingSettings: false, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewScheduleAndDetails: true, } as ReturnType); const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }); @@ -462,7 +462,7 @@ describe('header utils', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: true, + canViewAdvancedSettings: true, canViewGradingSettings: true, canViewScheduleAndDetails: true, canViewCourseTeam: true, @@ -504,7 +504,7 @@ describe('header utils', () => { expect(actualItemsTitle).not.toContain('Group Configurations'); }); - it('when authz flag is enabled and user lacks canManageCertificates should not include certificates option', () => { + it('when authz flag is enabled and user lacks canViewCertificates should not include certificates option', () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); setConfig({ ...getConfig(), @@ -513,7 +513,7 @@ describe('header utils', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageCertificates: false, + canViewCertificates: false, } as any); const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result .current.map((item) => item.title); diff --git a/src/header/hooks.tsx b/src/header/hooks.tsx index 71022dd223..65bee3e53c 100644 --- a/src/header/hooks.tsx +++ b/src/header/hooks.tsx @@ -96,7 +96,7 @@ export const useSettingMenuItems = (courseId: string) => { // legacy value: it would briefly show the link (and the Settings dropdown) to users // that authz then denies. const canAccessAdvancedSettings = perms.isAuthzEnabled - ? perms.canManageAdvancedSettings + ? perms.canViewAdvancedSettings : !perms.isLoading && legacyCanAccessAdvancedSettings; const items = [ @@ -138,7 +138,7 @@ export const useSettingMenuItems = (courseId: string) => { }] : []), ]; - if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true' && perms.canManageCertificates) { + if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true' && perms.canViewCertificates) { items.push({ href: `/course/${courseId}/certificates`, title: intl.formatMessage(messages['header.links.certificates']),