From a7c6b3e7d71fe4a6c2a7613de047f72356fb56dd Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 15 Nov 2021 16:16:06 -0500 Subject: [PATCH] Add/Edit/Delete enroll secret UI (#2645) --- changes/issue-2135-manage-enroll-secrets | 3 +- cypress/integration/free/admin.spec.ts | 3 + cypress/integration/premium/admin.spec.ts | 7 + .../integration/premium/maintainer.spec.ts | 4 +- .../premium/team_maintainer_observer.spec.ts | 24 +- docs/01-Using-Fleet/03-REST-API.md | 80 ++++++ .../EnrollSecretRow/EnrollSecretRow.tsx | 130 ++++++++++ .../EnrollSecretRow/_styles.scss | 91 +++++++ .../EnrollSecretRow/index.ts | 1 + .../EnrollSecretTable/EnrollSecretTable.jsx | 147 ----------- .../EnrollSecretTable.tests.jsx | 79 ------ .../EnrollSecretTable/EnrollSecretTable.tsx | 61 +++++ .../components/EnrollSecretTable/_styles.scss | 110 +------- .../EnrollSecretTable/{index.js => index.ts} | 0 frontend/fleet/endpoints.ts | 13 +- frontend/interfaces/enroll_secret.ts | 6 +- frontend/interfaces/team.ts | 12 +- .../pages/admin/AppSettingsPage/_styles.scss | 1 - .../hosts/ManageHostsPage/ManageHostsPage.tsx | 237 ++++++++++++++++-- .../DeleteSecretModal/DeleteSecretModal.tsx | 80 ++++++ .../components/DeleteSecretModal/_styles.scss | 15 ++ .../components/DeleteSecretModal/index.ts | 1 + .../EnrollSecretModal/EnrollSecretModal.tsx | 85 +++++-- .../GenerateInstallerModal.tsx | 3 +- .../SecretEditorModal/SecretEditorModal.tsx | 102 ++++++++ .../components/SecretEditorModal/_styles.scss | 31 +++ .../components/SecretEditorModal/index.ts | 1 + frontend/services/entities/enroll_secret.ts | 28 +++ frontend/services/entities/spec.ts | 23 ++ frontend/services/entities/teams.ts | 11 +- 30 files changed, 990 insertions(+), 399 deletions(-) create mode 100644 frontend/components/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx create mode 100644 frontend/components/EnrollSecretTable/EnrollSecretRow/_styles.scss create mode 100644 frontend/components/EnrollSecretTable/EnrollSecretRow/index.ts delete mode 100644 frontend/components/EnrollSecretTable/EnrollSecretTable.jsx delete mode 100644 frontend/components/EnrollSecretTable/EnrollSecretTable.tests.jsx create mode 100644 frontend/components/EnrollSecretTable/EnrollSecretTable.tsx rename frontend/components/EnrollSecretTable/{index.js => index.ts} (100%) create mode 100644 frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/DeleteSecretModal.tsx create mode 100644 frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/_styles.scss create mode 100644 frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/index.ts create mode 100644 frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/SecretEditorModal.tsx create mode 100644 frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/_styles.scss create mode 100644 frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/index.ts create mode 100644 frontend/services/entities/enroll_secret.ts create mode 100644 frontend/services/entities/spec.ts diff --git a/changes/issue-2135-manage-enroll-secrets b/changes/issue-2135-manage-enroll-secrets index 846850b1c..2ceea91d5 100644 --- a/changes/issue-2135-manage-enroll-secrets +++ b/changes/issue-2135-manage-enroll-secrets @@ -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 \ No newline at end of file +* Allow team admin or team maintainer to edit or delete team enroll secrets +* Modal in UI for all permissible enroll secret functionality \ No newline at end of file diff --git a/cypress/integration/free/admin.spec.ts b/cypress/integration/free/admin.spec.ts index 838cdc9ef..ebe38ec7e 100644 --- a/cypress/integration/free/admin.spec.ts +++ b/cypress/integration/free/admin.spec.ts @@ -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" diff --git a/cypress/integration/premium/admin.spec.ts b/cypress/integration/premium/admin.spec.ts index 76b955854..6dee5d42f 100644 --- a/cypress/integration/premium/admin.spec.ts +++ b/cypress/integration/premium/admin.spec.ts @@ -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 diff --git a/cypress/integration/premium/maintainer.spec.ts b/cypress/integration/premium/maintainer.spec.ts index 2f5f72028..4c1f766ad 100644 --- a/cypress/integration/premium/maintainer.spec.ts +++ b/cypress/integration/premium/maintainer.spec.ts @@ -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 diff --git a/cypress/integration/premium/team_maintainer_observer.spec.ts b/cypress/integration/premium/team_maintainer_observer.spec.ts index 65106cb7c..e09a60d28 100644 --- a/cypress/integration/premium/team_maintainer_observer.spec.ts +++ b/cypress/integration/premium/team_maintainer_observer.spec.ts @@ -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 diff --git a/docs/01-Using-Fleet/03-REST-API.md b/docs/01-Using-Fleet/03-REST-API.md index f773ec903..389465240 100644 --- a/docs/01-Using-Fleet/03-REST-API.md +++ b/docs/01-Using-Fleet/03-REST-API.md @@ -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. diff --git a/frontend/components/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx b/frontend/components/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx new file mode 100644 index 000000000..d05320a63 --- /dev/null +++ b/frontend/components/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx @@ -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 + >; +} +const EnrollSecretRow = ({ + secret, + toggleSecretEditorModal, + toggleDeleteSecretModal, + setSelectedSecret, +}: IEnrollSecretRowProps): JSX.Element | null => { + const [showSecret, setShowSecret] = useState(false); + const [copyMessage, setCopyMessage] = useState(""); + + 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) => { + evt.preventDefault(); + if (toggleSecretEditorModal && setSelectedSecret) { + setSelectedSecret(secret); + toggleSecretEditorModal(); + } + }; + + const onDeleteSecretClick = (evt: React.MouseEvent) => { + evt.preventDefault(); + if (toggleDeleteSecretModal && setSelectedSecret) { + setSelectedSecret(secret); + toggleDeleteSecretModal(); + } + }; + + const renderLabel = () => { + return ( + + + {copyMessage && {`${copyMessage} `}} + + + show/hide + + + + ); + }; + + return ( +
+ + {toggleSecretEditorModal && toggleDeleteSecretModal ? ( + <> + + + + ) : null} +
+ ); +}; + +export default EnrollSecretRow; diff --git a/frontend/components/EnrollSecretTable/EnrollSecretRow/_styles.scss b/frontend/components/EnrollSecretTable/EnrollSecretRow/_styles.scss new file mode 100644 index 000000000..3495365c9 --- /dev/null +++ b/frontend/components/EnrollSecretTable/EnrollSecretRow/_styles.scss @@ -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; + } +} diff --git a/frontend/components/EnrollSecretTable/EnrollSecretRow/index.ts b/frontend/components/EnrollSecretTable/EnrollSecretRow/index.ts new file mode 100644 index 000000000..b442ef535 --- /dev/null +++ b/frontend/components/EnrollSecretTable/EnrollSecretRow/index.ts @@ -0,0 +1 @@ +export { default } from "./EnrollSecretRow"; diff --git a/frontend/components/EnrollSecretTable/EnrollSecretTable.jsx b/frontend/components/EnrollSecretTable/EnrollSecretTable.jsx deleted file mode 100644 index 761e186df..000000000 --- a/frontend/components/EnrollSecretTable/EnrollSecretTable.jsx +++ /dev/null @@ -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 ( - - - {copyMessage && {`${copyMessage} `}} - - - show/hide - - - - ); - }; - - render() { - const { secret } = this.props; - const { showSecret } = this.state; - const { renderLabel, onDownloadSecret } = this; - - return ( - - ); - } -} - -class EnrollSecretTable extends Component { - static propTypes = { - secrets: PropTypes.arrayOf(enrollSecretInterface).isRequired, - }; - - render() { - const { secrets } = this.props; - - let enrollSecretsClass = baseClass; - if (secrets.length === 0) { - return ( -
- No active enroll secrets. -
- ); - } else if (secrets.length > 1) - enrollSecretsClass += ` ${baseClass}--multiple-secrets`; - - return ( -
- {secrets.map(({ secret }) => ( - - ))} -
- ); - } -} - -export default EnrollSecretTable; -export { EnrollSecretRow }; diff --git a/frontend/components/EnrollSecretTable/EnrollSecretTable.tests.jsx b/frontend/components/EnrollSecretTable/EnrollSecretTable.tests.jsx deleted file mode 100644 index 7c7bec248..000000000 --- a/frontend/components/EnrollSecretTable/EnrollSecretTable.tests.jsx +++ /dev/null @@ -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(); - expect(table.find("EnrollSecretRow").length).toEqual(3); - }); - - it("renders text when empty", () => { - const table = shallow(); - 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(); - const inputField = row.find("InputField").find("input"); - expect(inputField.prop("type")).toEqual("password"); - }); - - it("should show secret when enabled", () => { - const row = mount(); - 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(); - - 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(); - 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); - }); -}); diff --git a/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx b/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx new file mode 100644 index 000000000..f970e08b8 --- /dev/null +++ b/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx @@ -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 + >; +} +const EnrollSecretTable = ({ + secrets, + toggleSecretEditorModal, + toggleDeleteSecretModal, + setSelectedSecret, +}: IEnrollSecretRowProps): JSX.Element | null => { + let enrollSecretsClass = baseClass; + if (!secrets) { + return null; + } + + if (secrets.length === 0) { + return ( +
+ No active enroll secrets. +
+ ); + } else if (secrets.length > 1) + enrollSecretsClass += ` ${baseClass}--multiple-secrets`; + + if (toggleSecretEditorModal && toggleDeleteSecretModal) { + return ( +
+ {secrets.map((secretInfo) => ( + + ))} +
+ ); + } + return ( +
+ {secrets.map((secretInfo) => ( + + ))} +
+ ); +}; + +export default EnrollSecretTable; +export { EnrollSecretRow }; diff --git a/frontend/components/EnrollSecretTable/_styles.scss b/frontend/components/EnrollSecretTable/_styles.scss index a4a864bce..be65faa3f 100644 --- a/frontend/components/EnrollSecretTable/_styles.scss +++ b/frontend/components/EnrollSecretTable/_styles.scss @@ -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; - } } } diff --git a/frontend/components/EnrollSecretTable/index.js b/frontend/components/EnrollSecretTable/index.ts similarity index 100% rename from frontend/components/EnrollSecretTable/index.js rename to frontend/components/EnrollSecretTable/index.ts diff --git a/frontend/fleet/endpoints.ts b/frontend/fleet/endpoints.ts index e7a616729..17fbdde60 100644 --- a/frontend/fleet/endpoints.ts +++ b/frontend/fleet/endpoints.ts @@ -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`; }, diff --git a/frontend/interfaces/enroll_secret.ts b/frontend/interfaces/enroll_secret.ts index 7c81917e4..3134b61c1 100644 --- a/frontend/interfaces/enroll_secret.ts +++ b/frontend/interfaces/enroll_secret.ts @@ -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[]; +} diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 3d8897a86..587129055 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -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 }[]; +} diff --git a/frontend/pages/admin/AppSettingsPage/_styles.scss b/frontend/pages/admin/AppSettingsPage/_styles.scss index 2afb68fd4..62f75fdc9 100644 --- a/frontend/pages/admin/AppSettingsPage/_styles.scss +++ b/frontend/pages/admin/AppSettingsPage/_styles.scss @@ -21,7 +21,6 @@ } .fleeticon { - font-size: 8px; margin: 0 20px; vertical-align: 4px; } diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 73eb2d2c0..c43aa6b8a 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -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(); + const [selectedSecret, setSelectedSecret] = useState(); const [statusLabels, setStatusLabels] = useState(); + const [showDeleteSecretModal, setShowDeleteSecretModal] = useState( + false + ); + const [showSecretEditorModal, setShowSecretEditorModal] = useState( + false + ); const [showEnrollSecretModal, setShowEnrollSecretModal] = useState( 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( + ["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( ["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( + ["teams"], + () => teamsAPI.loadAll(), + { + enabled: !!isPremiumTier, + select: (data: ITeamsResponse) => data.teams, + } + ); useQuery( ["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 ( + + ); + }; + + const renderDeleteSecretModal = () => { + if (!canEnrollHosts || !showDeleteSecretModal) { + return null; + } + + return ( + + ); + }; + 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} /> ); @@ -1359,6 +1556,8 @@ const ManageHostsPage = ({ )} {!isLabelsLoading && renderSidePanel()} + {renderDeleteSecretModal()} + {renderSecretEditorModal()} {renderEnrollSecretModal()} {renderEditColumnsModal()} {renderDeleteLabelModal()} diff --git a/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/DeleteSecretModal.tsx b/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/DeleteSecretModal.tsx new file mode 100644 index 000000000..e4a77ddb1 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/DeleteSecretModal.tsx @@ -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 ( + +
+
+

+ This action will delete the secret used to enroll hosts to{" "} + {renderTeam()?.name}. +

+

+ Any hosts that attempt to enroll to Fleet using this secret will be + unable to enroll. +

+

You cannot undo this action.

+
+
+ + +
+
+
+ ); +}; + +export default DeleteSecretModal; diff --git a/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/_styles.scss new file mode 100644 index 000000000..5bdc143fe --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/index.ts b/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/index.ts new file mode 100644 index 000000000..d3a283bec --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/DeleteSecretModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteSecretModal"; diff --git a/frontend/pages/hosts/ManageHostsPage/components/EnrollSecretModal/EnrollSecretModal.tsx b/frontend/pages/hosts/ManageHostsPage/components/EnrollSecretModal/EnrollSecretModal.tsx index 06705642a..6e36bebc1 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/EnrollSecretModal/EnrollSecretModal.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/EnrollSecretModal/EnrollSecretModal.tsx @@ -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 + >; + 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 ( - +
-
- Use these secret(s) to enroll devices to {renderTeam()?.name}: -
-
- {isPremiumTier && ( - - )} - {!isPremiumTier && ( - - )} + {renderTeam()?.secrets?.length ? ( + <> +
+ Use these secret(s) to enroll hosts to {renderTeam()?.name} + : +
+
+ +
+ + ) : ( + <> +
+

+ You have no enroll secrets. +

+

+ Add secret(s) to enroll hosts to {renderTeam()?.name}. +

+
+ + )} +
+
+
+
+ + ); +}; + +export default SecretEditorModal; diff --git a/frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/_styles.scss new file mode 100644 index 000000000..fa0ecc473 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/index.ts b/frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/index.ts new file mode 100644 index 000000000..a2abc3715 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/SecretEditorModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SecretEditorModal"; diff --git a/frontend/services/entities/enroll_secret.ts b/frontend/services/entities/enroll_secret.ts new file mode 100644 index 000000000..60a797fd0 --- /dev/null +++ b/frontend/services/entities/enroll_secret.ts @@ -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); + }, +}; diff --git a/frontend/services/entities/spec.ts b/frontend/services/entities/spec.ts new file mode 100644 index 000000000..6a9855ed3 --- /dev/null +++ b/frontend/services/entities/spec.ts @@ -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); + }, +}; diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index 9e11e3d4b..b682d4353 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -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 }); + }, };