diff --git a/config/setupTests.js b/config/setupTests.js index 884be7999..4535df75f 100644 --- a/config/setupTests.js +++ b/config/setupTests.js @@ -51,3 +51,11 @@ global.matchMedia = }; Object.assign(global, { TextDecoder, TextEncoder }); + +// TODO several tests do not wrap components in a flag provider and assume this returns false +jest.mock('@unleash/proxy-client-react', () => { + return { + __esModule: true, + useFlag: () => false, + }; +}); diff --git a/src/Messages.js b/src/Messages.js index 526a0f3a5..2453d6be9 100644 --- a/src/Messages.js +++ b/src/Messages.js @@ -1,6 +1,93 @@ import { defineMessages } from 'react-intl'; export default defineMessages({ + inviteUsers: { + id: 'inviteUsers', + description: 'Invite users', + defaultMessage: 'Invite users', + }, + inviteUsersTitle: { + id: 'inviteUsersTitle', + description: 'Invite users modal title', + defaultMessage: 'Invite New Users', + }, + inviteUsersDescription: { + id: 'inviteUsersDescription', + description: 'Invite users modal description', + defaultMessage: + 'Invite users to create a Red Hat login with your organization. Your e-mail address will be included in the invite as a point of contact.', + }, + inviteUsersFormIsAdminFieldTitle: { + id: 'inviteUsersFormIsAdminFieldTitle', + description: 'Invite users form is admin field title', + defaultMessage: 'Organization Administrators', + }, + inviteUsersFormIsAdminFieldDescription: { + id: 'inviteUsersFormIsAdminFieldDescription', + description: 'Invite users form is admin field description', + defaultMessage: + 'The organization administrator role is the highest permission level with full access to content and features. This is the only role that can manage users.', + }, + inviteUsersFormEmailsFieldTitle: { + id: 'inviteUsersFormEmailsFieldTitle', + description: 'Invite users form emails field title', + defaultMessage: 'Enter the e-mail addresses of the users you would like to invite', + }, + inviteUsersFormEmailsFieldDescription: { + id: 'inviteUsersFormEmailsFieldDescription', + description: 'Invite users form emails field description', + defaultMessage: 'Enter up to 50 e-mail addresses separated by commas or returns.', + }, + inviteUsersCancelled: { + id: 'inviteUsersCancelled', + description: 'Invite users cancelled notification description', + defaultMessage: 'Invite users process was canceled by the user.', + }, + inviteUsersButton: { + id: 'inviteUsersButton', + description: 'Invite users button text', + defaultMessage: 'Invite new users', + }, + inviteUsersErrorTitle: { + id: 'inviteUsersErrorTitle', + description: 'Invite users error notification title', + defaultMessage: 'Failed inviting all users', + }, + inviteUsersErrorDescription: { + id: 'inviteUsersErrorDescription', + description: 'Invite users error notification description', + defaultMessage: 'Failed inviting users.', + }, + activateUsersButton: { + id: 'activateUsersButton', + description: 'activate users button text', + defaultMessage: 'Activate users', + }, + deactivateUsersButton: { + id: 'deactivateUsersButton', + description: 'deactivate users button text', + defaultMessage: 'Deactivate users', + }, + deactivateUsersConfirmationModalTitle: { + id: 'deactivateUsersConfirmationModalTitle', + description: 'deactivate users confirmation modal title text', + defaultMessage: 'Deactivate users', + }, + deactivateUsersConfirmationModalDescription: { + id: 'deactivateUsersConfirmationModalDescription', + description: 'deactivate users confirmation modal description text', + defaultMessage: 'Are you sure you want to deactivate the user(s) below from your Red Hat organization?', + }, + deactivateUsersConfirmationModalCheckboxText: { + id: 'deactivateUsersConfirmationModalCheckboxText', + description: 'deactivate users confirmation modal checkbox text', + defaultMessage: 'Yes, I confirm that I want to remove these users', + }, + deactivateUsersConfirmationButton: { + id: 'deactivateUsersConfirmationButton', + description: 'deactivate users confirmation button text', + defaultMessage: 'Deactivate user(s)', + }, notApplicable: { id: 'notApplicable', description: 'Not applicable text for resource definitions', @@ -222,6 +309,26 @@ export default defineMessages({ description: 'Edit group error notification description', defaultMessage: 'The group was not updated successfuly.', }, + editUserSuccessTitle: { + id: 'editUserSuccessTitle', + description: 'Edit user success notification title', + defaultMessage: 'Success updating user', + }, + editUserSuccessDescription: { + id: 'editUserSuccessDescription', + description: 'Edit user success notification description', + defaultMessage: 'The user was updated successfully.', + }, + editUserErrorTitle: { + id: 'editUserErrorTitle', + description: 'Edit user error notification title', + defaultMessage: 'Failed updating user', + }, + editUserErrorDescription: { + id: 'editUserErrorDescription', + description: 'Edit user error notification description', + defaultMessage: 'The user was not updated successfuly.', + }, removeGroupSuccess: { id: 'removeGroupSuccess', description: 'Remove group success notification title', diff --git a/src/Routing.tsx b/src/Routing.tsx index 04230c2ea..a7d70a33f 100644 --- a/src/Routing.tsx +++ b/src/Routing.tsx @@ -13,6 +13,7 @@ const Overview = lazy(() => import('./smart-components/overview/overview')); const Users = lazy(() => import('./smart-components/user/users')); const UserDetail = lazy(() => import('./smart-components/user/user')); const AddUserToGroup = lazy(() => import('./smart-components/user/add-user-to-group/add-user-to-group')); +const InviteUsersModal = lazy(() => import('./smart-components/user/invite-users/invite-users-modal')); const Roles = lazy(() => import('./smart-components/role/roles')); const Role = lazy(() => import('./smart-components/role/role')); @@ -37,7 +38,7 @@ const AddGroupServiceAccounts = lazy(() => import('./smart-components/group/serv const RemoveServiceAccountFromGroup = lazy(() => import('./smart-components/group/service-account/remove-group-service-accounts')); const QuickstartsTest = lazy(() => import('./smart-components/quickstarts/quickstarts-test')); -const getRoutes = ({ enableServiceAccounts }: Record) => [ +const getRoutes = ({ enableServiceAccounts, isITLess }: Record) => [ { path: pathnames.overview.path, element: Overview, @@ -60,7 +61,10 @@ const getRoutes = ({ enableServiceAccounts }: Record) => [ path: pathnames.users.path, element: Users, }, - + isITLess && { + path: pathnames['invite-users'].path, + element: InviteUsersModal, + }, { path: pathnames['role-detail'].path, element: Role, @@ -233,6 +237,7 @@ const renderRoutes = (routes: RouteType[] = []) => const Routing = () => { const location = useLocation(); const { updateDocumentTitle, isBeta } = useChrome(); + const isITLess = useFlag('platform.rbac.itless'); const enableServiceAccounts = (isBeta() && useFlag('platform.rbac.group-service-accounts')) || (!isBeta() && useFlag('platform.rbac.group-service-accounts.stable')); @@ -252,7 +257,7 @@ const Routing = () => { } }, [location.pathname, updateDocumentTitle]); - const routes = getRoutes({ enableServiceAccounts }); + const routes = getRoutes({ enableServiceAccounts, isITLess }); const renderedRoutes = useMemo(() => renderRoutes(routes as never), [routes]); return ( }> diff --git a/src/helpers/shared/helpers.js b/src/helpers/shared/helpers.js index 6cba8969a..8d13e6151 100644 --- a/src/helpers/shared/helpers.js +++ b/src/helpers/shared/helpers.js @@ -79,3 +79,19 @@ export const getDateFormat = (date) => { const monthAgo = new Date(Date.now()); return Date.parse(date) < monthAgo.setMonth(monthAgo.getMonth() - 1) ? 'onlyDate' : 'relative'; }; + +export const isExternalIdp = (token = '') => { + let roles = ['']; + let tokenArray = token.split('.'); + if (tokenArray.length > 1) { + let token1 = window.atob(tokenArray[1]); + if (token1) { + roles = JSON.parse(token1)?.realm_access?.roles; + if (roles.includes('external-idp')) { + return true; + } + } + } + + return false; +}; diff --git a/src/helpers/user/user-helper.js b/src/helpers/user/user-helper.js index 3c4e2398f..4e2edca08 100644 --- a/src/helpers/user/user-helper.js +++ b/src/helpers/user/user-helper.js @@ -1,5 +1,6 @@ import { getLastPageOffset, isOffsetValid } from '../shared/pagination'; import { getPrincipalApi } from '../shared/user-login'; +import { isInt, isStage, isITLessProd } from '../../itLessConfig'; const principalApi = getPrincipalApi(); @@ -8,6 +9,117 @@ const principalStatusApiMap = { Inactive: 'disabled', All: 'all', }; + +const getBaseUrl = (url) => { + if (isInt) { + return url.int; + } else if (isStage) { + return url.stage; + } else if (isITLessProd) { + return url.prod; + } else { + return ''; + } +}; + +async function fetchBaseUrl() { + try { + // TODO move to env var defined in cluster surfaced through chrome service + const response = await fetch('/apps/rbac/env.json'); + const jsonData = await response.json(); + return jsonData; + } catch (error) { + console.log(error); + } +} + +const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +function handleResponse(response, resolve, reject) { + if (response.ok && response.status !== 206) { + resolve(response); + } else if (response.ok && response.status === 206) { + response.json().then((body) => { + reject(body); + }); + } else { + reject(response); + } +} + +function handleError(error, reject) { + reject(new Error(error.message)); +} + +export async function addUsers(usersData = { emails: [], isAdmin: undefined }) { + const requestOpts = { + method: 'POST', + headers, + body: JSON.stringify({ + emails: usersData.emails, + isAdmin: usersData.isAdmin, + }), + }; + const url = await fetchBaseUrl(); + const baseUrl = getBaseUrl(url); + let promise = new Promise((resolve, reject) => { + return fetch(`${baseUrl}/user/invite`, requestOpts) + .then( + (response) => handleResponse(response, resolve, reject), + (error) => handleError(error, reject) + ) + .catch((error) => handleError(error, reject)); + }); + + return promise; +} + +export async function updateUserIsOrgAdminStatus(user) { + let requestOpts = { + method: 'PUT', + headers, + }; + + const url = await fetchBaseUrl(); + const baseUrl = getBaseUrl(url); + + let promise = new Promise((resolve, reject) => { + return fetch(`${baseUrl}/user/${user.id}/admin/${user.is_org_admin}`, requestOpts) + .then( + (response) => handleResponse(response, resolve, reject), + (error) => handleError(error, reject) + ) + .catch((error) => handleError(error, reject)); + }); + + return promise; +} + +export async function updateUsers(users) { + let requestOpts = { + method: 'PUT', + headers, + body: JSON.stringify({ users: users }), + }; + + const url = await fetchBaseUrl(); + const baseUrl = getBaseUrl(url); + + let promise = new Promise((resolve, reject) => { + return fetch(`${baseUrl}/change-users-status`, requestOpts) + .then( + (response) => handleResponse(response, resolve, reject), + (error) => handleError(error, reject) + ) + .catch((error) => handleError(error, reject)); + }); + + return promise; +} + export async function fetchUsers({ limit, offset = 0, orderBy, filters = {}, usesMetaInURL, matchCriteria = 'partial' }) { const { username, email, status = [] } = filters; const sortOrder = orderBy === '-username' ? 'desc' : 'asc'; diff --git a/src/itLessConfig.js b/src/itLessConfig.js new file mode 100644 index 000000000..821803bec --- /dev/null +++ b/src/itLessConfig.js @@ -0,0 +1,4 @@ +export const isEphem = insights.chrome.getEnvironment() === 'ephem'; +export const isInt = insights.chrome.getEnvironment() === 'int'; +export const isStage = insights.chrome.getEnvironment() === 'frhStage'; +export const isITLessProd = insights.chrome.getEnvironment() === 'frh'; diff --git a/src/presentational-components/shared/ActiveUsers.js b/src/presentational-components/shared/ActiveUsers.js index 26031064a..6948833c2 100644 --- a/src/presentational-components/shared/ActiveUsers.js +++ b/src/presentational-components/shared/ActiveUsers.js @@ -6,6 +6,7 @@ import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import PermissionsContext from '../../utilities/permissions-context'; import messages from '../../Messages'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; +import { useFlag } from '@unleash/proxy-client-react'; const ActiveUser = ({ linkDescription, linkTitle }) => { const intl = useIntl(); @@ -13,7 +14,8 @@ const ActiveUser = ({ linkDescription, linkTitle }) => { const env = chrome.getEnvironment(); const prefix = chrome.isProd() ? '' : `${env}.`; const { orgAdmin } = useContext(PermissionsContext); - return orgAdmin ? ( + const isITLess = useFlag('platform.rbac.itless'); + return !isITLess && orgAdmin ? ( {`${intl.formatMessage(messages.usersDescription)} `} {linkDescription} diff --git a/src/presentational-components/shared/table-toolbar-view.js b/src/presentational-components/shared/table-toolbar-view.js index 06c4fe2a9..1482d3e66 100644 --- a/src/presentational-components/shared/table-toolbar-view.js +++ b/src/presentational-components/shared/table-toolbar-view.js @@ -2,6 +2,7 @@ import React, { Fragment } from 'react'; import { useIntl } from 'react-intl'; import propTypes from 'prop-types'; import messages from '../../Messages'; +import { useFlag } from '@unleash/proxy-client-react'; import { TableVariant } from '@patternfly/react-table'; import { Table, TableHeader, TableBody } from '@patternfly/react-table/deprecated'; import TableToolbar from '@redhat-cloud-services/frontend-components/TableToolbar'; @@ -59,8 +60,10 @@ export const TableToolbarView = ({ tableId, containerRef, textFilterRef, + toolbarChildren, }) => { const intl = useIntl(); + const isITLess = useFlag('platform.rbac.itless'); const renderEmpty = () => ({ title: ( {isLoading ? ( 0 && { onSelect: (_e, isSelected, _idx, { uuid, cells: [name], requires }) => - setCheckedItems(selectedRows([{ uuid, name, requires }], isSelected)), + setCheckedItems(selectedRows([{ uuid, name, requires, ...(isITLess && { username: data[_idx]?.username }) }], isSelected)), })} {...(isExpandable && { onExpand })} rows={rows?.length > 0 ? rows : [{ fullWidth: true, cells: [renderEmpty()] }]} @@ -234,6 +238,7 @@ TableToolbarView.propTypes = { noDataDescription: propTypes.arrayOf(propTypes.node), filters: propTypes.array, tableId: propTypes.string.isRequired, + toolbarChildren: propTypes.func, }; TableToolbarView.defaultProps = { @@ -245,4 +250,5 @@ TableToolbarView.defaultProps = { hideFilterChips: false, checkedRows: [], hideHeader: false, + toolbarChildren: () => null, }; diff --git a/src/presentational-components/shared/toolbar.js b/src/presentational-components/shared/toolbar.js index 8e1203b45..770e0cc74 100644 --- a/src/presentational-components/shared/toolbar.js +++ b/src/presentational-components/shared/toolbar.js @@ -270,6 +270,7 @@ const Toolbar = ({ hideFilterChips, tableId, textFilterRef, + toolbarChildren, }) => ( + > + {toolbarChildren()} + ); Toolbar.propTypes = { @@ -348,6 +351,7 @@ Toolbar.propTypes = { hideFilterChips: PropTypes.bool, tableId: PropTypes.string, textFilterRef: PropTypes.object, + toolbarChildren: PropTypes.func, }; Toolbar.defaultProps = { @@ -366,6 +370,7 @@ Toolbar.defaultProps = { filters: [], isFilterable: false, hideFilterChips: false, + toolbarChildren: () => null, }; export default Toolbar; diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 51e960e14..004fa8c8a 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -9,6 +9,9 @@ export const UPDATE_GROUP = 'UPDATE_GROUP'; export const REMOVE_GROUPS = 'REMOVE_GROUPS'; export const UPDATE_GROUPS_FILTERS = 'UPDATE_GROUPS_FILTERS'; +export const ADD_USERS = 'ADD_USERS'; +export const UPDATE_USER_IS_ORG_ADMIN_STATUS = 'UPDATE_USER_IS_ORG_ADMIN_STATUS'; +export const UPDATE_USERS = 'UPDATE_USERS'; export const FETCH_USERS = 'FETCH_USERS'; export const UPDATE_USERS_FILTERS = 'UPDATE_USERS_FILTERS'; diff --git a/src/redux/actions/user-actions.js b/src/redux/actions/user-actions.js index b48724c72..4488a444d 100644 --- a/src/redux/actions/user-actions.js +++ b/src/redux/actions/user-actions.js @@ -1,5 +1,94 @@ import * as ActionTypes from '../action-types'; import * as UserHelper from '../../helpers/user/user-helper'; +import { createIntl, createIntlCache } from 'react-intl'; +import messages from '../../Messages'; +import providerMessages from '../../locales/data.json'; +import { locale } from '../../AppEntry'; + +export const addUsers = (usersData) => { + const cache = createIntlCache(); + const intl = createIntl({ locale, messages: providerMessages }, cache); + return { + type: ActionTypes.ADD_USERS, + payload: UserHelper.addUsers(usersData), + meta: { + notifications: { + rejected: (payload) => { + if (!payload.status) { + return { + variant: 'warning', + title: intl.formatMessage(messages.inviteUsersErrorTitle), + dismissDelay: 8000, + dismissable: true, + description: payload, + }; + } + return { + variant: 'danger', + title: intl.formatMessage(messages.inviteUsersErrorTitle), + dismissDelay: 8000, + dismissable: true, + description: intl.formatMessage(messages.inviteUsersErrorDescription), + }; + }, + }, + }, + }; +}; + +export const updateUserIsOrgAdminStatus = (user) => { + const cache = createIntlCache(); + const intl = createIntl({ locale, messages: providerMessages }, cache); + return { + type: ActionTypes.UPDATE_USER_IS_ORG_ADMIN_STATUS, + payload: UserHelper.updateUserIsOrgAdminStatus(user), + meta: { + notifications: { + fulfilled: { + variant: 'success', + title: intl.formatMessage(messages.editUserSuccessTitle), + dismissDelay: 8000, + dismissable: true, + description: intl.formatMessage(messages.editUserSuccessDescription), + }, + rejected: { + variant: 'danger', + title: intl.formatMessage(messages.editUserErrorTitle), + dismissDelay: 8000, + dismissable: true, + description: intl.formatMessage(messages.editUserErrorDescription), + }, + }, + }, + }; +}; + +export const updateUsers = (userList) => { + const cache = createIntlCache(); + const intl = createIntl({ locale, messages: providerMessages }, cache); + return { + type: ActionTypes.UPDATE_USERS, + payload: UserHelper.updateUsers(userList), + meta: { + notifications: { + fulfilled: { + variant: 'success', + title: intl.formatMessage(messages.editUserSuccessTitle), + dismissDelay: 8000, + dismissable: true, + description: intl.formatMessage(messages.editUserSuccessDescription), + }, + rejected: { + variant: 'danger', + title: intl.formatMessage(messages.editUserErrorTitle), + dismissDelay: 8000, + dismissable: true, + description: intl.formatMessage(messages.editUserErrorDescription), + }, + }, + }, + }; +}; export const fetchUsers = (apiProps) => ({ type: ActionTypes.FETCH_USERS, diff --git a/src/smart-components/group/add-group/set-users.js b/src/smart-components/group/add-group/set-users.js index 09c860cdb..6179d2fb6 100644 --- a/src/smart-components/group/add-group/set-users.js +++ b/src/smart-components/group/add-group/set-users.js @@ -1,9 +1,11 @@ import React, { Fragment, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; +import { useFlag } from '@unleash/proxy-client-react'; import { Form, FormGroup, Stack, StackItem, TextContent } from '@patternfly/react-core'; import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api'; import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api'; import UsersList from './users-list'; +import UsersListItless from './users-list-itless'; import ActiveUser from '../../../presentational-components/shared/ActiveUsers'; import { useIntl } from 'react-intl'; import messages from '../../../Messages'; @@ -14,6 +16,7 @@ const SetUsers = (props) => { const { input } = useFieldApi(props); const intl = useIntl(); const formOptions = useFormApi(); + const isITLess = useFlag('platform.rbac.itless'); useEffect(() => { setSelectedUsers(formOptions.getState().values['users-list'] || []); @@ -24,19 +27,27 @@ const SetUsers = (props) => { formOptions.change('users-list', selectedUsers); }, [selectedUsers]); + const activeUserProps = { + ...(!isITLess && { linkDescription: intl.formatMessage(messages.toManageUsersText) }), + }; + + const usersListProps = { + selectedUsers, + setSelectedUsers, + displayNarrow: true, + }; + return (
- + - - - + {isITLess ? : }
diff --git a/src/smart-components/group/add-group/users-list-itless.js b/src/smart-components/group/add-group/users-list-itless.js new file mode 100644 index 000000000..e5be53354 --- /dev/null +++ b/src/smart-components/group/add-group/users-list-itless.js @@ -0,0 +1,539 @@ +import React, { useEffect, Fragment, useState, useContext, useRef, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import truncate from 'lodash/truncate'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { TableToolbarView } from '../../../presentational-components/shared/table-toolbar-view'; +import AppLink, { mergeToBasename } from '../../../presentational-components/shared/AppLink'; +import { fetchUsers, updateUsersFilters, updateUsers, updateUserIsOrgAdminStatus } from '../../../redux/actions/user-actions'; +import { Button, Switch as PF4Switch, Label, Modal, ModalVariant, List, ListItem, Checkbox, Stack, StackItem } from '@patternfly/react-core'; +import { Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core/deprecated'; +import { sortable, nowrap } from '@patternfly/react-table'; +import { CheckIcon, CloseIcon } from '@patternfly/react-icons'; +import { mappedProps, isExternalIdp } from '../../../helpers/shared/helpers'; +import UsersRow from '../../../presentational-components/shared/UsersRow'; +import { + defaultSettings, + defaultAdminSettings, + syncDefaultPaginationWithUrl, + applyPaginationToUrl, + isPaginationPresentInUrl, +} from '../../../helpers/shared/pagination'; +import { syncDefaultFiltersWithUrl, applyFiltersToUrl, areFiltersPresentInUrl } from '../../../helpers/shared/filters'; +import messages from '../../../Messages'; +import PermissionsContext from '../../../utilities/permissions-context'; +import { useScreenSize, isSmallScreen } from '@redhat-cloud-services/frontend-components/useScreenSize'; +import paths from '../../../utilities/pathnames'; +import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; + +const IsAdminCellTextContent = ({ isOrgAdmin }) => { + const intl = useIntl(); + + return isOrgAdmin ? ( + + + {intl.formatMessage(messages.yes)} + + ) : ( + + + {intl.formatMessage(messages.no)} + + ); +}; + +IsAdminCellTextContent.propTypes = { + isOrgAdmin: PropTypes.bool, +}; + +const IsAdminCellDropdownContent = ({ isOrgAdmin, userId, isDisabled, toggleUserIsOrgAdminStatus }) => { + const [isAdminDropdownOpen, setIsAdminDropdownOpen] = useState(false); + const intl = useIntl(); + + const onIsAdminDropdownToggle = (isOpen) => { + setIsAdminDropdownOpen(isOpen); + }; + + const onIsAdminDropdownSelect = (_event) => { + const isAdminStatusMap = { yes: true, no: false }; + + toggleUserIsOrgAdminStatus(isAdminStatusMap[_event?.target?.id], null, { userId }); + setIsAdminDropdownOpen(false); + }; + + const dropdownItems = [ + + {intl.formatMessage(messages.yes)} + , + + {intl.formatMessage(messages.no)} + , + ]; + return ( + + {isOrgAdmin ? intl.formatMessage(messages.yes) : intl.formatMessage(messages.no)} + + } + isOpen={isAdminDropdownOpen} + dropdownItems={dropdownItems} + /> + ); +}; + +IsAdminCellDropdownContent.propTypes = { + isOrgAdmin: PropTypes.bool, + userId: PropTypes.string, + isDisabled: PropTypes.bool, + toggleUserIsOrgAdminStatus: PropTypes.func, +}; + +const UsersListItless = ({ selectedUsers, setSelectedUsers, userLinks, usesMetaInURL, displayNarrow, props }) => { + const intl = useIntl(); + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + const [selectedRows, setSelectedRows] = useState([]); + const [isDeactivateConfirmationModalOpen, setIsDeactivateConfirmationModalOpen] = useState(false); + const [isDeactivateConfirmationChecked, setIsDeactivateConfirmationChecked] = useState(false); + const [isToolbarDropdownOpen, setIsToolbarDropdownOpen] = useState(false); + const { orgAdmin } = useContext(PermissionsContext); + const screenSize = useScreenSize(); + // use for text filter to focus + const innerRef = useRef(null); + const isAdmin = orgAdmin; + const chrome = useChrome(); + const [currentUser, setCurrentUser] = useState({}); + const [userToken, setUserToken] = useState(''); + + // for usesMetaInURL (Users page) store pagination settings in Redux, otherwise use results from meta + let pagination = useSelector(({ userReducer: { users } }) => ({ + limit: (usesMetaInURL ? users.pagination.limit : users.meta.limit) ?? (orgAdmin ? defaultAdminSettings : defaultSettings).limit, + offset: (usesMetaInURL ? users.pagination.offset : users.meta.offset) ?? (orgAdmin ? defaultAdminSettings : defaultSettings).offset, + count: usesMetaInURL ? users.pagination.count : users.meta.count, + redirected: usesMetaInURL && users.pagination.redirected, + })); + + const { users, isLoading, stateFilters } = useSelector( + ({ + userReducer: { + users: { data, filters = {} }, + isUserDataLoading, + }, + }) => ({ + users: data?.map?.((data) => ({ ...data, uuid: data.external_source_id })), + isLoading: isUserDataLoading, + stateFilters: location.search.length > 0 || Object.keys(filters).length > 0 ? filters : { status: ['Active'] }, + }) + ); + + const fetchData = useCallback((apiProps) => dispatch(fetchUsers(apiProps)), [dispatch]); + + const confirmDeactivateUsers = () => { + toggleUserActivationStatus(false, null, selectedRows); + setIsDeactivateConfirmationModalOpen(false); + setIsDeactivateConfirmationChecked(false); + }; + + const toggleUserIsOrgAdminStatus = (isOrgAdmin, _event, user = {}) => { + const { limit, offset } = syncDefaultPaginationWithUrl(location, navigate, pagination); + const newFilters = usesMetaInURL + ? syncDefaultFiltersWithUrl(location, navigate, ['username', 'email', 'status'], filters) + : { status: filters.status }; + const newUserObj = { id: user.userId, is_org_admin: isOrgAdmin }; + dispatch(updateUserIsOrgAdminStatus(newUserObj)) + .then(() => { + setFilters(newFilters); + if (setSelectedUsers) { + setSelectedUsers([]); + } else { + setSelectedRows([]); + } + fetchData({ ...mappedProps({ limit, offset, filters: newFilters }), usesMetaInURL }); + }) + .catch((err) => { + console.error(err); + }); + }; + + const toolbarDropdowns = () => { + const onToggle = (isOpen) => { + setIsToolbarDropdownOpen(isOpen); + }; + const onToolbarDropdownSelect = async (_event) => { + const userActivationStatusMap = { activate: true, deactivate: false }; + + if (_event?.target?.id === 'deactivate') { + setIsDeactivateConfirmationModalOpen(true); + } else { + toggleUserActivationStatus(userActivationStatusMap[_event?.target?.id], null, selectedRows); + } + setIsToolbarDropdownOpen(false); + }; + const dropdownItems = [ + + {intl.formatMessage(messages.activateUsersButton)} + , + + {intl.formatMessage(messages.deactivateUsersButton)} + , + ]; + return ( + + {intl.formatMessage(messages.activateUsersButton)} + + } + isOpen={isToolbarDropdownOpen} + dropdownItems={dropdownItems} + /> + ); + }; + const toolbarButtons = () => [ + + + , + ...(isSmallScreen(screenSize) + ? [ + { + label: intl.formatMessage(messages.inviteUsers), + onClick: () => { + navigate(mergeToBasename(paths['invite-users'].link)); + }, + }, + ] + : []), + ]; + const toggleUserActivationStatus = (isActivated, _event, users = []) => { + const { limit, offset } = syncDefaultPaginationWithUrl(location, navigate, pagination); + const newFilters = usesMetaInURL + ? syncDefaultFiltersWithUrl(location, navigate, ['username', 'email', 'status'], filters) + : { status: filters.status }; + const newUserList = users.map((user) => { + return { id: user?.uuid || user?.external_source_id, is_active: isActivated }; + }); + dispatch(updateUsers(newUserList)) + .then(() => { + setFilters(newFilters); + if (setSelectedUsers) { + setSelectedUsers([]); + } else { + setSelectedRows([]); + } + fetchData({ ...mappedProps({ limit, offset, filters: newFilters }), usesMetaInURL }); + }) + .catch((err) => { + console.error(err); + }); + }; + + useEffect(() => { + chrome.auth.getUser().then((user) => setCurrentUser(user)); + chrome.auth.getToken().then((token) => setUserToken(token)); + }, []); + + const isUserSelectable = (external_source_id) => external_source_id != currentUser?.identity?.internal?.account_id; + + const createITLessRows = (userLinks, data, checkedRows = []) => { + const maxLength = 25; + return data + ? data.reduce( + ( + acc, + { external_source_id, username, is_active: is_active, email, first_name: firstName, last_name: lastName, is_org_admin: isOrgAdmin } + ) => [ + ...acc, + { + uuid: external_source_id, + cells: [ + { + title: + isAdmin && !displayNarrow ? ( + + ) : ( + + ), + props: { + 'data-is-active': isOrgAdmin, + }, + }, + { + title: userLinks ? ( + {username.toString()} + ) : displayNarrow ? ( + {truncate(username, { length: maxLength })} + ) : ( + username + ), + }, + { + title: displayNarrow ? {truncate(email, { length: maxLength })} : email, + }, + firstName, + lastName, + { + title: + isAdmin && !displayNarrow ? ( + { + toggleUserActivationStatus(checked, _event, [ + { + external_source_id, + is_active: is_active, + }, + ]); + }} + /> + ) : ( + + ), + props: { + 'data-is-active': is_active, + }, + }, + ], + selected: Boolean(checkedRows?.find?.(({ uuid }) => uuid === external_source_id)), + disableSelection: displayNarrow ? undefined : !isUserSelectable(external_source_id), + }, + ], + [] + ) + : []; + }; + + const updateStateFilters = useCallback((filters) => dispatch(updateUsersFilters(filters)), [dispatch]); + const columns = [ + { title: intl.formatMessage(displayNarrow ? messages.orgAdmin : messages.orgAdministrator), key: 'org-admin', transforms: [nowrap] }, + { title: intl.formatMessage(messages.username), key: 'username', transforms: [sortable] }, + { title: intl.formatMessage(messages.email) }, + { title: intl.formatMessage(messages.firstName), transforms: [nowrap] }, + { title: intl.formatMessage(messages.lastName), transforms: [nowrap] }, + { title: intl.formatMessage(messages.status), transforms: [nowrap] }, + ]; + const [sortByState, setSortByState] = useState({ index: 1, direction: 'asc' }); + + const [filters, setFilters] = useState( + usesMetaInURL + ? stateFilters + : { + username: '', + email: '', + status: [intl.formatMessage(messages.active)], + } + ); + + useEffect(() => { + usesMetaInURL && applyPaginationToUrl(location, navigate, pagination.limit, pagination.offset); + }, [pagination.offset, pagination.limit, pagination.count, pagination.redirected]); + + useEffect(() => { + const { limit, offset } = syncDefaultPaginationWithUrl(location, navigate, pagination); + const newFilters = usesMetaInURL + ? syncDefaultFiltersWithUrl(location, navigate, ['username', 'email', 'status'], filters) + : { status: filters.status }; + setFilters(newFilters); + fetchData({ ...mappedProps({ limit, offset, filters: newFilters }), usesMetaInURL }); + }, []); + + useEffect(() => { + if (usesMetaInURL) { + isPaginationPresentInUrl(location) || applyPaginationToUrl(location, navigate, pagination.limit, pagination.offset); + Object.values(filters).some((filter) => filter?.length > 0) && + !areFiltersPresentInUrl(location, Object.keys(filters)) && + syncDefaultFiltersWithUrl(location, navigate, Object.keys(filters), filters); + } + }); + + const setCheckedItems = (newSelection) => { + if (setSelectedUsers) { + setSelectedUsers((users) => { + return newSelection(users) + .filter((user) => (displayNarrow ? user : user?.uuid != currentUser?.identity?.internal?.account_id)) + .map(({ uuid, username }) => ({ uuid, label: username || uuid })); + }); + } else { + setSelectedRows((users) => { + return newSelection(users) + .filter((user) => (displayNarrow ? user : user?.uuid != currentUser?.identity?.internal?.account_id)) + .map(({ uuid, username }) => ({ uuid, label: username || uuid })); + }); + } + }; + + const updateFilters = (payload) => { + usesMetaInURL && updateStateFilters(payload); + setFilters({ username: '', ...payload }); + }; + return ( + <> + + + { + setIsDeactivateConfirmationChecked(checked); + }} + id="deactivateUsersConfirmationCheckbox" + name="deactivate-users-confirmation-checkbox" + /> + + + + + + + } + onClose={() => { + setIsDeactivateConfirmationModalOpen(false); + }} + > + + {selectedRows.map((user) => ( + {user.label} + ))} + + + null} + toolbarButtons={isAdmin && !displayNarrow && !isExternalIdp(userToken) ? toolbarButtons : () => []} + isCompact + isSelectable + borders={false} + columns={columns} + rows={createITLessRows(userLinks, users, selectedUsers ? selectedUsers : selectedRows)} + sortBy={sortByState} + onSort={(e, index, direction) => { + const orderBy = `${direction === 'desc' ? '-' : ''}${columns[index - 1].key}`; + setSortByState({ index, direction }); + fetchData({ ...pagination, filters, usesMetaInURL, orderBy }); + }} + data={users} + ouiaId="users-table" + fetchData={(config) => { + const status = Object.prototype.hasOwnProperty.call(config, 'status') ? config.status : filters.status; + const { username, email, count, limit, offset, orderBy } = config; + + fetchData({ ...mappedProps({ count, limit, offset, orderBy, filters: { username, email, status } }), usesMetaInURL }).then(() => { + innerRef?.current?.focus(); + }); + usesMetaInURL && applyFiltersToUrl(location, navigate, { username, email, status }); + }} + emptyFilters={{ username: '', email: '', status: '' }} + setFilterValue={({ username, email, status }) => { + updateFilters({ + username: typeof username === 'undefined' ? filters.username : username, + email: typeof email === 'undefined' ? filters.email : email, + status: typeof status === 'undefined' || status === filters.status ? filters.status : status, + }); + }} + isLoading={isLoading} + pagination={pagination} + checkedRows={selectedUsers ? selectedUsers : selectedRows} + setCheckedItems={setCheckedItems} + rowWrapper={UsersRow} + titlePlural={intl.formatMessage(messages.users).toLowerCase()} + titleSingular={intl.formatMessage(messages.user)} + filters={[ + { + key: 'username', + value: filters.username, + placeholder: intl.formatMessage(messages.filterByKey, { key: intl.formatMessage(messages.username).toLowerCase() }), + innerRef, + }, + { + key: 'email', + value: filters.email, + placeholder: intl.formatMessage(messages.filterByKey, { key: intl.formatMessage(messages.email).toLowerCase() }), + innerRef, + }, + { + key: 'status', + value: filters.status, + label: intl.formatMessage(messages.status), + type: 'checkbox', + items: [ + { label: intl.formatMessage(messages.active), value: 'Active' }, + { label: intl.formatMessage(messages.inactive), value: 'Inactive' }, + ], + }, + ]} + tableId="users-list" + {...props} + /> + + ); +}; + +UsersListItless.propTypes = { + displayNarrow: PropTypes.bool, + users: PropTypes.array, + searchFilter: PropTypes.string, + setSelectedUsers: PropTypes.func, + selectedUsers: PropTypes.array, + userLinks: PropTypes.bool, + props: PropTypes.object, + usesMetaInURL: PropTypes.bool, +}; + +UsersListItless.defaultProps = { + displayNarrow: false, + users: [], + userLinks: false, + usesMetaInURL: false, +}; + +export default UsersListItless; diff --git a/src/smart-components/group/member/add-group-members.js b/src/smart-components/group/member/add-group-members.js index 852233a62..c03a872b9 100644 --- a/src/smart-components/group/member/add-group-members.js +++ b/src/smart-components/group/member/add-group-members.js @@ -3,11 +3,13 @@ import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import { useIntl } from 'react-intl'; import PropTypes from 'prop-types'; +import { useFlag } from '@unleash/proxy-client-react'; import { Button, Modal, ModalVariant, StackItem, Stack, TextContent } from '@patternfly/react-core'; import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import { addMembersToGroup, fetchMembersForGroup, fetchGroups } from '../../../redux/actions/group-actions'; import UsersList from '../add-group/users-list'; +import UsersListItless from '../add-group/users-list-itless'; import ActiveUser from '../../../presentational-components/shared/ActiveUsers'; import useAppNavigate from '../../../hooks/useAppNavigate'; import messages from '../../../Messages'; @@ -19,6 +21,7 @@ const AddGroupMembers = ({ cancelRoute }) => { const navigate = useAppNavigate(); const { groupId } = useParams(); const dispatch = useDispatch(); + const isITLess = useFlag('platform.rbac.itless'); const [selectedUsers, setSelectedUsers] = useState([]); @@ -53,6 +56,16 @@ const AddGroupMembers = ({ cancelRoute }) => { navigate(cancelRoute); }; + const activeUserProps = { + ...(!isITLess && { linkDescription: intl.formatMessage(messages.toManageUsersText) }), + }; + + const usersListProps = { + selectedUsers, + setSelectedUsers, + displayNarrow: true, + }; + return ( { - + - - - + {isITLess ? : } ); diff --git a/src/smart-components/user/invite-users/invite-users-modal.js b/src/smart-components/user/invite-users/invite-users-modal.js new file mode 100644 index 000000000..ad7ec3246 --- /dev/null +++ b/src/smart-components/user/invite-users/invite-users-modal.js @@ -0,0 +1,133 @@ +import React, { useState, Fragment, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; +import { Modal, Button, ModalVariant, ExpandableSection, Form, FormGroup, TextArea, Checkbox } from '@patternfly/react-core'; +import { useDispatch } from 'react-redux'; +import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/'; +import WarningModal from '@patternfly/react-component-groups/dist/dynamic/WarningModal'; +import messages from '../../../Messages'; +import { addUsers } from '../../../redux/actions/user-actions'; +import PropTypes from 'prop-types'; +import paths from '../../../utilities/pathnames'; +import { mergeToBasename } from '../../../presentational-components/shared/AppLink'; + +const InviteUsersModal = ({ fetchData }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + const navigate = useNavigate(); + + const [isCheckboxLabelExpanded, setIsCheckboxLabelExpanded] = useState(false); + const [areNewUsersAdmins, setAreNewUsersAdmins] = useState(false); + const [rawEmails, setRawEmails] = useState(''); + const [userEmailList, setUserEmailList] = useState([]); + const [cancelWarningVisible, setCancelWarningVisible] = useState(false); + + const onSubmit = () => { + const newUsersData = { emails: userEmailList, isAdmin: areNewUsersAdmins }; + dispatch(addUsers(newUsersData)) + .then(() => { + fetchData(); + navigate(mergeToBasename(paths.users.link)); + }) + .catch((err) => { + console.error(err); + }); + }; + + const onCancel = () => (userEmailList?.length > 0 && setCancelWarningVisible(true)) || redirectToUsers(); + + const onCheckboxLabelToggle = (isExpanded) => { + setIsCheckboxLabelExpanded(isExpanded); + }; + + const extractEmails = (rawEmails) => { + const regex = /([a-zA-Z0-9._+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi; + let emails = rawEmails.match(regex) || []; + setUserEmailList(emails); + }; + + const handleRawEmailsChange = (value) => { + setRawEmails(value); + }; + + const redirectToUsers = () => { + dispatch( + addNotification({ + variant: 'warning', + title: intl.formatMessage(messages.inviteUsers), + dismissDelay: 8000, + description: intl.formatMessage(messages.inviteUsersCancelled), + }) + ); + navigate(mergeToBasename(paths.users.link)); + }; + + useEffect(() => { + extractEmails(rawEmails); + }, [rawEmails]); + + return ( + + setCancelWarningVisible(false)} + onConfirmCancel={redirectToUsers} + /> + + {intl.formatMessage(messages.inviteUsersButton)} + , + , + ]} + > +
+ +