From b12c7ab9257ee15eb91a29a7ca73cbab2c346c06 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Tue, 26 Oct 2021 09:24:16 -0500 Subject: [PATCH] Add UI for team admin role (#2637) --- changes/issue-2134-add-team-admin-role | 1 + .../AuthAnyAdminRoutes/AuthAnyAdminRoutes.tsx | 47 ++++ .../components/AuthAnyAdminRoutes/index.ts | 1 + .../AuthAnyMaintainerAnyAdminRoutes.tsx} | 11 +- .../AuthAnyMaintainerAnyAdminRoutes/index.ts | 1 + .../index.ts | 1 - .../TeamsDropdown/TeamsDropdown.tsx | 11 +- .../forms/fields/Dropdown/Dropdown.jsx | 18 +- .../side_panels/SiteTopNav/navItems.js | 31 ++- frontend/context/app.tsx | 6 + frontend/fleet/helpers.ts | 9 +- frontend/interfaces/role.ts | 13 + frontend/interfaces/user.ts | 9 + .../MembersPagePage/MembersPage.tsx | 159 ++++++++++- .../AddMemberModal/AddMemberModal.tsx | 16 ++ .../components/AddMemberModal/_styles.scss | 5 + .../TeamDetailsWrapper/TeamDetailsWrapper.tsx | 124 +++++++-- .../admin/TeamManagementPage/_styles.scss | 18 ++ .../UserManagementPage/UserManagementPage.jsx | 108 +++----- .../admin/UserManagementPage/_styles.scss | 19 +- .../CreateUserModal/CreateUserModal.tsx | 72 +++++ .../components/CreateUserModal/_styles.scss | 15 ++ .../components/CreateUserModal/index.ts | 1 + .../EditUserModal/EditUserModal.tsx | 13 + .../components/EditUserModal/_styles.scss | 16 ++ .../SelectRoleForm/SelectRoleForm.tsx | 93 +++++++ .../SelectedTeamsForm/SelectedTeamsForm.tsx | 5 + .../components/UserForm/UserForm.tsx | 255 +++++++++++------- .../components/UserForm/_styles.scss | 5 + .../helpers/userManagementHelpers.ts | 46 +++- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 9 +- frontend/router/index.tsx | 19 +- frontend/router/paths.ts | 1 - frontend/test/stubs.ts | 1 + frontend/utilities/constants.ts | 7 + frontend/utilities/permissions/permissions.ts | 15 ++ 36 files changed, 939 insertions(+), 242 deletions(-) create mode 100644 frontend/components/AuthAnyAdminRoutes/AuthAnyAdminRoutes.tsx create mode 100644 frontend/components/AuthAnyAdminRoutes/index.ts rename frontend/components/{AuthAnyMaintainerGlobalAdminRoutes/AuthAnyMaintainerGlobalAdminRoutes.tsx => AuthAnyMaintainerAnyAdminRoutes/AuthAnyMaintainerAnyAdminRoutes.tsx} (74%) create mode 100644 frontend/components/AuthAnyMaintainerAnyAdminRoutes/index.ts delete mode 100644 frontend/components/AuthAnyMaintainerGlobalAdminRoutes/index.ts create mode 100644 frontend/interfaces/role.ts create mode 100644 frontend/pages/admin/UserManagementPage/components/CreateUserModal/CreateUserModal.tsx create mode 100644 frontend/pages/admin/UserManagementPage/components/CreateUserModal/_styles.scss create mode 100644 frontend/pages/admin/UserManagementPage/components/CreateUserModal/index.ts create mode 100644 frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx diff --git a/changes/issue-2134-add-team-admin-role b/changes/issue-2134-add-team-admin-role index b9827f48e..748704370 100644 --- a/changes/issue-2134-add-team-admin-role +++ b/changes/issue-2134-add-team-admin-role @@ -1 +1,2 @@ * Add Team Admin role. +* Provide UI for Team Admin team management. diff --git a/frontend/components/AuthAnyAdminRoutes/AuthAnyAdminRoutes.tsx b/frontend/components/AuthAnyAdminRoutes/AuthAnyAdminRoutes.tsx new file mode 100644 index 000000000..9313f84a4 --- /dev/null +++ b/frontend/components/AuthAnyAdminRoutes/AuthAnyAdminRoutes.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { push } from "react-router-redux"; + +import { IUser } from "interfaces/user"; +import permissionUtils from "utilities/permissions"; +import paths from "router/paths"; +// @ts-ignore +import { renderFlash } from "redux/nodes/notifications/actions"; + +interface IAuthAnyAdminRoutesProps { + children: JSX.Element; +} + +interface IRootState { + auth: { + user: IUser; + }; +} + +const { HOME } = paths; + +/** + * Checks if a user is any admin when routing + */ +const AuthAnyAdminRoutes = ({ + children, +}: IAuthAnyAdminRoutesProps): JSX.Element | null => { + const dispatch = useDispatch(); + const user = useSelector((state: IRootState) => state.auth.user); + + if (!user) { + return null; + } + + if ( + !permissionUtils.isGlobalAdmin(user) && + !permissionUtils.isAnyTeamAdmin(user) + ) { + dispatch(push(HOME)); + dispatch(renderFlash("error", "You do not have permissions for that page")); + return null; + } + return <>{children}; +}; + +export default AuthAnyAdminRoutes; diff --git a/frontend/components/AuthAnyAdminRoutes/index.ts b/frontend/components/AuthAnyAdminRoutes/index.ts new file mode 100644 index 000000000..d7082b786 --- /dev/null +++ b/frontend/components/AuthAnyAdminRoutes/index.ts @@ -0,0 +1 @@ +export { default } from "./AuthAnyAdminRoutes"; diff --git a/frontend/components/AuthAnyMaintainerGlobalAdminRoutes/AuthAnyMaintainerGlobalAdminRoutes.tsx b/frontend/components/AuthAnyMaintainerAnyAdminRoutes/AuthAnyMaintainerAnyAdminRoutes.tsx similarity index 74% rename from frontend/components/AuthAnyMaintainerGlobalAdminRoutes/AuthAnyMaintainerGlobalAdminRoutes.tsx rename to frontend/components/AuthAnyMaintainerAnyAdminRoutes/AuthAnyMaintainerAnyAdminRoutes.tsx index 6ed3059c7..bcfa33570 100644 --- a/frontend/components/AuthAnyMaintainerGlobalAdminRoutes/AuthAnyMaintainerGlobalAdminRoutes.tsx +++ b/frontend/components/AuthAnyMaintainerAnyAdminRoutes/AuthAnyMaintainerAnyAdminRoutes.tsx @@ -8,7 +8,7 @@ import paths from "router/paths"; // @ts-ignore import { renderFlash } from "redux/nodes/notifications/actions"; -interface IAuthAnyMaintainerGlobalAdminRoutesProps { +interface IAuthAnyMaintainerAnyAdminRoutesProps { children: JSX.Element; } @@ -21,11 +21,11 @@ interface IRootState { const { HOME } = paths; /** - * Checks if a user is a global admin or global maintainer when routing + * Checks if a user is any maintainer or any admin when routing */ -const AuthAnyMaintainerGlobalAdminRoutes = ({ +const AuthAnyMaintainerAnyAdminRoutes = ({ children, -}: IAuthAnyMaintainerGlobalAdminRoutesProps): JSX.Element | null => { +}: IAuthAnyMaintainerAnyAdminRoutesProps): JSX.Element | null => { const dispatch = useDispatch(); const user = useSelector((state: IRootState) => state.auth.user); @@ -36,6 +36,7 @@ const AuthAnyMaintainerGlobalAdminRoutes = ({ if ( !permissionUtils.isGlobalAdmin(user) && !permissionUtils.isGlobalMaintainer(user) && + !permissionUtils.isAnyTeamAdmin(user) && !permissionUtils.isAnyTeamMaintainer(user) ) { dispatch(push(HOME)); @@ -45,4 +46,4 @@ const AuthAnyMaintainerGlobalAdminRoutes = ({ return <>{children}; }; -export default AuthAnyMaintainerGlobalAdminRoutes; +export default AuthAnyMaintainerAnyAdminRoutes; diff --git a/frontend/components/AuthAnyMaintainerAnyAdminRoutes/index.ts b/frontend/components/AuthAnyMaintainerAnyAdminRoutes/index.ts new file mode 100644 index 000000000..0e956055c --- /dev/null +++ b/frontend/components/AuthAnyMaintainerAnyAdminRoutes/index.ts @@ -0,0 +1 @@ +export { default } from "./AuthAnyMaintainerAnyAdminRoutes"; diff --git a/frontend/components/AuthAnyMaintainerGlobalAdminRoutes/index.ts b/frontend/components/AuthAnyMaintainerGlobalAdminRoutes/index.ts deleted file mode 100644 index b83934a15..000000000 --- a/frontend/components/AuthAnyMaintainerGlobalAdminRoutes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./AuthAnyMaintainerGlobalAdminRoutes"; diff --git a/frontend/components/TeamsDropdown/TeamsDropdown.tsx b/frontend/components/TeamsDropdown/TeamsDropdown.tsx index cbe408d4e..1cf981108 100644 --- a/frontend/components/TeamsDropdown/TeamsDropdown.tsx +++ b/frontend/components/TeamsDropdown/TeamsDropdown.tsx @@ -14,7 +14,10 @@ interface ITeamsDropdownProps { isLoading: boolean; teams: ITeam[]; currentTeamId: number; + hideAllTeamsOption?: boolean; onChange: (newSelectedValue: number) => void; + onOpen?: () => void; + onClose?: () => void; } const baseClass = "component__team-dropdown"; @@ -23,7 +26,10 @@ const TeamsDropdown = ({ isLoading, teams, currentTeamId, + hideAllTeamsOption = false, onChange, + onOpen, + onClose, }: ITeamsDropdownProps) => { const { currentUser, isPremiumTier, isOnGlobalTeam } = useContext(AppContext); @@ -36,7 +42,8 @@ const TeamsDropdown = ({ const teamOptions = generateTeamFilterDropdownOptions( teams, currentUser, - isOnGlobalTeam as boolean + isOnGlobalTeam as boolean, + hideAllTeamsOption ); const selectedTeamId = getValidatedTeamId( teams, @@ -54,6 +61,8 @@ const TeamsDropdown = ({ options={teamOptions} searchable={false} onChange={onChange} + onOpen={onOpen} + onClose={onClose} /> ); diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx index a360f07cf..52ce5c96c 100644 --- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx +++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx @@ -21,6 +21,8 @@ class Dropdown extends Component { multi: PropTypes.bool, name: PropTypes.string, onChange: PropTypes.func, + onOpen: PropTypes.func, + onClose: PropTypes.func, options: PropTypes.arrayOf(dropdownOptionInterface).isRequired, placeholder: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), value: PropTypes.oneOfType([ @@ -33,6 +35,8 @@ class Dropdown extends Component { static defaultProps = { onChange: noop, + onOpen: noop, + onClose: noop, clearable: false, searchable: true, disabled: false, @@ -41,6 +45,16 @@ class Dropdown extends Component { placeholder: "Select One...", }; + onMenuOpen = () => { + const { onOpen } = this.props; + onOpen(); + }; + + onMenuClose = () => { + const { onClose } = this.props; + onClose(); + }; + handleChange = (selected) => { const { multi, onChange, clearable } = this.props; @@ -84,7 +98,7 @@ class Dropdown extends Component { }; render() { - const { handleChange, renderOption } = this; + const { handleChange, renderOption, onMenuOpen, onMenuClose } = this; const { error, className, @@ -122,6 +136,8 @@ class Dropdown extends Component { optionRenderer={renderOption} placeholder={placeholder} value={value} + onOpen={onMenuOpen} + onClose={onMenuClose} /> ); diff --git a/frontend/components/side_panels/SiteTopNav/navItems.js b/frontend/components/side_panels/SiteTopNav/navItems.js index 9953ced76..4a7d0ec43 100644 --- a/frontend/components/side_panels/SiteTopNav/navItems.js +++ b/frontend/components/side_panels/SiteTopNav/navItems.js @@ -3,18 +3,6 @@ import URL_PREFIX from "router/url_prefix"; import permissionUtils from "utilities/permissions"; export default (currentUser) => { - const adminNavItems = [ - { - icon: "settings", - name: "Settings", - iconName: "settings", - location: { - regex: new RegExp(`^${URL_PREFIX}/settings/`), - pathname: PATHS.ADMIN_SETTINGS, - }, - }, - ]; - const userNavItems = [ { icon: "logo", @@ -69,7 +57,24 @@ export default (currentUser) => { }, ]; - if (permissionUtils.isGlobalAdmin(currentUser)) { + if ( + permissionUtils.isAnyTeamAdmin(currentUser) || + permissionUtils.isGlobalAdmin(currentUser) + ) { + const adminNavItems = [ + { + icon: "settings", + name: "Settings", + iconName: "settings", + location: { + regex: new RegExp(`^${URL_PREFIX}/settings/`), + pathname: + currentUser.global_role === "admin" + ? PATHS.ADMIN_SETTINGS + : `${PATHS.ADMIN_TEAMS}/${currentUser.teams[0].id}/members`, + }, + }, + ]; return [ ...userNavItems, ...teamMaintainerNavItems, diff --git a/frontend/context/app.tsx b/frontend/context/app.tsx index 6d572a75d..fba8cda8b 100644 --- a/frontend/context/app.tsx +++ b/frontend/context/app.tsx @@ -23,6 +23,8 @@ type InitialStateType = { isOnGlobalTeam: boolean | undefined; isAnyTeamMaintainer: boolean | undefined; isTeamMaintainer: boolean | undefined; + isAnyTeamAdmin: boolean | undefined; + isTeamAdmin: boolean | undefined; isOnlyObserver: boolean | undefined; setCurrentUser: (user: IUser) => void; setCurrentTeam: (team: ITeam | undefined) => void; @@ -43,6 +45,8 @@ const initialState = { isOnGlobalTeam: undefined, isAnyTeamMaintainer: undefined, isTeamMaintainer: undefined, + isAnyTeamAdmin: undefined, + isTeamAdmin: undefined, isOnlyObserver: undefined, setCurrentUser: () => null, setCurrentTeam: () => null, @@ -129,6 +133,8 @@ const AppProvider = ({ children }: Props) => { isOnGlobalTeam: state.isOnGlobalTeam, isAnyTeamMaintainer: state.isAnyTeamMaintainer, isTeamMaintainer: state.isTeamMaintainer, + isTeamAdmin: state.isTeamAdmin, + isAnyTeamAdmin: state.isAnyTeamAdmin, isOnlyObserver: state.isOnlyObserver, setCurrentUser: (currentUser: IUser) => { dispatch({ type: actions.SET_CURRENT_USER, currentUser }); diff --git a/frontend/fleet/helpers.ts b/frontend/fleet/helpers.ts index 3e14b667d..6a47fc1e2 100644 --- a/frontend/fleet/helpers.ts +++ b/frontend/fleet/helpers.ts @@ -689,7 +689,8 @@ const getSortedTeamOptions = memoize((teams: ITeam[]) => export const generateTeamFilterDropdownOptions = ( teams: ITeam[], currentUser: IUser | null, - isOnGlobalTeam: boolean + isOnGlobalTeam: boolean, + hideAllTeamsOption: boolean ) => { let currentUserTeams: ITeam[] = []; if (isOnGlobalTeam) { @@ -698,7 +699,7 @@ export const generateTeamFilterDropdownOptions = ( currentUserTeams = currentUser.teams; } - const allTeamsOption = [ + const allTeamOption = [ { disabled: false, label: "All teams", @@ -708,7 +709,9 @@ export const generateTeamFilterDropdownOptions = ( const sortedCurrentUserTeamOptions = getSortedTeamOptions(currentUserTeams); - return allTeamsOption.concat(sortedCurrentUserTeamOptions); + return !hideAllTeamsOption + ? allTeamOption.concat(sortedCurrentUserTeamOptions) + : sortedCurrentUserTeamOptions; }; export const getValidatedTeamId = ( diff --git a/frontend/interfaces/role.ts b/frontend/interfaces/role.ts new file mode 100644 index 000000000..826011867 --- /dev/null +++ b/frontend/interfaces/role.ts @@ -0,0 +1,13 @@ +import PropTypes from "prop-types"; + +export default PropTypes.shape({ + disabled: PropTypes.bool, + label: PropTypes.string, + value: PropTypes.string, +}); + +export interface IRole { + disabled: boolean; + label: string; + value: string; +} diff --git a/frontend/interfaces/user.ts b/frontend/interfaces/user.ts index 28668200d..b0d60c3c8 100644 --- a/frontend/interfaces/user.ts +++ b/frontend/interfaces/user.ts @@ -7,6 +7,7 @@ export default PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, email: PropTypes.string, + role: PropTypes.string, force_password_reset: PropTypes.bool, gravatar_url: PropTypes.string, sso_enabled: PropTypes.bool, @@ -21,6 +22,7 @@ export interface IUser { id: number; name: string; email: string; + role: string; force_password_reset: boolean; gravatar_url: string; sso_enabled: boolean; @@ -39,3 +41,10 @@ export interface IUserUpdateBody { email?: string; sso_enabled?: boolean; } + +export interface IUserFormErrors { + email: string | null; + name: string | null; + password: string | null; + sso_enabled: boolean | null; +} diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx index 6483aadd8..ca0642559 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/MembersPage.tsx @@ -1,24 +1,31 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; // @ts-ignore import memoize from "memoize-one"; - +import PATHS from "router/paths"; import { IConfig } from "interfaces/config"; import { IUser } from "interfaces/user"; import { INewMembersBody, ITeam } from "interfaces/team"; import { Link } from "react-router"; +import { AppContext } from "context/app"; // ignore TS error for now until these are rewritten in ts. // @ts-ignore import { renderFlash } from "redux/nodes/notifications/actions"; // @ts-ignore import userActions from "redux/nodes/entities/users/actions"; import teamActions from "redux/nodes/entities/teams/actions"; +// @ts-ignore +import inviteActions from "redux/nodes/entities/invites/actions"; import Button from "components/buttons/Button"; import TableContainer from "components/TableContainer"; import TableDataError from "components/TableDataError"; -import PATHS from "router/paths"; +import CreateUserModal from "pages/admin/UserManagementPage/components/CreateUserModal"; +import { DEFAULT_CREATE_USER_ERRORS } from "utilities/constants"; import EditUserModal from "../../../UserManagementPage/components/EditUserModal"; -import { IFormData } from "../../../UserManagementPage/components/UserForm/UserForm"; +import { + IFormData, + NewUserType, +} from "../../../UserManagementPage/components/UserForm/UserForm"; import userManagementHelpers from "../../../UserManagementPage/helpers"; import AddMemberModal from "./components/AddMemberModal"; import RemoveMemberModal from "./components/RemoveMemberModal"; @@ -78,12 +85,15 @@ const MembersPage = ({ const teamId = parseInt(team_id, 10); const dispatch = useDispatch(); + const { isGlobalAdmin, currentUser } = useContext(AppContext); + const isPremiumTier = useSelector((state: IRootState) => { return state.app.config.tier === "premium"; }); const loadingTableData = useSelector( (state: IRootState) => state.entities.users.loading ); + const users = useSelector((state: IRootState) => generateDataSet(teamId, state.entities.users.data) ); @@ -110,11 +120,19 @@ const MembersPage = ({ return state.app.config.enable_sso; }); + const config = useSelector((state: IRootState) => { + return state.app.config; + }); + const [showAddMemberModal, setShowAddMemberModal] = useState(false); const [showRemoveMemberModal, setShowRemoveMemberModal] = useState(false); const [showEditUserModal, setShowEditUserModal] = useState(false); + const [showCreateUserModal, setShowCreateUserModal] = useState(false); + const [isFormSubmitting, setIsFormSubmitting] = useState(false); const [userEditing, setUserEditing] = useState(); const [searchString, setSearchString] = useState(""); + const [createUserErrors] = useState(DEFAULT_CREATE_USER_ERRORS); + const [editUserErrors] = useState(DEFAULT_CREATE_USER_ERRORS); const toggleAddUserModal = useCallback(() => { setShowAddMemberModal(!showAddMemberModal); @@ -136,6 +154,18 @@ const MembersPage = ({ [showEditUserModal, setShowEditUserModal, setUserEditing] ); + const toggleCreateMemberModal = useCallback(() => { + setShowCreateUserModal(!showCreateUserModal); + setShowAddMemberModal(false); + currentUser ? setUserEditing(currentUser) : setUserEditing(undefined); + }, [ + showCreateUserModal, + currentUser, + setShowCreateUserModal, + setUserEditing, + setShowAddMemberModal, + ]); + const onRemoveMemberSubmit = useCallback(() => { const removedUsers = { users: [{ id: userEditing?.id }] }; dispatch(teamActions.removeMembers(teamId, removedUsers)) @@ -194,6 +224,88 @@ const MembersPage = ({ [dispatch, teamId] ); + const onCreateMemberSubmit = (formData: IFormData) => { + setIsFormSubmitting(true); + + if (formData.newUserType === NewUserType.AdminInvited) { + // Do some data formatting adding `invited_by` for the request to be correct and deleteing uncessary fields + const requestData = { + ...formData, + invited_by: formData.currentUserId, + }; + delete requestData.currentUserId; // this field is not needed for the request + delete requestData.newUserType; // this field is not needed for the request + delete requestData.password; // this field is not needed for the request + dispatch(inviteActions.create(requestData)) + .then(() => { + dispatch( + renderFlash( + "success", + `An invitation email was sent from ${config.sender_address} to ${formData.email}.` + ) + ); + fetchUsers(tableQueryData); + toggleCreateMemberModal(); + }) + .catch((userErrors: any) => { + if (userErrors.base.includes("Duplicate")) { + dispatch( + renderFlash( + "error", + "A user with this email address already exists." + ) + ); + } else { + dispatch( + renderFlash("error", "Could not create user. Please try again.") + ); + } + }) + .finally(() => { + setIsFormSubmitting(false); + }); + } else { + // Do some data formatting deleteing uncessary fields + const requestData = { + ...formData, + }; + delete requestData.currentUserId; // this field is not needed for the request + delete requestData.newUserType; // this field is not needed for the request + dispatch(userActions.createUserWithoutInvitation(requestData)) + .then(() => { + dispatch( + renderFlash("success", `Successfully created ${requestData.name}.`) + ); + fetchUsers(tableQueryData); + toggleCreateMemberModal(); + }) + .catch((userErrors: any) => { + if (userErrors.base.includes("Duplicate")) { + dispatch( + renderFlash( + "error", + "A user with this email address already exists." + ) + ); + } else if (userErrors.base.includes("already invited")) { + dispatch( + renderFlash( + "error", + "A user with this email address has already been invited." + ) + ); + } else { + dispatch( + renderFlash("error", "Could not create user. Please try again.") + ); + } + }) + .finally(() => { + setIsFormSubmitting(false); + }); + } + }; + const onEditMemberSubmit = useCallback( (formData: IFormData) => { const updatedAttrs = userManagementHelpers.generateUpdateData( @@ -201,6 +313,8 @@ const MembersPage = ({ formData ); + setIsFormSubmitting(true); + const userName = userEditing?.name; dispatch(userActions.update(userEditing, updatedAttrs)) .then(() => { @@ -214,12 +328,19 @@ const MembersPage = ({ `Could not edit ${userName}. Please try again.` ) ); + }) + .finally(() => { + setIsFormSubmitting(false); }); toggleEditMemberModal(); }, [dispatch, toggleEditMemberModal, userEditing, fetchUsers] ); + useEffect(() => { + fetchUsers(tableQueryData); + }, [team_id]); + // NOTE: this will fire on initial render, so we use this to get the list of // users for this team, as well as use it as a handler when the table query // changes. @@ -277,7 +398,7 @@ const MembersPage = ({ ); - }, [searchString]); + }, [searchString, toggleAddUserModal]); const tableHeaders = generateTableHeaders(onActionSelection); @@ -285,7 +406,11 @@ const MembersPage = ({

Users can either be a member of team(s) or a global user.{" "} - Manage users with global access here + {isGlobalAdmin && ( + + Manage users with global access here + + )}

{Object.keys(usersError).length > 0 ? ( @@ -315,21 +440,43 @@ const MembersPage = ({ disabledMembers={memberIds} onCancel={toggleAddUserModal} onSubmit={onAddMemberSubmit} + onCreateNewMember={toggleCreateMemberModal} /> ) : null} {showEditUserModal ? ( + ) : null} + {showCreateUserModal ? ( + ) : null} {showRemoveMemberModal ? ( diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/components/AddMemberModal/AddMemberModal.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/components/AddMemberModal/AddMemberModal.tsx index c0e587a3b..2757f64dc 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/components/AddMemberModal/AddMemberModal.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage/components/AddMemberModal/AddMemberModal.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from "react"; import { INewMembersBody, ITeam } from "interfaces/team"; +import { IUser } from "interfaces/user"; import endpoints from "fleet/endpoints"; import Modal from "components/modals/Modal"; import Button from "components/buttons/Button"; @@ -14,12 +15,14 @@ interface IAddMemberModal { disabledMembers: number[]; onCancel: () => void; onSubmit: (userIds: INewMembersBody) => void; + onCreateNewMember: () => void; } const AddMemberModal = ({ disabledMembers, onCancel, onSubmit, + onCreateNewMember, team, }: IAddMemberModal): JSX.Element => { const [selectedMembers, setSelectedMembers] = useState([]); @@ -41,6 +44,7 @@ const AddMemberModal = ({ return (
+

Add team members

+

+ User not here?  + +

- + {isGlobalAdmin && ( + + )}
navigateToNav(i)} > diff --git a/frontend/pages/admin/TeamManagementPage/_styles.scss b/frontend/pages/admin/TeamManagementPage/_styles.scss index c75501a6c..0a379b655 100644 --- a/frontend/pages/admin/TeamManagementPage/_styles.scss +++ b/frontend/pages/admin/TeamManagementPage/_styles.scss @@ -1,4 +1,5 @@ .team-management { + padding: 0; &__page-description { font-size: $x-small; color: $core-fleet-black; @@ -7,6 +8,23 @@ } } +.team-details__team-details { + .form-field--dropdown { + margin-bottom: 0; + } +} + +.team-details.team-select-open { + .component__tabs-wrapper { + position: relative; + z-index: 2; + } + .members { + position: relative; + z-index: 1; + } +} + .no-teams { display: flex; flex-direction: column; diff --git a/frontend/pages/admin/UserManagementPage/UserManagementPage.jsx b/frontend/pages/admin/UserManagementPage/UserManagementPage.jsx index 2508fd98b..c932e7ed0 100644 --- a/frontend/pages/admin/UserManagementPage/UserManagementPage.jsx +++ b/frontend/pages/admin/UserManagementPage/UserManagementPage.jsx @@ -21,24 +21,18 @@ import teamActions from "redux/nodes/entities/teams/actions"; import TableContainer from "components/TableContainer"; import TableDataError from "components/TableDataError"; import Modal from "components/modals/Modal"; -import Spinner from "components/loaders/Spinner"; -import UserForm from "./components/UserForm"; +import { DEFAULT_CREATE_USER_ERRORS } from "utilities/constants"; import EmptyUsers from "./components/EmptyUsers"; import { generateTableHeaders, combineDataSets } from "./UsersTableConfig"; import DeleteUserForm from "./components/DeleteUserForm"; import ResetPasswordModal from "./components/ResetPasswordModal"; import ResetSessionsModal from "./components/ResetSessionsModal"; import { NewUserType } from "./components/UserForm/UserForm"; +import CreateUserModal from "../UserManagementPage/components/CreateUserModal"; +import EditUserModal from "../UserManagementPage/components/EditUserModal"; const baseClass = "user-management"; -const DEFAULT_CREATE_USER_ERRORS = { - email: null, - name: null, - password: null, - sso_enabled: null, -}; - const generateUpdateData = (currentUserData, formData) => { const updatableFields = [ "global_role", @@ -92,7 +86,7 @@ export class UserManagementPage extends Component { isFormSubmitting: false, userEditing: null, usersEditing: [], - createUserErrors: DEFAULT_CREATE_USER_ERRORS, + createUserErrors: { DEFAULT_CREATE_USER_ERRORS }, }; } @@ -154,7 +148,6 @@ export class UserManagementPage extends Component { onCreateUserSubmit = (formData) => { const { dispatch, config } = this.props; - const { createUserErrors } = this.state; this.setState({ isFormSubmitting: true }); @@ -179,12 +172,12 @@ export class UserManagementPage extends Component { }) .catch((userErrors) => { if (userErrors.base.includes("Duplicate")) { - this.setState({ - createUserErrors: { - ...createUserErrors, - email: "A user with this email address already exists", - }, - }); + dispatch( + renderFlash( + "error", + "A user with this email address already exists." + ) + ); } else { dispatch( renderFlash("error", "Could not create user. Please try again.") @@ -210,12 +203,12 @@ export class UserManagementPage extends Component { }) .catch((userErrors) => { if (userErrors.base.includes("Duplicate")) { - this.setState({ - createUserErrors: { - ...createUserErrors, - email: "A user with this email address already exists", - }, - }); + dispatch( + renderFlash( + "error", + "A user with this email address already exists." + ) + ); } else { dispatch( renderFlash("error", "Could not create user. Please try again.") @@ -459,7 +452,7 @@ export class UserManagementPage extends Component { teams, isPremiumTier, } = this.props; - const { showEditUserModal, userEditing, isFormSubmitting } = this.state; + const { showEditUserModal, userEditing } = this.state; const { onEditUser, toggleEditUserModal, getUser } = this; if (!showEditUserModal) return null; @@ -473,13 +466,8 @@ export class UserManagementPage extends Component { className={`${baseClass}__edit-user-modal`} > <> - {isFormSubmitting && ( -
- -
- )} - @@ -500,45 +489,36 @@ export class UserManagementPage extends Component { }; renderCreateUserModal = () => { - const { currentUser, config, teams, isPremiumTier } = this.props; const { - showCreateUserModal, - createUserErrors, - isFormSubmitting, - } = this.state; + currentUser, + config, + teams, + userErrors, + isPremiumTier, + } = this.props; + const { showCreateUserModal, isFormSubmitting } = this.state; const { onCreateUserSubmit, toggleCreateUserModal } = this; if (!showCreateUserModal) return null; return ( - - <> - {isFormSubmitting && ( -
- -
- )} - - -
+ ); }; diff --git a/frontend/pages/admin/UserManagementPage/_styles.scss b/frontend/pages/admin/UserManagementPage/_styles.scss index 2796ab303..c5a04fd7f 100644 --- a/frontend/pages/admin/UserManagementPage/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/_styles.scss @@ -1,6 +1,5 @@ .user-management { - padding: $pad-xlarge; - + padding: 0; &__page-description { font-size: $x-small; color: $core-fleet-black; @@ -129,20 +128,4 @@ font-style: italic; } } - - &__create-user-modal, - &__edit-user-modal { - .loading-spinner { - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; - display: flex; - align-items: center; - background: rgba(255,255,255,0.75); - z-index: 1; - border-radius: 8px; - } - } } diff --git a/frontend/pages/admin/UserManagementPage/components/CreateUserModal/CreateUserModal.tsx b/frontend/pages/admin/UserManagementPage/components/CreateUserModal/CreateUserModal.tsx new file mode 100644 index 000000000..71b3944a8 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/CreateUserModal/CreateUserModal.tsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; + +import { ITeam } from "interfaces/team"; +import { IUserFormErrors } from "interfaces/user"; +import Modal from "components/modals/Modal"; +import Spinner from "components/loaders/Spinner"; +import UserForm from "../UserForm"; +import { IFormData } from "../UserForm/UserForm"; + +interface ICreateUserModalProps { + onCancel: () => void; + onSubmit: (formData: IFormData) => void; + defaultGlobalRole?: string | null; + defaultTeamRole?: string; + defaultTeams?: ITeam[]; + availableTeams: ITeam[]; + isPremiumTier: boolean; + smtpConfigured: boolean; + currentTeam?: ITeam; + canUseSso: boolean; // corresponds to whether SSO is enabled for the organization + isModifiedByGlobalAdmin?: boolean | false; + isFormSubmitting?: boolean | false; + serverErrors?: IUserFormErrors; +} + +const baseClass = "create-user-modal"; + +const CreateUserModal = ({ + onCancel, + onSubmit, + currentTeam, + defaultGlobalRole, + defaultTeamRole, + defaultTeams, + availableTeams, + isPremiumTier, + smtpConfigured, + canUseSso, + isModifiedByGlobalAdmin, + isFormSubmitting, + serverErrors, +}: ICreateUserModalProps): JSX.Element => { + return ( + + <> + {isFormSubmitting && ( +
+ +
+ )} + + +
+ ); +}; + +export default CreateUserModal; diff --git a/frontend/pages/admin/UserManagementPage/components/CreateUserModal/_styles.scss b/frontend/pages/admin/UserManagementPage/components/CreateUserModal/_styles.scss new file mode 100644 index 000000000..b9e2d3b05 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/CreateUserModal/_styles.scss @@ -0,0 +1,15 @@ +.create-user-modal { + .loading-spinner { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.75); + z-index: 1; + border-radius: 8px; + margin: 0; + } +} diff --git a/frontend/pages/admin/UserManagementPage/components/CreateUserModal/index.ts b/frontend/pages/admin/UserManagementPage/components/CreateUserModal/index.ts new file mode 100644 index 000000000..162abf04a --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/CreateUserModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateUserModal"; diff --git a/frontend/pages/admin/UserManagementPage/components/EditUserModal/EditUserModal.tsx b/frontend/pages/admin/UserManagementPage/components/EditUserModal/EditUserModal.tsx index b81afde18..723353375 100644 --- a/frontend/pages/admin/UserManagementPage/components/EditUserModal/EditUserModal.tsx +++ b/frontend/pages/admin/UserManagementPage/components/EditUserModal/EditUserModal.tsx @@ -1,6 +1,7 @@ import React from "react"; import { ITeam } from "interfaces/team"; +import { IUserFormErrors } from "interfaces/user"; import Modal from "components/modals/Modal"; import UserForm from "../UserForm"; import { IFormData } from "../UserForm/UserForm"; @@ -11,12 +12,16 @@ interface IEditUserModalProps { defaultName?: string; defaultEmail?: string; defaultGlobalRole?: string | null; + defaultTeamRole?: string; defaultTeams?: ITeam[]; availableTeams: ITeam[]; + currentTeam: ITeam; isPremiumTier: boolean; smtpConfigured: boolean; canUseSso: boolean; // corresponds to whether SSO is enabled for the organization isSsoEnabled?: boolean; // corresponds to whether SSO is enabled for the individual user + serverErrors?: IUserFormErrors; + isModifiedByGlobalAdmin?: boolean | false; } const baseClass = "edit-user-modal"; @@ -27,12 +32,16 @@ const EditUserModal = ({ defaultName, defaultEmail, defaultGlobalRole, + defaultTeamRole, defaultTeams, availableTeams, isPremiumTier, smtpConfigured, canUseSso, isSsoEnabled, + isModifiedByGlobalAdmin, + currentTeam, + serverErrors, }: IEditUserModalProps): JSX.Element => { return ( ); diff --git a/frontend/pages/admin/UserManagementPage/components/EditUserModal/_styles.scss b/frontend/pages/admin/UserManagementPage/components/EditUserModal/_styles.scss index e69de29bb..460f09a6f 100644 --- a/frontend/pages/admin/UserManagementPage/components/EditUserModal/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/components/EditUserModal/_styles.scss @@ -0,0 +1,16 @@ +.edit-user-modal { + position: relative; + .loading-spinner { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.75); + z-index: 1; + border-radius: 8px; + margin: 0; + } +} diff --git a/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx new file mode 100644 index 000000000..c9d3eecc4 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; + +import { ITeam } from "interfaces/team"; +import { IRole } from "interfaces/role"; +// ignore TS error for now until these are rewritten in ts. +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; + +interface ISelectRoleFormProps { + defaultTeamRole: string; + currentTeam: ITeam; + teams: ITeam[]; + onFormChange: (teams: ITeam[]) => void; +} + +const baseClass = "select-role-form"; + +const roles: IRole[] = [ + { + disabled: false, + label: "Observer", + value: "observer", + }, + { + disabled: false, + label: "Maintainer", + value: "maintainer", + }, + { + disabled: false, + label: "Admin", + value: "admin", + }, +]; + +const generateSelectedTeamData = ( + allTeams: ITeam[], + updatedTeam: ITeam +): ITeam[] => { + const filtered = allTeams.map( + (teamItem): ITeam => { + const teamRole = + teamItem.id === updatedTeam.id ? updatedTeam.role : teamItem.role; + return { + description: teamItem.description, + id: teamItem.id, + host_count: teamItem.host_count, + user_count: teamItem.user_count, + name: teamItem.name, + role: teamRole, + }; + } + ); + return filtered; +}; + +const SelectRoleForm = ({ + defaultTeamRole, + currentTeam, + teams, + onFormChange, +}: ISelectRoleFormProps): JSX.Element => { + const [selectedRole, setSelectedRole] = useState( + defaultTeamRole.toLowerCase() + ); + + const updateSelectedRole = (newRoleValue: string) => { + const updatedTeam = { ...currentTeam }; + + updatedTeam.role = newRoleValue; + + onFormChange(generateSelectedTeamData(teams, updatedTeam)); + + setSelectedRole(newRoleValue); + }; + + return ( +
+
+ updateSelectedRole(newRoleValue)} + testId={`${name}-checkbox`} + /> +
+
+ ); +}; + +export default SelectRoleForm; diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx index 014e9b5c5..042c1cb61 100644 --- a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx @@ -30,6 +30,11 @@ const roles = [ label: "Maintainer", value: "maintainer", }, + { + disabled: false, + label: "Admin", + value: "admin", + }, ]; const generateFormListItems = ( diff --git a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx index 12ddf8ba9..3e3140217 100644 --- a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx @@ -4,6 +4,7 @@ import { Link } from "react-router"; import PATHS from "router/paths"; import { ITeam } from "interfaces/team"; +import { IUserFormErrors } from "interfaces/user"; import Button from "components/buttons/Button"; import validatePresence from "components/forms/validators/validate_presence"; import validEmail from "components/forms/validators/valid_email"; @@ -25,6 +26,7 @@ import Radio from "components/forms/fields/Radio"; import InfoBanner from "components/InfoBanner/InfoBanner"; import SelectedTeamsForm from "../SelectedTeamsForm/SelectedTeamsForm"; import OpenNewTabIcon from "../../../../../../assets/images/open-new-tab-12x12@2x.png"; +import SelectRoleForm from "../SelectRoleForm/SelectRoleForm"; const baseClass = "create-user-form"; @@ -68,13 +70,6 @@ export interface IFormData { invited_by?: number; } -interface IUserFormErrors { - email: string | null; - name: string | null; - password: string | null; - sso_enabled: boolean | null; -} - interface ICreateUserFormProps { availableTeams: ITeam[]; onCancel: () => void; @@ -83,7 +78,10 @@ interface ICreateUserFormProps { defaultName?: string; defaultEmail?: string; currentUserId?: number; + currentTeam?: ITeam; + isModifiedByGlobalAdmin?: boolean | false; defaultGlobalRole?: string | null; + defaultTeamRole?: string; defaultTeams?: ITeam[]; isPremiumTier: boolean; smtpConfigured?: boolean; @@ -185,6 +183,16 @@ class UserForm extends Component { }); }; + onTeamRoleChange = (teams: ITeam[]): void => { + const { formData } = this.state; + this.setState({ + formData: { + ...formData, + teams, + }, + }); + }; + onFormSubmit = (evt: FormEvent): void => { const { createSubmitData, validate } = this; evt.preventDefault(); @@ -291,9 +299,13 @@ class UserForm extends Component { renderGlobalRoleForm = (): JSX.Element => { const { onGlobalUserRoleChange } = this; const { - formData: { global_role }, + formData: { global_role, teams }, } = this.state; - const { isPremiumTier } = this.props; + const { + availableTeams, + isModifiedByGlobalAdmin, + isPremiumTier, + } = this.props; return ( <> {isPremiumTier && ( @@ -346,37 +358,58 @@ class UserForm extends Component { }; renderTeamsForm = (): JSX.Element => { - const { onSelectedTeamChange, renderNoTeamsMessage } = this; - const { availableTeams, isPremiumTier } = this.props; + const { + onSelectedTeamChange, + renderNoTeamsMessage, + onTeamRoleChange, + } = this; + const { + availableTeams, + isModifiedByGlobalAdmin, + defaultTeamRole, + currentTeam, + } = this.props; const { formData: { teams }, } = this.state; return ( <> - -

- Users can be members of multiple teams and can only manage or - observe team-specific users, entities, and settings in Fleet. -

- - Learn more about user permissions - open new tab - -
- {availableTeams.length > 0 ? ( - - ) : ( - renderNoTeamsMessage() - )} + {availableTeams.length && + (isModifiedByGlobalAdmin ? ( + <> + +

+ Users can be members of multiple teams and can only manage or + observe team-specific users, entities, and settings in Fleet. +

+ + Learn more about user permissions + open new tab + +
+ + + ) : ( + <> +

Role

+ + + ))} + {!availableTeams.length && renderNoTeamsMessage()} ); }; @@ -394,6 +427,8 @@ class UserForm extends Component { smtpConfigured, canUseSso, isNewUser, + currentTeam, + isModifiedByGlobalAdmin, serverErrors, } = this.props; const { @@ -422,7 +457,7 @@ class UserForm extends Component { name="name" onChange={onInputChange("name")} placeholder="Full name" - value={name} + value={name || ""} />
{ name="email" onChange={onInputChange("email")} placeholder="Email" - value={email} + value={email || ""} disabled={!isNewUser && !smtpConfigured} />
@@ -498,53 +533,65 @@ class UserForm extends Component { {isNewUser && (
- -
- + +
+ + + + The "Invite user" feature requires that SMTP + is +
+ configured in order to send invitation emails.
+
+ SMTP can be configured in{" "} + + Settings >
+ Organization settings +
+ . +
+
+
+ + ) : ( + - - - The "Invite user" feature requires that SMTP is -
- configured in order to send invitation emails.
-
- SMTP can be configured in{" "} - - Settings >
- Organization settings -
- . -
-
-
+ )}
{newUserType !== NewUserType.AdminInvited && !sso_enabled && ( <> @@ -554,7 +601,7 @@ class UserForm extends Component { name="password" onChange={onInputChange("password")} placeholder="Password" - value={password} + value={password || ""} type="password" hint={[ "Must include 7 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)", @@ -580,24 +627,32 @@ class UserForm extends Component {

Team

- - + {isModifiedByGlobalAdmin ? ( + <> + + + + ) : ( +

+ {currentTeam ? currentTeam.name : ""} +

+ )}
{isGlobalUser ? renderGlobalRoleForm() : renderTeamsForm()} diff --git a/frontend/pages/admin/UserManagementPage/components/UserForm/_styles.scss b/frontend/pages/admin/UserManagementPage/components/UserForm/_styles.scss index 681ae8aec..9fe792989 100644 --- a/frontend/pages/admin/UserManagementPage/components/UserForm/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/components/UserForm/_styles.scss @@ -45,6 +45,11 @@ width: 310px; } + .current-team { + margin-top: 4px; + margin-bottom: 24px; + } + &__label { color: $core-fleet-black; font-size: $x-small; diff --git a/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts b/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts index 9deae9ba4..c53285427 100644 --- a/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts +++ b/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts @@ -1,4 +1,12 @@ -import { isEqual } from "lodash"; +import { + isEmpty, + isEqual, + isPlainObject, + isString, + reduce, + trim, + union, +} from "lodash"; import { IInvite } from "interfaces/invite"; import { IUser, IUserUpdateBody } from "interfaces/user"; @@ -9,6 +17,12 @@ type ICurrentUserData = Pick< "global_role" | "teams" | "name" | "email" | "sso_enabled" >; +interface ILocationParams { + pathPrefix?: string; + routeTemplate?: string; + routeParams?: { [key: string]: any }; +} + /** * Helper function that will compare the current user with data from the editing * form and return an object with the difference between the two. This can be @@ -49,6 +63,36 @@ const generateUpdateData = ( ); }; +export const getNextLocationPath = ({ + pathPrefix = "", + routeTemplate = "", + routeParams = {}, +}: ILocationParams): string => { + const pathPrefixFinal = isString(pathPrefix) ? pathPrefix : ""; + const routeTemplateFinal = (isString(routeTemplate) && routeTemplate) || ""; + const routeParamsFinal = isPlainObject(routeParams) ? routeParams : {}; + + let routeString = ""; + + if (!isEmpty(routeParamsFinal)) { + routeString = reduce( + routeParamsFinal, + (string, value, key) => { + return string.replace(`:${key}`, encodeURIComponent(value)); + }, + routeTemplateFinal + ); + } + + const nextLocation = union( + trim(pathPrefixFinal, "/").split("/"), + routeString.split("/") + ).join("/"); + + return `/${nextLocation}`; +}; + export default { generateUpdateData, + getNextLocationPath, }; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 8751b92db..ecd3d6d16 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -123,6 +123,8 @@ const ManageHostsPage = ({ isGlobalMaintainer, isAnyTeamMaintainer, isTeamMaintainer, + isAnyTeamAdmin, + isTeamAdmin, isOnGlobalTeam, isOnlyObserver, isPremiumTier, @@ -213,9 +215,12 @@ const ManageHostsPage = ({ // ===== end filter matching const canAddNewHosts = - isGlobalAdmin || isGlobalMaintainer || isAnyTeamMaintainer; + isGlobalAdmin || + isGlobalMaintainer || + isAnyTeamAdmin || + isAnyTeamMaintainer; const canEnrollHosts = - isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer; + isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer; const canAddNewLabels = isGlobalAdmin || isGlobalMaintainer; const generateInstallerTeam = currentTeam || { diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 719ecfe25..9ebad7ffc 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -17,9 +17,10 @@ import AdminTeamManagementPage from "pages/admin/TeamManagementPage"; import TeamDetailsWrapper from "pages/admin/TeamManagementPage/TeamDetailsWrapper"; import App from "components/App"; import AuthenticatedAdminRoutes from "components/AuthenticatedAdminRoutes"; +import AuthAnyAdminRoutes from "components/AuthAnyAdminRoutes"; import AuthenticatedRoutes from "components/AuthenticatedRoutes"; import AuthGlobalAdminMaintainerRoutes from "components/AuthGlobalAdminMaintainerRoutes"; -import AuthAnyMaintainerGlobalAdminRoutes from "components/AuthAnyMaintainerGlobalAdminRoutes"; +import AuthAnyMaintainerAnyAdminRoutes from "components/AuthAnyMaintainerAnyAdminRoutes"; import PremiumTierRoutes from "components/PremiumTierRoutes"; import ConfirmInvitePage from "pages/ConfirmInvitePage"; import ConfirmSSOInvitePage from "pages/ConfirmSSOInvitePage"; @@ -88,12 +89,14 @@ const routes = ( - + - - - - + + + + + + @@ -128,7 +131,7 @@ const routes = ( - + - + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 72134737b..d537696c9 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -1,5 +1,4 @@ import { IHost } from "../interfaces/host"; -import { IPack } from "../interfaces/pack"; import { IQuery } from "../interfaces/query"; import URL_PREFIX from "./url_prefix"; diff --git a/frontend/test/stubs.ts b/frontend/test/stubs.ts index bad6acee3..58a3011c4 100644 --- a/frontend/test/stubs.ts +++ b/frontend/test/stubs.ts @@ -214,6 +214,7 @@ export const userStub: IUser = { id: 1, name: "Gnar Mike", email: "hi@gnar.dog", + role: "Observer", global_role: null, api_only: false, force_password_reset: false, diff --git a/frontend/utilities/constants.ts b/frontend/utilities/constants.ts index e89c477e8..7fab4c100 100644 --- a/frontend/utilities/constants.ts +++ b/frontend/utilities/constants.ts @@ -150,3 +150,10 @@ export const PLATFORM_DROPDOWN_OPTIONS = [ { label: "Linux", value: "linux" }, { label: "macOS", value: "darwin" }, ]; + +export const DEFAULT_CREATE_USER_ERRORS = { + email: "", + name: "", + password: "", + sso_enabled: null, +}; diff --git a/frontend/utilities/permissions/permissions.ts b/frontend/utilities/permissions/permissions.ts index 055eb5d77..0edbd4269 100644 --- a/frontend/utilities/permissions/permissions.ts +++ b/frontend/utilities/permissions/permissions.ts @@ -39,6 +39,11 @@ const isTeamMaintainer = ( return userTeamRole === "maintainer"; }; +const isTeamAdmin = (user: IUser | null, teamId: number | null): boolean => { + const userTeamRole = user?.teams.find((team) => team.id === teamId)?.role; + return userTeamRole === "admin"; +}; + // This checks against all teams const isAnyTeamMaintainer = (user: IUser): boolean => { if (!isOnGlobalTeam(user)) { @@ -48,6 +53,14 @@ const isAnyTeamMaintainer = (user: IUser): boolean => { return false; }; +const isAnyTeamAdmin = (user: IUser): boolean => { + if (!isOnGlobalTeam(user)) { + return user.teams.some((team) => team?.role === "admin"); + } + + return false; +}; + const isOnlyObserver = (user: IUser): boolean => { if (isGlobalObserver(user)) { return true; @@ -71,5 +84,7 @@ export default { isTeamObserver, isTeamMaintainer, isAnyTeamMaintainer, + isTeamAdmin, + isAnyTeamAdmin, isOnlyObserver, };