Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ export const CONTENT_LIBRARY_PERMISSIONS = {

export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',

VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
};
79 changes: 79 additions & 0 deletions src/authz/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { renderHook } from '@testing-library/react';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { useCourseUserPermissions } from './hooks';
import { COURSE_PERMISSIONS } from './constants';

jest.mock('@src/authz/data/apiHooks', () => ({
useUserPermissions: jest.fn(),
}));

const courseId = 'course-v1:org+course+run';
const permissions = {
canView: { action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, scope: courseId },
canEdit: { action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS, scope: courseId },
};

describe('useCourseUserPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: undefined,
} as unknown as ReturnType<typeof useUserPermissions>);
});

it('defaults all permissions to true when authz is disabled', () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: false });

const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));

expect(result.current.isLoading).toBe(false);
expect(result.current.isAuthzEnabled).toBe(false);
expect(result.current.canView).toBe(true);
expect(result.current.canEdit).toBe(true);
});

it('returns actual permission values when authz is enabled and permissions are loaded', () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canView: true, canEdit: false },
} as unknown as ReturnType<typeof useUserPermissions>);

const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));

expect(result.current.isLoading).toBe(false);
expect(result.current.isAuthzEnabled).toBe(true);
expect(result.current.canView).toBe(true);
expect(result.current.canEdit).toBe(false);
});

it('returns isLoading=true and no permission keys while authz permissions are loading', () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: true,
data: undefined,
} as unknown as ReturnType<typeof useUserPermissions>);

const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));

expect(result.current.isLoading).toBe(true);
expect(result.current.isAuthzEnabled).toBe(true);
expect(result.current.canView).toBeUndefined();
expect(result.current.canEdit).toBeUndefined();
});

it('falls back to false for permissions absent from server response when authz is enabled', () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: {},
} as unknown as ReturnType<typeof useUserPermissions>);

const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));

expect(result.current.canView).toBe(false);
expect(result.current.canEdit).toBe(false);
});
});
71 changes: 71 additions & 0 deletions src/authz/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';

type UseCourseUserPermissionsReturn<Query extends PermissionValidationQuery> = {
isLoading: boolean;
isAuthzEnabled: boolean;
} & PermissionValidationAnswer<Query>;

/**
* Custom hook to retrieve and evaluate user permissions for the current course using the openedx-authz service.
*
* The hook:
* 1. Validate if authz is enabled via waffle flag
* 2. Fetch user permissions when authz is enabled
* 3. Fallback all permissions to 'true' when authz is disabled
* 4. Provide fallback values for undefined permissions
*
* @param courseId - The course ID to check permissions for
* @param permissions - Object mapping permission names to their action/scope definitions
* @returns Object containing loading state, permissions results, and authz status
*
* @example
* ```tsx
* const { isLoading, canViewGradingSettings, canEditGradingSettings, isAuthzEnabled } = useCourseUserPermissions(
* courseId,
* {
* canViewGradingSettings: {
* action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
* scope: courseId,
* },
* canEditGradingSettings: {
* action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
* scope: courseId,
* },
* }
* );
* ```
*/
export const useCourseUserPermissions = <Query extends PermissionValidationQuery>(
courseId: string,
permissions: Query,
): UseCourseUserPermissionsReturn<Query> => {
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;

const {
isLoading: isLoadingUserPermissions,
data: userPermissions,
} = useUserPermissions(permissions, isAuthzEnabled);

const resolvePermission = (key: string): boolean => {
if (!isAuthzEnabled) {
return true;
}
return userPermissions?.[key] ?? false;
};

const permissionResults: Record<string, boolean> = isLoadingUserPermissions
? {}
: Object.keys(permissions).reduce<Record<string, boolean>>((acc, key) => {
acc[key] = resolvePermission(key);
return acc;
}, {});

return {
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
isAuthzEnabled,
...permissionResults as PermissionValidationAnswer<Query>,
};
};
12 changes: 12 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { COURSE_PERMISSIONS } from './constants';

export const getGradingPermissions = (courseId: string) => ({
canViewGradingSettings: {
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
scope: courseId,
},
canEditGradingSettings: {
action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
scope: courseId,
},
});
6 changes: 3 additions & 3 deletions src/authz/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ export interface PermissionValidationQuery {
[permissionKey: string]: PermissionValidationRequestItem;
}

export interface PermissionValidationAnswer {
[permissionKey: string]: boolean;
}
export type PermissionValidationAnswer<Query extends PermissionValidationQuery = PermissionValidationQuery> = {
[K in keyof Query]: boolean;
};
24 changes: 23 additions & 1 deletion src/grading-settings/GradingSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { Helmet } from 'react-helmet';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { STATEFUL_BUTTON_STATES } from '@src/constants';
import { useCourseSettings } from '@src/data/apiHooks';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { getGradingPermissions } from '@src/authz/permissionHelpers';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
import SectionSubHeader from '@src/generic/section-sub-header';
import SubHeader from '@src/generic/sub-header/SubHeader';
import AlertMessage from '@src/generic/alert-message';
Expand All @@ -34,6 +37,13 @@ import messages from './messages';
const GradingSettings = () => {
const intl = useIntl();
const { courseId, courseDetails } = useCourseAuthoringContext();

const {
isLoading: isLoadingUserPermissions,
canViewGradingSettings,
canEditGradingSettings,
} = useCourseUserPermissions(courseId, getGradingPermissions(courseId));

const {
data: gradingSettings,
isLoading: isGradingSettingsLoading,
Expand All @@ -55,7 +65,7 @@ const GradingSettings = () => {
const courseGradingDetails = gradingSettings?.courseDetails;
const isLoadingDenied = isGradingSettingsError || isCourseSettingsError;
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const isLoading = isCourseSettingsLoading || isGradingSettingsLoading;
const isLoading = isCourseSettingsLoading || isGradingSettingsLoading || isLoadingUserPermissions;
const [isQueryPending, setIsQueryPending] = useState(false);
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
const [eligibleGrade, setEligibleGrade] = useState(null);
Expand Down Expand Up @@ -93,6 +103,10 @@ const GradingSettings = () => {
}
}, [savePending]);

if (!isLoadingUserPermissions && !canViewGradingSettings) {
return <PermissionDeniedAlert />;
}

if (isLoadingDenied) {
return (
<Container size="xl" className="course-unit px-4 mt-4">
Expand All @@ -105,6 +119,8 @@ const GradingSettings = () => {
return null;
}

const isEditable = !isLoadingUserPermissions && canEditGradingSettings;

const handleQueryProcessing = () => {
setShowSuccessAlert(false);
updateGradingSettings(gradingData);
Expand Down Expand Up @@ -177,6 +193,7 @@ const GradingSettings = () => {
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
setEligibleGrade={setEligibleGrade}
defaultGradeDesignations={gradingSettings?.defaultGradeDesignations}
isEditable={isEditable}
/>
</section>
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
Expand All @@ -191,6 +208,7 @@ const GradingSettings = () => {
minimumGradeCredit={minimumGradeCredit}
setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert}
isEditable={isEditable}
/>
</section>
)}
Expand All @@ -204,6 +222,7 @@ const GradingSettings = () => {
gracePeriod={gracePeriod}
setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert}
isEditable={isEditable}
/>
</section>
<section>
Expand All @@ -222,11 +241,13 @@ const GradingSettings = () => {
setGradingData={setGradingData}
courseAssignmentLists={courseAssignmentLists}
setShowSuccessAlert={setShowSuccessAlert}
isEditable={isEditable}
/>
<Button
variant="primary"
iconBefore={IconAdd}
onClick={handleAddAssignment}
disabled={!isEditable}
>
{intl.formatMessage(messages.addNewAssignmentTypeBtn)}
</Button>
Expand Down Expand Up @@ -270,6 +291,7 @@ const GradingSettings = () => {
key="statefulBtn"
onClick={handleSendGradingSettingsData}
state={isQueryPending ? STATEFUL_BUTTON_STATES.pending : STATEFUL_BUTTON_STATES.default}
disabled={!isEditable}
{...updateValuesButtonState}
/>,
].filter(Boolean)}
Expand Down
77 changes: 77 additions & 0 deletions src/grading-settings/GradingSettings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@ import {
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getCourseSettingsApiUrl } from '@src/data/api';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { useCourseUserPermissions } from '@src/authz/hooks';

import gradingSettings from './__mocks__/gradingSettings';
import { getGradingSettingsApiUrl } from './data/api';
import * as apiHooks from './data/apiHooks';
import GradingSettings from './GradingSettings';
import messages from './messages';

jest.mock('@src/authz/hooks', () => ({
useCourseUserPermissions: jest.fn().mockReturnValue({
isLoading: false,
canViewGradingSettings: true,
canEditGradingSettings: true,
}),
}));

const courseId = '123';
let axiosMock;

Expand Down Expand Up @@ -129,3 +139,70 @@ describe('<GradingSettings />', () => {
expect(screen.getByTestId('connectionErrorAlert')).toBeInTheDocument();
});
});

describe('<GradingSettings /> permissions', () => {
beforeEach(() => {
jest.restoreAllMocks();
const mocks = initializeMocks();
Object.defineProperty(window, 'scrollTo', { value: jest.fn(), writable: true });
const { axiosMock: mock } = mocks;
mock.onGet(getGradingSettingsApiUrl(courseId)).reply(200, gradingSettings);
mock.onPost(getGradingSettingsApiUrl(courseId)).reply(200, {});
mock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, {});
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
canViewGradingSettings: true,
canEditGradingSettings: true,
});
});

it('should render normally when authz flag is disabled (no regression)', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
render(<RootWrapper />);
expect(await screen.findAllByText(messages.headingTitle.defaultMessage)).not.toHaveLength(0);
});

it('should render normally when user has view and edit permissions', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
render(<RootWrapper />);
expect(await screen.findAllByText(messages.headingTitle.defaultMessage)).not.toHaveLength(0);
});

it('should show permission denied alert when user lacks view permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
canViewGradingSettings: false,
canEditGradingSettings: false,
});
render(<RootWrapper />);
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});

it('should disable inputs when user has view but not edit permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
canViewGradingSettings: true,
canEditGradingSettings: false,
});
render(<RootWrapper />);
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
segmentInputs.forEach((input) => expect(input).toBeDisabled());
});

it('should disable save button when user lacks edit permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
canViewGradingSettings: true,
canEditGradingSettings: false,
});
render(<RootWrapper />);
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
// Trigger a change to show the save alert
fireEvent.change(segmentInputs[1], { target: { value: 'Test' } });
const saveBtn = screen.getByTestId('grading-settings-save-alert').querySelector('button[type="button"]:last-child');
expect(saveBtn).toBeDisabled();
});
});
Loading