Add/Edit/Delete enroll secret UI (#2645)

This commit is contained in:
RachelElysia 2021-11-15 16:16:06 -05:00 committed by GitHub
parent 5c1edaf527
commit a7c6b3e7d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 990 additions and 399 deletions

View File

@ -1,3 +1,4 @@
* Add `PATCH /api/v1/fleet/teams/{id}/secrets` endpoint for management of team enroll secrets
* Allow global admin or global maintainer to edit or delete enroll secrets (global or team)
* Allow team admin or team maintainer to edit or delete team enroll secrets
* Allow team admin or team maintainer to edit or delete team enroll secrets
* Modal in UI for all permissible enroll secret functionality

View File

@ -43,7 +43,10 @@ describe(
cy.contains("button", /done/i).click();
// See the "Manage" enroll secret” button. A modal appears after the user selects the button
// Add secret tests same API as edit and delete
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /add secret/i).click();
cy.contains("button", /save/i).click();
cy.contains("button", /done/i).click();
// See and select "add label"

View File

@ -40,6 +40,13 @@ describe(
cy.contains("button", /generate installer/i).click();
cy.contains("button", /done/i).click();
// See the "Manage" enroll secret” button. A modal appears after the user selects the button
// Add secret tests same API as edit and delete
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /add secret/i).click();
cy.contains("button", /save/i).click();
cy.contains("button", /done/i).click();
// On the Host details page, they should…
// See the “Team” information below the hostname
// Be able to transfer Teams

View File

@ -29,7 +29,9 @@ describe(
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/manage enroll secret/i).should("exist");
// 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", /generate installer/i).click();
// TODO: Check Team Apples is in Select a team dropdown

View File

@ -27,11 +27,10 @@ describe(
// On the Hosts page, they should…
// See hosts
// 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
cy.findByText(/generate installer/i).should("not.exist");
// See the “Teams” column in the Hosts table
// cy.get("thead").contains(/team/i).should("exist");
cy.get("thead").contains(/team/i).should("exist");
// Nav restrictions
cy.findByText(/settings/i).should("not.exist");
@ -137,13 +136,28 @@ describe(
// On the hosts page, they should…
// See the “Teams” column in the Hosts table
// 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
cy.get("thead").contains(/team/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();
// See the "Manage" enroll secret” button on team Oranges only
cy.findByText(/all teams/i).should("exist");
cy.findByText(/manage enroll secret/i).should("not.exist");
cy.visit("/hosts/manage/?team_id=1");
cy.findAllByText(/apples/i).should("exist");
cy.findByText(/manage enroll secret/i).should("not.exist");
// Add secret tests same API as edit and delete
cy.visit("/hosts/manage/?team_id=2");
cy.findAllByText(/oranges/i).should("exist");
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /add secret/i).click();
cy.contains("button", /save/i).click();
cy.contains("button", /done/i).click();
// On the Host details page, they should…
// cy.visit("/hosts/1");
// ^^TODO hosts details page returning 403 likely because we need new forEach script/command for admin to assign team after the host is added

View File

@ -4414,6 +4414,86 @@ Modifies the Fleet's configuration with the supplied information.
}
```
### Get enroll secrets
Returns the valid global enroll secrets.
`GET /api/v1/fleet/spec/enroll_secret`
#### Parameters
None.
#### Example
`GET /api/v1/fleet/spec/enroll_secret`
##### Default response
`Status: 200`
```json
{
"spec": {
"secrets": [
{
"secret": "vhPzPOnCMOMoqSrLxKxzSADyqncayacB",
"created_at": "2021-11-12T20:24:57Z"
},
{
"secret": "jZpexWGiXmXaFAKdrdttFHdJBqEnqlVF",
"created_at": "2021-11-12T20:24:57Z"
}
]
}
}
```
### Modify enroll secrets
Replaces all existing team enroll secrets.
`PATCH /api/v1/fleet/spec/enroll_secret`
#### Parameters
None.
#### Example
Replace all of a team's existing enroll secrets with a new enroll secret.
`PATCH /api/v1/fleet/teams/2/secrets`
##### Request body
```json
{
"secrets": [
{
"secret": "KuSkYFsHBQVlaFtqOLwoUIWniHhpvEhP",
}
]
}
```
##### Default response
`Status: 200`
```json
{
"spec": {
"secrets": [
{
"secret": "KuSkYFsHBQVlaFtqOLwoUIWniHhpvEhP",
"created_at": "2021-11-12T20:27:02Z"
}
]
}
}
```
### Get enroll secret for a team
Returns the valid team enroll secret.

View File

@ -0,0 +1,130 @@
import React, { useState } from "react";
import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
// @ts-ignore
import { stringToClipboard } from "utilities/copy_text";
import { IEnrollSecret } from "interfaces/enroll_secret";
import EyeIcon from "../../../../assets/images/icon-eye-16x16@2x.png";
import EditIcon from "../../../../assets/images/icon-pencil-14x14@2x.png";
import DeleteIcon from "../../../../assets/images/icon-trash-14x14@2x.png";
const baseClass = "enroll-secrets";
interface IEnrollSecretRowProps {
secret: IEnrollSecret;
toggleSecretEditorModal?: () => void;
toggleDeleteSecretModal?: () => void;
setSelectedSecret?: React.Dispatch<
React.SetStateAction<IEnrollSecret | undefined>
>;
}
const EnrollSecretRow = ({
secret,
toggleSecretEditorModal,
toggleDeleteSecretModal,
setSelectedSecret,
}: IEnrollSecretRowProps): JSX.Element | null => {
const [showSecret, setShowSecret] = useState<boolean>(false);
const [copyMessage, setCopyMessage] = useState<string>("");
const onCopySecret = (evt: React.MouseEvent) => {
evt.preventDefault();
stringToClipboard(secret.secret)
.then(() => setCopyMessage("Copied!"))
.catch(() => setCopyMessage("Copy failed"));
// Clear message after 1 second
setTimeout(() => setCopyMessage(""), 1000);
return false;
};
const onToggleSecret = (evt: React.MouseEvent) => {
evt.preventDefault();
setShowSecret(!showSecret);
return false;
};
const onEditSecretClick = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
if (toggleSecretEditorModal && setSelectedSecret) {
setSelectedSecret(secret);
toggleSecretEditorModal();
}
};
const onDeleteSecretClick = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
if (toggleDeleteSecretModal && setSelectedSecret) {
setSelectedSecret(secret);
toggleDeleteSecretModal();
}
};
const renderLabel = () => {
return (
<span className={`${baseClass}__name`}>
<span className="buttons">
{copyMessage && <span>{`${copyMessage} `}</span>}
<Button
variant="unstyled"
className={`${baseClass}__secret-copy-icon`}
onClick={onCopySecret}
>
<FleetIcon name="clipboard" />
</Button>
<a
href="#showSecret"
onClick={onToggleSecret}
className={`${baseClass}__show-secret`}
>
<img src={EyeIcon} alt="show/hide" />
</a>
</span>
</span>
);
};
return (
<div className={`${baseClass}__secret`} key={secret.secret}>
<InputField
disabled
inputWrapperClass={`${baseClass}__secret-input`}
name="osqueryd-secret"
label={renderLabel()}
type={showSecret ? "text" : "password"}
value={secret.secret}
/>
{toggleSecretEditorModal && toggleDeleteSecretModal ? (
<>
<Button
onClick={onEditSecretClick}
className={`${baseClass}__edit-secret-btn`}
variant="text-icon"
>
<>
<img src={EditIcon} alt="Edit secret icon" />
</>
</Button>
<Button
onClick={onDeleteSecretClick}
className={`${baseClass}__delete-secret-btn`}
variant="text-icon"
>
<>
<img src={DeleteIcon} alt="Delete secret icon" />
</>
</Button>
</>
) : null}
</div>
);
};
export default EnrollSecretRow;

View File

@ -0,0 +1,91 @@
.enroll-secrets {
&__secret {
display: flex;
}
&__secret-input {
.form-field__label {
position: relative;
font-size: $x-small;
font-weight: $bold;
margin-top: $pad-small;
width: 560px;
}
.input-field {
width: 560px;
&--disabled {
letter-spacing: 0;
}
&--password {
letter-spacing: 4px;
}
}
}
&__secret-copy-icon {
color: $core-vibrant-blue;
margin-left: $pad-small;
margin-right: $pad-medium;
}
.buttons {
display: flex;
align-items: center;
position: absolute;
right: 16px;
top: 13px;
height: 16px;
span {
font-weight: $regular;
}
a {
display: flex;
align-items: center;
}
img {
width: 16px;
height: 16px;
}
}
&__secret-download-icon {
display: block;
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
a {
display: flex;
align-items: center;
}
img {
width: 12px;
height: 12px;
margin-left: 7px;
}
}
&--multiple-secrets {
.form-field__label {
margin-top: 0;
}
.enroll-secrets__secret-download-icon {
margin-bottom: $pad-medium;
}
}
&__edit-secret-btn {
margin: $pad-small $pad-small $pad-xsmall $pad-large;
}
&__delete-secret-btn {
margin: $pad-small 0 $pad-xsmall;
}
}

View File

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

View File

@ -1,147 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import FileSaver from "file-saver";
import Button from "components/buttons/Button";
import enrollSecretInterface from "interfaces/enroll_secret";
import InputField from "components/forms/fields/InputField";
import FleetIcon from "components/icons/FleetIcon";
import { stringToClipboard } from "utilities/copy_text";
import EyeIcon from "../../../assets/images/icon-eye-16x16@2x.png";
import DownloadIcon from "../../../assets/images/icon-download-12x12@2x.png";
const baseClass = "enroll-secrets";
class EnrollSecretRow extends Component {
static propTypes = {
secret: PropTypes.string.isRequired,
};
constructor(props) {
super(props);
this.state = { showSecret: false, copyMessage: "" };
}
onCopySecret = (evt) => {
evt.preventDefault();
const { secret } = this.props;
stringToClipboard(secret)
.then(() => this.setState({ copyMessage: "Copied!" }))
.catch(() => this.setState({ copyMessage: "Copy failed" }));
// Clear message after 1 second
setTimeout(() => this.setState({ copyMessage: "" }), 1000);
return false;
};
onDownloadSecret = (evt) => {
evt.preventDefault();
const { secret } = this.props;
const filename = "secret.txt";
const file = new global.window.File([secret], filename);
FileSaver.saveAs(file);
return false;
};
onToggleSecret = (evt) => {
evt.preventDefault();
const { showSecret } = this.state;
this.setState({ showSecret: !showSecret });
return false;
};
renderLabel = () => {
const { copyMessage } = this.state;
const { onCopySecret, onToggleSecret } = this;
return (
<span className={`${baseClass}__name`}>
<span className="buttons">
{copyMessage && <span>{`${copyMessage} `}</span>}
<Button
variant="unstyled"
className={`${baseClass}__secret-copy-icon`}
onClick={onCopySecret}
>
<FleetIcon name="clipboard" />
</Button>
<a
href="#showSecret"
onClick={onToggleSecret}
className={`${baseClass}__show-secret`}
>
<img src={EyeIcon} alt="show/hide" />
</a>
</span>
</span>
);
};
render() {
const { secret } = this.props;
const { showSecret } = this.state;
const { renderLabel, onDownloadSecret } = this;
return (
<div>
<InputField
disabled
inputWrapperClass={`${baseClass}__secret-input`}
name="osqueryd-secret"
label={renderLabel()}
type={showSecret ? "text" : "password"}
value={secret}
/>
<a
href="#onDownloadSecret"
variant="unstyled"
className={`${baseClass}__secret-download-icon`}
onClick={onDownloadSecret}
>
Download
<img src={DownloadIcon} alt="download" />
</a>
</div>
);
}
}
class EnrollSecretTable extends Component {
static propTypes = {
secrets: PropTypes.arrayOf(enrollSecretInterface).isRequired,
};
render() {
const { secrets } = this.props;
let enrollSecretsClass = baseClass;
if (secrets.length === 0) {
return (
<div className={baseClass}>
<em>No active enroll secrets.</em>
</div>
);
} else if (secrets.length > 1)
enrollSecretsClass += ` ${baseClass}--multiple-secrets`;
return (
<div className={enrollSecretsClass}>
{secrets.map(({ secret }) => (
<EnrollSecretRow key={secret} secret={secret} />
))}
</div>
);
}
}
export default EnrollSecretTable;
export { EnrollSecretRow };

View File

@ -1,79 +0,0 @@
import React from "react";
import { shallow, mount } from "enzyme";
import * as copy from "utilities/copy_text";
import EnrollSecretTable, { EnrollSecretRow } from "./EnrollSecretTable";
describe("EnrollSecretTable", () => {
const defaultProps = {
secrets: [
{ secret: "foo_secret" },
{ secret: "bar_secret" },
{ secret: "baz_secret" },
],
};
it("renders properly filtered rows", () => {
const table = shallow(<EnrollSecretTable {...defaultProps} />);
expect(table.find("EnrollSecretRow").length).toEqual(3);
});
it("renders text when empty", () => {
const table = shallow(<EnrollSecretTable secrets={[]} />);
expect(table.find("EnrollSecretRow").length).toEqual(0);
expect(table.find("div").text()).toEqual("No active enroll secrets.");
});
});
describe("EnrollSecretRow", () => {
const defaultProps = { name: "foo", secret: "bar" };
it("should hide secret by default", () => {
const row = mount(<EnrollSecretRow {...defaultProps} />);
const inputField = row.find("InputField").find("input");
expect(inputField.prop("type")).toEqual("password");
});
it("should show secret when enabled", () => {
const row = mount(<EnrollSecretRow {...defaultProps} />);
row.setState({ showSecret: true });
const inputField = row.find("InputField").find("input");
expect(inputField.prop("type")).toEqual("text");
});
it("should change input type when show/hide is clicked", () => {
const row = mount(<EnrollSecretRow {...defaultProps} />);
let inputField = row.find("InputField").find("input");
expect(inputField.prop("type")).toEqual("password");
const showLink = row.find(".enroll-secrets__show-secret");
expect(showLink.find("img").prop("alt")).toEqual("show/hide");
showLink.simulate("click");
inputField = row.find("InputField").find("input");
expect(inputField.prop("type")).toEqual("text");
const hideLink = row.find(".enroll-secrets__show-secret");
expect(showLink.find("img").prop("alt")).toEqual("show/hide");
hideLink.simulate("click");
inputField = row.find("InputField").find("input");
expect(inputField.prop("type")).toEqual("password");
});
it("should call copy when button is clicked", () => {
const row = mount(<EnrollSecretRow {...defaultProps} />);
const spy = jest
.spyOn(copy, "stringToClipboard")
.mockImplementation(() => Promise.resolve());
const copyLink = row
.find(".enroll-secrets__secret-copy-icon")
.find("Button");
copyLink.simulate("click");
expect(spy).toHaveBeenCalledWith(defaultProps.secret);
});
});

View File

@ -0,0 +1,61 @@
import React from "react";
import { IEnrollSecret } from "interfaces/enroll_secret";
import EnrollSecretRow from "./EnrollSecretRow";
const baseClass = "enroll-secrets";
interface IEnrollSecretRowProps {
secrets: IEnrollSecret[] | undefined;
toggleSecretEditorModal?: () => void;
toggleDeleteSecretModal?: () => void;
setSelectedSecret: React.Dispatch<
React.SetStateAction<IEnrollSecret | undefined>
>;
}
const EnrollSecretTable = ({
secrets,
toggleSecretEditorModal,
toggleDeleteSecretModal,
setSelectedSecret,
}: IEnrollSecretRowProps): JSX.Element | null => {
let enrollSecretsClass = baseClass;
if (!secrets) {
return null;
}
if (secrets.length === 0) {
return (
<div className={baseClass}>
<em>No active enroll secrets.</em>
</div>
);
} else if (secrets.length > 1)
enrollSecretsClass += ` ${baseClass}--multiple-secrets`;
if (toggleSecretEditorModal && toggleDeleteSecretModal) {
return (
<div className={enrollSecretsClass}>
{secrets.map((secretInfo) => (
<EnrollSecretRow
secret={secretInfo}
key={secretInfo.secret}
toggleSecretEditorModal={toggleSecretEditorModal}
toggleDeleteSecretModal={toggleDeleteSecretModal}
setSelectedSecret={setSelectedSecret}
/>
))}
</div>
);
}
return (
<div className={enrollSecretsClass}>
{secrets.map((secretInfo) => (
<EnrollSecretRow secret={secretInfo} key={secretInfo.secret} />
))}
</div>
);
};
export default EnrollSecretTable;
export { EnrollSecretRow };

View File

@ -1,113 +1,5 @@
.enroll-secrets {
max-height: 10em;
overflow: auto;
&__secret-input {
.form-field__label {
position: relative;
font-size: $x-small;
font-weight: $bold;
margin-top: $pad-small;
}
.input-field {
&--disabled {
letter-spacing: 0;
}
&--password {
letter-spacing: 4px;
}
}
}
&__secret-copy-icon {
color: $core-vibrant-blue;
margin-left: $pad-small;
margin-right: $pad-medium;
}
.buttons {
&__secret {
display: flex;
align-items: center;
position: absolute;
right: 16px;
transform: translateY(80%);
height: 16px;
span {
font-weight: $regular;
}
a {
display: flex;
align-items: center;
}
img {
width: 16px;
height: 16px;
}
}
&__secret-download-icon {
display: block;
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
a {
display: flex;
align-items: center;
}
img {
width: 12px;
height: 12px;
margin-left: 7px;
}
}
&--multiple-secrets {
&:before {
content: "";
position: sticky;
display: block;
z-index: 1;
top: -2px;
left: 0;
width: 100%;
height: 17px;
// We explicityly use rgba(255,255,255,0) because it's equivalent to transparent for most broswers except Safari.
background-image: linear-gradient(
to bottom,
$core-white,
rgba(255, 255, 255, 0)
);
}
&:after {
content: "";
position: sticky;
display: block;
bottom: -1px;
left: 0;
width: 100%;
height: 17px;
// We explicityly use rgba(255,255,255,0) because it's equivalent to transparent for most broswers except Safari.
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0),
$core-white
);
}
.form-field__label {
margin-top: 0;
}
.enroll-secrets__secret-download-icon {
margin-bottom: $pad-medium;
}
}
}

View File

@ -13,6 +13,7 @@ export default {
return `/v1/fleet/users/${id}/enable`;
},
FORGOT_PASSWORD: "/v1/fleet/forgot_password",
GLOBAL_ENROLL_SECRETS: "/v1/fleet/spec/enroll_secret",
GLOBAL_POLICIES: "/v1/fleet/global/policies",
GLOBAL_SCHEDULE: "/v1/fleet/global/schedule",
HOST_SUMMARY: (teamId: number | undefined): string => {
@ -56,18 +57,18 @@ export default {
return `/v1/fleet/teams/${teamId}/schedule`;
},
TEAMS: "/v1/fleet/teams",
TEAMS_AGENT_OPTIONS: (teamId: number): string => {
return `/v1/fleet/teams/${teamId}/agent_options`;
},
TEAMS_ENROLL_SECRETS: (teamId: number): string => {
return `/v1/fleet/teams/${teamId}/secrets`;
},
TEAMS_MEMBERS: (teamId: number): string => {
return `/v1/fleet/teams/${teamId}/users`;
},
TEAMS_TRANSFER_HOSTS: (teamId: number): string => {
return `/v1/fleet/teams/${teamId}/hosts`;
},
TEAMS_ENROLL_SECRETS: (teamId: number): string => {
return `/v1/fleet/teams/${teamId}/secrets`;
},
TEAMS_AGENT_OPTIONS: (teamId: number): string => {
return `/v1/fleet/teams/${teamId}/agent_options`;
},
UPDATE_USER_ADMIN: (id: number): string => {
return `/v1/fleet/users/${id}/admin`;
},

View File

@ -8,6 +8,10 @@ export default PropTypes.shape({
export interface IEnrollSecret {
secret: string;
created_at: string;
created_at?: string;
team_id?: number;
}
export interface IEnrollSecretsResponse {
secrets: IEnrollSecret[];
}

View File

@ -44,7 +44,17 @@ interface INewMember {
export interface INewMembersBody {
users: INewMember[];
}
export interface IRemoveMembersBody {
users: { id: number }[];
}
interface INewTeamSecret {
team_id: number;
secret: string;
created_at?: string;
}
export interface INewTeamSecretBody {
secrets: INewTeamSecret[];
}
export interface IRemoveTeamSecretBody {
secrets: { secret: string }[];
}

View File

@ -21,7 +21,6 @@
}
.fleeticon {
font-size: 8px;
margin: 0 20px;
vertical-align: 4px;
}

View File

@ -1,4 +1,4 @@
import React, { useState, useContext, useEffect } from "react";
import React, { useState, useContext } from "react";
import { useDispatch } from "react-redux";
import { useQuery } from "react-query";
import { InjectedRouter, Params } from "react-router/lib/Router";
@ -6,6 +6,7 @@ import { RouteProps } from "react-router/lib/Route";
import { find, isEmpty, isEqual, omit } from "lodash";
import ReactTooltip from "react-tooltip";
import enrollSecretsAPI from "services/entities/enroll_secret";
import labelsAPI from "services/entities/labels";
import statusLabelsAPI from "services/entities/statusLabels";
import teamsAPI from "services/entities/teams";
@ -22,12 +23,16 @@ import hostCountAPI, {
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { ILabel, ILabelFormData } from "interfaces/label";
import { IStatusLabels } from "interfaces/status_labels";
import { ITeam } from "interfaces/team";
import {
IEnrollSecret,
IEnrollSecretsResponse,
} from "interfaces/enroll_secret";
import { IHost } from "interfaces/host";
import { ILabel, ILabelFormData } from "interfaces/label";
import { IPolicy } from "interfaces/policy";
import { ISoftware } from "interfaces/software";
import { IStatusLabels } from "interfaces/status_labels";
import { ITeam } from "interfaces/team";
import { useDeepEffect } from "utilities/hooks"; // @ts-ignore
import deepDifference from "utilities/deep_difference";
import {
@ -65,6 +70,8 @@ import {
getNextLocationPath,
} from "./helpers";
import DeleteSecretModal from "./components/DeleteSecretModal";
import SecretEditorModal from "./components/SecretEditorModal";
import EnrollSecretModal from "./components/EnrollSecretModal"; // @ts-ignore
import NoHosts from "./components/NoHosts";
import EmptyHosts from "./components/EmptyHosts";
@ -130,7 +137,6 @@ const ManageHostsPage = ({
isPremiumTier,
currentTeam,
setCurrentTeam,
enrollSecret: globalSecret,
} = useContext(AppContext);
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
@ -156,7 +162,14 @@ const ManageHostsPage = ({
// ========= states
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
const [statusLabels, setStatusLabels] = useState<IStatusLabels>();
const [showDeleteSecretModal, setShowDeleteSecretModal] = useState<boolean>(
false
);
const [showSecretEditorModal, setShowSecretEditorModal] = useState<boolean>(
false
);
const [showEnrollSecretModal, setShowEnrollSecretModal] = useState<boolean>(
false
);
@ -221,13 +234,9 @@ const ManageHostsPage = ({
isAnyTeamMaintainer;
const canEnrollHosts =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer;
const canAddNewLabels = isGlobalAdmin || isGlobalMaintainer;
const generateInstallerTeam = currentTeam || {
name: "No team",
secrets: globalSecret,
};
const {
isLoading: isLabelsLoading,
data: labels,
@ -241,6 +250,39 @@ const ManageHostsPage = ({
}
);
const { data: globalSecrets, refetch: refetchGlobalSecrets } = useQuery<
IEnrollSecretsResponse,
Error,
IEnrollSecret[]
>(["global secrets"], () => enrollSecretsAPI.getGlobalEnrollSecrets(), {
enabled: !!canEnrollGlobalHosts,
select: (data: IEnrollSecretsResponse) => data.secrets,
});
const {
isLoading: isTeamSecretsLoading,
data: teamSecrets,
error: teamSecretsError,
refetch: refetchTeamSecrets,
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
["team secrets", currentTeam],
() => {
if (currentTeam) {
return enrollSecretsAPI.getTeamEnrollSecrets(currentTeam.id);
}
return { secrets: [] };
},
{
enabled: !!currentTeam?.id && !!canEnrollHosts,
select: (data: IEnrollSecretsResponse) => data.secrets,
}
);
const generateInstallerTeam = currentTeam || {
name: "No team",
secrets: globalSecrets || null,
};
// TODO: add counts to status dropdown
useQuery<IStatusLabels, Error>(
["status labels"],
@ -252,14 +294,18 @@ const ManageHostsPage = ({
}
);
const { data: teams, isLoading: isLoadingTeams } = useQuery<
ITeamsResponse,
Error,
ITeam[]
>(["teams"], () => teamsAPI.loadAll(), {
enabled: !!isPremiumTier,
select: (data: ITeamsResponse) => data.teams,
});
const {
data: teams,
isLoading: isLoadingTeams,
refetch: refetchTeams,
} = useQuery<ITeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
enabled: !!isPremiumTier,
select: (data: ITeamsResponse) => data.teams,
}
);
useQuery<IPolicyAPIResponse, Error>(
["policy"],
@ -278,6 +324,21 @@ const ManageHostsPage = ({
}
);
const toggleDeleteSecretModal = () => {
// open and closes delete modal
setShowDeleteSecretModal(!showDeleteSecretModal);
// open and closes main enroll secret modal
setShowEnrollSecretModal(!showEnrollSecretModal);
};
// this is called when we click add or edit
const toggleSecretEditorModal = () => {
// open and closes add/edit modal
setShowSecretEditorModal(!showSecretEditorModal);
// open and closes main enroll secret modall
setShowEnrollSecretModal(!showEnrollSecretModal);
};
const toggleDeleteLabelModal = () => {
setShowDeleteLabelModal(!showDeleteLabelModal);
};
@ -692,6 +753,108 @@ const ManageHostsPage = ({
);
};
const onSaveSecret = async (enrollSecretString: string) => {
const { MANAGE_HOSTS } = PATHS;
// Creates new list of secrets removing selected secret and adding new secret
const currentSecrets = currentTeam
? teamSecrets || []
: globalSecrets || [];
const newSecrets = currentSecrets.filter(
(s) => s.secret !== selectedSecret?.secret
);
if (enrollSecretString) {
newSecrets.push({ secret: enrollSecretString });
}
try {
if (currentTeam?.id) {
await enrollSecretsAPI.modifyTeamEnrollSecrets(
currentTeam.id,
newSecrets
);
refetchTeamSecrets();
} else {
await enrollSecretsAPI.modifyGlobalEnrollSecrets(newSecrets);
refetchGlobalSecrets();
}
toggleSecretEditorModal();
refetchTeams();
router.push(
getNextLocationPath({
pathPrefix: MANAGE_HOSTS,
routeTemplate: routeTemplate.replace("/labels/:label_id", ""),
routeParams,
queryParams,
})
);
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 () => {
const { MANAGE_HOSTS } = PATHS;
// create new list of secrets removing selected secret
const currentSecrets = currentTeam
? teamSecrets || []
: globalSecrets || [];
const newSecrets = currentSecrets.filter(
(s) => s.secret !== selectedSecret?.secret
);
try {
if (currentTeam?.id) {
await enrollSecretsAPI.modifyTeamEnrollSecrets(
currentTeam.id,
newSecrets
);
refetchTeamSecrets();
} else {
await enrollSecretsAPI.modifyGlobalEnrollSecrets(newSecrets);
refetchGlobalSecrets();
}
toggleDeleteSecretModal();
refetchTeams();
router.push(
getNextLocationPath({
pathPrefix: MANAGE_HOSTS,
routeTemplate: routeTemplate.replace("/labels/:label_id", ""),
routeParams,
queryParams,
})
);
dispatch(renderFlash("success", `Successfully deleted enroll secret.`));
} catch (error) {
console.error(error);
dispatch(
renderFlash(
"error",
"Could not delete enroll secret. Please try again."
)
);
}
};
const onEditLabel = async (formData: ILabelFormData) => {
if (!selectedLabel) {
console.error("Label isn't available. This should not happen.");
@ -996,6 +1159,37 @@ const ManageHostsPage = ({
);
};
const renderSecretEditorModal = () => {
if (!canEnrollHosts || !showSecretEditorModal) {
return null;
}
return (
<SecretEditorModal
selectedTeam={currentTeam?.id || 0}
teams={teams || []}
onSaveSecret={onSaveSecret}
toggleSecretEditorModal={toggleSecretEditorModal}
selectedSecret={selectedSecret}
/>
);
};
const renderDeleteSecretModal = () => {
if (!canEnrollHosts || !showDeleteSecretModal) {
return null;
}
return (
<DeleteSecretModal
onDeleteSecret={onDeleteSecret}
selectedTeam={currentTeam?.id || 0}
teams={teams || []}
toggleDeleteSecretModal={toggleDeleteSecretModal}
/>
);
};
const renderEnrollSecretModal = () => {
if (!canEnrollHosts || !showEnrollSecretModal) {
return null;
@ -1011,7 +1205,10 @@ const ManageHostsPage = ({
selectedTeam={currentTeam?.id || 0}
teams={teams || []}
onReturnToApp={() => setShowEnrollSecretModal(false)}
isPremiumTier={isPremiumTier as boolean}
toggleSecretEditorModal={toggleSecretEditorModal}
toggleDeleteSecretModal={toggleDeleteSecretModal}
setSelectedSecret={setSelectedSecret}
globalSecrets={globalSecrets}
/>
</Modal>
);
@ -1359,6 +1556,8 @@ const ManageHostsPage = ({
</div>
)}
{!isLabelsLoading && renderSidePanel()}
{renderDeleteSecretModal()}
{renderSecretEditorModal()}
{renderEnrollSecretModal()}
{renderEditColumnsModal()}
{renderDeleteLabelModal()}

View File

@ -0,0 +1,80 @@
import React from "react";
import { useSelector } from "react-redux";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";
interface IDeleteSecretModal {
selectedTeam: number;
teams: ITeam[];
onDeleteSecret: () => void;
toggleDeleteSecretModal: () => void;
}
interface IRootState {
app: {
enrollSecret: IEnrollSecret[];
};
}
const baseClass = "delete-secret-modal";
const DeleteSecretModal = ({
selectedTeam,
teams,
onDeleteSecret,
toggleDeleteSecretModal,
}: IDeleteSecretModal): JSX.Element => {
const renderTeam = () => {
if (typeof selectedTeam === "string") {
selectedTeam = parseInt(selectedTeam, 10);
}
if (selectedTeam === 0) {
return { name: "No team" };
}
return teams.find((team) => team.id === selectedTeam);
};
return (
<Modal
onExit={toggleDeleteSecretModal}
title={"Delete secret"}
className={baseClass}
>
<div className={baseClass}>
<div className={`${baseClass}__description`}>
<p>
This action will delete the secret used to enroll hosts to{" "}
<b>{renderTeam()?.name}</b>.
</p>
<p>
Any hosts that attempt to enroll to Fleet using this secret will be
unable to enroll.
</p>
<p>You cannot undo this action.</p>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button
className={`${baseClass}__btn`}
onClick={toggleDeleteSecretModal}
variant="inverse-alert"
>
Cancel
</Button>
<Button
className={`${baseClass}__btn`}
type="button"
variant="alert"
onClick={onDeleteSecret}
>
Delete
</Button>
</div>
</div>
</Modal>
);
};
export default DeleteSecretModal;

View File

@ -0,0 +1,15 @@
.delete-secret-modal {
&__button-wrap {
display: flex;
justify-content: flex-end;
margin: $pad-large 0 0;
}
&__btn {
margin-left: $pad-large;
}
&__error {
color: $ui-error;
}
}

View File

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

View File

@ -1,5 +1,4 @@
import React from "react";
import { useSelector } from "react-redux";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
// @ts-ignore
@ -7,17 +6,18 @@ 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";
interface IEnrollSecretModal {
selectedTeam: number;
onReturnToApp: () => void;
isPremiumTier: boolean;
teams: ITeam[];
}
interface IRootState {
app: {
enrollSecret: IEnrollSecret[];
};
toggleSecretEditorModal: () => void;
toggleDeleteSecretModal: () => void;
setSelectedSecret: React.Dispatch<
React.SetStateAction<IEnrollSecret | undefined>
>;
globalSecrets: IEnrollSecret[] | undefined;
}
const baseClass = "enroll-secret-modal";
@ -25,37 +25,72 @@ const baseClass = "enroll-secret-modal";
const EnrollSecretModal = ({
onReturnToApp,
selectedTeam,
isPremiumTier,
teams,
toggleSecretEditorModal,
toggleDeleteSecretModal,
setSelectedSecret,
globalSecrets,
}: IEnrollSecretModal): JSX.Element => {
const globalSecret = useSelector(
(state: IRootState) => state.app.enrollSecret
);
const renderTeam = () => {
if (typeof selectedTeam === "string") {
selectedTeam = parseInt(selectedTeam, 10);
}
if (selectedTeam === 0) {
return { name: "No team", secrets: globalSecret };
return { name: "No team", secrets: globalSecrets };
}
return teams.find((team) => team.id === selectedTeam);
};
const addNewSecretClick = () => {
setSelectedSecret(undefined);
toggleSecretEditorModal();
};
return (
<Modal onExit={onReturnToApp} title={"Enroll secret"} className={baseClass}>
<Modal
onExit={onReturnToApp}
title={"Manage enroll secrets"}
className={baseClass}
>
<div className={baseClass}>
<div className={`${baseClass}__description`}>
Use these secret(s) to enroll devices to <b>{renderTeam()?.name}</b>:
</div>
<div className={`${baseClass}__secret-wrapper`}>
{isPremiumTier && (
<EnrollSecretTable secrets={renderTeam()?.secrets} />
)}
{!isPremiumTier && (
<EnrollSecretTable secrets={renderTeam()?.secrets} />
)}
{renderTeam()?.secrets?.length ? (
<>
<div className={`${baseClass}__description`}>
Use these secret(s) to enroll hosts to <b>{renderTeam()?.name}</b>
:
</div>
<div className={`${baseClass}__secret-wrapper`}>
<EnrollSecretTable
secrets={renderTeam()?.secrets}
toggleSecretEditorModal={toggleSecretEditorModal}
toggleDeleteSecretModal={toggleDeleteSecretModal}
setSelectedSecret={setSelectedSecret}
/>
</div>
</>
) : (
<>
<div className={`${baseClass}__description`}>
<p>
<b>You have no enroll secrets.</b>
</p>
<p>
Add secret(s) to enroll hosts to <b>{renderTeam()?.name}</b>.
</p>
</div>
</>
)}
<div className={`${baseClass}__add-secret`}>
<Button
onClick={addNewSecretClick}
className={`${baseClass}__add-secret-btn`}
variant="text-icon"
>
<>
Add secret <img src={PlusIcon} alt="Add secret icon" />
</>
</Button>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button onClick={onReturnToApp} className="button button--brand">

View File

@ -1,6 +1,5 @@
import React, { useState } from "react";
import React from "react";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";

View File

@ -0,0 +1,102 @@
import React, { useState, useCallback } from "react";
import { useSelector } from "react-redux";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";
interface IAddSecretModal {
selectedTeam: number;
onSaveSecret: (newEnrollSecret: string) => void;
teams: ITeam[];
toggleSecretEditorModal: () => void;
selectedSecret: IEnrollSecret | undefined;
}
const baseClass = "secret-editor-modal";
const randomSecretGenerator = () => {
const randomChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let result = "";
for (let i = 0; i < 32; i += 1) {
result += randomChars.charAt(
Math.floor(Math.random() * randomChars.length)
);
}
return result;
};
const SecretEditorModal = ({
onSaveSecret,
selectedTeam,
teams,
toggleSecretEditorModal,
selectedSecret,
}: IAddSecretModal): JSX.Element => {
const [enrollSecretString, setEnrollSecretString] = useState<string>(
selectedSecret ? selectedSecret.secret : randomSecretGenerator()
);
const [errors, setErrors] = useState<{ [key: string]: any }>({});
const renderTeam = () => {
if (typeof selectedTeam === "string") {
selectedTeam = parseInt(selectedTeam, 10);
}
if (selectedTeam === 0) {
return { name: "No team" };
}
return teams.find((team) => team.id === selectedTeam);
};
const onSecretChange = (value: string) => {
setEnrollSecretString(value);
};
const onSaveSecretClick = () => {
if (enrollSecretString.length < 32) {
setErrors({
secret: "Secret",
});
} else {
setErrors({});
onSaveSecret(enrollSecretString);
}
};
return (
<Modal
onExit={toggleSecretEditorModal}
title={selectedSecret ? "Edit secret" : "Add secret"}
className={baseClass}
>
<div className={baseClass}>
<div className={`${baseClass}__description`}>
Create or edit the generated secret to enroll hosts to{" "}
<b>{renderTeam()?.name}</b>:
</div>
<div className={`${baseClass}__secret-wrapper`}>
<InputField
inputWrapperClass={`${baseClass}__secret-input`}
name="osqueryd-secret"
label={"Secret"}
type={"text"}
value={enrollSecretString}
onChange={onSecretChange}
error={errors.secret}
hint={"Must contain at least 32 characters."}
/>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button onClick={onSaveSecretClick} className="button button--brand">
Save
</Button>
</div>
</div>
</Modal>
);
};
export default SecretEditorModal;

View File

@ -0,0 +1,31 @@
.secret-editor-modal {
&__button-wrap {
display: flex;
justify-content: flex-end;
margin: $pad-large 0 0;
}
&__reveal-secret {
float: right;
text-decoration: none;
}
&__secret-wrapper {
position: relative;
margin: $pad-medium 0 0 0;
}
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;
}
}

View File

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

View File

@ -0,0 +1,28 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import specAPI from "services/entities/spec";
import teamsAPI from "services/entities/teams";
import { IEnrollSecret } from "interfaces/enroll_secret";
interface IEnrollSecretSpec {
spec: {
secrets: IEnrollSecret[];
};
}
export default {
getGlobalEnrollSecrets: () => {
return specAPI.getEnrollSecretSpec().then((res) => res.spec);
},
modifyGlobalEnrollSecrets: (secrets: IEnrollSecret[]) => {
return specAPI
.applyEnrollSecretSpec({ spec: { secrets } })
.then((res) => res.spec);
},
getTeamEnrollSecrets: (teamId: number) => {
return teamsAPI.getEnrollSecrets(teamId);
},
modifyTeamEnrollSecrets: (teamId: number, secrets: IEnrollSecret[]) => {
return teamsAPI.modifyEnrollSecrets(teamId, secrets);
},
};

View File

@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import endpoints from "fleet/endpoints";
import { IEnrollSecret } from "interfaces/enroll_secret";
interface IEnrollSecretSpec {
spec: {
secrets: IEnrollSecret[];
};
}
export default {
getEnrollSecretSpec: () => {
const { GLOBAL_ENROLL_SECRETS } = endpoints;
return sendRequest("GET", GLOBAL_ENROLL_SECRETS);
},
applyEnrollSecretSpec: (spec: IEnrollSecretSpec) => {
const { GLOBAL_ENROLL_SECRETS } = endpoints;
return sendRequest("POST", GLOBAL_ENROLL_SECRETS, spec);
},
};

View File

@ -3,6 +3,7 @@ import sendRequest from "services";
import endpoints from "fleet/endpoints";
import { INewMembersBody, IRemoveMembersBody, ITeam } from "interfaces/team";
import { ICreateTeamFormData } from "pages/admin/TeamManagementPage/components/CreateTeamModal/CreateTeamModal";
import { IEnrollSecret } from "interfaces/enroll_secret";
interface ILoadAllTeamsResponse {
teams: ITeam[];
@ -12,8 +13,8 @@ interface ILoadTeamResponse {
team: ITeam;
}
interface IGetTeamSecretsResponse {
secrets: any[]; // TODO: fill this out when API is defined
interface ITeamEnrollSecretsResponse {
secrets: IEnrollSecret[];
}
interface ITeamSearchOptions {
@ -89,4 +90,10 @@ export default {
return sendRequest("GET", path);
},
modifyEnrollSecrets: (teamId: number, secrets: IEnrollSecret[]) => {
const { TEAMS_ENROLL_SECRETS } = endpoints;
const path = TEAMS_ENROLL_SECRETS(teamId);
return sendRequest("PATCH", path, { secrets });
},
};