Manage Host Page: No host renders generate installer modal (#2493)

* Update Context API to contain enrollSecret
* Update e2e tests with generate installer modal
* Edge case with empty Team showing Generate Installer CTA
This commit is contained in:
RachelElysia
2021-10-15 09:40:42 -04:00
committed by GitHub
parent 89c6d18ed2
commit cc40bf132d
23 changed files with 546 additions and 647 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

@@ -0,0 +1 @@
* New CTA for new users to generate installer to add hosts onto Fleet
+7 -8
View File
@@ -27,9 +27,10 @@ describe(
() => {
cy.visit("/hosts/manage");
cy.contains("button", /add new host/i).click();
cy.get('a[href*="showSecret"]').click();
cy.contains("button", /generate installer/i).click();
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(2000);
cy.findByText(/rpm/i).click();
cy.contains("a", /download/i)
.first()
.click();
@@ -40,13 +41,11 @@ describe(
if (Cypress.platform !== "win32") {
// windows has issues with downloads location
cy.readFile(
path.join(Cypress.config("downloadsFolder"), "secret.txt"),
path.join(Cypress.config("downloadsFolder"), "fleet.pem"),
{
timeout: 5000,
}
).then((contents) => {
cy.get("input[disabled]").should("have.value", contents);
});
);
}
cy.visit("/hosts/manage");
@@ -104,7 +103,7 @@ describe(
// })
// .then(() => {
// cy.findByText(/successfully deleted/i).should("exist");
// cy.findByText(/kinda empty in here/i).should("exist");
// cy.findByText(/generate installer/i).should("exist");
// cy.findByText(/about this host/i).should("not.exist");
// cy.findByText(hostname).should("not.exist");
// });
+4 -5
View File
@@ -37,13 +37,12 @@ describe(
cy.findByText(/settings/i).should("exist");
});
// See and select "add new host"
cy.findByRole("button", { name: /add new host/i }).click();
cy.contains(/team/i).should("not.exist");
// See and select "generate installer"
cy.findByRole("button", { name: /generate installer/i }).click();
cy.findByRole("button", { name: /done/i }).click();
// See the “Show enroll secret” button. A modal appears after the user selects the button
cy.contains("button", /show enroll secret/i).click();
// See the "Manage enroll secret” button. A modal appears after the user selects the button
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /done/i).click();
// See and select "add label"
+3 -4
View File
@@ -36,12 +36,11 @@ describe(
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/teams/i).should("not.exist");
cy.contains("button", /add new host/i).click();
cy.findByText("select a team").should("not.exist");
cy.contains("button", /generate installer/i).click();
cy.contains("button", /done/i).click();
// See the “Show enroll secret” button. A modal appears after the user selects the button
cy.contains("button", /show enroll secret/i).click();
// See the “Manage enroll secret” button. A modal appears after the user selects the button
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /done/i).click();
cy.contains("button", /add label/i).click();
+2 -2
View File
@@ -39,9 +39,9 @@ describe("Free tier - Observer user", () => {
// Host manage page: No team UI, cannot add host, add label, nor enroll secret
cy.visit("/hosts/manage");
cy.findByText(/teams/i).should("not.exist");
cy.contains("button", /add new host/i).should("not.exist");
cy.contains("button", /generate installer/i).should("not.exist");
cy.contains("button", /add label/i).should("not.exist");
cy.contains("button", /show enroll secret/i).should("not.exist");
cy.contains("button", /manage enroll secret/i).should("not.exist");
// Host details page: No team UI, cannot delete or query
cy.get("tbody").within(() => {
+2 -14
View File
@@ -36,20 +36,8 @@ describe(
// See the “Teams” column in the Hosts table
cy.get("thead").contains(/team/i).should("exist");
// See and select the “Add new host” button
cy.contains("button", /add new host/i).click();
// See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button
cy.get(
".add-host-modal__team-dropdown-wrapper .Select-control"
).click();
cy.get(".Select-menu-outer").within(() => {
cy.findByText(/no team/i).should("exist");
cy.findByText(/apples/i).should("exist");
cy.findByText(/oranges/i).should("exist");
});
// See and select the “Generate installer” button
cy.contains("button", /generate installer/i).click();
cy.contains("button", /done/i).click();
// On the Host details page, they should…
@@ -29,10 +29,9 @@ describe(
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/show enroll secret/i).should("exist");
cy.findByText(/manage enroll secret/i).should("exist");
cy.contains("button", /add new host/i).click();
// TODO: Check Team Apples is in Select a team dropdown
cy.contains("button", /generate installer/i).click();
cy.contains("button", /done/i).click();
// Host details page: Can see team UI
+2 -2
View File
@@ -38,8 +38,8 @@ describe("Premium tier - Observer user", () => {
cy.contains("button", /query/i).click();
cy.contains("button", /create custom query/i).should("not.exist");
// Not see the “Show enroll secret” button
cy.contains("button", /show enroll secret/i).should("not.exist");
// Not see the “Manage enroll secret” button
cy.contains("button", /manage enroll secret/i).should("not.exist");
// TODO - Fix tests according to improved query experience - MP
// Query pages: Can see team in select targets dropdown
@@ -27,7 +27,7 @@ describe(
// On the Hosts page, they should…
// See hosts
// cy.findByText(/kinda empty in here/i).should("not.exist");
// cy.findByText(/generate installer/i).should("not.exist");
// ^^TODO hosts table is not rendering because we need new forEach script/command for admin to assign team after the host is added
// See the “Teams” column in the Hosts table
@@ -140,17 +140,8 @@ describe(
// cy.get("thead").contains(/team/i).should("exist");
// ^^TODO hosts table is not rendering because we need new forEach script/command for admin to assign team after the host is added
// See and select the “Add new host” button
cy.findByRole("button", { name: /add new host/i }).click();
// See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button
cy.get(".add-host-modal__team-dropdown-wrapper .Select-control").click();
cy.get(".Select-menu-outer").within(() => {
cy.findByText(/no team/i).should("not.exist");
cy.findByText(/apples/i).should("not.exist");
cy.findByText(/oranges/i).should("exist");
});
// See and select the “Generate installer” button
cy.findByRole("button", { name: /generate installer/i }).click();
cy.findByRole("button", { name: /done/i }).click();
// On the Host details page, they should…
+14 -1
View File
@@ -13,11 +13,18 @@ import { IConfig } from "interfaces/config";
import TableProvider from "context/table";
import QueryProvider from "context/query";
import { AppContext } from "context/app";
import { IEnrollSecret } from "interfaces/enroll_secret";
interface IAppProps {
children: JSX.Element;
}
interface ISecretResponse {
spec: {
secrets: IEnrollSecret[];
};
}
interface IRootState {
auth: {
user: IUser;
@@ -31,10 +38,12 @@ const App = ({ children }: IAppProps) => {
const {
setCurrentUser,
setConfig,
setEnrollSecret,
currentUser,
isGlobalObserver,
isOnlyObserver,
isAnyTeamMaintainer,
enrollSecret,
} = useContext(AppContext);
useDeepEffect(() => {
@@ -62,7 +71,11 @@ const App = ({ children }: IAppProps) => {
!isAnyTeamMaintainer;
if (canGetEnrollSecret) {
dispatch(getEnrollSecret()).catch(() => false);
dispatch(getEnrollSecret())
.then((response: ISecretResponse) => {
setEnrollSecret(response.spec.secrets);
})
.catch(() => false);
}
}, [currentUser, isGlobalObserver, isOnlyObserver]);
+15
View File
@@ -4,6 +4,7 @@ import { IUser } from "interfaces/user";
import { IConfig } from "interfaces/config";
import { ITeam } from "interfaces/team";
import permissions from "utilities/permissions";
import { IEnrollSecret } from "interfaces/enroll_secret";
type Props = {
children: ReactNode;
@@ -13,6 +14,7 @@ type InitialStateType = {
config: IConfig | null;
currentUser: IUser | null;
currentTeam: ITeam | undefined;
enrollSecret: IEnrollSecret[] | null;
isFreeTier: boolean | undefined;
isPremiumTier: boolean | undefined;
isGlobalAdmin: boolean | undefined;
@@ -25,12 +27,14 @@ type InitialStateType = {
setCurrentUser: (user: IUser) => void;
setCurrentTeam: (team: ITeam | undefined) => void;
setConfig: (config: IConfig) => void;
setEnrollSecret: (enrollSecret: IEnrollSecret[]) => void;
};
const initialState = {
config: null,
currentUser: null,
currentTeam: undefined,
enrollSecret: null,
isFreeTier: undefined,
isPremiumTier: undefined,
isGlobalAdmin: undefined,
@@ -43,12 +47,14 @@ const initialState = {
setCurrentUser: () => null,
setCurrentTeam: () => null,
setConfig: () => null,
setEnrollSecret: () => null,
};
const actions = {
SET_CURRENT_USER: "SET_CURRENT_USER",
SET_CURRENT_TEAM: "SET_CURRENT_TEAM",
SET_CONFIG: "SET_CONFIG",
SET_ENROLL_SECRET: "SET_ENROLL_SECRET",
};
// helper function - this is run every
@@ -95,6 +101,11 @@ const reducer = (state: any, action: any) => {
config: action.config,
...setPermissions(state.currentUser, action.config),
};
case actions.SET_ENROLL_SECRET:
return {
...state,
enrollSecret: action.enrollSecret,
};
default:
return state;
}
@@ -109,6 +120,7 @@ const AppProvider = ({ children }: Props) => {
config: state.config,
currentUser: state.currentUser,
currentTeam: state.currentTeam,
enrollSecret: state.enrollSecret,
isFreeTier: state.isFreeTier,
isPremiumTier: state.isPremiumTier,
isGlobalAdmin: state.isGlobalAdmin,
@@ -127,6 +139,9 @@ const AppProvider = ({ children }: Props) => {
setConfig: (config: IConfig) => {
dispatch({ type: actions.SET_CONFIG, config });
},
setEnrollSecret: (enrollSecret: IEnrollSecret[]) => {
dispatch({ type: actions.SET_ENROLL_SECRET, enrollSecret });
},
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
@@ -63,13 +63,13 @@ import {
getValidatedTeamId,
} from "./helpers";
import EnrollSecretModal from "./components/EnrollSecretModal"; // @ts-ignore
import AddHostModal from "./components/AddHostModal";
import NoHosts from "./components/NoHosts";
import EmptyHosts from "./components/EmptyHosts";
import PoliciesFilter from "./components/PoliciesFilter"; // @ts-ignore
import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal";
import TransferHostModal from "./components/TransferHostModal";
import DeleteHostModal from "./components/DeleteHostModal";
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";
@@ -124,6 +124,7 @@ const ManageHostsPage = ({
isPremiumTier,
currentTeam,
setCurrentTeam,
enrollSecret: globalSecret,
} = useContext(AppContext);
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
@@ -150,7 +151,6 @@ const ManageHostsPage = ({
// ========= states
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
const [statusLabels, setStatusLabels] = useState<IStatusLabels>();
const [showAddHostModal, setShowAddHostModal] = useState<boolean>(false);
const [showEnrollSecretModal, setShowEnrollSecretModal] = useState<boolean>(
false
);
@@ -166,6 +166,10 @@ const ManageHostsPage = ({
const [showDeleteHostModal, setShowDeleteHostModal] = useState<boolean>(
false
);
const [
showGenerateInstallerModal,
setShowGenerateInstallerModal,
] = useState<boolean>(false);
const [hiddenColumns, setHiddenColumns] = useState<string[]>(
storedHiddenColumns || defaultHiddenColumns
);
@@ -206,6 +210,11 @@ const ManageHostsPage = ({
isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer;
const canAddNewLabels = isGlobalAdmin || isGlobalMaintainer;
const generateInstallerTeam = currentTeam || {
name: "No team",
secrets: globalSecret,
};
const {
isLoading: isLabelsLoading,
data: labels,
@@ -268,6 +277,10 @@ const ManageHostsPage = ({
setShowDeleteHostModal(!showDeleteHostModal);
};
const toggleGenerateInstallerModal = () => {
setShowGenerateInstallerModal(!showGenerateInstallerModal);
};
const toggleAllMatchingHosts = (shouldSelect: boolean) => {
if (typeof shouldSelect !== "undefined") {
setIsAllMatchingHostsSelected(shouldSelect);
@@ -930,27 +943,6 @@ const ManageHostsPage = ({
);
};
const renderAddHostModal = () => {
if (!canAddNewHosts || !showAddHostModal) {
return null;
}
return (
<Modal
title="New host"
onExit={() => setShowAddHostModal(false)}
className={`${baseClass}__invite-modal`}
>
<AddHostModal
teams={teams}
onReturnToApp={() => setShowAddHostModal(false)}
config={config}
currentUser={currentUser}
/>
</Modal>
);
};
const renderDeleteLabelModal = () => {
if (!showDeleteLabelModal) {
return false;
@@ -1007,6 +999,19 @@ const ManageHostsPage = ({
);
};
const renderGenerateInstallerModal = () => {
if (!showGenerateInstallerModal) {
return null;
}
return (
<GenerateInstallerModal
onCancel={toggleGenerateInstallerModal}
selectedTeam={generateInstallerTeam}
/>
);
};
const renderHeaderLabelBlock = ({
description,
display_text: displayText,
@@ -1139,7 +1144,7 @@ const ManageHostsPage = ({
);
};
const renderTable = () => {
const renderTable = (selectedTeam: number) => {
if (
!config ||
!currentUser ||
@@ -1155,8 +1160,15 @@ const ManageHostsPage = ({
}
// Hosts have not been set up for this instance yet.
if (getStatusSelected() === ALL_HOSTS_LABEL && selectedLabel.count === 0) {
return <NoHosts />;
if (
(getStatusSelected() === ALL_HOSTS_LABEL && selectedLabel.count === 0) ||
(getStatusSelected() === ALL_HOSTS_LABEL &&
filteredHostCount === 0 &&
searchQuery === "")
) {
return (
<NoHosts toggleGenerateInstallerModal={toggleGenerateInstallerModal} />
);
}
const secondarySelectActions: IActionButtonProps[] = [
@@ -1212,6 +1224,8 @@ const ManageHostsPage = ({
);
};
const selectedTeam = currentTeam?.id || 0;
return (
<div className="has-sidebar">
{renderForm()}
@@ -1226,30 +1240,38 @@ const ManageHostsPage = ({
className={`${baseClass}__enroll-hosts button`}
variant="inverse"
>
<span>Show enroll secret</span>
</Button>
)}
{canAddNewHosts && (
<Button
onClick={() => setShowAddHostModal(true)}
className={`${baseClass}__add-hosts button button--brand`}
>
<span>Add new host</span>
<span>Manage enroll secret</span>
</Button>
)}
{canAddNewHosts &&
!(
getStatusSelected() === ALL_HOSTS_LABEL &&
selectedLabel?.count === 0
) &&
!(
getStatusSelected() === ALL_HOSTS_LABEL &&
filteredHostCount === 0
) && (
<Button
onClick={toggleGenerateInstallerModal}
className={`${baseClass}__add-hosts button button--brand`}
>
<span>Generate installer</span>
</Button>
)}
</div>
</div>
{renderLabelOrPolicyBlock()}
{config && (!isPremiumTier || teams) && renderTable()}
{config && (!isPremiumTier || teams) && renderTable(selectedTeam)}
</div>
)}
{!isLabelsLoading && renderSidePanel()}
{renderEnrollSecretModal()}
{renderAddHostModal()}
{renderEditColumnsModal()}
{renderDeleteLabelModal()}
{renderTransferHostModal()}
{renderDeleteHostModal()}
{renderGenerateInstallerModal()}
</div>
);
};
@@ -1,378 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import FileSaver from "file-saver";
import Fleet from "fleet";
import Button from "components/buttons/Button";
import configInterface from "interfaces/config";
import teamInterface from "interfaces/team";
import userInterface from "interfaces/user";
import permissionUtils from "utilities/permissions";
import EnrollSecretTable from "components/config/EnrollSecretTable";
import FleetIcon from "components/icons/FleetIcon";
import Dropdown from "components/forms/fields/Dropdown";
import InputField from "components/forms/fields/InputField";
import { stringToClipboard } from "utilities/copy_text";
import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png";
const baseClass = "add-host-modal";
const NO_TEAM_OPTION = {
value: "no-team",
label: "No team",
};
class AddHostModal extends Component {
static propTypes = {
teams: PropTypes.arrayOf(teamInterface),
onReturnToApp: PropTypes.func,
config: configInterface,
currentUser: userInterface,
};
constructor(props) {
super(props);
this.userRole = {
isAnyTeamMaintainer: permissionUtils.isAnyTeamMaintainer(
this.props.currentUser
),
isGlobalAdmin: permissionUtils.isGlobalAdmin(this.props.currentUser),
isGlobalMaintainer: permissionUtils.isGlobalMaintainer(
this.props.currentUser
),
};
this.currentUserTeams = this.userRole.isAnyTeamMaintainer
? Object.values(this.props.currentUser.teams).filter(
(team) => team.role === "maintainer"
)
: this.props.teams;
this.teamSecrets = this.props.teams
? Object.values(this.props.teams).map((team) => {
return { id: team.id, name: team.name, secrets: team.secrets };
})
: [];
this.state = {
fetchCertificateError: undefined,
selectedTeam: null,
globalSecrets: [],
selectedEnrollSecrets: [],
copyMessage: "",
};
}
componentDidMount() {
const { isGlobalAdmin, isGlobalMaintainer } = this.userRole;
(() => {
if (isGlobalAdmin || isGlobalMaintainer) {
Fleet.config
.loadEnrollSecret()
.then((response) => {
this.setState({
globalSecrets: response.spec.secrets,
selectedTeam: { id: NO_TEAM_OPTION.value }, // Reset initial selectedTeam value to "no-team" in the case of global users
});
})
.catch((err) => {
console.log(err);
});
} else {
this.setState({ selectedTeam: this.currentUserTeams[0] });
}
})();
Fleet.config
.loadCertificate()
.then((certificate) => {
this.setState({ certificate });
})
.catch(() => {
this.setState({
fetchCertificateError:
"Failed to load certificate. Is Fleet app URL configured properly?",
});
});
}
onFetchCertificate = (evt) => {
evt.preventDefault();
const { certificate } = this.state;
const filename = "fleet.pem";
const file = new global.window.File([certificate], filename, {
type: "application/x-pem-file",
});
FileSaver.saveAs(file);
return false;
};
onCopyRunCommand = (evt) => {
evt.preventDefault();
stringToClipboard("osqueryd --flagfile=flagfile.txt --verbose")
.then(() => this.setState({ copyMessage: "Copied!" }))
.catch(() => this.setState({ copyMessage: "Copy failed" }));
// Clear message after 1 second
setTimeout(() => this.setState({ copyMessage: "" }), 1000);
return false;
};
// if isGlobalAdmin or isGlobalMaintainer, we include a "No team" option and reveal globalSecrets
// if not, we pull secrets for the user's teams from the teamsSecrets
onChangeSelectTeam = (teamId) => {
const { globalSecrets } = this.state;
const { currentUserTeams, teamSecrets } = this;
if (teamId === "no-team") {
this.setState({ selectedTeam: { id: NO_TEAM_OPTION.value } });
this.setState({ selectedEnrollSecrets: globalSecrets || [] });
} else {
const selectedTeam = currentUserTeams.find((team) => team.id === teamId);
const selectedEnrollSecrets =
teamSecrets.find((e) => e.id === selectedTeam.id)?.secrets || "";
this.setState({ selectedTeam });
this.setState({
selectedEnrollSecrets,
});
}
};
getSelectedEnrollSecrets = (selectedTeam) => {
if (selectedTeam.id === NO_TEAM_OPTION.value) {
return this.state.globalSecrets;
}
return (
this.teamSecrets.find((e) => e.id === selectedTeam.id)?.secrets || ""
);
};
createTeamDropdownOptions = (currentUserTeams = []) => {
const teamOptions = currentUserTeams.map((team) => {
return {
value: team.id,
label: team.name,
};
});
return this.userRole.isAnyTeamMaintainer
? teamOptions
: [NO_TEAM_OPTION, ...teamOptions];
};
renderRunCommandLabel = () => {
const { copyMessage } = this.state;
const { onCopyRunCommand } = this;
return (
<span className={`${baseClass}__name`}>
<span className="buttons">
{copyMessage && <span>{`${copyMessage} `}</span>}
<Button
variant="unstyled"
className={`${baseClass}__run-osquery-copy-icon`}
onClick={onCopyRunCommand}
>
<FleetIcon name="clipboard" />
</Button>
</span>
</span>
);
};
render() {
const { config, onReturnToApp } = this.props;
const { fetchCertificateError, selectedTeam, globalSecrets } = this.state;
const {
createTeamDropdownOptions,
currentUserTeams,
getSelectedEnrollSecrets,
onChangeSelectTeam,
renderRunCommandLabel,
} = this;
const isPremiumTier = permissionUtils.isPremiumTier(config);
let tlsHostname = config.server_url;
try {
const serverUrl = new URL(config.server_url);
tlsHostname = serverUrl.hostname;
if (serverUrl.port) {
tlsHostname += `:${serverUrl.port}`;
}
} catch (e) {
if (!(e instanceof TypeError)) {
throw e;
}
}
const flagfileContent = `# Server
--tls_hostname=${tlsHostname}
--tls_server_certs=fleet.pem
# Enrollment
--host_identifier=instance
--enroll_secret_path=secret.txt
--enroll_tls_endpoint=/api/v1/osquery/enroll
# Configuration
--config_plugin=tls
--config_tls_endpoint=/api/v1/osquery/config
--config_refresh=10
# Live query
--disable_distributed=false
--distributed_plugin=tls
--distributed_interval=10
--distributed_tls_max_attempts=3
--distributed_tls_read_endpoint=/api/v1/osquery/distributed/read
--distributed_tls_write_endpoint=/api/v1/osquery/distributed/write
# Logging
--logger_plugin=tls
--logger_tls_endpoint=/api/v1/osquery/log
--logger_tls_period=10
# File carving
--disable_carver=false
--carver_start_endpoint=/api/v1/osquery/carve/begin
--carver_continue_endpoint=/api/v1/osquery/carve/block
--carver_block_size=2000000`;
const onDownloadFlagfile = (evt) => {
evt.preventDefault();
const filename = "flagfile.txt";
const file = new global.window.File([flagfileContent], filename);
FileSaver.saveAs(file);
return false;
};
return (
<div className={baseClass}>
<div className={`${baseClass}__manual-install-content`}>
<div className={`${baseClass}__documentation-link`}>
<h4>
<a
href="https://github.com/fleetdm/fleet/blob/2f42c281f98e39a72ab4a5125ecd26d303a16a6b/docs/1-Using-Fleet/4-Adding-hosts.md"
target="_blank"
rel="noopener noreferrer"
>
Add Hosts Documentation <FleetIcon name="external-link" />
</a>
</h4>
</div>
<ol className={`${baseClass}__install-steps`}>
<li>
<h4>
<span className={`${baseClass}__step-number`}>1</span>Enroll
secret
</h4>
<p>
Osquery uses an enroll secret to authenticate with the Fleet
server.
</p>
<div className={`${baseClass}__secret-wrapper`}>
{isPremiumTier && currentUserTeams ? (
<Dropdown
wrapperClassName={`${baseClass}__team-dropdown-wrapper`}
label={"Select a team for this new host:"}
value={selectedTeam && selectedTeam.id}
options={createTeamDropdownOptions(currentUserTeams)}
onChange={onChangeSelectTeam}
placeholder={"Select a team"}
searchable={false}
/>
) : null}
{isPremiumTier && selectedTeam && (
<EnrollSecretTable
secrets={getSelectedEnrollSecrets(selectedTeam)}
/>
)}
{!isPremiumTier && (
<EnrollSecretTable secrets={globalSecrets} />
)}
</div>
</li>
<li>
<h4>
<span className={`${baseClass}__step-number`}>2</span>Server
certificate
</h4>
<p>
Provide the TLS certificate used by the Fleet server to enable
secure connections from osquery:
</p>
<p>
{fetchCertificateError ? (
<span className={`${baseClass}__error`}>
{fetchCertificateError}
</span>
) : (
<a
href="#downloadCertificate"
onClick={this.onFetchCertificate}
>
Download
<img src={DownloadIcon} alt="download icon" />
</a>
)}
</p>
</li>
<li>
<h4>
<span className={`${baseClass}__step-number`}>3</span>Flagfile
</h4>
<p>
If using the enroll secret and server certificate downloaded
above, use the generated flagfile. In some configurations,
modifications may need to be made:
</p>
<p>
<a href="#downloadFlagfile" onClick={onDownloadFlagfile}>
Download
<img src={DownloadIcon} alt="download icon" />
</a>
</p>
</li>
<li>
<h4>
<span className={`${baseClass}__step-number`}>4</span>Run
osquery
</h4>
<p>
Run osquery from the directory containing the above files (may
require sudo or Run as Administrator privileges):
</p>
<div>
<InputField
disabled
inputWrapperClass={`${baseClass}__run-osquery-input`}
name="run-osquery"
label={renderRunCommandLabel()}
type={"text"}
value={"osqueryd --flagfile=flagfile.txt --verbose"}
/>
</div>
</li>
</ol>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button onClick={onReturnToApp} className="button button--brand">
Done
</Button>
</div>
</div>
);
}
}
export default AddHostModal;
@@ -1,171 +0,0 @@
.add-host-modal {
&__manual-install-header {
position: relative;
z-index: 2;
h2 {
font-size: 18px;
font-weight: $bold;
line-height: 1.33;
letter-spacing: -0.6px;
color: #48c586;
margin: 0;
}
h3 {
font-size: 15px;
font-weight: $regular;
line-height: 1.6;
letter-spacing: normal;
color: rgba(32, 37, 50, 0.48);
margin: 0;
}
.fleeticon {
float: left;
font-size: 44px;
color: #48c586;
margin-right: 15px;
}
}
&__manual-install-content {
h4 {
font-size: $small;
font-weight: $bold;
margin: $pad-xlarge 0 0;
.fleeticon {
margin-left: 5px;
font-size: 18px;
}
}
p {
font-size: $x-small;
font-weight: $regular;
line-height: 20px;
letter-spacing: normal;
color: $core-fleet-black;
margin: 8px 0 0 44px;
}
a {
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
img {
width: 12px;
height: 12px;
margin-left: 7px;
}
}
}
&__documentation-link {
h4 {
margin: 0;
}
}
&__install-steps {
margin: 0;
padding: 0;
list-style: none;
}
&__step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 100px;
border: 2px solid $core-vibrant-blue;
color: $core-vibrant-blue;
font-weight: $bold;
text-align: center;
margin-right: $pad-medium;
}
&__button-wrap {
display: flex;
justify-content: flex-end;
margin: $pad-large 0 0;
}
&__download-cert {
text-align: center;
padding-top: 15px;
span {
display: block;
}
}
&__reveal-secret {
float: right;
text-decoration: none;
}
&__secret-wrapper {
position: relative;
margin: $pad-medium 0 0 44px;
}
&__team-dropdown-wrapper {
.form-field__label {
font-weight: $bold;
}
}
&__team-dropdown-wrapper {
.form-field__label {
font-weight: $bold;
}
}
pre,
code {
background-color: $ui-off-white;
color: $core-fleet-blue;
border: 1px solid $ui-fleet-blue-15;
border-radius: 4px;
padding: 7px $pad-medium;
margin: $pad-large 0 0 44px;
}
&__error {
color: $ui-error;
}
&__run-osquery-input {
margin-left: 44px;
}
&__run-osquery-copy-icon {
color: $core-vibrant-blue;
margin-left: $pad-small;
margin-right: $pad-xxlarge;
}
.buttons {
display: flex;
align-items: center;
position: absolute;
right: 16px;
transform: translateY(80%);
height: 16px;
span {
font-weight: $regular;
}
img {
width: 16px;
height: 16px;
}
}
}
@@ -1 +0,0 @@
export { default } from "./AddHostModal";
@@ -0,0 +1,27 @@
import React, { useState } from "react";
import Button from "components/buttons/Button";
import Modal from "components/modals/Modal";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";
import PlatformWrapper from "./PlatformWrapper/PlatformWrapper";
const baseClass = "generate-installer-modal";
interface IGenerateInstallerModal {
onCancel: () => void;
selectedTeam: ITeam | { name: string; secrets: IEnrollSecret[] | null };
}
const GenerateInstallerModal = ({
onCancel,
selectedTeam,
}: IGenerateInstallerModal): JSX.Element => {
return (
<Modal onExit={onCancel} title={"Generate installer"} className={baseClass}>
<PlatformWrapper onCancel={onCancel} selectedTeam={selectedTeam} />
</Modal>
);
};
export default GenerateInstallerModal;
@@ -0,0 +1,217 @@
import React, { useState } from "react";
import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
// @ts-ignore
import Fleet from "fleet";
import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import { stringToClipboard } from "utilities/copy_text";
import FileSaver from "file-saver";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";
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;
type: string;
}
const platformSubNav: IPlatformSubNav[] = [
{
name: "macOS",
type: "pkg",
},
{
name: "Windows",
type: "msi",
},
{
name: "Linux (RPM)",
type: "rpm",
},
{
name: "Linux (DEB)",
type: "deb",
},
];
interface IPlatformWrapperProp {
selectedTeam: ITeam | { name: string; secrets: IEnrollSecret[] | null };
onCancel: () => void;
}
const baseClass = "platform-wrapper";
const PlatformWrapper = ({
selectedTeam,
onCancel,
}: IPlatformWrapperProp): JSX.Element => {
const [copyMessage, setCopyMessage] = useState<string>("");
const [certificate, setCertificate] = useState<string | undefined>(undefined);
const [fetchCertificateError, setFetchCertificateError] = useState<
string | undefined
>(undefined);
Fleet.config
.loadCertificate()
.then((loadedCertificate: any) => {
setCertificate(loadedCertificate);
})
.catch(() => {
setFetchCertificateError(
"Failed to load certificate. Is Fleet app URL configured properly?"
);
});
const onDownloadCertificate = (evt: React.MouseEvent) => {
evt.preventDefault();
if (certificate) {
const filename = "fleet.pem";
const file = new global.window.File([certificate], filename, {
type: "application/x-pem-file",
});
FileSaver.saveAs(file);
}
return false;
};
const renderInstallerString = (platform: string) => {
let enrollSecret;
if (selectedTeam.secrets) {
enrollSecret = selectedTeam.secrets[0].secret;
}
let installerString = `fleetctl package --type=${platform} --fleet-url=https://localhost:8412 --enroll-secret=${enrollSecret}`;
if (platform === "rpm" || platform === "deb") {
installerString +=
" --fleet-certificate=/home/username/Downloads/fleet.pem";
}
return installerString;
};
const renderLabel = (installerString: string) => {
const onCopyInstaller = (evt: React.MouseEvent) => {
evt.preventDefault();
stringToClipboard(installerString)
.then(() => setCopyMessage("Copied!"))
.catch(() => setCopyMessage("Copy failed"));
// Clear message after 1 second
setTimeout(() => setCopyMessage(""), 1000);
return false;
};
return (
<>
<span className={`${baseClass}__cta`}>
With the{" "}
<a
href="https://fleetdm.com/get-started"
target="_blank"
rel="noopener noreferrer"
className={`${baseClass}__command-line-tool`}
>
Fleet command-line tool
</a>{" "}
installed:
</span>{" "}
<span className={`${baseClass}__name`}>
<span className="buttons">
{copyMessage && <span>{`${copyMessage} `}</span>}
<Button
variant="unstyled"
className={`${baseClass}__installer-copy-icon`}
onClick={onCopyInstaller}
>
<img src={CopyIcon} alt="copy" />
</Button>
</span>
</span>
</>
);
};
const renderTab = (platform: string) => {
return (
<>
{(platform === "rpm" || platform === "deb") && (
<>
<span className={`${baseClass}__cta`}>
Download your Fleet certificate:
</span>
<p>
{fetchCertificateError ? (
<span className={`${baseClass}__error`}>
{fetchCertificateError}
</span>
) : (
<a
href="#downloadCertificate"
className={`${baseClass}__fleet-certificate-download`}
onClick={onDownloadCertificate}
>
Download
<img src={DownloadIcon} alt="download" />
</a>
)}
</p>
</>
)}
<InputField
disabled
inputWrapperClass={`${baseClass}__installer-input ${baseClass}__installer-input-${platform}`}
name="installer"
label={renderLabel(renderInstallerString(platform))}
type={"textarea"}
value={renderInstallerString(platform)}
/>
<span>
Generates an installer that your devices will use to connect to Fleet.
</span>
</>
);
};
return (
<div className={baseClass}>
<div className={`${baseClass}__nav-header`}>
<Tabs>
<TabList>
{platformSubNav.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>
{platformSubNav.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 (
<TabPanel className={`${baseClass}__info`} key={navItem.type}>
{renderTab(navItem.type)}
</TabPanel>
);
})}
</Tabs>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button onClick={onCancel} className="button button--brand">
Done
</Button>
</div>
</div>
);
};
export default PlatformWrapper;
@@ -0,0 +1,153 @@
@import "../../../../../../../node_modules/react-tabs/style/react-tabs.scss";
.platform-wrapper {
padding: 0; // different to pad sticky subnav properly
h1 {
margin-bottom: $pad-small;
}
a {
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
img {
transform: scale(0.5);
vertical-align: bottom;
}
}
p {
margin-top: 0;
}
.body-wrap {
padding: 0;
}
.react-tabs {
.react-tabs__tab-list {
border-bottom: 1px solid $ui-gray;
}
&__tab {
font-size: $x-small;
border: none;
padding: 0 0 $pad-medium 0;
margin-left: $pad-xxlarge;
display: inline-flex;
flex-direction: column;
align-items: center;
&:focus {
box-shadow: none;
outline: 0;
}
// Bolding text when the tab is active causes a layout shift
// so we add a hidden pseudo element with the same text string
&:before {
content: attr(data-text);
height: 0;
visibility: hidden;
overflow: hidden;
user-select: none;
pointer-events: none;
font-weight: $bold;
}
&:after {
background-color: transparent;
}
&:first-child {
margin-left: 0;
}
}
&__tab--selected,
&__tab--selected:focus {
font-weight: $bold;
border-bottom: 1px solid $core-vibrant-blue;
}
}
&__info {
margin-top: $pad-small;
}
&__cta {
font-weight: $bold;
}
.form-field {
margin-bottom: $pad-small;
&__label {
position: relative;
font-size: $x-small;
font-weight: $bold;
margin-top: $pad-medium;
}
}
.input-field {
&--disabled {
letter-spacing: 0;
}
&__textarea {
font-family: "SourceCodePro", $monospace;
font-weight: 600;
font-size: 13px;
color: $core-fleet-blue;
min-height: 70px;
line-height: 20px;
margin-bottom: 0;
padding: $pad-medium $pad-large $pad-medium $pad-medium;
}
}
&__installer-input-deb,
&__installer-input-rpm {
.input-field__textarea {
min-height: 95px;
}
}
&__installer-copy-icon {
color: $core-vibrant-blue;
margin-left: $pad-small;
margin-right: $pad-medium;
}
.buttons {
display: flex;
align-items: center;
position: absolute;
right: 0;
top: 40px;
span {
font-weight: $regular;
}
a {
display: flex;
align-items: center;
}
img {
width: 16px;
height: 16px;
}
}
&__button-wrap {
display: flex;
justify-content: flex-end;
margin: $pad-xxlarge 0 0;
}
}
@@ -0,0 +1,11 @@
.generate-installer-modal {
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}
@@ -0,0 +1 @@
export { default } from "./GenerateInstallerModal";
@@ -2,25 +2,35 @@
* Component when there is no hosts set up in fleet
*/
import React from "react";
import Button from "components/buttons/Button";
import { ITeam } from "interfaces/team";
import RoboDogImage from "../../../../../../assets/images/robo-dog-176x144@2x.png";
interface INoHostsProps {
toggleGenerateInstallerModal: () => void;
}
const baseClass = "no-hosts";
const NoHosts = (): JSX.Element => {
const NoHosts = ({
toggleGenerateInstallerModal,
}: INoHostsProps): JSX.Element => {
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__inner`}>
<img src={RoboDogImage} alt="No Hosts" />
<div>
<h1>It&#39;s kinda empty in here...</h1>
<h2>Get started adding hosts to Fleet.</h2>
<p>Add your laptops and servers to securely monitor them.</p>
<div className={`${baseClass}__no-hosts-contact`}>
<p>Still having trouble?</p>
<a href="https://github.com/fleetdm/fleet/issues">
File a GitHub issue
</a>
<h2>Add your devices to Fleet</h2>
<p>Generate an installer to add your own devices.</p>
<div className={`${baseClass}__no-hosts-button`}>
<Button
onClick={toggleGenerateInstallerModal}
type="button"
className="button button--brand"
>
Generate installer
</Button>
</div>
</div>
</div>
@@ -86,4 +86,8 @@
text-decoration: none;
}
}
&__no-hosts-button {
margin-top: $pad-large;
}
}