diff --git a/changes/1694-team-maintainer-add-host b/changes/1694-team-maintainer-add-host new file mode 100644 index 0000000000..a50d637557 --- /dev/null +++ b/changes/1694-team-maintainer-add-host @@ -0,0 +1,2 @@ +Add user role validations to select team dropdown in "Add host" modal +Remove Redux enrollSecret state from ManageHostsPage and instead handle as local state in "Add host" modal \ No newline at end of file diff --git a/cypress/integration/all/app/hosts.spec.ts b/cypress/integration/all/app/hosts.spec.ts index ead1cbedef..7bf717e66f 100644 --- a/cypress/integration/all/app/hosts.spec.ts +++ b/cypress/integration/all/app/hosts.spec.ts @@ -23,13 +23,14 @@ describe("Hosts page", () => { cy.contains("button", /add new host/i).click(); + cy.get('a[href*="showSecret"]').click(); cy.contains("a", /download/i) .first() .click(); - cy.get('a[href*="showSecret"]').click(); - // Assert enroll secret downloaded matches the one displayed + // NOTE: This test often fails when the Cypress downloads folder was not cleared properly + // before each test run (seems to be related to issues with Cypress trashAssetsBeforeRun) cy.readFile(path.join(Cypress.config("downloadsFolder"), "secret.txt"), { timeout: 5000, }).then((contents) => { diff --git a/cypress/integration/basic/admin.spec.ts b/cypress/integration/basic/admin.spec.ts index 3d6e32debe..3a47c25669 100644 --- a/cypress/integration/basic/admin.spec.ts +++ b/cypress/integration/basic/admin.spec.ts @@ -30,7 +30,7 @@ describe("Basic tier - Admin user", () => { // See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button cy.get(".add-host-modal__team-dropdown-wrapper .Select-control").click(); - cy.get(".add-host-modal__team-dropdown-wrapper").within(() => { + cy.get(".Select-menu-outer").within(() => { cy.findByText(/no team/i).should("exist"); cy.findByText(/apples/i).should("exist"); cy.findByText(/oranges/i).should("exist"); diff --git a/cypress/integration/basic/team_maintainer_observer.spec.ts b/cypress/integration/basic/team_maintainer_observer.spec.ts index 85244a8214..ffdd0450e1 100644 --- a/cypress/integration/basic/team_maintainer_observer.spec.ts +++ b/cypress/integration/basic/team_maintainer_observer.spec.ts @@ -137,16 +137,13 @@ describe("Basic tier - Team observer/maintainer user", () => { cy.findByText(/add new host/i).click(); // See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button - cy.get(".add-host-modal__team-dropdown-wrapper").within(() => { - cy.findByText(/select a team for this new host/i).should("exist"); - cy.get(".Select").within(() => { - cy.findByText(/select a team/i).click(); - cy.findByText(/no team/i).should("exist"); - // cy.findByText(/apples/i).should("exist"); - // cy.findByText(/oranges/i).should("not exist"); - // ^ TODO: Team maintainer has access to only their teams, team observer does not have access - }); + cy.get(".add-host-modal__team-dropdown-wrapper .Select-control").click(); + cy.get(".Select-menu-outer").within(() => { + cy.findByText(/no team/i).should("not.exist"); + cy.findByText(/apples/i).should("not.exist"); + cy.findByText(/oranges/i).should("exist"); }); + cy.findByRole("button", { name: /done/i }).click(); // On the Host details page, they should… diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx index 2e04223791..4e73ee32fa 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx @@ -17,7 +17,6 @@ import teamInterface from "interfaces/team"; import userInterface from "interfaces/user"; import osqueryTableInterface from "interfaces/osquery_table"; import statusLabelsInterface from "interfaces/status_labels"; -import enrollSecretInterface from "interfaces/enroll_secret"; import { selectOsqueryTable } from "redux/nodes/components/QueryPages/actions"; import { renderFlash } from "redux/nodes/notifications/actions"; import labelActions from "redux/nodes/entities/labels/actions"; @@ -103,7 +102,6 @@ export class ManageHostsPage extends PureComponent { routeParams: PropTypes.objectOf( PropTypes.oneOfType([PropTypes.string, PropTypes.number]) ), - enrollSecret: enrollSecretInterface, selectedFilters: PropTypes.arrayOf(PropTypes.string), selectedLabel: labelInterface, selectedTeam: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), @@ -223,12 +221,6 @@ export class ManageHostsPage extends PureComponent { toggleAddHostModal(); }; - // The onChange method below is for the dropdown used in modals - onChangeTeam = (team) => { - const { dispatch } = this.props; - dispatch(teamActions.getEnrollSecrets(team)); - }; - // NOTE: this is called once on the initial rendering. The initial render of // the TableContainer child component will call this handler. onTableQueryChange = async ({ @@ -707,7 +699,7 @@ export class ManageHostsPage extends PureComponent { renderAddHostModal = () => { const { toggleAddHostModal, onChangeTeam } = this; const { showAddHostModal } = this.state; - const { enrollSecret, config, canAddNewHosts, teams } = this.props; + const { config, currentUser, canAddNewHosts, teams } = this.props; if (!canAddNewHosts || !showAddHostModal) { return null; @@ -723,8 +715,8 @@ export class ManageHostsPage extends PureComponent { teams={teams} onChangeTeam={onChangeTeam} onReturnToApp={toggleAddHostModal} - enrollSecret={enrollSecret} config={config} + currentUser={currentUser} /> ); @@ -1056,7 +1048,6 @@ const mapStateToProps = (state, ownProps) => { const { selectedOsqueryTable } = state.components.QueryPages; const { errors: labelErrors, loading: loadingLabels } = state.entities.labels; - const enrollSecret = state.app.enrollSecret; const config = state.app.config; const { loading: loadingHosts } = state.entities.hosts; @@ -1090,7 +1081,6 @@ const mapStateToProps = (state, ownProps) => { labelErrors, labels, loadingLabels, - enrollSecret, selectedLabel, selectedOsqueryTable, statusLabels, diff --git a/frontend/pages/hosts/ManageHostsPage/components/AddHostModal/AddHostModal.jsx b/frontend/pages/hosts/ManageHostsPage/components/AddHostModal/AddHostModal.jsx index 2fd4847c37..eb3af3a913 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/AddHostModal/AddHostModal.jsx +++ b/frontend/pages/hosts/ManageHostsPage/components/AddHostModal/AddHostModal.jsx @@ -6,7 +6,7 @@ import Fleet from "fleet"; import Button from "components/buttons/Button"; import configInterface from "interfaces/config"; import teamInterface from "interfaces/team"; -import enrollSecretInterface from "interfaces/enroll_secret"; +import userInterface from "interfaces/user"; import permissionUtils from "utilities/permissions"; import EnrollSecretTable from "components/config/EnrollSecretTable"; import FleetIcon from "components/icons/FleetIcon"; @@ -23,18 +23,62 @@ const NO_TEAM_OPTION = { class AddHostModal extends Component { static propTypes = { teams: PropTypes.arrayOf(teamInterface), - onChangeTeam: PropTypes.func, onReturnToApp: PropTypes.func, - enrollSecret: enrollSecretInterface, config: configInterface, + currentUser: userInterface, }; constructor(props) { super(props); - this.state = { fetchCertificateError: undefined, selectedTeam: null }; + + this.userRole = { + isAnyTeamMaintainer: permissionUtils.isAnyTeamMaintainer( + this.props.currentUser + ), + isGlobalAdmin: permissionUtils.isGlobalAdmin(this.props.currentUser), + isGlobalMaintainer: permissionUtils.isGlobalMaintainer( + this.props.currentUser + ), + }; + this.currentUserTeams = this.userRole.isAnyTeamMaintainer + ? Object.values(this.props.currentUser.teams).filter( + (team) => team.role === "maintainer" + ) + : this.props.teams; + + this.teamSecrets = Object.values(this.props.teams).map((team) => { + return { id: team.id, name: team.name, secrets: team.secrets }; + }); + + this.state = { + fetchCertificateError: undefined, + selectedTeam: null, + globalSecrets: [], + selectedEnrollSecrets: [], + }; } componentDidMount() { + const { isGlobalAdmin, isGlobalMaintainer } = this.userRole; + + (() => { + if (isGlobalAdmin || isGlobalMaintainer) { + Fleet.config + .loadEnrollSecret() + .then((response) => { + this.setState({ + globalSecrets: response.spec.secrets, + selectedTeam: { id: NO_TEAM_OPTION.value }, // Reset initial selectedTeam value to "no-team" in the case of global users + }); + }) + .catch((err) => { + console.log(err); + }); + } else { + this.setState({ selectedTeam: this.currentUserTeams[0] }); + } + })(); + Fleet.config .loadCertificate() .then((certificate) => { @@ -63,32 +107,57 @@ class AddHostModal extends Component { return false; }; + // if isGlobalAdmin or isGlobalMaintainer, we include a "No team" option and reveal globalSecrets + // if not, we pull secrets for the user's teams from the teamsSecrets onChangeSelectTeam = (teamId) => { - const { teams, onChangeTeam } = this.props; + const { globalSecrets } = this.state; + const { currentUserTeams, teamSecrets } = this; if (teamId === "no-team") { - onChangeTeam(null); this.setState({ selectedTeam: { id: NO_TEAM_OPTION.value } }); + this.setState({ selectedEnrollSecrets: globalSecrets || [] }); } else { - const selectedTeam = teams.find((team) => team.id === teamId); - onChangeTeam(selectedTeam); + const selectedTeam = currentUserTeams.find((team) => team.id === teamId); + const selectedEnrollSecrets = + teamSecrets.find((e) => e.id === selectedTeam.id)?.secrets || ""; this.setState({ selectedTeam }); + this.setState({ + selectedEnrollSecrets, + }); } }; - createTeamDropdownOptions = (teams) => { - const teamOptions = teams.map((team) => { + getSelectedEnrollSecrets = (selectedTeam) => { + if (selectedTeam.id === NO_TEAM_OPTION.value) { + return this.state.globalSecrets; + } + return ( + this.teamSecrets.find((e) => e.id === selectedTeam.id)?.secrets || "" + ); + }; + + createTeamDropdownOptions = (currentUserTeams) => { + const teamOptions = currentUserTeams.map((team) => { return { value: team.id, label: team.name, }; }); - return [NO_TEAM_OPTION, ...teamOptions]; + return this.userRole.isAnyTeamMaintainer + ? teamOptions + : [NO_TEAM_OPTION, ...teamOptions]; }; render() { - const { config, onReturnToApp, enrollSecret, teams } = this.props; - const { fetchCertificateError, selectedTeam } = this.state; - const { createTeamDropdownOptions, onChangeSelectTeam } = this; + const { config, onReturnToApp } = this.props; + const { fetchCertificateError, selectedTeam, globalSecrets } = this.state; + const { + createTeamDropdownOptions, + currentUserTeams, + getSelectedEnrollSecrets, + onChangeSelectTeam, + } = this; + + const isBasicTier = permissionUtils.isBasicTier(config); let tlsHostname = config.server_url; try { @@ -172,18 +241,23 @@ class AddHostModal extends Component { server.

- {permissionUtils.isBasicTier(config) ? ( + {isBasicTier ? ( ) : null} - + {isBasicTier && selectedTeam && ( + + )} + {!isBasicTier && }