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; +}