diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx
index 43f3da1d..66414a10 100644
--- a/src/authz-module/authz-home/index.test.tsx
+++ b/src/authz-module/authz-home/index.test.tsx
@@ -16,6 +16,10 @@ jest.mock('@openedx/paragon', () => ({
Tabs: ({ children }: { children: React.ReactNode }) =>
{children}
,
}));
+jest.mock('@src/authz-module/team-members/TeamMembersTable', () => function MockTeamMembersTable() {
+ return Team Members Table Content
;
+});
+
describe('AuthzHome', () => {
it('renders without crashing', () => {
renderWrapper();
@@ -37,4 +41,9 @@ describe('AuthzHome', () => {
renderWrapper();
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
});
+
+ it('renders the TeamMembersTable component in the team members tab', () => {
+ renderWrapper();
+ expect(screen.getByText('Team Members Table Content')).toBeInTheDocument();
+ });
});
diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx
index f6cc7169..6537e28c 100644
--- a/src/authz-module/authz-home/index.tsx
+++ b/src/authz-module/authz-home/index.tsx
@@ -1,6 +1,8 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
-import { useLocation } from 'react-router-dom';
+import { useLocation, useSearchParams } from 'react-router-dom';
+import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable';
+import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import RolesPermissions from '../roles-permissions/RolesPermissions';
import AuthZLayout from '../components/AuthZLayout';
@@ -9,6 +11,8 @@ import messages from './messages';
const AuthzHome = () => {
const { hash } = useLocation();
const intl = useIntl();
+ const [searchParams] = useSearchParams();
+ const presetScope = searchParams.get('scope') || undefined;
const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || '';
const pageTitle = intl.formatMessage(messages['authz.manage.page.title']);
@@ -22,11 +26,7 @@ const AuthzHome = () => {
pageTitle={pageTitle}
pageSubtitle=""
actions={
- []
- // this needs to be enable again once is refactored to be used outside of library context
- // [
- // ,
- // ]
+ []
}
>
{
className="bg-light-100 px-5"
>
- {/* TODO: once TeamTable is refactored we can call it here. For now, this tab will be empty. */}
- {/* */}
+
diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx
new file mode 100644
index 00000000..8a44e750
--- /dev/null
+++ b/src/authz-module/components/AddRoleButton.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button } from '@openedx/paragon';
+import { Plus } from '@openedx/paragon/icons';
+
+import baseMessages from '@src/authz-module/messages';
+import { useNavigate } from 'react-router-dom';
+
+interface AddRoleButtonProps {
+ presetUsername?: string;
+}
+
+const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => {
+ const intl = useIntl();
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`;
+ navigate(assignRolePath);
+ };
+
+ return (
+
+ );
+};
+
+export default AddRoleButton;
diff --git a/src/authz-module/components/AuthZLayout.tsx b/src/authz-module/components/AuthZLayout.tsx
index 9845b90c..06b572cc 100644
--- a/src/authz-module/components/AuthZLayout.tsx
+++ b/src/authz-module/components/AuthZLayout.tsx
@@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps {
const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => (
<>
{children}
diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx
new file mode 100644
index 00000000..36fd9c4c
--- /dev/null
+++ b/src/authz-module/components/TableCells.tsx
@@ -0,0 +1,96 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Icon, IconButton } from '@openedx/paragon';
+import { AppContext } from '@edx/frontend-platform/react';
+import {
+ RemoveRedEye,
+} from '@openedx/paragon/icons';
+import { TableCellValue, AppContextType, UserRole } from '@src/types';
+import { useNavigate } from 'react-router-dom';
+import { useContext, useMemo } from 'react';
+import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants';
+import messages from './messages';
+import { RESOURCE_ICONS } from './constants';
+
+type CellProps = TableCellValue;
+type CellPropsWithValue = CellProps & {
+ value: string;
+};
+type ExtendedCellProps = CellPropsWithValue & {
+ cell: {
+ getCellProps: (props?: Record) => Record;
+ };
+};
+
+const NameCell = ({ row }: CellProps) => {
+ const intl = useIntl();
+ const { authenticatedUser } = useContext(AppContext) as AppContextType;
+ const username = authenticatedUser?.username;
+
+ if (row.original.username === username) {
+ return (
+
+ {row.original.fullName || row.original.username}
+ {intl.formatMessage(messages['authz.table.username.current'])}
+
+ );
+ }
+ return row.original.fullName || row.original.username;
+};
+
+const ActionCell = ({ row }: CellProps) => {
+ const { formatMessage } = useIntl();
+ const navigate = useNavigate();
+ const viewPath = `/authz/user/${row.original.username}`;
+ return (
+ navigate(viewPath)}
+ />
+ );
+};
+
+const OrgCell = ({ value, row }: CellPropsWithValue) => {
+ const { formatMessage } = useIntl();
+ return (
+
+ {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value}
+ |
+ );
+};
+
+const ScopeCell = ({ row }: CellProps) => {
+ const { formatMessage } = useIntl();
+
+ const { scopeText, iconSrc } = useMemo(() => {
+ if (DJANGO_MANAGED_ROLES.includes(row.original.role)) {
+ return {
+ scopeText: formatMessage(messages['authz.user.table.scope.global.label']),
+ iconSrc: RESOURCE_ICONS.GLOBAL,
+ };
+ }
+ const scopeIcon = row.original.role.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
+ return {
+ scopeText: row.original.scope,
+ iconSrc: scopeIcon,
+ };
+ }, [row.original.role, row.original.scope, formatMessage]);
+
+ return (
+
+ {iconSrc && }
+ {scopeText}
+
+ );
+};
+
+const RoleCell = ({ value, cell }: ExtendedCellProps) => (
+
+ {MAP_ROLE_KEY_TO_LABEL[value] || ''}
+ |
+);
+
+export {
+ NameCell, ActionCell, ScopeCell, RoleCell, OrgCell,
+};
diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx
new file mode 100644
index 00000000..8c6ca8fc
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx
@@ -0,0 +1,129 @@
+import {
+ Dropdown, Form, Icon, Stack,
+} from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { FilterList, Search } from '@openedx/paragon/icons';
+import { useState } from 'react';
+import messages from '../messages';
+import { MultipleChoiceFilterProps } from './types';
+
+const MultipleChoiceFilter = ({
+ filterButtonText,
+ filterChoices,
+ filterValue,
+ setFilter,
+ isGrouped = false,
+ isSearchable = false,
+ onSearchChange,
+ iconSrc,
+ disabled = false,
+}: MultipleChoiceFilterProps) => {
+ const [searchValue, setSearchValue] = useState(undefined);
+ const { formatMessage } = useIntl();
+
+ const checkedBoxes = filterValue || [];
+ const handleClickCheckbox = (value, displayName) => {
+ const newValue = {
+ groupName: filterButtonText?.toLocaleLowerCase() || '',
+ value,
+ displayName,
+ };
+ if (checkedBoxes.includes(value)) {
+ const newCheckedBoxes = checkedBoxes.filter((val) => val !== value);
+ return setFilter(newCheckedBoxes, newValue);
+ }
+ const newCheckedBoxes = [...checkedBoxes, value];
+ return setFilter(newCheckedBoxes, newValue);
+ };
+
+ const getGroupedChoices = () => {
+ const groupedFilterChoices = filterChoices.reduce((groups, choice) => {
+ const groupName = choice.groupName || 'Ungrouped';
+ const icon = choice.groupIcon || undefined;
+ if (!groups.has(groupName)) {
+ groups.set(groupName, { groupName, options: [], icon });
+ }
+ groups.get(groupName)!.options.push({
+ displayName: choice.displayName,
+ value: choice.value,
+ });
+ return groups;
+ }, new Map; icon?: any }>());
+ return Array.from(groupedFilterChoices.values());
+ };
+ return (
+
+ 0 ? 'primary' : 'outline-primary'}>
+
+ {iconSrc && }
+ {filterButtonText}
+ {checkedBoxes.length > 0 && ` (${checkedBoxes.length})`}
+
+
+
+
+
+ {isSearchable && (
+ }
+ placeholder={formatMessage(messages['authz.table.controlbar.search'])}
+ onChange={(e) => {
+ setSearchValue(e.target.value);
+ onSearchChange?.(e.target.value);
+ }}
+ value={searchValue}
+ />
+ )}
+
+
+ {formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })}
+
+ {!isGrouped ? filterChoices.map(({
+ displayName, value,
+ }) => (
+ handleClickCheckbox(value, displayName)}
+ aria-label={displayName}
+ disabled={checkedBoxes.includes(value) ? false : disabled}
+ >
+ {displayName}
+
+ ))
+ : getGroupedChoices().map(({ groupName, icon, options }) => (
+
+
+ {icon && }
+ {groupName}
+
+ {options.map(({ displayName, value }) => (
+
handleClickCheckbox(value, displayName)}
+ disabled={checkedBoxes.includes(value) ? false : disabled}
+ aria-label={displayName}
+ >
+ {displayName}
+
+ ))}
+
+ ))}
+
+
+
+ );
+};
+
+export default MultipleChoiceFilter;
diff --git a/src/authz-module/components/TableControlBar/OrgFilter.tsx b/src/authz-module/components/TableControlBar/OrgFilter.tsx
new file mode 100644
index 00000000..89c00b87
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/OrgFilter.tsx
@@ -0,0 +1,41 @@
+import React, { useMemo } from 'react';
+import { Business } from '@openedx/paragon/icons';
+import { useOrgs } from '@src/authz-module/data/hooks';
+import { MultipleChoiceFilterProps } from './types';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+
+type OrgFilterProps = Omit;
+
+const OrgFilter = ({
+ filterButtonText, filterValue, setFilter, disabled,
+}: OrgFilterProps) => {
+ const [searchValue, setSearchValue] = React.useState(undefined);
+ const {
+ data: orgsData = {
+ count: 0, next: null, previous: null, results: [],
+ },
+ } = useOrgs(searchValue);
+ const filterChoices = useMemo(() => orgsData?.results?.map((org) => ({
+ displayName: org.name,
+ value: org.shortName,
+ })) || [], [orgsData]);
+
+ const handleSearchChange = (value: string) => {
+ setSearchValue(value);
+ };
+
+ return (
+
+ );
+};
+
+export default OrgFilter;
diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx
new file mode 100644
index 00000000..d0e061e9
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx
@@ -0,0 +1,28 @@
+import { useMemo } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Person } from '@openedx/paragon/icons';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+import { MultipleChoiceFilterProps } from './types';
+import { getRolesFiltersOptions } from '../constants';
+
+type RolesFilterProps = Omit;
+
+const RolesFilter = ({
+ filterButtonText, filterValue, setFilter, disabled,
+}: RolesFilterProps) => {
+ const intl = useIntl();
+ const rolesOptions = useMemo(() => getRolesFiltersOptions(intl), [intl]);
+ return (
+
+ );
+};
+
+export default RolesFilter;
diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx
new file mode 100644
index 00000000..9820128a
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx
@@ -0,0 +1,52 @@
+import React, { useMemo } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { LocationOn } from '@openedx/paragon/icons';
+import { useScopes } from '@src/authz-module/data/hooks';
+import { MultipleChoiceFilterProps } from './types';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+import { RESOURCE_ICONS } from '../constants';
+import messages from '../messages';
+
+type ScopesFilterProps = Omit;
+
+const ScopesFilter = ({
+ filterButtonText, filterValue, setFilter, disabled,
+}: ScopesFilterProps) => {
+ const { formatMessage } = useIntl();
+ const [searchValue, setSearchValue] = React.useState(undefined);
+ const { data: scopesData = { results: [] } } = useScopes(searchValue);
+
+ const filterChoices = useMemo(() => scopesData.results.map((scope) => {
+ const scopeIcon = scope.externalKey.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
+ let groupName = formatMessage(messages['authz.team.members.table.group.courses']);
+ if (scope.externalKey.startsWith('lib')) {
+ groupName = formatMessage(messages['authz.team.members.table.group.libraries']);
+ }
+ return {
+ displayName: scope.displayName,
+ value: scope.externalKey,
+ groupName,
+ groupIcon: scopeIcon,
+ };
+ }), [scopesData, formatMessage]);
+
+ const handleSearchChange = (value: string) => {
+ setSearchValue(value);
+ };
+
+ return (
+
+ );
+};
+
+export default ScopesFilter;
diff --git a/src/authz-module/components/TableControlBar/SearchFilter.tsx b/src/authz-module/components/TableControlBar/SearchFilter.tsx
new file mode 100644
index 00000000..b2480f1d
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/SearchFilter.tsx
@@ -0,0 +1,30 @@
+import {
+ Form,
+ Icon,
+} from '@openedx/paragon';
+import { Search } from '@openedx/paragon/icons';
+
+interface SearchFilterProps {
+ filterValue: string;
+ setFilter: (value: string) => void;
+ placeholder: string;
+}
+
+const SearchFilter = ({
+ filterValue, setFilter, placeholder,
+}: SearchFilterProps) => (
+
+ }
+ value={filterValue || ''}
+ type="text"
+ onChange={e => {
+ setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely
+ }}
+ placeholder={placeholder}
+ />
+
+);
+
+export default SearchFilter;
diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx
new file mode 100644
index 00000000..3d937c0b
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx
@@ -0,0 +1,199 @@
+import { useContext, useEffect, useState } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ DataTableContext,
+ Stack,
+ TextFilter,
+ Button,
+ Chip,
+ Alert,
+ Icon,
+} from '@openedx/paragon';
+import {
+ Business, Close, LocationOn, Person,
+ Warning,
+} from '@openedx/paragon/icons';
+
+import { MAX_TABLE_FILTERS_APPLIED } from '@src/authz-module/constants';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+import SearchFilter from './SearchFilter';
+import messages from '../messages';
+import RolesFilter from './RolesFilter';
+import OrgFilter from './OrgFilter';
+import ScopesFilter from './ScopesFilter';
+import { FilterChoice } from './types';
+
+const FILTER_CHIPS_ICONS = {
+ role: Person,
+ organization: Business,
+ scope: LocationOn,
+};
+
+const FILTER_GROUP_TO_ID = {
+ role: 'role',
+ organization: 'org',
+ scope: 'scope',
+};
+
+interface TableControlBarProps {
+ onFilterChange?: (filters: string[]) => void;
+}
+
+const TableControlBar = ({ onFilterChange }: TableControlBarProps) => {
+ const intl = useIntl();
+ // applied filters in the order they were selected by the user, to display on the control bar as chips
+ const [chronologicalFilters, setChronologicalFilters] = useState([]);
+ const [filtersLimitReached, setFiltersLimitReached] = useState(false);
+ const {
+ columns,
+ setAllFilters,
+ state,
+ // @ts-ignore-next-line - Paragon's DataTableContext is not typed
+ } = useContext(DataTableContext);
+
+ useEffect(() => {
+ if (state.filters.length > 0) {
+ const formattedInitialFilters = state.filters.map((filter) => ({
+ groupName: filter.id,
+ value: filter.value[0] || '',
+ displayName: filter.value[0] || '',
+ }));
+ setChronologicalFilters(formattedInitialFilters);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ setFiltersLimitReached(chronologicalFilters.length >= MAX_TABLE_FILTERS_APPLIED);
+ if (onFilterChange) {
+ onFilterChange(state.filters.map((filter) => filter.id) || []);
+ }
+ }, [chronologicalFilters, onFilterChange, state.filters]);
+
+ const availableFilters = columns.filter((column) => column.canFilter)
+ .sort((a, b) => (a.filterOrder || 0) - (b.filterOrder || 0));
+
+ const columnTextFilterHeaders = columns
+ .filter((column) => column.Filter === TextFilter)
+ .map((column) => column.Header);
+
+ const getSearchPlaceholder = () => intl.formatMessage(messages['authz.table.controlbar.search.by.fields'], {
+ firstField: columnTextFilterHeaders[0] || '',
+ secondField: columnTextFilterHeaders[1] || '',
+ });
+
+ const handleCloseFilter = (filterName, filterValue) => {
+ const actualFilterId = FILTER_GROUP_TO_ID[filterName] || filterName;
+ const filterGroup = state.filters.find((filter) => filter.id === actualFilterId);
+ const newFilterValue = filterGroup?.value.filter(item => item !== filterValue) || [];
+ setAllFilters(state.filters.map(item => (
+ item.id !== actualFilterId ? item : { id: item.id, value: newFilterValue })));
+ setChronologicalFilters((prevFilters) => prevFilters.filter((filter) => filter.value !== filterValue));
+ };
+
+ const handleSetFilters = (setFilter) => (allFilters: string[], newFilter: FilterChoice) => {
+ setFilter(allFilters);
+ setChronologicalFilters((prevFilters) => {
+ if (!prevFilters.find((filter) => filter.value === newFilter.value)) {
+ return [...prevFilters, newFilter];
+ }
+ return prevFilters.filter((filter) => filter.value !== newFilter.value);
+ });
+ };
+
+ const clearAllFilters = () => {
+ setAllFilters([]);
+ setChronologicalFilters([]);
+ };
+ return (
+
+
+ {availableFilters.map((column) => {
+ const { Filter } = column;
+ if (Filter === RolesFilter) {
+ return (
+
+ );
+ }
+ if (Filter === OrgFilter) {
+ return (
+
+ );
+ }
+ if (Filter === MultipleChoiceFilter) {
+ return (
+
+ );
+ }
+ if (Filter === ScopesFilter) {
+ return (
+ filter.id === 'scope')?.value || null}
+ />
+ );
+ }
+
+ if (Filter === TextFilter) {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
+ {chronologicalFilters.length > 0 && (
+
+ {intl.formatMessage(messages['authz.table.controlbar.filterby.label'])}
+
+ {chronologicalFilters.map((filter) => (
+ handleCloseFilter(filter.groupName, filter.value)}
+ >
+ {filter.displayName}
+
+ ))}
+ {chronologicalFilters.length > 1 && (
+
+ )}
+
+ )}
+ { filtersLimitReached && (
+
+
+
+ {intl.formatMessage(messages['authz.table.controlbar.filters.limit.reached'])}
+
+
+ )}
+
+
+ );
+};
+
+export default TableControlBar;
diff --git a/src/authz-module/components/TableControlBar/types.ts b/src/authz-module/components/TableControlBar/types.ts
new file mode 100644
index 00000000..b6ba740e
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/types.ts
@@ -0,0 +1,18 @@
+export type FilterChoice = {
+ groupName?: string;
+ groupIcon?: React.ComponentType<{}>;
+ displayName: string;
+ value: string;
+};
+
+export interface MultipleChoiceFilterProps {
+ filterButtonText: string;
+ filterChoices: Array;
+ filterValue: string[] | undefined;
+ setFilter: (value: string[], newItem: FilterChoice) => void;
+ isGrouped?: boolean;
+ isSearchable?: boolean;
+ onSearchChange?: (value: string) => void;
+ iconSrc?: React.ComponentType<{}> | undefined;
+ disabled?: boolean;
+}
diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx
new file mode 100644
index 00000000..26b3a974
--- /dev/null
+++ b/src/authz-module/components/TableFooter/TableFooter.tsx
@@ -0,0 +1,28 @@
+import React, { useContext } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { DataTableContext, Pagination, TableFooter } from '@openedx/paragon';
+import messages from '../messages';
+
+const Footer = () => {
+ const { formatMessage } = useIntl();
+ const {
+ pageCount, gotoPage, state, itemCount, rows,
+ // @ts-ignore-next-line - Paragon's DataTableContext is not typed
+ } = useContext(DataTableContext);
+ const { pageIndex } = state;
+ return (
+
+
+ {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize: rows.length, itemCount })}
+
+ gotoPage(pageNum - 1)}
+ />
+
+ );
+};
+
+export default Footer;
diff --git a/src/authz-module/components/constants.ts b/src/authz-module/components/constants.ts
new file mode 100644
index 00000000..185eecaf
--- /dev/null
+++ b/src/authz-module/components/constants.ts
@@ -0,0 +1,74 @@
+import { IntlShape } from '@edx/frontend-platform/i18n';
+import { Language, LibraryBooks, School } from '@openedx/paragon/icons';
+import messages from './messages';
+
+export const getRolesFiltersOptions = (intl: IntlShape) => [
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.global']),
+ groupIcon: Language,
+ displayName: 'Super Admin',
+ value: 'super_admin',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.global']),
+ groupIcon: Language,
+ displayName: 'Global Staff',
+ value: 'global_staff',
+ },
+
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Admin',
+ value: 'course_admin',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Staff',
+ value: 'course_staff',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Editor',
+ value: 'course_editor',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Auditor',
+ value: 'course_auditor',
+ },
+
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library Admin',
+ value: 'library_admin',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library Author',
+ value: 'library_author',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library Collaborator',
+ value: 'library_collaborator',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library User',
+ value: 'library_user',
+ },
+];
+
+export const RESOURCE_ICONS = {
+ COURSE: School,
+ LIBRARY: LibraryBooks,
+ GLOBAL: Language,
+};
diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts
index 707dde7c..05988fdc 100644
--- a/src/authz-module/components/messages.ts
+++ b/src/authz-module/components/messages.ts
@@ -21,6 +21,77 @@ const messages = defineMessages({
defaultMessage: 'Scroll to top',
description: 'Alt text for the scroll to top anchor button',
},
+ 'authz.table.controlbar.clearFilters': {
+ id: 'authz.table.controlbar.clearFilters',
+ defaultMessage: 'Clear filters',
+ description: 'Button to clear all active filters in the table',
+ },
+ 'authz.user.table.org.all.organizations.label': {
+ id: 'authz.user.table.org.all.organizations.label',
+ defaultMessage: 'All Organizations',
+ description: 'Label for the "All Organizations" message on the user assignments table when a user has a django managed role assigned.',
+ },
+ 'authz.table.controlbar.search': {
+ id: 'authz.table.controlbar.search',
+ defaultMessage: 'Search',
+ description: 'Search placeholder for two specific fields',
+ },
+ 'authz.user.table.scope.global.label': {
+ id: 'authz.user.table.scope.global.label',
+ defaultMessage: 'Global',
+ description: 'Label for the "Global" scope in the user assignments table when a user has a django managed role assigned.',
+ },
+ 'authz.table.controlbar.search.by.fields': {
+ id: 'authz.table.controlbar.search.by.fields',
+ defaultMessage: 'Search by {firstField} or {secondField}',
+ description: 'Search placeholder for two specific fields',
+ },
+ 'authz.table.controlbar.filterby.label': {
+ id: 'authz.table.controlbar.filterby.label',
+ defaultMessage: 'Filtered by: ',
+ description: 'Label for active filters in the table',
+ },
+ 'authz.table.controlbar.filters.limit.reached': {
+ id: 'authz.table.controlbar.filters.limit.reached',
+ defaultMessage: '10 filter limit reached. Remove one of the applied filters so you can select another one.',
+ description: 'Message displayed when the user reaches the applied filters limit',
+ },
+ 'authz.table.controlbar.filters.items.showing': {
+ id: 'authz.table.controlbar.filters.limit.reached',
+ defaultMessage: 'Showing {current} of {total}.',
+ description: 'Message displayed when the user reaches the applied filters limit',
+ },
+ 'authz.table.footer.items.showing.text': {
+ id: 'authz.table.footer.items.showing.text',
+ defaultMessage: 'Showing {pageSize} of {itemCount} users.',
+ description: 'Message displayed when the user reaches the applied filters limit',
+ },
+ 'authz.table.username.current': {
+ id: 'authz.table.username.current',
+ defaultMessage: '(Me)',
+ description: 'Indicates the current user in the team members table',
+ },
+
+ 'authz.table.column.actions.view.title': {
+ id: 'authz.table.column.actions.view.title',
+ defaultMessage: 'View',
+ description: 'Team members table view action text',
+ },
+ 'authz.team.members.table.group.courses': {
+ id: 'authz.team.members.table.group.courses',
+ defaultMessage: 'Courses',
+ description: 'Label for the "Courses" group in the team members table filters',
+ },
+ 'authz.team.members.table.group.libraries': {
+ id: 'authz.team.members.table.group.libraries',
+ defaultMessage: 'Libraries',
+ description: 'Label for the "Libraries" group in the team members table filters',
+ },
+ 'authz.team.members.table.group.global': {
+ id: 'authz.team.members.table.group.global',
+ defaultMessage: 'Global',
+ description: 'Label for the "Global" group in the team members table filters',
+ },
});
export default messages;
diff --git a/src/authz-module/components/utils.tsx b/src/authz-module/components/utils.tsx
new file mode 100644
index 00000000..4d0bd222
--- /dev/null
+++ b/src/authz-module/components/utils.tsx
@@ -0,0 +1,14 @@
+import { Icon } from '@openedx/paragon';
+import { FilterList } from '@openedx/paragon/icons';
+
+export const getCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
+ if (filtersApplied.includes(columnId)) {
+ return (
+
+
+ {columnTitle}
+
+ );
+ }
+ return columnTitle;
+};
diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts
index 3256bc2c..9145f78c 100644
--- a/src/authz-module/constants.ts
+++ b/src/authz-module/constants.ts
@@ -1,6 +1,7 @@
export const ROUTES = {
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
+
};
export enum RoleOperationErrorStatus {
@@ -10,3 +11,22 @@ export enum RoleOperationErrorStatus {
ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
ROLE_REMOVAL_ERROR = 'role_removal_error',
}
+
+export const MAX_TABLE_FILTERS_APPLIED = 10;
+
+export const MAP_ROLE_KEY_TO_LABEL: Record = {
+ library_admin: 'Library Admin',
+ library_author: 'Library Author',
+ library_contributor: 'Library Contributor',
+ library_user: 'Library User',
+ course_admin: 'Course Admin',
+ course_staff: 'Course Staff',
+ course_editor: 'Course Editor',
+ course_auditor: 'Course Auditor',
+ 'django.superuser': 'Super Admin',
+ 'django.globalstaff': 'Global Staff',
+};
+
+export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff'];
+
+export const TABLE_DEFAULT_PAGE_SIZE = 10;
diff --git a/src/authz-module/courses/constants.ts b/src/authz-module/courses/constants.ts
new file mode 100644
index 00000000..e0500f6d
--- /dev/null
+++ b/src/authz-module/courses/constants.ts
@@ -0,0 +1,433 @@
+import { PermissionMetadata, ResourceMetadata } from 'types';
+import {
+ School, LibraryBooks, Article, Group, LocalOffer,
+ BookOpen,
+ Sync,
+ Folder,
+ Calendar,
+ Download,
+ DrawShapes,
+ CheckCircle,
+} from '@openedx/paragon/icons';
+
+export const CONTENT_COURSE_PERMISSIONS = {
+ VIEW_COURSE: 'courses.view_course',
+ CREATE_COURSE: 'courses.create_course',
+ EDIT_COURSE_CONTENT: 'courses.edit_course_content',
+ PUBLISH_COURSE_CONTENT: 'courses.publish_course_content',
+
+ REVIEW_COURSE_LIBRARY_UPDATES: 'courses.manage_library_updates',
+
+ VIEW_COURSE_UPDATES: 'courses.view_course_updates',
+ MANAGE_COURSE_UPDATES: 'courses.manage_course_updates',
+
+ VIEW_COURSE_PAGES_RESOURCES: 'courses.view_pages_and_resources',
+ MANAGE_COURSE_PAGES_RESOURCES: 'courses.manage_pages_and_resources',
+
+ VIEW_COURSE_FILES: 'courses.view_files',
+ CREATE_COURSE_FILES: 'courses.create_files',
+ EDIT_COURSE_FILES: 'courses.edit_files',
+ DELETE_COURSE_FILES: 'courses.delete_files',
+
+ VIEW_COURSE_SCHEDULE: 'courses.view_schedule',
+ EDIT_COURSE_SCHEDULE: 'courses.edit_schedule',
+ VIEW_COURSE_DETAILS: 'courses.view_details',
+ EDIT_COURSE_DETAILS: 'courses.edit_details',
+
+ VIEW_COURSE_GRADING_SETTINGS: 'courses.view_grading_settings',
+ EDIT_COURSE_GRADING_SETTINGS: 'courses.edit_grading_settings',
+
+ VIEW_COURSE_TEAM: 'courses.view_course_team',
+ MANAGE_COURSE_TEAM: 'courses.manage_course_team',
+ MANAGE_COURSE_GROUP_CONFIGURATION: 'courses.manage_group_configurations',
+
+ MANAGE_COURSE_TAGS: 'courses.manage_tags',
+ MANAGE_COURSE_TAXONOMIES: 'courses.manage_taxonomies',
+
+ MANAGE_COURSE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
+ MANAGE_COURSE_CERTIFICATES: 'courses.manage_certificates',
+
+ IMPORT_COURSE: 'courses.import_course',
+ EXPORT_COURSE: 'courses.export_course',
+ EXPORT_COURSE_TAGS: 'courses.export_tags',
+
+ VIEW_COURSE_CHECKLISTS: 'courses.view_checklists',
+ VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins',
+};
+
+export const courseResourceTypes: ResourceMetadata[] = [
+ {
+ key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.', icon: BookOpen,
+ },
+ {
+ key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.', icon: LibraryBooks,
+ },
+ {
+ key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.', icon: Sync,
+ },
+ {
+ key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Article,
+ },
+ {
+ key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Folder,
+ },
+ {
+ key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.', icon: Calendar,
+ },
+ {
+ key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.', icon: School,
+ },
+ {
+ key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.', icon: Group,
+ },
+ {
+ key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.', icon: LocalOffer,
+ },
+ {
+ key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.', icon: CheckCircle,
+ },
+ {
+ key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.', icon: Download,
+ },
+ {
+ key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.', icon: DrawShapes,
+ },
+
+];
+
+export const coursePermissions: PermissionMetadata[] = [
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ resource: 'course_access_content',
+ description: 'View course in the course list, access the course outline in read only mode, includes the "View Live" entry point.',
+ label: 'View course',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE,
+ resource: 'course_access_content',
+ description: 'Create a new course in Studio.',
+ label: 'Create course',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ resource: 'course_access_content',
+ description: 'Edit course content, outline, units, components.',
+ label: 'Edit course content',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
+ resource: 'course_access_content',
+ description: 'Publish course content.',
+ label: 'Publish course content',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ resource: 'course_library_updates',
+ description: 'Accept or reject library updates in Studio.',
+ label: 'Review library updates',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ resource: 'course_updates_handouts',
+ description: 'View course updates and handouts.',
+ label: 'View course updates',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ resource: 'course_updates_handouts',
+ description: 'Manage course updates and handouts, create, edit, delete.',
+ label: 'Manage course updates',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ resource: 'course_pages_resources',
+ description: 'View Pages and Resources.',
+ label: 'View pages & resources',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ resource: 'course_pages_resources',
+ description: 'Edit Pages and Resources, including toggles and content managed from that section.',
+ label: 'Manage pages & resources',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ resource: 'course_files',
+ description: 'View the Files page.',
+ label: 'View files',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ resource: 'course_files',
+ description: 'Upload files.',
+ label: 'Create files',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ resource: 'course_files',
+ description: 'Non destructive file actions, for example lock or unlock, exact actions depend on implementation.',
+ label: 'Edit files',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES,
+ resource: 'course_files',
+ description: 'Delete files.',
+ label: 'Delete files',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ resource: 'course_schedule_details',
+ description: 'View course schedule.',
+ label: 'View schedule',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE,
+ resource: 'course_schedule_details',
+ description: 'Edit course schedule.',
+ label: 'Edit schedule',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ resource: 'course_schedule_details',
+ description: 'View course details.',
+ label: 'View course details',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ resource: 'course_schedule_details',
+ description: 'Edit course details, includes Course Summary, Course Pacing, Course Details, Course Pre requisite.',
+ label: 'Edit course details',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ resource: 'course_grading',
+ description: 'View grading settings page.',
+ label: 'View grading settings',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ resource: 'course_grading',
+ description: 'Edit grading settings.',
+ label: 'Edit grading settings',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ resource: 'course_team_group',
+ description: 'View the course team roster.',
+ label: 'View course team',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ resource: 'course_team_group',
+ description: 'Edit course team membership and roles.',
+ label: 'Manage course team',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ resource: 'course_team_group',
+ description: 'Manage content groups.',
+ label: 'Manage group configuration',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ resource: 'course_tags_taxonomies',
+ description: 'Create, edit, delete tags.',
+ label: 'Manage tags',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES,
+ resource: 'course_tags_taxonomies',
+ description: 'Create, edit, delete taxonomies.',
+ label: 'Manage taxonomies',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS,
+ resource: 'course_advanced_certificates',
+ description: 'Access and edit Advanced Settings.',
+ label: 'Manage advanced settings',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES,
+ resource: 'course_advanced_certificates',
+ description: 'Access and edit Certificates.',
+ label: 'Manage certificates',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE,
+ resource: 'course_import_export',
+ description: 'Show Import in Studio, this is treated as a high privilege action and effectively implies most authoring permissions.',
+ label: 'Import course',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE,
+ resource: 'course_import_export',
+ description: 'Show Export in Studio.',
+ label: 'Export course',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS,
+ resource: 'course_import_export',
+ description: 'Export tags.',
+ label: 'Export tags',
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ resource: 'course_other',
+ description: 'View checklists.',
+ label: 'View checklists',
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ resource: 'course_other',
+ description: 'Allow course or library admins to view the list of global Staff and Super Admin users.',
+ label: 'View global staff & super admins',
+ },
+
+];
+
+// roles hardcoded, todo: need to add the constants from above in order to merge the different permissions array.
+export const rolesObject = [
+ {
+ role: 'course_admin',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES,
+ CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES,
+ ],
+ userCount: 1,
+ name: 'Course Admin',
+ description: 'course level administration, including access and role management for the course team, plus all Staff capabilities.',
+ },
+
+ {
+ role: 'course_staff',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES,
+ CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS,
+ ],
+ userCount: 1,
+ name: 'Course Staff',
+ description: 'operating the course lifecycle in Studio, publishing content, handling scheduling, and managing high impact configuration for the course.',
+ },
+ {
+ role: 'course_editor',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ ],
+ userCount: 1,
+ name: 'Course Editor',
+ description: 'building and maintaining course content and supporting assets, without operational controls or high impact actions that can affect a live course.',
+ disable: true,
+ },
+ {
+ role: 'course_auditor',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ ],
+ userCount: 1,
+ name: 'Course Auditor',
+ description: ' QA, compliance review, content review, and general oversight, no changes in Studio.',
+ disable: true,
+ },
+
+];
+
+export const DEFAULT_TOAST_DELAY = 5000;
+export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
+export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
+ username: 'skeleton',
+ name: '',
+ email: '',
+ roles: [],
+}));
diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts
index bf5ff1ae..d5eb8437 100644
--- a/src/authz-module/data/api.ts
+++ b/src/authz-module/data/api.ts
@@ -1,10 +1,15 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { LibraryMetadata, TeamMember } from '@src/types';
+import {
+ LibraryMetadata, Org, Scope, TeamMember,
+ UserRole,
+} from '@src/types';
import { camelCaseObject } from '@edx/frontend-platform';
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';
export interface QuerySettings {
roles: string | null;
+ scopes: string | null;
+ organizations: string | null;
search: string | null;
order: string | null;
sortBy: string | null;
@@ -50,6 +55,27 @@ export interface AssignTeamMembersRoleRequest {
scope: string;
}
+export interface GetAllRoleAssignmentsResponse {
+ results: UserRole[];
+ count: number;
+ next: string | null;
+ previous: string | null;
+}
+
+export interface GetOrgsResponse {
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results:Array;
+}
+
+export interface GetScopesResponse {
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results:Array;
+}
+
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => {
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
@@ -108,3 +134,60 @@ export const revokeUserRoles = async (
const res = await getAuthenticatedHttpClient().delete(url.toString());
return camelCaseObject(res.data);
};
+
+export const getAllRoleAssignments = async (querySettings: QuerySettings)
+: Promise => {
+ const url = new URL(getApiUrl('/api/authz/v1/assignments/'));
+
+ if (querySettings.roles) {
+ url.searchParams.set('roles', querySettings.roles);
+ }
+ if (querySettings.scopes) {
+ url.searchParams.set('scopes', querySettings.scopes);
+ }
+ if (querySettings.organizations) {
+ url.searchParams.set('orgs', querySettings.organizations);
+ }
+ if (querySettings.search) {
+ url.searchParams.set('search', querySettings.search);
+ }
+ if (querySettings.sortBy && querySettings.order) {
+ url.searchParams.set('sort_by', querySettings.sortBy);
+ url.searchParams.set('order', querySettings.order);
+ }
+ url.searchParams.set('page_size', querySettings.pageSize.toString());
+ url.searchParams.set('page', (querySettings.pageIndex + 1).toString());
+
+ const { data } = await getAuthenticatedHttpClient().get(url);
+ return camelCaseObject(data);
+};
+
+export const getOrgs = async (search?: string, page?: number, pageSize?: number): Promise => {
+ const url = new URL(getApiUrl('/api/authz/v1/orgs/'));
+ if (search !== undefined) {
+ url.searchParams.set('search', search);
+ }
+ if (page !== undefined) {
+ url.searchParams.set('page', page.toString());
+ }
+ if (pageSize !== undefined) {
+ url.searchParams.set('page_size', pageSize.toString());
+ }
+ const { data } = await getAuthenticatedHttpClient().get(url);
+ return camelCaseObject(data);
+};
+
+export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise => {
+ const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
+ if (search !== undefined) {
+ url.searchParams.set('search', search);
+ }
+ if (page !== undefined) {
+ url.searchParams.set('page', page.toString());
+ }
+ if (pageSize !== undefined) {
+ url.searchParams.set('page_size', pageSize.toString());
+ }
+ const { data } = await getAuthenticatedHttpClient().get(url);
+ return camelCaseObject(data);
+};
diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts
index bc5090e0..8b0cfa1d 100644
--- a/src/authz-module/data/hooks.ts
+++ b/src/authz-module/data/hooks.ts
@@ -4,8 +4,11 @@ import {
import { appId } from '@src/constants';
import { LibraryMetadata } from '@src/types';
import {
- assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
- GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
+ assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments,
+ GetAllRoleAssignmentsResponse, getLibrary, getOrgs, GetOrgsResponse,
+ getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers,
+ GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles,
+ RevokeUserRolesRequest,
} from './api';
const authzQueryKeys = {
@@ -15,6 +18,9 @@ const authzQueryKeys = {
...authzQueryKeys.teamMembersAll(scope), querySettings] as const,
permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const,
library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const,
+ allRoleAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'allRoleAssignments', querySettings] as const,
+ orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const,
+ scopes: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'scopes', search, page, pageSize] as const,
};
/**
@@ -110,3 +116,54 @@ export const useRevokeUserRoles = () => {
},
});
};
+
+/**
+ * React Query hook to fetch all role assignments across scopes and roles,
+ * with support for filtering, sorting, and pagination.
+ * It retrieves a comprehensive list of user-role assignments based
+ * on the provided query settings.
+ *
+ * @param querySettings - Optional parameters for filtering by roles, scopes,
+ * organizations, search term, sorting, and pagination.
+ *
+ * @example
+ * const { data: roleAssignments } = useAllRoleAssignments({ roles: 'editor', pageSize: 20 });
+ */
+export const useAllRoleAssignments = (querySettings: QuerySettings) => {
+ const result = useQuery({
+ queryKey: authzQueryKeys.allRoleAssignments(querySettings),
+ queryFn: () => getAllRoleAssignments(querySettings),
+ staleTime: 1000 * 60 * 30, // refetch after 30 minutes
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+ return result;
+};
+
+/**
+ * React query hook to fetch the list of organizations for the organization filter component.
+ * @param search - The search term to filter organizations.
+ * @returns The list of organizations matching the search term.
+ */
+export const useOrgs = (search?: string, page?: number, pageSize?: number) => {
+ const result = useQuery({
+ queryKey: authzQueryKeys.orgs(search, page, pageSize),
+ queryFn: () => getOrgs(search, page, pageSize),
+ refetchOnWindowFocus: false,
+ });
+ return result;
+};
+
+/*
+ * React query hook to fetch the list of scopes for the scope filter component.
+ * @param search - The search term to filter scopes.
+ * @returns The list of scopes matching the search term.
+ */
+export const useScopes = (search?: string, page?: number, pageSize?: number) => {
+ const result = useQuery({
+ queryKey: authzQueryKeys.scopes(search, page, pageSize),
+ queryFn: () => getScopes(search, page, pageSize),
+ refetchOnWindowFocus: false,
+ });
+ return result;
+};
diff --git a/src/authz-module/hooks/useQuerySettings.test.tsx b/src/authz-module/hooks/useQuerySettings.test.tsx
new file mode 100644
index 00000000..6a342fa6
--- /dev/null
+++ b/src/authz-module/hooks/useQuerySettings.test.tsx
@@ -0,0 +1,464 @@
+import { renderHook, act } from '@testing-library/react';
+import { QuerySettings } from '@src/authz-module/data/api';
+import { useQuerySettings } from './useQuerySettings';
+
+describe('useQuerySettings', () => {
+ const defaultQuerySettings: QuerySettings = {
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: null,
+ order: null,
+ scopes: null,
+ organizations: null,
+ };
+
+ it('should initialize with default query settings when no initial settings provided', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ expect(result.current.querySettings).toEqual(defaultQuerySettings);
+ expect(typeof result.current.handleTableFetch).toBe('function');
+ });
+
+ it('should initialize with custom initial query settings', () => {
+ const customInitialSettings: QuerySettings = {
+ roles: 'admin,editor',
+ search: 'test-user',
+ pageSize: 20,
+ pageIndex: 2,
+ sortBy: 'username',
+ order: 'asc',
+ scopes: null,
+ organizations: null,
+ };
+
+ const { result } = renderHook(() => useQuerySettings(customInitialSettings));
+
+ expect(result.current.querySettings).toEqual(customInitialSettings);
+ });
+
+ it('should update query settings when handleTableFetch is called with new filters', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 15,
+ pageIndex: 1,
+ sortBy: [{ id: 'username', desc: false }],
+ filters: [
+ { id: 'role', value: ['admin', 'editor'] },
+ { id: 'name', value: 'john' },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: 'admin,editor',
+ search: 'john',
+ pageSize: 15,
+ pageIndex: 1,
+ sortBy: 'username',
+ order: 'asc',
+ scopes: null,
+ organizations: null,
+ });
+ });
+
+ it('should handle descending sort order by adding minus prefix', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'email', desc: true }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.order).toBe('desc');
+ });
+
+ it('should convert camelCase sort field to snake_case', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'firstName', desc: false }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.sortBy).toBe('first_name');
+ });
+
+ it('should convert camelCase sort field to snake_case with descending order', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'lastName', desc: true }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.order).toBe('desc');
+ });
+
+ it('should handle empty filters by setting values to null', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ organizations: null,
+ scopes: null,
+ });
+ });
+
+ it('should handle empty roles filter array by setting roles to null', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [
+ { id: 'roles', value: [] },
+ { id: 'username', value: '' },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ organizations: null,
+ scopes: null,
+ });
+ });
+
+ it('should handle missing filters by setting default values', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [
+ { id: 'roles', value: undefined },
+ { id: 'username', value: undefined },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ organizations: null,
+ scopes: null,
+ });
+ });
+
+ it('should use default pagination values when not provided', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ sortBy: [],
+ filters: [],
+ } as any; // Missing pageSize and pageIndex
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.pageSize).toBe(10);
+ expect(result.current.querySettings.pageIndex).toBe(0);
+ });
+
+ it('should not update state if settings have not changed', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ };
+
+ const initialSettings = result.current.querySettings;
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ // Should be the same object reference since no changes occurred
+ expect(result.current.querySettings).toBe(initialSettings);
+ });
+
+ it('should update state when settings have changed', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const initialSettings = result.current.querySettings;
+
+ const tableFilters = {
+ pageSize: 20, // Different from default
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ // Should be a different object reference since pageSize changed
+ expect(result.current.querySettings).not.toBe(initialSettings);
+ expect(result.current.querySettings.pageSize).toBe(20);
+ });
+
+ it('should handle complex filter combinations', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 25,
+ pageIndex: 3,
+ sortBy: [{ id: 'userRole', desc: true }],
+ filters: [
+ { id: 'role', value: ['admin', 'editor', 'viewer'] },
+ { id: 'name', value: 'test@example.com' },
+ { id: 'otherFilter', value: 'ignored' }, // Should be ignored
+ { id: 'org', value: ['org1', 'org2'] },
+ { id: 'scope', value: ['scope1', 'scope2'] },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: 'admin,editor,viewer',
+ search: 'test@example.com',
+ pageSize: 25,
+ pageIndex: 3,
+ order: 'desc',
+ sortBy: 'user_role',
+ organizations: 'org1,org2',
+ scopes: 'scope1,scope2',
+
+ });
+ });
+
+ it('should handle multiple camelCase words in sort field', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'userFirstLastName', desc: false }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.sortBy).toBe('user_first_last_name');
+ });
+
+ it('should preserve handleTableFetch function reference across renders', () => {
+ const { result, rerender } = renderHook(() => useQuerySettings());
+
+ const initialHandleTableFetch = result.current.handleTableFetch;
+
+ rerender();
+
+ expect(result.current.handleTableFetch).toBe(initialHandleTableFetch);
+ });
+
+ it('should handle whitespace-only search values as provided', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [
+ { id: 'name', value: ' ' }, // Whitespace only
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.search).toBe(' ');
+ });
+
+ it('should detect changes in roles filter', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ // First set some roles
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'role', value: ['admin'] }],
+ });
+ });
+
+ const settingsAfterFirstUpdate = result.current.querySettings;
+
+ // Then change roles
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'role', value: ['editor'] }],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
+ expect(result.current.querySettings.roles).toBe('editor');
+ });
+
+ it('should detect changes in search filter', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ // First set a search term
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'name', value: 'john' }],
+ });
+ });
+
+ const settingsAfterFirstUpdate = result.current.querySettings;
+
+ // Then change search term
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'name', value: 'jane' }],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
+ expect(result.current.querySettings.search).toBe('jane');
+ });
+
+ it('should detect changes in ordering', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ // First set ordering
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'username', desc: false }],
+ filters: [],
+ });
+ });
+
+ const settingsAfterFirstUpdate = result.current.querySettings;
+
+ // Then change ordering
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'email', desc: true }],
+ filters: [],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
+ expect(result.current.querySettings.sortBy).toBe('email');
+ expect(result.current.querySettings.order).toBe('desc');
+ });
+
+ it('should detect changes in pageSize', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const initialSettings = result.current.querySettings;
+
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 50,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(initialSettings);
+ expect(result.current.querySettings.pageSize).toBe(50);
+ });
+
+ it('should detect changes in pageIndex', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const initialSettings = result.current.querySettings;
+
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 5,
+ sortBy: [],
+ filters: [],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(initialSettings);
+ expect(result.current.querySettings.pageIndex).toBe(5);
+ });
+});
diff --git a/src/authz-module/hooks/useQuerySettings.tsx b/src/authz-module/hooks/useQuerySettings.tsx
new file mode 100644
index 00000000..33675c82
--- /dev/null
+++ b/src/authz-module/hooks/useQuerySettings.tsx
@@ -0,0 +1,96 @@
+import { useCallback, useState } from 'react';
+import { QuerySettings } from '@src/authz-module/data/api';
+
+interface DataTableFilters {
+ pageSize: number;
+ pageIndex: number;
+ sortBy: Array<{ id: string; desc: boolean }>;
+ filters: Array<{ id: string; value: any }>;
+}
+
+interface UseQuerySettingsReturn {
+ querySettings: QuerySettings;
+ handleTableFetch: (tableFilters: DataTableFilters) => void;
+}
+
+enum SortOrderKeys {
+ ASC = 'asc',
+ DESC = 'desc',
+}
+
+/**
+ * Custom hook to manage query settings for table data fetching
+ * Converts DataTable filter/sort/pagination settings to API query parameters
+ * and manages URL synchronization
+ *
+ * @param initialQuerySettings - Initial query settings
+ * @returns Object containing querySettings and handleTableFetch function
+ */
+export const useQuerySettings = (
+ initialQuerySettings: QuerySettings = {
+ roles: null,
+ scopes: null,
+ organizations: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ },
+): UseQuerySettingsReturn => {
+ const [querySettings, setQuerySettings] = useState(initialQuerySettings);
+
+ const handleTableFetch = useCallback((tableFilters: DataTableFilters) => {
+ setQuerySettings((prevSettings) => {
+ // Extract filters
+ const rolesFilter = tableFilters.filters?.find((filter) => filter.id === 'role')?.value?.join(',') ?? '';
+ const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'name')?.value ?? '';
+ const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'org')?.value?.join(',') ?? '';
+ const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? '';
+
+ // Extract pagination
+ const { pageSize = 10, pageIndex = 0 } = tableFilters;
+
+ // Extract and convert sorting
+ let sortByOption = '';
+ let sortByOrder = '';
+ if (tableFilters.sortBy?.length) {
+ sortByOption = tableFilters.sortBy[0]?.id.replace(/([A-Z])/g, '_$1').toLowerCase();
+ sortByOrder = tableFilters.sortBy[0]?.desc ? SortOrderKeys.DESC : SortOrderKeys.ASC;
+ }
+
+ const newQuerySettings: QuerySettings = {
+ roles: rolesFilter || null,
+ scopes: scopesFilter || null,
+ organizations: organizationsFilter || null,
+ search: searchFilter || null,
+ sortBy: sortByOption || null,
+ order: sortByOrder || null,
+ pageSize,
+ pageIndex,
+ };
+
+ const hasChanged = (
+ prevSettings.roles !== newQuerySettings.roles
+ || prevSettings.scopes !== newQuerySettings.scopes
+ || prevSettings.organizations !== newQuerySettings.organizations
+ || prevSettings.search !== newQuerySettings.search
+ || prevSettings.pageSize !== newQuerySettings.pageSize
+ || prevSettings.pageIndex !== newQuerySettings.pageIndex
+ || prevSettings.sortBy !== newQuerySettings.sortBy
+ || prevSettings.order !== newQuerySettings.order
+ );
+
+ if (!hasChanged) {
+ return prevSettings; // No change, prevent unnecessary update
+ }
+
+ return newQuerySettings;
+ });
+ }, []);
+
+ return {
+ querySettings,
+ handleTableFetch,
+ };
+};
diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss
index 4c6ef34f..34f941ac 100644
--- a/src/authz-module/index.scss
+++ b/src/authz-module/index.scss
@@ -1,19 +1,20 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
-.authz-libraries {
+.authz-module {
--height-action-divider: 30px;
- hr {
- border-top: var(--pgn-size-border-width) solid var(--pgn-color-border);
- width: 100%;
+ .filters .dropdown-toggle::after {
+ display: none !important;
+ }
+ .pgn__data-table tr:has(td[data-role="Super Admin"]),
+ .pgn__data-table tr:has(td[data-role="Global Staff"]) {
+ background-color: var(--pgn-color-primary-200);
}
- @media (--pgn-size-breakpoint-min-width-lg) {
- hr {
- border-right: var(--pgn-size-border-width) solid var(--pgn-color-border);
- height: var(--height-action-divider);
- width: 0;
- }
+ hr {
+ border-right: var(--pgn-size-border-width) solid var(--pgn-color-border);
+ height: var(--height-action-divider);
+ width: 0;
}
.tab-content {
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
index 0ed2346a..63aba40e 100644
--- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
@@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';
-import { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api';
+import { PutAssignTeamMembersRoleResponse } from '@src/authz-module/data/api';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import { RoleOperationErrorStatus } from '@src/authz-module/constants';
import { AppToast, useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx
index 3763bd8d..5b1bc893 100644
--- a/src/authz-module/libraries-manager/components/TeamTable/index.tsx
+++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx
@@ -12,6 +12,7 @@ import { useTeamMembers } from '@src/authz-module/data/hooks';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants';
+import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
import { useQuerySettings } from './hooks/useQuerySettings';
import TableControlBar from './components/TableControlBar';
import messages from './messages';
@@ -19,8 +20,6 @@ import {
ActionCell, EmailCell, NameCell, RolesCell,
} from './components/Cells';
-const DEFAULT_PAGE_SIZE = 10;
-
const TeamTable = () => {
const intl = useIntl();
const {
@@ -39,7 +38,7 @@ const TeamTable = () => {
}
const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS);
- const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1;
+ const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / TABLE_DEFAULT_PAGE_SIZE) : 1;
const adaptedFilterChoices = useMemo(
() => roles.map((role) => ({
@@ -68,7 +67,7 @@ const TeamTable = () => {
data={rows}
itemCount={teamMembers?.count || 0}
pageCount={pageCount}
- initialState={{ pageSize: DEFAULT_PAGE_SIZE }}
+ initialState={{ pageSize: TABLE_DEFAULT_PAGE_SIZE }}
additionalColumns={[
{
id: 'action',
diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts
index d1368961..787edfb9 100644
--- a/src/authz-module/libraries-manager/constants.ts
+++ b/src/authz-module/libraries-manager/constants.ts
@@ -1,20 +1,26 @@
import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types';
+import {
+ Group, CollectionsBookmark, Notes, AutoAwesomeMosaic,
+} from '@openedx/paragon/icons';
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
+ CREATE_LIBRARY_CONTENT: 'content_libraries.create_library_content',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
+ DELETE_LIBRARY_CONTENT: 'content_libraries.delete_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
+ IMPORT_LIBRARY_CONTENT: 'content_libraries.import_library_content',
+
+ MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
+ VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
-
- MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
- VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};
// Note: this information will eventually come from the backend API
@@ -27,27 +33,114 @@ export const libraryRolesMetadata: RoleMetadata[] = [
];
export const libraryResourceTypes: ResourceMetadata[] = [
- { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' },
- { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' },
- { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' },
- { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' },
+ {
+ key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.', icon: CollectionsBookmark,
+ },
+ {
+ key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.', icon: Notes,
+ },
+ {
+ key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.', icon: Group,
+ },
+ {
+ key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.', icon: AutoAwesomeMosaic,
+ },
];
export const libraryPermissions: PermissionMetadata[] = [
- { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
- { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, resource: 'library_content', description: 'Create content within the library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, resource: 'library_content', description: 'Delete content within the library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' },
{ key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', description: 'Import content from courses.' },
+
+ { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' },
+];
+
+export const rolesLibraryObject = [
+ {
+ role: 'library_admin',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT,
+ ],
+ userCount: 1,
+ name: 'Library Admin',
+ description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.',
+ },
+ {
+ role: 'library_author',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT,
+ ],
+ userCount: 1,
+ name: 'Library Author',
+ description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.',
+ },
+ {
+ role: 'library_contributor',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT,
- { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' },
- { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
+ ],
+ userCount: 1,
+ name: 'Library Contributor',
+ description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.',
+ },
+ {
+ role: 'library_user',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ ],
+ userCount: 1,
+ name: 'Library User',
+ description: 'The Library User can view and reuse content but cannot edit or delete anything.',
+ },
];
export const DEFAULT_TOAST_DELAY = 5000;
diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts
index 9ed3f7d4..bbb2ad41 100644
--- a/src/authz-module/libraries-manager/messages.ts
+++ b/src/authz-module/libraries-manager/messages.ts
@@ -4,27 +4,72 @@ const messages = defineMessages({
'library.authz.manage.page.title': {
id: 'library.authz.manage.page.title',
defaultMessage: 'Library Team Management',
- description: 'Libreries AuthZ page title',
+ description: 'Libraries AuthZ page title',
},
'library.authz.breadcrumb.root': {
id: 'library.authz.breadcrumb.root',
defaultMessage: 'Manage Access',
- description: 'Libreries AuthZ root breafcrumb',
+ description: 'Libraries AuthZ root breadcrumb',
},
'library.authz.tabs.team': {
id: 'library.authz.tabs.team',
defaultMessage: 'Team Members',
- description: 'Libreries AuthZ title for the team management tab',
+ description: 'Libraries AuthZ title for the team management tab',
},
'library.authz.tabs.roles': {
id: 'library.authz.tabs.roles',
defaultMessage: 'Roles',
- description: 'Libreries AuthZ title for the roles tab',
+ description: 'Libraries AuthZ title for the roles tab',
},
'library.authz.tabs.permissions': {
id: 'library.authz.tabs.permissions',
defaultMessage: 'Permissions',
- description: 'Libreries AuthZ title for the permissions tab',
+ description: 'Libraries AuthZ title for the permissions tab',
+ },
+ 'library.authz.tabs.permissionsRoles': {
+ id: 'library.authz.tabs.permissionsRoles',
+ defaultMessage: 'Roles and Permissions',
+ description: 'Libraries AuthZ title for the permissions and roles tab',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.title': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.title',
+ defaultMessage: 'Course Roles',
+ description: 'Libraries AuthZ title for the course roles alert',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.tab': {
+ id: 'library.authz.tabs.permissionsRoles.courses.tab',
+ defaultMessage: 'Courses',
+ description: 'Libraries AuthZ title for the course roles tab',
+ },
+ 'library.authz.tabs.permissionsRoles.libraries.tab': {
+ id: 'library.authz.tabs.permissionsRoles.libraries.tab',
+ defaultMessage: 'Libraries',
+ description: 'Libraries AuthZ title for the libraries roles tab',
+ },
+ 'library.authz.tabs.permissionsRoles.libraries.tab.title': {
+ id: 'library.authz.tabs.permissionsRoles.libraries.tab.title',
+ defaultMessage: 'Library Roles',
+ description: 'Libraries AuthZ title for the library roles table',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.tab.title': {
+ id: 'library.authz.tabs.permissionsRoles.courses.tab.title',
+ defaultMessage: 'Course Roles',
+ description: 'Libraries AuthZ title for the course roles table',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.note': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.note',
+ defaultMessage: 'Note:',
+ description: 'Libraries AuthZ note for the course roles alert',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.description': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.description',
+ defaultMessage: 'This list shows the permissions currently available in Authoring Studio. Some roles may grant additional permissions manages outside this interface.',
+ description: 'Libraries AuthZ description for the course roles alert',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.link': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.link',
+ defaultMessage: 'See full documentation',
+ description: 'Libraries AuthZ link for the course roles alert',
},
'library.authz.team.remove.user.toast.success.description': {
id: 'library.authz.team.remove.user.toast.success.description',
@@ -44,12 +89,12 @@ const messages = defineMessages({
'library.authz.team.toast.502.error.message': {
id: 'library.authz.team.toast.502.error.message',
defaultMessage: 'We\'re having trouble connecting to our services.
Please try again later.',
- description: 'Libraries bad getaway error message',
+ description: 'Libraries bad gateway error message',
},
'library.authz.team.toast.503.error.message': {
id: 'library.authz.team.toast.503.error.message',
defaultMessage: 'The service is temporarily unavailable.
Please try again in a few moments.',
- description: 'Libraries service temporary unabailable message',
+ description: 'Libraries service temporary unavailable message',
},
'library.authz.team.toast.408.error.message': {
id: 'library.authz.team.toast.408.error.message',
diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts
new file mode 100644
index 00000000..88229f8c
--- /dev/null
+++ b/src/authz-module/messages.ts
@@ -0,0 +1,13 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages(
+ {
+ 'authz.management.assign.role.title': {
+ id: 'authz.management.assign.role.title',
+ defaultMessage: 'Assign Role',
+ description: 'Text for the assign role button',
+ },
+ },
+);
+
+export default messages;
diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx
new file mode 100644
index 00000000..af83452b
--- /dev/null
+++ b/src/authz-module/team-members/TeamMembersTable.tsx
@@ -0,0 +1,145 @@
+import { useEffect, useMemo, useState } from 'react';
+import debounce from 'lodash.debounce';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ DataTable,
+ TextFilter,
+} from '@openedx/paragon';
+
+import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
+import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
+import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter';
+import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilter';
+import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter';
+import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar';
+import { getCellHeader } from '@src/authz-module/components/utils';
+import {
+ ActionCell, NameCell, OrgCell, RoleCell, ScopeCell,
+} from '@src/authz-module/components/TableCells';
+import { useAllRoleAssignments } from '@src/authz-module/data/hooks';
+import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
+import messages from './messages';
+import TableFooter from '../components/TableFooter/TableFooter';
+
+interface TeamMembersTableProps {
+ presetScope?: string;
+}
+
+const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => {
+ const intl = useIntl();
+ const { showErrorToast } = useToastManager();
+ const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]);
+
+ const initialQuerySettings = presetScope ? {
+ scopes: presetScope,
+ pageSize: TABLE_DEFAULT_PAGE_SIZE,
+ pageIndex: 0,
+ roles: null,
+ organizations: null,
+ search: null,
+ order: null,
+ sortBy: null,
+ } : undefined;
+
+ const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings);
+
+ const {
+ data: { results: roleAssignments, count } = { results: [], count: 0 },
+ isLoading: isLoadingAllRoleAssignments,
+ error,
+ refetch,
+ } = useAllRoleAssignments(querySettings);
+
+ const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : [];
+
+ useEffect(() => {
+ if (error) {
+ showErrorToast(error, refetch);
+ }
+ }, [error, showErrorToast, refetch]);
+
+ const pageCount = Math.ceil(count / TABLE_DEFAULT_PAGE_SIZE);
+
+ const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]);
+
+ useEffect(() => () => fetchData.cancel(), [fetchData]);
+
+ return (
+
+ );
+};
+
+export default TeamMembersTable;
diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts
new file mode 100644
index 00000000..30eebf70
--- /dev/null
+++ b/src/authz-module/team-members/messages.ts
@@ -0,0 +1,37 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ 'authz.team.members.table.column.name.title': {
+ id: 'authz.team.members.table.column.name.title',
+ defaultMessage: 'Name',
+ description: 'Team members table name column header',
+ },
+ 'authz.team.members.table.column.email.title': {
+ id: 'authz.team.members.table.column.email.title',
+ defaultMessage: 'Email',
+ description: 'Team members table email column header',
+ },
+ 'authz.team.members.table.column.organization.title': {
+ id: 'authz.team.members.table.column.organization.title',
+ defaultMessage: 'Organization',
+ description: 'Team members table organization column header',
+ },
+ 'authz.team.members.table.column.scope.title': {
+ id: 'authz.team.members.table.column.scope.title',
+ defaultMessage: 'Scope',
+ description: 'Team members table scope column header',
+ },
+ 'authz.team.members.table.column.role.title': {
+ id: 'authz.team.members.table.column.role.title',
+ defaultMessage: 'Role',
+ description: 'Team members table role column header',
+ },
+ 'authz.team.members.table.column.actions.title': {
+ id: 'authz.team.members.table.column.actions.title',
+ defaultMessage: 'Actions',
+ description: 'Team members table actions column header',
+ },
+
+});
+
+export default messages;
diff --git a/src/types.ts b/src/types.ts
index 27d78f9f..1ce1c194 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -14,6 +14,9 @@ export interface TeamMember {
email: string;
roles: string[];
createdAt: string;
+ scope: { resource: string, type: 'COURSE' | 'LIBRARY' | 'GLOBAL' };
+ organization: string;
+ role: string;
}
export interface LibraryMetadata {
@@ -49,6 +52,18 @@ export type PermissionMetadata = {
description?: string;
};
+export type Org = {
+ id: string;
+ name: string;
+ shortName: string;
+};
+
+export type Scope = {
+ externalKey: string;
+ displayName: string;
+ org: Org;
+};
+
// Permissions Matrix
export type EnrichedPermission = PermissionMetadata & {
@@ -84,3 +99,21 @@ export interface TableCellValue {
original: T;
};
}
+
+export type AppContextType = {
+ authenticatedUser: {
+ username: string;
+ email: string;
+ };
+};
+
+export interface UserRole {
+ isSuperadmin?: boolean;
+ role: string;
+ org: string;
+ scope: string;
+ permissionCount: number;
+ fullName?: string;
+ username?: string;
+ email?: string;
+}