diff --git a/package.json b/package.json index 24472c2bf..b00b98966 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@testing-library/user-event": "^13.2.1", "axios-mock-adapter": "1.22.0", "glob": "7.2.3", - "husky": "7.0.4", + "husky": "^7.0.4", "jest-canvas-mock": "^2.5.2", "jest-expect-message": "^1.1.3", "react-test-renderer": "17.0.2", diff --git a/src/course-team/CourseTeam.jsx b/src/course-team/CourseTeam.jsx index e22b1721b..937a76620 100644 --- a/src/course-team/CourseTeam.jsx +++ b/src/course-team/CourseTeam.jsx @@ -18,12 +18,11 @@ import AddUserForm from './add-user-form/AddUserForm'; import AddTeamMember from './add-team-member/AddTeamMember'; import CourseTeamMember from './course-team-member/CourseTeamMember'; import InfoModal from './info-modal/InfoModal'; -import { useCourseTeam } from './hooks'; +import { useCourseTeam, useUserPermissions } from './hooks'; import getPageHeadTitle from '../generic/utils'; const CourseTeam = ({ courseId }) => { const intl = useIntl(); - const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); @@ -55,6 +54,12 @@ const CourseTeam = ({ courseId }) => { handleInternetConnectionFailed, } = useCourseTeam({ intl, courseId }); + const { + hasPermissions, + } = useUserPermissions(); + + const hasManageAllUsersPerm = hasPermissions('manage_all_users'); + if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; @@ -77,7 +82,7 @@ const CourseTeam = ({ courseId }) => { { role={role} email={email} currentUserEmail={currentUserEmail || ''} - isAllowActions={isAllowActions} + isAllowActions={isAllowActions || hasManageAllUsersPerm} isHideActions={role === USER_ROLES.admin && isSingleAdmin} onChangeRole={handleChangeRoleUserSubmit} onDelete={handleOpenDeleteModal} /> )) : null} - {isShowAddTeamMember && ( + {(isShowAddTeamMember || hasManageAllUsersPerm) && ( ({ ...jest.requireActual('react-router-dom'), @@ -44,7 +47,7 @@ describe('', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { - userId: 3, + userId, username: 'abc123', administrator: true, roles: [], @@ -53,6 +56,12 @@ describe('', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getUserPermissionsEnabledFlagUrl) + .reply(200, { enabled: true }); + axiosMock + .onGet(getUserPermissionsUrl(courseId, userId)) + .reply(200, userPermissionsData); }); it('render CourseTeam component with 3 team members correctly', async () => { @@ -165,13 +174,13 @@ describe('', () => { await waitFor(() => { expect(queryByTestId('add-user-form')).not.toBeInTheDocument(); - const addButton = getByRole('button', { name: 'Add a new team member' }); + const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage }); fireEvent.click(addButton); expect(queryByTestId('add-user-form')).toBeInTheDocument(); }); }); - it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => { + it('not displays "Add New Member" and AddTeamMember component when isAllowActions or hasManageAllUsersPerm is false', async () => { cleanup(); axiosMock .onGet(getCourseTeamApiUrl(courseId)) @@ -179,6 +188,9 @@ describe('', () => { ...courseTeamWithOneUser, allowActions: false, }); + axiosMock + .onGet(getUserPermissionsEnabledFlagUrl) + .reply(200, { enabled: false }); const { queryByRole, queryByTestId } = render(); diff --git a/src/course-team/course-team-member/CourseTeamMember.jsx b/src/course-team/course-team-member/CourseTeamMember.jsx index d0e53709a..816b02dd6 100644 --- a/src/course-team/course-team-member/CourseTeamMember.jsx +++ b/src/course-team/course-team-member/CourseTeamMember.jsx @@ -31,7 +31,7 @@ const CourseTeamMember = ({
- {isAdminRole + {(isAdminRole) ? intl.formatMessage(messages.roleAdmin) : intl.formatMessage(messages.roleStaff)} {currentUserEmail === email && ( @@ -46,11 +46,13 @@ const CourseTeamMember = ({ !isHideActions ? (
{ useEffect(() => { dispatch(fetchCourseTeamQuery(courseId)); + dispatch(fetchUserPermissionsEnabledFlag()); + dispatch(fetchUserPermissionsQuery(courseId)); }, [courseId]); useEffect(() => { @@ -135,5 +139,20 @@ const useCourseTeam = ({ courseId }) => { }; }; +const useUserPermissions = () => { + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const userPermissions = useSelector(getUserPermissions); + const hasPermissions = (checkPermissions) => { + if (userPermissionsEnabled) { + return userPermissions?.includes(checkPermissions); + } + return false; + }; + + return { + hasPermissions, + }; +}; + // eslint-disable-next-line import/prefer-default-export -export { useCourseTeam }; +export { useCourseTeam, useUserPermissions }; diff --git a/src/generic/data/api.js b/src/generic/data/api.js index c00e302ef..71a10e34e 100644 --- a/src/generic/data/api.js +++ b/src/generic/data/api.js @@ -1,6 +1,6 @@ // @ts-check import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { convertObjectToSnakeCase } from '../../utils'; @@ -8,7 +8,8 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href; export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href; export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href; - +export const getUserPermissionsUrl = (courseId, userId) => `${getApiBaseUrl()}/api/course_roles/v1/user_permissions/?course_id=${encodeURIComponent(courseId)}&user_id=${userId}`; +export const getUserPermissionsEnabledFlagUrl = new URL('/api/course_roles/v1/user_permissions/enabled/', getApiBaseUrl()).href; /** * Get's organizations data. Returns list of organization names. * @returns {Promise} @@ -43,3 +44,23 @@ export async function createOrRerunCourse(courseData) { ); return camelCaseObject(data); } + +/** + * Get user course roles permissions. + * @param {string} courseId + * @param {string} userId + * @returns {Promise} + */ +export async function getUserPermissions(courseId) { + const { userId } = getAuthenticatedUser(); + + const { data } = await getAuthenticatedHttpClient() + .get(getUserPermissionsUrl(courseId, userId)); + return camelCaseObject(data); +} + +export async function getUserPermissionsEnabledFlag() { + const { data } = await getAuthenticatedHttpClient() + .get(getUserPermissionsEnabledFlagUrl); + return data || false; +} diff --git a/src/generic/data/api.test.js b/src/generic/data/api.test.js index 7abd51794..5a64394bb 100644 --- a/src/generic/data/api.test.js +++ b/src/generic/data/api.test.js @@ -9,9 +9,14 @@ import { getCreateOrRerunCourseUrl, getCourseRerunUrl, getCourseRerun, + getUserPermissions, + getUserPermissionsUrl, + getUserPermissionsEnabledFlag, + getUserPermissionsEnabledFlagUrl, } from './api'; let axiosMock; +const courseId = 'course-123'; describe('generic api calls', () => { beforeEach(() => { @@ -22,6 +27,8 @@ describe('generic api calls', () => { administrator: true, roles: [], }, + userPermissions: [], + userPermissionsEnabled: false, }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); @@ -41,7 +48,6 @@ describe('generic api calls', () => { }); it('should get course rerun', async () => { - const courseId = 'course-mock-id'; const courseRerunData = { allowUnicodeCourseId: false, courseCreatorStatus: 'granted', @@ -72,4 +78,24 @@ describe('generic api calls', () => { expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl()); expect(result).toEqual(courseRerunData); }); + + it('should get user permissions', async () => { + const permissionsData = { permissions: ['manage_all_users'] }; + const queryUrl = getUserPermissionsUrl(courseId, 3); + axiosMock.onGet(queryUrl).reply(200, permissionsData); + const result = await getUserPermissions(courseId); + + expect(axiosMock.history.get[0].url).toEqual(queryUrl); + expect(result).toEqual(permissionsData); + }); + + it('should get user permissions enabled flag', async () => { + const permissionsEnabledData = { enabled: true }; + const queryUrl = getUserPermissionsEnabledFlagUrl; + axiosMock.onGet(queryUrl).reply(200, permissionsEnabledData); + const result = await getUserPermissionsEnabledFlag(); + + expect(axiosMock.history.get[0].url).toEqual(queryUrl); + expect(result).toEqual(permissionsEnabledData); + }); }); diff --git a/src/generic/data/selectors.js b/src/generic/data/selectors.js index 461e09fe9..48b458c64 100644 --- a/src/generic/data/selectors.js +++ b/src/generic/data/selectors.js @@ -5,3 +5,5 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData; export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj; export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors; +export const getUserPermissions = (state) => state.generic.userPermissions.permissions; +export const getUserPermissionsEnabled = (state) => state.generic.userPermissionsEnabled; diff --git a/src/generic/data/slice.js b/src/generic/data/slice.js index a25112704..7cf6dc636 100644 --- a/src/generic/data/slice.js +++ b/src/generic/data/slice.js @@ -9,6 +9,8 @@ const slice = createSlice({ loadingStatuses: { organizationLoadingStatus: RequestStatus.IN_PROGRESS, courseRerunLoadingStatus: RequestStatus.IN_PROGRESS, + userPermissionsLoadingStatus: RequestStatus.IN_PROGRESS, + userPermissionsEnabledLoadingStatus: RequestStatus.IN_PROGRESS, }, savingStatus: '', organizations: [], @@ -18,6 +20,8 @@ const slice = createSlice({ redirectUrlObj: {}, postErrors: {}, }, + userPermissions: [], + userPermissionsEnabled: false, }, reducers: { fetchOrganizations: (state, { payload }) => { @@ -41,6 +45,12 @@ const slice = createSlice({ updatePostErrors: (state, { payload }) => { state.createOrRerunCourse.postErrors = payload; }, + updateUserPermissions: (state, { payload }) => { + state.userPermissions = payload; + }, + updateUserPermissionsEnabled: (state, { payload }) => { + state.userPermissionsEnabled = payload; + }, }, }); @@ -52,6 +62,8 @@ export const { updateSavingStatus, updateCourseData, updateRedirectUrlObj, + updateUserPermissions, + updateUserPermissionsEnabled, } = slice.actions; export const { diff --git a/src/generic/data/thunks.js b/src/generic/data/thunks.js index 0008a187f..043f02b47 100644 --- a/src/generic/data/thunks.js +++ b/src/generic/data/thunks.js @@ -1,5 +1,7 @@ import { RequestStatus } from '../../data/constants'; -import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api'; +import { + createOrRerunCourse, getOrganizations, getCourseRerun, getUserPermissions, getUserPermissionsEnabledFlag, +} from './api'; import { fetchOrganizations, updatePostErrors, @@ -7,6 +9,8 @@ import { updateRedirectUrlObj, updateCourseRerunData, updateSavingStatus, + updateUserPermissions, + updateUserPermissionsEnabled, } from './slice'; export function fetchOrganizationsQuery() { @@ -49,3 +53,28 @@ export function updateCreateOrRerunCourseQuery(courseData) { } }; } + +export function fetchUserPermissionsQuery(courseId) { + return async (dispatch) => { + try { + const userPermissions = await getUserPermissions(courseId); + dispatch(updateUserPermissions(userPermissions)); + dispatch(updateLoadingStatuses({ userPermissionsLoadingStatus: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatuses({ userPermissionsLoadingStatus: RequestStatus.FAILED })); + } + }; +} + +export function fetchUserPermissionsEnabledFlag() { + return async (dispatch) => { + try { + const data = await getUserPermissionsEnabledFlag(); + dispatch(updateUserPermissionsEnabled(data.enabled || false)); + dispatch(updateLoadingStatuses({ userPermissionsEnabledLoadingStatus: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateUserPermissionsEnabled(false)); + dispatch(updateLoadingStatuses({ userPermissionsEnabledLoadingStatus: RequestStatus.FAILED })); + } + }; +}