Teams Details Page: Add generate installer and manage enroll secret CTA buttons (#3864)

This commit is contained in:
RachelElysia
2022-01-25 13:20:24 -05:00
committed by GitHub
parent 9bfb8c75d2
commit 66b3f97b37
21 changed files with 281 additions and 177 deletions
+1
View File
@@ -0,0 +1 @@
* Add generate installer and manage enroll secrets to team details page
@@ -6,7 +6,7 @@ import EnrollSecretTable from "components/EnrollSecretTable";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";
import PlusIcon from "../../../../../../assets/images/icon-plus-16x16@2x.png";
import PlusIcon from "../../../assets/images/icon-plus-16x16@2x.png";
interface IEnrollSecretModal {
selectedTeam: number;
@@ -17,7 +17,7 @@ interface IEnrollSecretModal {
setSelectedSecret: React.Dispatch<
React.SetStateAction<IEnrollSecret | undefined>
>;
globalSecrets: IEnrollSecret[] | undefined;
globalSecrets?: IEnrollSecret[] | undefined;
}
const baseClass = "enroll-secret-modal";
@@ -18,10 +18,10 @@ import Button from "components/buttons/Button";
import InputField from "components/forms/fields/InputField";
import TabsWrapper from "components/TabsWrapper";
import { isValidPemCertificate } from "../../../helpers";
import { isValidPemCertificate } from "../../../pages/hosts/ManageHostsPage/helpers";
import CopyIcon from "../../../../../../../assets/images/icon-copy-clipboard-fleet-blue-20x20@2x.png";
import DownloadIcon from "../../../../../../../assets/images/icon-download-12x12@2x.png";
import CopyIcon from "../../../../assets/images/icon-copy-clipboard-fleet-blue-20x20@2x.png";
import DownloadIcon from "../../../../assets/images/icon-download-12x12@2x.png";
interface IPlatformSubNav {
name: string;
@@ -1,19 +1,26 @@
import React, { useState, useEffect, useCallback, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { useQuery } from "react-query";
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 { find, toNumber } from "lodash";
import classnames from "classnames";
import PATHS from "router/paths";
import { ITeam } from "interfaces/team";
import { IUser } from "interfaces/user";
import { ITeam, ITeamSummary } from "interfaces/team";
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";
import teamsAPI from "services/entities/teams";
import enrollSecretsAPI from "services/entities/enroll_secret";
import teamActions from "redux/nodes/entities/teams/actions";
import {
IEnrollSecret,
IEnrollSecretsResponse,
} from "interfaces/enroll_secret";
import sortUtils from "utilities/sort";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
import TabsWrapper from "components/TabsWrapper";
@@ -22,9 +29,13 @@ import { getNextLocationPath } from "pages/admin/UserManagementPage/helpers/user
import DeleteTeamModal from "../components/DeleteTeamModal";
import EditTeamModal from "../components/EditTeamModal";
import { IEditTeamFormData } from "../components/EditTeamModal/EditTeamModal";
import AddHostsRedirectModal from "./components/AddHostsModal/AddHostsRedirectModal";
import DeleteSecretModal from "../../../../components/DeleteSecretModal";
import SecretEditorModal from "../../../../components/SecretEditorModal";
import GenerateInstallerModal from "../../../../components/GenerateInstallerModal";
import EnrollSecretModal from "../../../../components/EnrollSecretModal";
import BackChevron from "../../../../../assets/images/icon-chevron-down-9x6@2x.png";
import EyeIcon from "../../../../../assets/images/icon-eye-16x16@2x.png";
import PencilIcon from "../../../../../assets/images/icon-pencil-14x14@2x.png";
import TrashIcon from "../../../../../assets/images/icon-trash-14x14@2x.png";
@@ -46,16 +57,8 @@ const teamDetailsSubNav: ITeamDetailsSubNavItem[] = [
},
];
interface IRootState {
auth: {
user: IUser;
};
entities: {
teams: {
loading: boolean;
data: { [id: number]: ITeam };
};
};
interface ITeamsResponse {
teams: ITeam[];
}
interface ITeamDetailsPageProps {
@@ -70,19 +73,11 @@ interface ITeamDetailsPageProps {
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,
currentTeam: ITeamSummary,
formData: IEditTeamFormData
): IEditTeamFormData | null => {
if (currentTeamData.name !== formData.name) {
if (currentTeam.name !== formData.name) {
return {
name: formData.name,
};
@@ -103,31 +98,66 @@ const TeamDetailsWrapper = ({
location: { pathname },
params: routeParams,
}: ITeamDetailsPageProps): JSX.Element => {
const { isGlobalAdmin, isOnGlobalTeam, setCurrentTeam } = useContext(
AppContext
);
const dispatch = useDispatch();
const isLoadingTeams = useSelector(
(state: IRootState) => state.entities.teams.loading
);
const team = useSelector((state: IRootState) => {
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 {
currentUser,
isGlobalAdmin,
currentTeam,
isOnGlobalTeam,
isPremiumTier,
setCurrentTeam,
} = useContext(AppContext);
const userTeams = currentUser?.teams || [];
const routeTemplate = route && route.path ? route.path : "";
const [showAddHostsRedirectModal, setShowAddHostsRedirectModal] = useState(
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
const [showGenerateInstallerModal, setShowGenerateInstallerModal] = useState(
false
);
const [
showManageEnrollSecretsModal,
setShowManageEnrollSecretsModal,
] = useState(false);
const [showDeleteSecretModal, setShowDeleteSecretModal] = useState(false);
const [showEnrollSecretModal, setShowEnrollSecretModal] = useState(false);
const [showSecretEditorModal, setShowSecretEditorModal] = useState(false);
const [showDeleteTeamModal, setShowDeleteTeamModal] = useState(false);
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const dispatch = useDispatch();
const {
data: teams,
isLoading: isLoadingTeams,
refetch: refetchTeams,
} = useQuery<ITeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
select: (data: ITeamsResponse) =>
data.teams.sort((a, b) => sortUtils.caseInsensitiveAsc(a.name, b.name)),
onSuccess: (responseTeams: ITeam[]) => {
const findTeam = responseTeams.find(
(team) => team.id === Number(routeParams.team_id)
);
setCurrentTeam(findTeam);
},
}
);
const {
isLoading: isTeamSecretsLoading,
data: teamSecrets,
refetch: refetchTeamSecrets,
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
["team secrets", routeParams],
() => {
return enrollSecretsAPI.getTeamEnrollSecrets(routeParams.team_id);
},
{
select: (data: IEnrollSecretsResponse) => data.secrets,
}
);
const navigateToNav = (i: number): void => {
const navPath = teamDetailsSubNav[i].getPathname(routeParams.team_id);
@@ -140,9 +170,38 @@ const TeamDetailsWrapper = ({
const [teamMenuIsOpen, setTeamMenuIsOpen] = useState<boolean>(false);
const toggleAddHostsRedirectModal = useCallback(() => {
setShowAddHostsRedirectModal(!showAddHostsRedirectModal);
}, [showAddHostsRedirectModal, setShowAddHostsRedirectModal]);
const toggleGenerateInstallerModal = useCallback(() => {
setShowGenerateInstallerModal(!showGenerateInstallerModal);
}, [showGenerateInstallerModal, setShowGenerateInstallerModal]);
const toggleManageEnrollSecretsModal = useCallback(() => {
setShowManageEnrollSecretsModal(!showManageEnrollSecretsModal);
}, [showManageEnrollSecretsModal, setShowManageEnrollSecretsModal]);
const toggleDeleteSecretModal = useCallback(() => {
// open and closes delete modal
setShowDeleteSecretModal(!showDeleteSecretModal);
// open and closes main enroll secret modal
setShowEnrollSecretModal(!showEnrollSecretModal);
}, [
setShowDeleteSecretModal,
showDeleteSecretModal,
setShowEnrollSecretModal,
showEnrollSecretModal,
]);
// this is called when we click add or edit
const toggleSecretEditorModal = useCallback(() => {
// open and closes add/edit modal
setShowSecretEditorModal(!showSecretEditorModal);
// open and closes main enroll secret modall
setShowEnrollSecretModal(!showEnrollSecretModal);
}, [
setShowSecretEditorModal,
showSecretEditorModal,
setShowEnrollSecretModal,
showEnrollSecretModal,
]);
const toggleDeleteTeamModal = useCallback(() => {
setShowDeleteTeamModal(!showDeleteTeamModal);
@@ -152,12 +211,76 @@ const TeamDetailsWrapper = ({
setShowEditTeamModal(!showEditTeamModal);
}, [showEditTeamModal, setShowEditTeamModal]);
const onAddHostsRedirectClick = useCallback(() => {
dispatch(push(PATHS.MANAGE_HOSTS));
}, [dispatch]);
const onSaveSecret = async (enrollSecretString: string) => {
// Creates new list of secrets removing selected secret and adding new secret
const currentSecrets = teamSecrets || [];
const newSecrets = currentSecrets.filter(
(s) => s.secret !== selectedSecret?.secret
);
if (enrollSecretString) {
newSecrets.push({ secret: enrollSecretString });
}
try {
await enrollSecretsAPI.modifyTeamEnrollSecrets(
routeParams.team_id,
newSecrets
);
refetchTeamSecrets();
toggleSecretEditorModal();
isPremiumTier && refetchTeams();
dispatch(
renderFlash(
"success",
`Successfully ${selectedSecret ? "edited" : "added"} enroll secret.`
)
);
} catch (error) {
console.error(error);
dispatch(
renderFlash(
"error",
`Could not ${
selectedSecret ? "edit" : "add"
} enroll secret. Please try again.`
)
);
}
};
const onDeleteSecret = async () => {
// create new list of secrets removing selected secret
const currentSecrets = teamSecrets || [];
const newSecrets = currentSecrets.filter(
(s) => s.secret !== selectedSecret?.secret
);
try {
await enrollSecretsAPI.modifyTeamEnrollSecrets(
routeParams.team_id,
newSecrets
);
refetchTeamSecrets();
toggleDeleteSecretModal();
refetchTeams();
dispatch(renderFlash("success", `Successfully deleted enroll secret.`));
} catch (error) {
console.error(error);
dispatch(
renderFlash(
"error",
"Could not delete enroll secret. Please try again."
)
);
}
};
const onDeleteSubmit = useCallback(() => {
dispatch(teamActions.destroy(team?.id))
dispatch(teamActions.destroy(currentTeam?.id))
.then(() => {
dispatch(renderFlash("success", "Team removed"));
dispatch(push(PATHS.ADMIN_TEAMS));
@@ -165,17 +288,18 @@ const TeamDetailsWrapper = ({
})
.catch(() => null);
toggleDeleteTeamModal();
}, [dispatch, toggleDeleteTeamModal, team?.id]);
}, [dispatch, toggleDeleteTeamModal, currentTeam?.id]);
const onEditSubmit = useCallback(
(formData: IEditTeamFormData) => {
const updatedAttrs = generateUpdateData(team, formData);
const updatedAttrs =
currentTeam && generateUpdateData(currentTeam, formData);
// no updates, so no need for a request.
if (updatedAttrs === null) {
toggleEditTeamModal();
return;
}
dispatch(teamActions.update(team?.id, updatedAttrs))
dispatch(teamActions.update(currentTeam?.id, updatedAttrs))
.then(() => {
dispatch(teamActions.loadAll({ perPage: 500 }));
dispatch(renderFlash("success", "Team updated"));
@@ -184,7 +308,7 @@ const TeamDetailsWrapper = ({
.catch(() => null);
toggleEditTeamModal();
},
[dispatch, toggleEditTeamModal, team]
[dispatch, toggleEditTeamModal, currentTeam]
);
const handleTeamSelect = (teamId: number) => {
@@ -220,14 +344,16 @@ const TeamDetailsWrapper = ({
"team-settings": !isOnGlobalTeam,
});
if (isLoadingTeams || team === undefined) {
if (isLoadingTeams || isTeamSecretsLoading || currentTeam === undefined) {
return (
<div className={`${baseClass}__loading-spinner`}>
<Spinner />
</div>
);
}
const hostsCount = team.host_count;
// const hostsCount = currentTeam.host_count;
const hostsCount = teams?.length || 1;
const hostsTotalDisplay = hostsCount === 1 ? "1 host" : `${hostsCount} hosts`;
const userAdminTeams = userTeams.filter(
(thisTeam) => thisTeam.role === "admin"
@@ -247,8 +373,8 @@ const TeamDetailsWrapper = ({
</>
<div className={`${baseClass}__team-header`}>
<div className={`${baseClass}__team-details`}>
{adminTeams.length === 1 ? (
<h1>{team.name}</h1>
{adminTeams?.length === 1 ? (
<h1>{currentTeam.name}</h1>
) : (
<TeamsDropdown
selectedTeamId={toNumber(routeParams.team_id)}
@@ -267,18 +393,27 @@ const TeamDetailsWrapper = ({
</span>
</div>
<div className={`${baseClass}__team-actions`}>
<Button onClick={toggleAddHostsRedirectModal}>Add hosts</Button>
<Button onClick={toggleGenerateInstallerModal}>
Generate installer
</Button>
<Button
onClick={toggleManageEnrollSecretsModal}
variant={"text-icon"}
>
<>
Manage enroll secrets{" "}
<img src={EyeIcon} alt="Manage enroll secrets icon" />
</>
</Button>
<Button onClick={toggleEditTeamModal} variant={"text-icon"}>
<>
<img src={PencilIcon} alt="Edit team icon" />
Edit team
Edit team <img src={PencilIcon} alt="Edit team icon" />
</>
</Button>
{isGlobalAdmin && (
<Button onClick={toggleDeleteTeamModal} variant={"text-icon"}>
<>
<img src={TrashIcon} alt="Delete team icon" />
Delete team
Delete team <img src={TrashIcon} alt="Delete team icon" />
</>
</Button>
)}
@@ -301,26 +436,56 @@ const TeamDetailsWrapper = ({
</TabList>
</Tabs>
</TabsWrapper>
{showAddHostsRedirectModal ? (
<AddHostsRedirectModal
onCancel={toggleAddHostsRedirectModal}
onSubmit={onAddHostsRedirectClick}
{showGenerateInstallerModal && (
<GenerateInstallerModal
onCancel={toggleGenerateInstallerModal}
selectedTeam={{
name: currentTeam.name,
secrets: teamSecrets || null,
}}
/>
) : null}
{showDeleteTeamModal ? (
)}
{showManageEnrollSecretsModal && (
<EnrollSecretModal
selectedTeam={routeParams.team_id}
teams={teams || []}
onReturnToApp={toggleManageEnrollSecretsModal}
toggleSecretEditorModal={toggleSecretEditorModal}
toggleDeleteSecretModal={toggleDeleteSecretModal}
setSelectedSecret={setSelectedSecret}
/>
)}
{showSecretEditorModal && (
<SecretEditorModal
selectedTeam={routeParams.team_id}
teams={teams || []}
onSaveSecret={onSaveSecret}
toggleSecretEditorModal={toggleSecretEditorModal}
selectedSecret={selectedSecret}
/>
)}
{showDeleteSecretModal && (
<DeleteSecretModal
onDeleteSecret={onDeleteSecret}
selectedTeam={routeParams.team_id}
teams={teams || []}
toggleDeleteSecretModal={toggleDeleteSecretModal}
/>
)}
{showDeleteTeamModal && (
<DeleteTeamModal
onCancel={toggleDeleteTeamModal}
onSubmit={onDeleteSubmit}
name={team.name}
name={currentTeam.name}
/>
) : null}
{showEditTeamModal ? (
)}
{showEditTeamModal && (
<EditTeamModal
onCancel={toggleEditTeamModal}
onSubmit={onEditSubmit}
defaultName={team.name}
defaultName={currentTeam.name}
/>
) : null}
)}
{children}
</div>
);
@@ -51,7 +51,7 @@
display: flex;
button {
margin-left: $pad-small;
margin-left: $pad-medium;
}
}
}
@@ -1,45 +0,0 @@
import React from "react";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
interface IAddHostsModalProps {
onCancel: () => void;
onSubmit: () => void;
}
const baseClass = "add-hosts-redirect-modal";
const AddHostsRedirectModal = ({
onCancel,
onSubmit,
}: IAddHostsModalProps): JSX.Element => {
return (
<Modal title={"Add hosts"} onExit={onCancel} className={baseClass}>
<div className={`${baseClass}__modal-body`}>
<p>
Head to the Hosts page to transfer hosts that are already enrolled to
Fleet or enroll new hosts.
</p>
<div className={`${baseClass}__btn-wrap`}>
<Button
className={`${baseClass}__btn`}
type="button"
variant="brand"
onClick={onSubmit}
>
Go to Hosts page
</Button>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="inverse"
>
Cancel
</Button>
</div>
</div>
</Modal>
);
};
export default AddHostsRedirectModal;
@@ -1,11 +0,0 @@
.add-hosts-redirect-modal {
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}
@@ -1 +0,0 @@
export { default } from "./AddHostsRedirectModal";
@@ -74,9 +74,10 @@ import {
getNextLocationPath,
} from "./helpers";
import DeleteSecretModal from "./components/DeleteSecretModal";
import SecretEditorModal from "./components/SecretEditorModal";
import EnrollSecretModal from "./components/EnrollSecretModal";
import DeleteSecretModal from "../../../components/DeleteSecretModal";
import SecretEditorModal from "../../../components/SecretEditorModal";
import GenerateInstallerModal from "../../../components/GenerateInstallerModal";
import EnrollSecretModal from "../../../components/EnrollSecretModal";
// @ts-ignore
import NoHosts from "./components/NoHosts";
import EmptyHosts from "./components/EmptyHosts";
@@ -86,8 +87,6 @@ import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal";
import TransferHostModal from "./components/TransferHostModal";
import DeleteHostModal from "./components/DeleteHostModal";
import SoftwareVulnerabilities from "./components/SoftwareVulnerabilities";
// @ts-ignore
import GenerateInstallerModal from "./components/GenerateInstallerModal";
import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x16@2x.png";
import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png";
import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png";
@@ -1186,21 +1185,15 @@ const ManageHostsPage = ({
}
return (
<Modal
title="Enroll secret"
onExit={() => setShowEnrollSecretModal(false)}
className={`${baseClass}__enroll-secret-modal`}
>
<EnrollSecretModal
selectedTeam={currentTeam?.id || 0}
teams={teams || []}
onReturnToApp={() => setShowEnrollSecretModal(false)}
toggleSecretEditorModal={toggleSecretEditorModal}
toggleDeleteSecretModal={toggleDeleteSecretModal}
setSelectedSecret={setSelectedSecret}
globalSecrets={globalSecrets}
/>
</Modal>
<EnrollSecretModal
selectedTeam={currentTeam?.id || 0}
teams={teams || []}
onReturnToApp={() => setShowEnrollSecretModal(false)}
toggleSecretEditorModal={toggleSecretEditorModal}
toggleDeleteSecretModal={toggleDeleteSecretModal}
setSelectedSecret={setSelectedSecret}
globalSecrets={globalSecrets}
/>
);
};
@@ -1529,28 +1522,30 @@ const ManageHostsPage = ({
!isGlobalSecretsLoading &&
!globalSecrets?.length;
return ((canEnrollHosts && noTeamEnrollSecrets) ||
(canEnrollGlobalHosts && noGlobalEnrollSecrets)) &&
showNoEnrollSecretBanner ? (
<div className={`${baseClass}__no-enroll-secret-banner`}>
<div>
<span>
You have no enroll secrets. Manage enroll secrets to enroll hosts to{" "}
<b>{currentTeam?.id ? currentTeam.name : "Fleet"}</b>.
</span>
return (
((canEnrollHosts && noTeamEnrollSecrets) ||
(canEnrollGlobalHosts && noGlobalEnrollSecrets)) &&
showNoEnrollSecretBanner && (
<div className={`${baseClass}__no-enroll-secret-banner`}>
<div>
<span>
You have no enroll secrets. Manage enroll secrets to enroll hosts
to <b>{currentTeam?.id ? currentTeam.name : "Fleet"}</b>.
</span>
</div>
<div className={`dismiss-banner-button`}>
<button
className="button button--unstyled"
onClick={() =>
setShowNoEnrollSecretBanner(!showNoEnrollSecretBanner)
}
>
<img alt="Dismiss no enroll secret banner" src={CloseIconBlack} />
</button>
</div>
</div>
<div className={`dismiss-banner-button`}>
<button
className="button button--unstyled"
onClick={() =>
setShowNoEnrollSecretBanner(!showNoEnrollSecretBanner)
}
>
<img alt="Dismiss no enroll secret banner" src={CloseIconBlack} />
</button>
</div>
</div>
) : null;
)
);
};
return (