mirror of
https://github.com/empayre/fleet.git
synced 2024-11-08 01:38:57 +00:00
530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
import React, { useState, useCallback, useContext } from "react";
|
|
import { useQuery } from "react-query";
|
|
import { useErrorHandler } from "react-error-boundary";
|
|
import { InjectedRouter } from "react-router";
|
|
import { Tab, TabList, Tabs } from "react-tabs";
|
|
|
|
import { NotificationContext } from "context/notification";
|
|
import { AppContext } from "context/app";
|
|
import useTeamIdParam from "hooks/useTeamIdParam";
|
|
import {
|
|
IEnrollSecret,
|
|
IEnrollSecretsResponse,
|
|
} from "interfaces/enroll_secret";
|
|
import { ITeam, ITeamSummary } from "interfaces/team";
|
|
import PATHS from "router/paths";
|
|
import enrollSecretsAPI from "services/entities/enroll_secret";
|
|
import teamsAPI, {
|
|
ILoadTeamsResponse,
|
|
ITeamFormData,
|
|
} from "services/entities/teams";
|
|
import usersAPI, { IGetMeResponse } from "services/entities/users";
|
|
import formatErrorResponse from "utilities/format_error_response";
|
|
import sortUtils from "utilities/sort";
|
|
|
|
import ActionButtons from "components/buttons/ActionButtons/ActionButtons";
|
|
import Spinner from "components/Spinner";
|
|
import TabsWrapper from "components/TabsWrapper";
|
|
import BackLink from "components/BackLink";
|
|
import TeamsDropdown from "components/TeamsDropdown";
|
|
import MainContent from "components/MainContent";
|
|
import DeleteTeamModal from "../components/DeleteTeamModal";
|
|
import EditTeamModal from "../components/EditTeamModal";
|
|
import DeleteSecretModal from "../../../../components/EnrollSecrets/DeleteSecretModal";
|
|
import SecretEditorModal from "../../../../components/EnrollSecrets/SecretEditorModal";
|
|
import AddHostsModal from "../../../../components/AddHostsModal";
|
|
import EnrollSecretModal from "../../../../components/EnrollSecrets/EnrollSecretModal";
|
|
|
|
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";
|
|
|
|
const baseClass = "team-details";
|
|
|
|
interface ITeamDetailsSubNavItem {
|
|
name: string;
|
|
getPathname: (id?: number) => string;
|
|
}
|
|
|
|
const teamDetailsSubNav: ITeamDetailsSubNavItem[] = [
|
|
{
|
|
name: "Members",
|
|
getPathname: PATHS.TEAM_DETAILS_MEMBERS,
|
|
},
|
|
{
|
|
name: "Agent options",
|
|
getPathname: PATHS.TEAM_DETAILS_OPTIONS,
|
|
},
|
|
];
|
|
|
|
interface ITeamDetailsPageProps {
|
|
children: JSX.Element;
|
|
location: {
|
|
pathname: string;
|
|
search: string;
|
|
hash?: string;
|
|
query: { team_id?: string };
|
|
};
|
|
router: InjectedRouter;
|
|
}
|
|
|
|
const generateUpdateData = (
|
|
currentTeam: ITeamSummary,
|
|
formData: ITeamFormData
|
|
): ITeamFormData | null => {
|
|
if (currentTeam.name !== formData.name) {
|
|
return {
|
|
name: formData.name,
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getTabIndex = (path: string, teamId: number): number => {
|
|
return teamDetailsSubNav.findIndex((navItem) => {
|
|
return navItem.getPathname(teamId).includes(path);
|
|
});
|
|
};
|
|
|
|
const TeamDetailsWrapper = ({
|
|
router,
|
|
children,
|
|
location,
|
|
}: ITeamDetailsPageProps): JSX.Element => {
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
const handlePageError = useErrorHandler();
|
|
const {
|
|
isGlobalAdmin,
|
|
isPremiumTier,
|
|
setAvailableTeams,
|
|
setCurrentUser,
|
|
} = useContext(AppContext);
|
|
|
|
const {
|
|
currentTeamId,
|
|
currentTeamName,
|
|
isAnyTeamSelected,
|
|
isRouteOk,
|
|
teamIdForApi,
|
|
userTeams,
|
|
handleTeamChange,
|
|
} = useTeamIdParam({
|
|
location,
|
|
router,
|
|
includeAllTeams: false,
|
|
includeNoTeam: false,
|
|
permittedAccessByTeamRole: {
|
|
admin: true,
|
|
maintainer: false,
|
|
observer: false,
|
|
observer_plus: false,
|
|
},
|
|
});
|
|
|
|
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
|
|
const [showAddHostsModal, setShowAddHostsModal] = 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 [backendValidators, setBackendValidators] = useState<{
|
|
[key: string]: string;
|
|
}>({});
|
|
const [isUpdatingTeams, setIsUpdatingTeams] = useState(false);
|
|
const [isUpdatingSecret, setIsUpdatingSecret] = useState(false);
|
|
|
|
const { refetch: refetchMe } = useQuery(["me"], () => usersAPI.me(), {
|
|
enabled: false,
|
|
onSuccess: ({ user, available_teams }: IGetMeResponse) => {
|
|
setCurrentUser(user);
|
|
setAvailableTeams(user, available_teams);
|
|
},
|
|
});
|
|
|
|
const {
|
|
data: teams,
|
|
isLoading: isLoadingTeams,
|
|
refetch: refetchTeams,
|
|
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
|
|
["teams"],
|
|
() => teamsAPI.loadAll(),
|
|
{
|
|
enabled: isRouteOk,
|
|
select: (data: ILoadTeamsResponse) =>
|
|
data.teams.sort((a, b) => sortUtils.caseInsensitiveAsc(a.name, b.name)),
|
|
onSuccess: (responseTeams: ITeam[]) => {
|
|
if (!responseTeams?.find((team) => team.id === teamIdForApi)) {
|
|
handlePageError({ status: 404 });
|
|
}
|
|
},
|
|
onError: (error) => handlePageError(error),
|
|
}
|
|
);
|
|
const currentTeamDetails = teams?.find((team) => team.id === teamIdForApi);
|
|
|
|
const {
|
|
isLoading: isTeamSecretsLoading,
|
|
data: teamSecrets,
|
|
refetch: refetchTeamSecrets,
|
|
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
|
|
["team secrets", teamIdForApi],
|
|
() => {
|
|
return enrollSecretsAPI.getTeamEnrollSecrets(teamIdForApi);
|
|
},
|
|
{
|
|
enabled: isRouteOk,
|
|
select: (data: IEnrollSecretsResponse) => data.secrets,
|
|
}
|
|
);
|
|
|
|
const navigateToNav = (i: number): void => {
|
|
const navPath = teamDetailsSubNav[i].getPathname(teamIdForApi);
|
|
router.push(navPath);
|
|
};
|
|
|
|
const toggleAddHostsModal = useCallback(() => {
|
|
setShowAddHostsModal(!showAddHostsModal);
|
|
}, [showAddHostsModal, setShowAddHostsModal]);
|
|
|
|
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);
|
|
}, [showDeleteTeamModal, setShowDeleteTeamModal]);
|
|
|
|
const toggleEditTeamModal = useCallback(() => {
|
|
setShowEditTeamModal(!showEditTeamModal);
|
|
setBackendValidators({});
|
|
}, [showEditTeamModal, setShowEditTeamModal, setBackendValidators]);
|
|
|
|
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 });
|
|
}
|
|
setIsUpdatingSecret(true);
|
|
try {
|
|
await enrollSecretsAPI.modifyTeamEnrollSecrets(teamIdForApi, newSecrets);
|
|
refetchTeamSecrets();
|
|
|
|
toggleSecretEditorModal();
|
|
isPremiumTier && refetchTeams();
|
|
renderFlash(
|
|
"success",
|
|
`Successfully ${selectedSecret ? "edited" : "added"} enroll secret.`
|
|
);
|
|
} catch (error) {
|
|
console.error(error);
|
|
renderFlash(
|
|
"error",
|
|
`Could not ${
|
|
selectedSecret ? "edit" : "add"
|
|
} enroll secret. Please try again.`
|
|
);
|
|
} finally {
|
|
setIsUpdatingSecret(false);
|
|
}
|
|
};
|
|
|
|
const onDeleteSecret = async () => {
|
|
// create new list of secrets removing selected secret
|
|
const currentSecrets = teamSecrets || [];
|
|
|
|
const newSecrets = currentSecrets.filter(
|
|
(s) => s.secret !== selectedSecret?.secret
|
|
);
|
|
setIsUpdatingSecret(true);
|
|
try {
|
|
await enrollSecretsAPI.modifyTeamEnrollSecrets(teamIdForApi, newSecrets);
|
|
refetchTeamSecrets();
|
|
toggleDeleteSecretModal();
|
|
refetchTeams();
|
|
renderFlash("success", `Successfully deleted enroll secret.`);
|
|
} catch (error) {
|
|
console.error(error);
|
|
renderFlash("error", "Could not delete enroll secret. Please try again.");
|
|
} finally {
|
|
setIsUpdatingSecret(false);
|
|
}
|
|
};
|
|
|
|
const onDeleteSubmit = useCallback(async () => {
|
|
if (!teamIdForApi) {
|
|
return false;
|
|
}
|
|
|
|
setIsUpdatingTeams(true);
|
|
|
|
try {
|
|
await teamsAPI.destroy(teamIdForApi);
|
|
renderFlash("success", "Team removed");
|
|
// setCurrentTeam(undefined);
|
|
return router.push(PATHS.ADMIN_TEAMS);
|
|
} catch (response) {
|
|
renderFlash("error", "Something went wrong removing the team");
|
|
console.error(response);
|
|
return false;
|
|
} finally {
|
|
toggleDeleteTeamModal();
|
|
setIsUpdatingTeams(false);
|
|
}
|
|
}, [teamIdForApi, renderFlash, router, toggleDeleteTeamModal]);
|
|
|
|
const onEditSubmit = useCallback(
|
|
async (formData: ITeamFormData) => {
|
|
if (!currentTeamDetails) {
|
|
return;
|
|
}
|
|
const updatedAttrs = generateUpdateData(currentTeamDetails, formData);
|
|
// no updates, so no need for a request.
|
|
if (!updatedAttrs) {
|
|
toggleEditTeamModal();
|
|
return;
|
|
}
|
|
|
|
setIsUpdatingTeams(true);
|
|
try {
|
|
await teamsAPI.update(updatedAttrs, teamIdForApi);
|
|
renderFlash(
|
|
"success",
|
|
`Successfully updated team name to ${updatedAttrs?.name}`
|
|
);
|
|
setBackendValidators({});
|
|
refetchTeams();
|
|
refetchMe();
|
|
} catch (response) {
|
|
console.error(response);
|
|
const errorObject = formatErrorResponse(response);
|
|
if (errorObject.base.includes("Duplicate")) {
|
|
setBackendValidators({
|
|
name: "A team with this name already exists",
|
|
});
|
|
} else {
|
|
renderFlash("error", "Could not create team. Please try again.");
|
|
}
|
|
} finally {
|
|
toggleEditTeamModal();
|
|
setIsUpdatingTeams(false);
|
|
}
|
|
},
|
|
[
|
|
currentTeamDetails,
|
|
toggleEditTeamModal,
|
|
teamIdForApi,
|
|
renderFlash,
|
|
refetchTeams,
|
|
refetchMe,
|
|
]
|
|
);
|
|
|
|
if (
|
|
!isRouteOk ||
|
|
isLoadingTeams ||
|
|
isTeamSecretsLoading ||
|
|
!userTeams?.length ||
|
|
currentTeamDetails === undefined
|
|
) {
|
|
return (
|
|
<div className={`${baseClass}__loading-spinner`}>
|
|
<Spinner />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hostCount = currentTeamDetails.host_count;
|
|
let hostsTotalDisplay: string | undefined;
|
|
if (hostCount !== undefined) {
|
|
hostsTotalDisplay =
|
|
hostCount === 1 ? `${hostCount} host` : `${hostCount} hosts`;
|
|
}
|
|
|
|
return (
|
|
<MainContent className={baseClass}>
|
|
<>
|
|
<TabsWrapper>
|
|
{isGlobalAdmin ? (
|
|
<div className={`${baseClass}__header-links`}>
|
|
<BackLink text="Back to teams" path={PATHS.ADMIN_TEAMS} />
|
|
</div>
|
|
) : (
|
|
<></>
|
|
)}
|
|
<div className={`${baseClass}__team-header`}>
|
|
<div className={`${baseClass}__team-details`}>
|
|
{userTeams?.length === 1 ? (
|
|
<h1>{currentTeamDetails.name}</h1>
|
|
) : (
|
|
<TeamsDropdown
|
|
selectedTeamId={currentTeamId}
|
|
currentUserTeams={userTeams || []}
|
|
isDisabled={isLoadingTeams}
|
|
includeAll={false}
|
|
onChange={handleTeamChange}
|
|
/>
|
|
)}
|
|
{!!hostsTotalDisplay && (
|
|
<span className={`${baseClass}__host-count`}>
|
|
{hostsTotalDisplay}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<ActionButtons
|
|
baseClass={baseClass}
|
|
actions={[
|
|
{
|
|
type: "primary",
|
|
label: "Add hosts",
|
|
onClick: toggleAddHostsModal,
|
|
},
|
|
{
|
|
type: "secondary",
|
|
label: "Manage enroll secrets",
|
|
buttonVariant: "text-icon",
|
|
icon: EyeIcon,
|
|
onClick: toggleManageEnrollSecretsModal,
|
|
},
|
|
{
|
|
type: "secondary",
|
|
label: "Edit team",
|
|
buttonVariant: "text-icon",
|
|
icon: PencilIcon,
|
|
onClick: toggleEditTeamModal,
|
|
},
|
|
{
|
|
type: "secondary",
|
|
label: "Delete team",
|
|
buttonVariant: "text-icon",
|
|
icon: TrashIcon,
|
|
hideAction: !isGlobalAdmin,
|
|
onClick: toggleDeleteTeamModal,
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
<Tabs
|
|
selectedIndex={getTabIndex(
|
|
location.pathname,
|
|
currentTeamDetails.id
|
|
)}
|
|
onSelect={(i) => navigateToNav(i)}
|
|
>
|
|
<TabList>
|
|
{teamDetailsSubNav.map((navItem) => {
|
|
// Bolding text when the tab is active causes a layout shift
|
|
// so we add a hidden pseudo element with the same text string
|
|
return (
|
|
<Tab key={navItem.name} data-text={navItem.name}>
|
|
{navItem.name}
|
|
</Tab>
|
|
);
|
|
})}
|
|
</TabList>
|
|
</Tabs>
|
|
</TabsWrapper>
|
|
{showAddHostsModal && (
|
|
<AddHostsModal
|
|
currentTeamName={currentTeamName}
|
|
enrollSecret={teamSecrets?.[0]?.secret}
|
|
isAnyTeamSelected={isAnyTeamSelected}
|
|
isLoading={isLoadingTeams}
|
|
// TODO: Currently, prepacked installers in Fleet Sandbox use the global enroll secret,
|
|
// and Fleet Sandbox runs Fleet Free so explicitly setting isSandboxMode here is an
|
|
// additional precaution/reminder to revisit this in connection with future changes.
|
|
// See https://github.com/fleetdm/fleet/issues/4970#issuecomment-1187679407.
|
|
isSandboxMode={false}
|
|
onCancel={toggleAddHostsModal}
|
|
openEnrollSecretModal={toggleManageEnrollSecretsModal}
|
|
/>
|
|
)}
|
|
{showManageEnrollSecretsModal && (
|
|
<EnrollSecretModal
|
|
selectedTeam={teamIdForApi || 0} // TODO: confirm teamIdForApi vs currentTeamId throughout
|
|
teams={teams || []} // TODO: confirm teams vs available teams throughout
|
|
onReturnToApp={toggleManageEnrollSecretsModal}
|
|
toggleSecretEditorModal={toggleSecretEditorModal}
|
|
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
|
setSelectedSecret={setSelectedSecret}
|
|
/>
|
|
)}
|
|
{showSecretEditorModal && (
|
|
<SecretEditorModal
|
|
selectedTeam={currentTeamDetails.id}
|
|
teams={teams || []}
|
|
onSaveSecret={onSaveSecret}
|
|
toggleSecretEditorModal={toggleSecretEditorModal}
|
|
selectedSecret={selectedSecret}
|
|
isUpdatingSecret={isUpdatingSecret}
|
|
/>
|
|
)}
|
|
{showDeleteSecretModal && (
|
|
<DeleteSecretModal
|
|
onDeleteSecret={onDeleteSecret}
|
|
selectedTeam={teamIdForApi || 0}
|
|
teams={teams || []}
|
|
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
|
isUpdatingSecret={isUpdatingSecret}
|
|
/>
|
|
)}
|
|
{showDeleteTeamModal && (
|
|
<DeleteTeamModal
|
|
onCancel={toggleDeleteTeamModal}
|
|
onSubmit={onDeleteSubmit}
|
|
name={currentTeamDetails.name}
|
|
isUpdatingTeams={isUpdatingTeams}
|
|
/>
|
|
)}
|
|
{showEditTeamModal && (
|
|
<EditTeamModal
|
|
onCancel={toggleEditTeamModal}
|
|
onSubmit={onEditSubmit}
|
|
defaultName={currentTeamDetails.name}
|
|
backendValidators={backendValidators}
|
|
isUpdatingTeams={isUpdatingTeams}
|
|
/>
|
|
)}
|
|
{children}
|
|
</>
|
|
</MainContent>
|
|
);
|
|
};
|
|
|
|
export default TeamDetailsWrapper;
|