mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
Add/Edit/Delete enroll secret UI (#2645)
This commit is contained in:
parent
5c1edaf527
commit
a7c6b3e7d7
@ -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
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./EnrollSecretRow";
|
@ -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 };
|
@ -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);
|
||||
});
|
||||
});
|
61
frontend/components/EnrollSecretTable/EnrollSecretTable.tsx
Normal file
61
frontend/components/EnrollSecretTable/EnrollSecretTable.tsx
Normal 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 };
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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`;
|
||||
},
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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 }[];
|
||||
}
|
||||
|
@ -21,7 +21,6 @@
|
||||
}
|
||||
|
||||
.fleeticon {
|
||||
font-size: 8px;
|
||||
margin: 0 20px;
|
||||
vertical-align: 4px;
|
||||
}
|
||||
|
@ -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()}
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./DeleteSecretModal";
|
@ -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">
|
||||
|
@ -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";
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./SecretEditorModal";
|
28
frontend/services/entities/enroll_secret.ts
Normal file
28
frontend/services/entities/enroll_secret.ts
Normal 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);
|
||||
},
|
||||
};
|
23
frontend/services/entities/spec.ts
Normal file
23
frontend/services/entities/spec.ts
Normal 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);
|
||||
},
|
||||
};
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user