feat: [ROLES-26] Helper function for ingesting permission data (#670)

* feat: Add UserPermissions api, specs, feature flag api
This commit is contained in:
Lucas Calviño
2023-12-18 14:34:34 -03:00
committed by hsinkoff
parent 51c5f9c4dc
commit 1137dae97a
10 changed files with 145 additions and 17 deletions

View File

@@ -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",

View File

@@ -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 }) => {
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions && (
headerActions={(isAllowActions || hasManageAllUsersPerm) && (
<Button
variant="primary"
iconBefore={IconAdd}
@@ -104,13 +109,13 @@ 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) && (
<AddTeamMember
onFormOpen={openForm}
isButtonDisable={isFormVisible}

View File

@@ -14,6 +14,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../store';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import CourseTeam from './CourseTeam';
import messages from './messages';
import { USER_ROLES } from '../constants';
@@ -24,6 +25,8 @@ let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['manage_all_users'] };
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -44,7 +47,7 @@ describe('<CourseTeam />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -53,6 +56,12 @@ describe('<CourseTeam />', () => {
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('<CourseTeam />', () => {
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('<CourseTeam />', () => {
...courseTeamWithOneUser,
allowActions: false,
});
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
const { queryByRole, queryByTestId } = render(<RootWrapper />);

View File

@@ -31,7 +31,7 @@ const CourseTeamMember = ({
<div className="course-team-member" data-testid="course-team-member">
<div className="member-info">
<Badge className={`badge-current-user bg-${badgeColor} text-light-100`}>
{isAdminRole
{(isAdminRole)
? intl.formatMessage(messages.roleAdmin)
: intl.formatMessage(messages.roleStaff)}
{currentUserEmail === email && (
@@ -46,11 +46,13 @@ const CourseTeamMember = ({
!isHideActions ? (
<div className="member-actions">
<Button
variant={isAdminRole ? 'tertiary' : 'primary'}
variant={(isAdminRole) ? 'tertiary' : 'primary'}
size="sm"
onClick={() => onChangeRole(email, isAdminRole ? USER_ROLES.staff : USER_ROLES.admin)}
>
{isAdminRole ? intl.formatMessage(messages.removeButton) : intl.formatMessage(messages.addButton)}
{(isAdminRole)
? intl.formatMessage(messages.removeButton)
: intl.formatMessage(messages.addButton)}
</Button>
<IconButtonWithTooltip
src={DeleteOutline}

View File

@@ -6,6 +6,8 @@ import { useToggle } from '@edx/paragon';
import { USER_ROLES } from '../constants';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
import { getUserPermissions, getUserPermissionsEnabled } from '../generic/data/selectors';
import {
changeRoleTeamUserQuery,
createCourseTeamQuery,
@@ -96,6 +98,8 @@ const useCourseTeam = ({ courseId }) => {
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 };

View File

@@ -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<string[]>}
@@ -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<Object>}
*/
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;
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 }));
}
};
}