Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,11 @@ 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',

VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details',
EDIT_SCHEDULE: 'courses.edit_schedule',
EDIT_DETAILS: 'courses.edit_details',
};
78 changes: 78 additions & 0 deletions src/authz/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { renderHook } from '@testing-library/react';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { useUserPermissionsWithAuthzCourse } 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('useUserPermissionsWithAuthzCourse', () => {
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(() => useUserPermissionsWithAuthzCourse(courseId, permissions));

expect(result.current.isLoading).toBe(false);
expect(result.current.isAuthzEnabled).toBe(false);
expect(result.current.permissions.canView).toBe(true);
expect(result.current.permissions.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(() => useUserPermissionsWithAuthzCourse(courseId, permissions));

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

it('returns isLoading=true and empty permissions 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(() => useUserPermissionsWithAuthzCourse(courseId, permissions));

expect(result.current.isLoading).toBe(true);
expect(result.current.isAuthzEnabled).toBe(true);
expect(result.current.permissions).toEqual({});
});

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(() => useUserPermissionsWithAuthzCourse(courseId, permissions));

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

interface UseUserPermissionsWithAuthzCourseReturn {
isLoading: boolean;
permissions: PermissionValidationAnswer;
isAuthzEnabled: boolean;
}

/**
* Custom hook to handle user permissions with course authorization waffle flag
*
* This hook abstracts the common pattern of:
* 1. Checking if authz is enabled via waffle flag
* 2. Fetching user permissions when authz is enabled
* 3. Defaulting all permissions to true when authz is disabled
* 4. Providing 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, permissions, isAuthzEnabled } = useUserPermissionsWithAuthzCourse(
* courseId,
* {
* canViewGradingSettings: {
* action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
* scope: courseId,
* },
* canEditGradingSettings: {
* action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
* scope: courseId,
* },
* }
* );
*
* const { canViewGradingSettings, canEditGradingSettings } = permissions;
* ```
*/
export const useUserPermissionsWithAuthzCourse = (
courseId: string,
permissions: PermissionValidationQuery,
): UseUserPermissionsWithAuthzCourseReturn => {
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;

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

const permissionResults: PermissionValidationAnswer = {};

if (isAuthzEnabled && !isLoadingUserPermissions) {
Object.keys(permissions).forEach((permissionKey: string) => {
permissionResults[permissionKey] = userPermissions?.[permissionKey] ?? false;
});
} else if (!isLoadingUserPermissions) {
Object.keys(permissions).forEach((permissionKey: string) => {
permissionResults[permissionKey] = true;
});
}

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

export const getScheduleAndDetailsPermissions = (courseId: string) => ({
canViewScheduleAndDetails: {
action: COURSE_PERMISSIONS.VIEW_SCHEDULE_AND_DETAILS,
scope: courseId,
},
canEditSchedule: {
action: COURSE_PERMISSIONS.EDIT_SCHEDULE,
scope: courseId,
},
canEditDetails: {
action: COURSE_PERMISSIONS.EDIT_DETAILS,
scope: courseId,
},
});

export const getGradingPermissions = (courseId: string) => ({
canViewGradingSettings: {
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
scope: courseId,
},
canEditGradingSettings: {
action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
scope: courseId,
},
});
5 changes: 4 additions & 1 deletion src/generic/WysiwygEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const SUPPORTED_TEXT_EDITORS = {
};

export const WysiwygEditor = ({
initialValue, editorType, onChange, minHeight,
initialValue, editorType, onChange, minHeight, disabled,
}) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const { courseId } = useCourseAuthoringContext();
Expand Down Expand Up @@ -60,6 +60,7 @@ export const WysiwygEditor = ({
images={{}}
enableImageUpload={false}
onEditorChange={() => ({})}
disabled={disabled}
/>
);
};
Expand All @@ -68,11 +69,13 @@ WysiwygEditor.defaultProps = {
initialValue: '',
editorType: SUPPORTED_TEXT_EDITORS.text,
minHeight: 200,
disabled: false,
};

WysiwygEditor.propTypes = {
initialValue: PropTypes.string,
editorType: PropTypes.oneOf(Object.values(SUPPORTED_TEXT_EDITORS)),
onChange: PropTypes.func.isRequired,
minHeight: PropTypes.number,
disabled: PropTypes.bool,
};
21 changes: 14 additions & 7 deletions src/generic/course-upload-image/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const CourseUploadImage = ({
identifierFieldText,
showImageBodyText,
customInputPlaceholder,
disabled,
onChange,
}) => {
const { courseId } = useParams();
Expand Down Expand Up @@ -109,13 +110,16 @@ const CourseUploadImage = ({
<Form.Label>{label}</Form.Label>
<Card>
<Card.Body className="image-body">
<Dropzone
onProcessUpload={handleProcessUpload}
inputComponent={inputComponent}
accept={{
'image/*': ['.png', '.jpeg'],
}}
/>
<div style={disabled ? { pointerEvents: 'none' } : undefined}>
<Dropzone
onProcessUpload={handleProcessUpload}
inputComponent={inputComponent}
accept={{
'image/*': ['.png', '.jpeg'],
}}
disabled={disabled}
/>
</div>
{showImageBodyText && cardImageTextBody}
</Card.Body>
<Card.Divider />
Expand All @@ -129,6 +133,7 @@ const CourseUploadImage = ({
identifierFieldText,
})
}
disabled={disabled}
/>
</Card.Footer>
</Card>
Expand All @@ -150,6 +155,7 @@ CourseUploadImage.defaultProps = {
showImageBodyText: false,
identifierFieldText: '',
customInputPlaceholder: '',
disabled: false,
};

CourseUploadImage.propTypes = {
Expand All @@ -161,6 +167,7 @@ CourseUploadImage.propTypes = {
showImageBodyText: PropTypes.bool,
identifierFieldText: PropTypes.string,
customInputPlaceholder: PropTypes.string,
disabled: PropTypes.bool,
onChange: PropTypes.func.isRequired,
};

Expand Down
23 changes: 22 additions & 1 deletion src/grading-settings/GradingSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,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 { useUserPermissionsWithAuthzCourse } 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 @@ -31,6 +34,12 @@ import messages from './messages';
const GradingSettings = () => {
const intl = useIntl();
const { courseId, courseDetails } = useCourseAuthoringContext();

const {
isLoading: isLoadingUserPermissions,
permissions: userPermissions,
} = useUserPermissionsWithAuthzCourse(courseId, getGradingPermissions(courseId));

const {
data: gradingSettings,
isLoading: isGradingSettingsLoading,
Expand All @@ -52,7 +61,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 @@ -90,6 +99,10 @@ const GradingSettings = () => {
}
}, [savePending]);

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

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

const isEditable = !isLoadingUserPermissions && userPermissions.canEditGradingSettings;

const handleQueryProcessing = () => {
setShowSuccessAlert(false);
updateGradingSettings(gradingData);
Expand Down Expand Up @@ -174,6 +189,7 @@ const GradingSettings = () => {
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
setEligibleGrade={setEligibleGrade}
defaultGradeDesignations={gradingSettings?.defaultGradeDesignations}
isEditable={isEditable}
/>
</section>
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
Expand All @@ -188,6 +204,7 @@ const GradingSettings = () => {
minimumGradeCredit={minimumGradeCredit}
setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert}
isEditable={isEditable}
/>
</section>
)}
Expand All @@ -201,6 +218,7 @@ const GradingSettings = () => {
gracePeriod={gracePeriod}
setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert}
isEditable={isEditable}
/>
</section>
<section>
Expand All @@ -219,11 +237,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 @@ -267,6 +287,7 @@ const GradingSettings = () => {
key="statefulBtn"
onClick={handleSendGradingSettingsData}
state={isQueryPending ? STATEFUL_BUTTON_STATES.pending : STATEFUL_BUTTON_STATES.default}
disabled={!isEditable}
{...updateValuesButtonState}
/>,
].filter(Boolean)}
Expand Down
Loading