Add UI for team admin role (#2637)

This commit is contained in:
Luke Heath 2021-10-26 09:24:16 -05:00 committed by GitHub
parent 3136cc105e
commit b12c7ab925
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 939 additions and 242 deletions

View File

@ -1 +1,2 @@
* Add Team Admin role.
* Provide UI for Team Admin team management.

View File

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

View File

@ -0,0 +1 @@
export { default } from "./AuthAnyAdminRoutes";

View File

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

View File

@ -0,0 +1 @@
export { default } from "./AuthAnyMaintainerAnyAdminRoutes";

View File

@ -1 +0,0 @@
export { default } from "./AuthAnyMaintainerGlobalAdminRoutes";

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = (

View File

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

View File

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

View File

@ -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<IUser>();
const [searchString, setSearchString] = useState<string>("");
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 = ({
</div>
</div>
);
}, [searchString]);
}, [searchString, toggleAddUserModal]);
const tableHeaders = generateTableHeaders(onActionSelection);
@ -285,7 +406,11 @@ const MembersPage = ({
<div className={baseClass}>
<p className={`${baseClass}__page-description`}>
Users can either be a member of team(s) or a global user.{" "}
<Link to={PATHS.ADMIN_USERS}>Manage users with global access here</Link>
{isGlobalAdmin && (
<Link to={PATHS.ADMIN_USERS}>
Manage users with global access here
</Link>
)}
</p>
{Object.keys(usersError).length > 0 ? (
<TableDataError />
@ -315,21 +440,43 @@ const MembersPage = ({
disabledMembers={memberIds}
onCancel={toggleAddUserModal}
onSubmit={onAddMemberSubmit}
onCreateNewMember={toggleCreateMemberModal}
/>
) : null}
{showEditUserModal ? (
<EditUserModal
serverErrors={editUserErrors}
onCancel={toggleEditMemberModal}
onSubmit={onEditMemberSubmit}
defaultName={userEditing?.name}
defaultEmail={userEditing?.email}
defaultGlobalRole={userEditing?.global_role}
defaultTeamRole={userEditing?.role}
defaultTeams={userEditing?.teams}
availableTeams={teams}
isPremiumTier={isPremiumTier}
smtpConfigured={smtpConfigured}
canUseSso={canUseSso}
isSsoEnabled={userEditing?.sso_enabled}
isModifiedByGlobalAdmin={isGlobalAdmin}
currentTeam={team}
/>
) : null}
{showCreateUserModal ? (
<CreateUserModal
serverErrors={createUserErrors}
onCancel={toggleCreateMemberModal}
onSubmit={onCreateMemberSubmit}
defaultGlobalRole={userEditing?.global_role}
defaultTeamRole={userEditing?.role}
defaultTeams={userEditing?.teams}
availableTeams={teams}
isPremiumTier={isPremiumTier}
smtpConfigured={smtpConfigured}
canUseSso={canUseSso}
currentTeam={team}
isModifiedByGlobalAdmin={isGlobalAdmin}
isFormSubmitting={isFormSubmitting}
/>
) : null}
{showRemoveMemberModal ? (

View File

@ -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 (
<Modal onExit={onCancel} title={"Add Members"} className={baseClass}>
<form className={`${baseClass}__form`}>
<p className="title">Add team members</p>
<AutocompleteDropdown
team={team}
id={"member-autocomplete"}
@ -50,6 +54,18 @@ const AddMemberModal = ({
disabledOptions={disabledMembers}
value={selectedMembers}
/>
<p>
User not here?&nbsp;
<Button
onClick={onCreateNewMember}
variant={"text-link"}
className={"light-text"}
>
<>
<strong>Create a user</strong>
</>
</Button>
</p>
<div className={`${baseClass}__btn-wrap`}>
<Button
disabled={selectedMembers.length === 0}

View File

@ -1,4 +1,9 @@
.add-member-modal {
.title {
font-weight: $bold;
margin-bottom: 4px;
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;

View File

@ -1,11 +1,15 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router";
import { InjectedRouter, Link, RouteProps } from "react-router";
import { push } from "react-router-redux";
import { Tab, TabList, Tabs } from "react-tabs";
import { find, memoize, toNumber } from "lodash";
import classnames from "classnames";
import PATHS from "router/paths";
import { ITeam } from "interfaces/team";
import { IUser } from "interfaces/user";
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";
@ -13,6 +17,8 @@ import teamActions from "redux/nodes/entities/teams/actions";
import Spinner from "components/loaders/Spinner";
import Button from "components/buttons/Button";
import TabsWrapper from "components/TabsWrapper";
import TeamsDropdown from "components/TeamsDropdown";
import { getNextLocationPath } from "pages/admin/UserManagementPage/helpers/userManagementHelpers";
import DeleteTeamModal from "../components/DeleteTeamModal";
import EditTeamModal from "../components/EditTeamModal";
import { IEditTeamFormData } from "../components/EditTeamModal/EditTeamModal";
@ -41,6 +47,9 @@ const teamDetailsSubNav: ITeamDetailsSubNavItem[] = [
];
interface IRootState {
auth: {
user: IUser;
};
entities: {
teams: {
loading: boolean;
@ -57,8 +66,18 @@ interface ITeamDetailsPageProps {
location: {
pathname: string;
};
route: RouteProps;
router: InjectedRouter;
}
const getTeams = (data: { [id: string]: ITeam }) => {
return Object.keys(data).map((teamId) => {
return data[teamId];
});
};
const memoizedGetTeams = memoize(getTeams);
const generateUpdateData = (
currentTeamData: ITeam,
formData: IEditTeamFormData
@ -78,16 +97,27 @@ const getTabIndex = (path: string, teamId: number): number => {
};
const TeamDetailsWrapper = ({
route,
router,
children,
location: { pathname },
params: { team_id },
params: routeParams,
}: ITeamDetailsPageProps): JSX.Element => {
const { isGlobalAdmin, setCurrentTeam } = useContext(AppContext);
const isLoadingTeams = useSelector(
(state: IRootState) => state.entities.teams.loading
);
const team = useSelector((state: IRootState) => {
return state.entities.teams.data[team_id];
return state.entities.teams.data[routeParams.team_id];
});
const teams = useSelector((state: IRootState) => {
return memoizedGetTeams(state.entities.teams.data);
});
const userTeams = useSelector((state: IRootState) => {
return state.auth.user.teams;
});
const routeTemplate = route && route.path ? route.path : "";
const [showAddHostsRedirectModal, setShowAddHostsRedirectModal] = useState(
false
@ -98,7 +128,7 @@ const TeamDetailsWrapper = ({
const dispatch = useDispatch();
const navigateToNav = (i: number): void => {
const navPath = teamDetailsSubNav[i].getPathname(team_id);
const navPath = teamDetailsSubNav[i].getPathname(routeParams.team_id);
dispatch(push(navPath));
};
@ -106,6 +136,8 @@ const TeamDetailsWrapper = ({
dispatch(teamActions.loadAll({ perPage: 500 }));
}, [dispatch]);
const [teamMenuIsOpen, setTeamMenuIsOpen] = useState<boolean>(false);
const toggleAddHostsRedirectModal = useCallback(() => {
setShowAddHostsRedirectModal(!showAddHostsRedirectModal);
}, [showAddHostsRedirectModal, setShowAddHostsRedirectModal]);
@ -153,6 +185,38 @@ const TeamDetailsWrapper = ({
[dispatch, toggleEditTeamModal, team]
);
const handleTeamSelect = (teamId: number) => {
const selectedTeam = find(teams, ["id", teamId]);
const { ADMIN_TEAMS } = PATHS;
const newRouteParams = {
...routeParams,
team_id: selectedTeam ? selectedTeam.id : teamId,
};
const nextLocation = getNextLocationPath({
pathPrefix: ADMIN_TEAMS,
routeTemplate,
routeParams: newRouteParams,
});
router.replace(`${nextLocation}/members`);
setCurrentTeam(selectedTeam);
};
const handleTeamMenuOpen = () => {
setTeamMenuIsOpen(true);
};
const handleTeamMenuClose = () => {
setTeamMenuIsOpen(false);
};
const teamDetailsClasses = classnames(baseClass, {
"team-select-open": teamMenuIsOpen,
});
if (isLoadingTeams || team === undefined) {
return (
<div className={`${baseClass}__loading-spinner`}>
@ -162,17 +226,39 @@ const TeamDetailsWrapper = ({
}
const hostsCount = team.host_count;
const hostsTotalDisplay = hostsCount === 1 ? "1 host" : `${hostsCount} hosts`;
const userAdminTeams = userTeams.filter(
(thisTeam) => thisTeam.role === "admin"
);
const adminTeams = isGlobalAdmin ? teams : userAdminTeams;
return (
<div className={baseClass}>
<div className={teamDetailsClasses}>
<TabsWrapper>
<Link to={PATHS.ADMIN_TEAMS} className={`${baseClass}__back-link`}>
<img src={BackChevron} alt="back chevron" id="back-chevron" />
<span>Back to teams</span>
</Link>
<>
{isGlobalAdmin && (
<Link to={PATHS.ADMIN_TEAMS} className={`${baseClass}__back-link`}>
<img src={BackChevron} alt="back chevron" id="back-chevron" />
<span>Back to teams</span>
</Link>
)}
</>
<div className={`${baseClass}__team-header`}>
<div className={`${baseClass}__team-details`}>
<h1>{team.name}</h1>
{adminTeams.length === 1 ? (
<h1>{team.name}</h1>
) : (
<TeamsDropdown
currentTeamId={toNumber(routeParams.team_id)}
isLoading={isLoadingTeams}
teams={adminTeams || []}
hideAllTeamsOption
onChange={(newSelectedValue: number) =>
handleTeamSelect(newSelectedValue)
}
onOpen={handleTeamMenuOpen}
onClose={handleTeamMenuClose}
/>
)}
<span className={`${baseClass}__host-count`}>
{hostsTotalDisplay}
</span>
@ -185,16 +271,18 @@ const TeamDetailsWrapper = ({
Edit team
</>
</Button>
<Button onClick={toggleDeleteTeamModal} variant={"text-icon"}>
<>
<img src={TrashIcon} alt="Delete team icon" />
Delete team
</>
</Button>
{isGlobalAdmin && (
<Button onClick={toggleDeleteTeamModal} variant={"text-icon"}>
<>
<img src={TrashIcon} alt="Delete team icon" />
Delete team
</>
</Button>
)}
</div>
</div>
<Tabs
selectedIndex={getTabIndex(pathname, team_id)}
selectedIndex={getTabIndex(pathname, routeParams.team_id)}
onSelect={(i) => navigateToNav(i)}
>
<TabList>

View File

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

View File

@ -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 && (
<div className="loading-spinner">
<Spinner />
</div>
)}
<UserForm
serverErrors={inviteErrors}
<EditUserModal
serverError={inviteErrors}
defaultEmail={userData.email}
defaultName={userData.name}
defaultGlobalRole={userData.global_role}
@ -493,6 +481,7 @@ export class UserManagementPage extends Component {
smtpConfigured={config.configured}
canUseSso={config.enable_sso}
isSsoEnabled={userData.sso_enabled}
isModifiedByGlobalAdmin
/>
</>
</Modal>
@ -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 (
<Modal
title="Create user"
onExit={toggleCreateUserModal}
className={`${baseClass}__create-user-modal`}
>
<>
{isFormSubmitting && (
<div className="loading-spinner">
<Spinner />
</div>
)}
<UserForm
serverErrors={createUserErrors}
currentUserId={currentUser.id}
onCancel={toggleCreateUserModal}
onSubmit={onCreateUserSubmit}
availableTeams={teams}
defaultGlobalRole={"observer"}
defaultTeams={[]}
defaultNewUserType={false}
submitText={"Create"}
isPremiumTier={isPremiumTier}
smtpConfigured={config.configured}
canUseSso={config.enable_sso}
isNewUser
/>
</>
</Modal>
<CreateUserModal
serverError={userErrors}
currentUserId={currentUser.id}
onCancel={toggleCreateUserModal}
onSubmit={onCreateUserSubmit}
availableTeams={teams}
defaultGlobalRole={"observer"}
defaultTeams={[]}
defaultNewUserType={false}
submitText={"Create"}
isPremiumTier={isPremiumTier}
smtpConfigured={config.configured}
canUseSso={config.enable_sso}
isFormSubmitting={isFormSubmitting}
isModifiedByGlobalAdmin
isNewUser
/>
);
};

View File

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

View File

@ -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 (
<Modal title="Create user" onExit={onCancel} className={baseClass}>
<>
{isFormSubmitting && (
<div className="loading-spinner">
<Spinner />
</div>
)}
<UserForm
serverErrors={serverErrors}
defaultGlobalRole={defaultGlobalRole}
defaultTeamRole={defaultTeamRole}
defaultTeams={defaultTeams}
onCancel={onCancel}
onSubmit={onSubmit}
availableTeams={availableTeams}
submitText={"Create"}
isPremiumTier={isPremiumTier}
smtpConfigured={smtpConfigured}
canUseSso={canUseSso}
isModifiedByGlobalAdmin={isModifiedByGlobalAdmin}
currentTeam={currentTeam}
isNewUser
/>
</>
</Modal>
);
};
export default CreateUserModal;

View File

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

View File

@ -0,0 +1 @@
export { default } from "./CreateUserModal";

View File

@ -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 (
<Modal
@ -41,9 +50,11 @@ const EditUserModal = ({
className={`${baseClass}__edit-user-modal`}
>
<UserForm
serverErrors={serverErrors}
defaultName={defaultName}
defaultEmail={defaultEmail}
defaultGlobalRole={defaultGlobalRole}
defaultTeamRole={defaultTeamRole}
defaultTeams={defaultTeams}
onCancel={onCancel}
onSubmit={onSubmit}
@ -53,6 +64,8 @@ const EditUserModal = ({
smtpConfigured={smtpConfigured}
canUseSso={canUseSso}
isSsoEnabled={isSsoEnabled}
isModifiedByGlobalAdmin={isModifiedByGlobalAdmin}
currentTeam={currentTeam}
/>
</Modal>
);

View File

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

View File

@ -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<string>(
defaultTeamRole.toLowerCase()
);
const updateSelectedRole = (newRoleValue: string) => {
const updatedTeam = { ...currentTeam };
updatedTeam.role = newRoleValue;
onFormChange(generateSelectedTeamData(teams, updatedTeam));
setSelectedRole(newRoleValue);
};
return (
<div className={baseClass}>
<div className={`${baseClass}__select-role`}>
<Dropdown
value={selectedRole}
className={`${baseClass}__role-dropdown`}
options={roles}
searchable={false}
onChange={(newRoleValue: string) => updateSelectedRole(newRoleValue)}
testId={`${name}-checkbox`}
/>
</div>
</div>
);
};
export default SelectRoleForm;

View File

@ -30,6 +30,11 @@ const roles = [
label: "Maintainer",
value: "maintainer",
},
{
disabled: false,
label: "Admin",
value: "admin",
},
];
const generateFormListItems = (

View File

@ -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<ICreateUserFormProps, ICreateUserFormState> {
});
};
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<ICreateUserFormProps, ICreateUserFormState> {
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<ICreateUserFormProps, ICreateUserFormState> {
};
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 (
<>
<InfoBanner className={`${baseClass}__user-permissions-info`}>
<p>
Users can be members of multiple teams and can only manage or
observe team-specific users, entities, and settings in Fleet.
</p>
<a
href="https://fleetdm.com/docs/using-fleet/permissions#team-member-permissions"
target="_blank"
rel="noopener noreferrer"
>
Learn more about user permissions
<img src={OpenNewTabIcon} alt="open new tab" />
</a>
</InfoBanner>
{availableTeams.length > 0 ? (
<SelectedTeamsForm
availableTeams={availableTeams}
usersCurrentTeams={teams}
onFormChange={onSelectedTeamChange}
/>
) : (
renderNoTeamsMessage()
)}
{availableTeams.length &&
(isModifiedByGlobalAdmin ? (
<>
<InfoBanner className={`${baseClass}__user-permissions-info`}>
<p>
Users can be members of multiple teams and can only manage or
observe team-specific users, entities, and settings in Fleet.
</p>
<a
href="https://fleetdm.com/docs/using-fleet/permissions#team-member-permissions"
target="_blank"
rel="noopener noreferrer"
>
Learn more about user permissions
<img src={OpenNewTabIcon} alt="open new tab" />
</a>
</InfoBanner>
<SelectedTeamsForm
availableTeams={availableTeams}
usersCurrentTeams={teams}
onFormChange={onSelectedTeamChange}
/>
</>
) : (
<>
<p className={`${baseClass}__label`}>Role</p>
<SelectRoleForm
currentTeam={currentTeam || teams[0]}
teams={teams}
defaultTeamRole={defaultTeamRole || "observer"}
onFormChange={onTeamRoleChange}
/>
</>
))}
{!availableTeams.length && renderNoTeamsMessage()}
</>
);
};
@ -394,6 +427,8 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
smtpConfigured,
canUseSso,
isNewUser,
currentTeam,
isModifiedByGlobalAdmin,
serverErrors,
} = this.props;
const {
@ -422,7 +457,7 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
name="name"
onChange={onInputChange("name")}
placeholder="Full name"
value={name}
value={name || ""}
/>
<div
className="email-disabled"
@ -435,7 +470,7 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
name="email"
onChange={onInputChange("email")}
placeholder="Email"
value={email}
value={email || ""}
disabled={!isNewUser && !smtpConfigured}
/>
</div>
@ -498,53 +533,65 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
{isNewUser && (
<div className={`${baseClass}__new-user-container`}>
<div className={`${baseClass}__new-user-radios`}>
<Radio
className={`${baseClass}__radio-input`}
label={"Create user"}
id={"create-user"}
checked={newUserType !== NewUserType.AdminInvited}
value={NewUserType.AdminCreated}
name={"newUserType"}
onChange={onRadioChange("newUserType")}
/>
<div
className="invite-disabled"
data-tip
data-for="invite-disabled-tooltip"
data-tip-disable={smtpConfigured}
>
<Radio
className={`${baseClass}__radio-input`}
label={"Invite user"}
id={"invite-user"}
disabled={!smtpConfigured}
checked={newUserType === NewUserType.AdminInvited}
value={NewUserType.AdminInvited}
{isModifiedByGlobalAdmin ? (
<>
<Radio
className={`${baseClass}__radio-input`}
label={"Create user"}
id={"create-user"}
checked={newUserType !== NewUserType.AdminInvited}
value={NewUserType.AdminCreated}
name={"newUserType"}
onChange={onRadioChange("newUserType")}
/>
<div
className="invite-disabled"
data-tip
data-for="invite-disabled-tooltip"
data-tip-disable={smtpConfigured}
>
<Radio
className={`${baseClass}__radio-input`}
label={"Invite user"}
id={"invite-user"}
disabled={!smtpConfigured}
checked={newUserType === NewUserType.AdminInvited}
value={NewUserType.AdminInvited}
name={"newUserType"}
onChange={onRadioChange("newUserType")}
/>
<ReactTooltip
place="bottom"
type="dark"
effect="solid"
id="invite-disabled-tooltip"
backgroundColor="#3e4771"
data-html
>
<span className={`${baseClass}__tooltip-text`}>
The &quot;Invite user&quot; feature requires that SMTP
is
<br />
configured in order to send invitation emails. <br />
<br />
SMTP can be configured in{" "}
<strong>
Settings &gt; <br />
Organization settings
</strong>
.
</span>
</ReactTooltip>
</div>
</>
) : (
<input
type="hidden"
id={"create-user"}
value={NewUserType.AdminCreated}
name={"newUserType"}
onChange={onRadioChange("newUserType")}
/>
<ReactTooltip
place="bottom"
type="dark"
effect="solid"
id="invite-disabled-tooltip"
backgroundColor="#3e4771"
data-html
>
<span className={`${baseClass}__tooltip-text`}>
The &quot;Invite user&quot; feature requires that SMTP is
<br />
configured in order to send invitation emails. <br />
<br />
SMTP can be configured in{" "}
<strong>
Settings &gt; <br />
Organization settings
</strong>
.
</span>
</ReactTooltip>
</div>
)}
</div>
{newUserType !== NewUserType.AdminInvited && !sso_enabled && (
<>
@ -554,7 +601,7 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
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<ICreateUserFormProps, ICreateUserFormState> {
<div className={`${baseClass}__selected-teams-container`}>
<div className={`${baseClass}__team-radios`}>
<p className={`${baseClass}__label`}>Team</p>
<Radio
className={`${baseClass}__radio-input`}
label={"Global user"}
id={"global-user"}
checked={isGlobalUser}
value={UserTeamType.GlobalUser}
name={"userTeamType"}
onChange={onIsGlobalUserChange}
/>
<Radio
className={`${baseClass}__radio-input`}
label={"Assign teams"}
id={"assign-teams"}
checked={!isGlobalUser}
value={UserTeamType.AssignTeams}
name={"userTeamType"}
onChange={onIsGlobalUserChange}
/>
{isModifiedByGlobalAdmin ? (
<>
<Radio
className={`${baseClass}__radio-input`}
label={"Global user"}
id={"global-user"}
checked={isGlobalUser}
value={UserTeamType.GlobalUser}
name={"userTeamType"}
onChange={onIsGlobalUserChange}
/>
<Radio
className={`${baseClass}__radio-input`}
label={"Assign teams"}
id={"assign-teams"}
checked={!isGlobalUser}
value={UserTeamType.AssignTeams}
name={"userTeamType"}
onChange={onIsGlobalUserChange}
/>
</>
) : (
<p className="current-team">
{currentTeam ? currentTeam.name : ""}
</p>
)}
</div>
<div className={`${baseClass}__teams-form-container`}>
{isGlobalUser ? renderGlobalRoleForm() : renderTeamsForm()}

View File

@ -45,6 +45,11 @@
width: 310px;
}
.current-team {
margin-top: 4px;
margin-bottom: 24px;
}
&__label {
color: $core-fleet-black;
font-size: $x-small;

View File

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

View File

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

View File

@ -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 = (
<Route component={CoreLayout}>
<IndexRedirect to={"dashboard"} />
<Route path="dashboard" component={Homepage} />
<Route path="settings" component={AuthenticatedAdminRoutes}>
<Route path="settings" component={AuthAnyAdminRoutes}>
<Route component={SettingsWrapper}>
<Route path="organization" component={AdminAppSettingsPage} />
<Route path="users" component={AdminUserManagementPage} />
<Route component={PremiumTierRoutes}>
<Route path="teams" component={AdminTeamManagementPage} />
<Route component={AuthenticatedAdminRoutes}>
<Route path="organization" component={AdminAppSettingsPage} />
<Route path="users" component={AdminUserManagementPage} />
<Route component={PremiumTierRoutes}>
<Route path="teams" component={AdminTeamManagementPage} />
</Route>
</Route>
</Route>
<Route path="teams/:team_id" component={TeamDetailsWrapper}>
@ -128,7 +131,7 @@ const routes = (
</Route>
</Route>
</Route>
<Route component={AuthAnyMaintainerGlobalAdminRoutes}>
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="schedule" component={SchedulePageWrapper}>
<Route path="manage" component={ManageSchedulePage} />
<Route
@ -139,7 +142,7 @@ const routes = (
</Route>
<Route path="queries" component={QueryPageWrapper}>
<Route path="manage" component={ManageQueriesPage} />
<Route component={AuthAnyMaintainerGlobalAdminRoutes}>
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={QueryPage} />
</Route>
<Route path=":id" component={QueryPage} />

View File

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

View File

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

View File

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

View File

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