From d0ded91d0b4f9f2af890e21a03d52c5b58cd8076 Mon Sep 17 00:00:00 2001 From: Gabe Hernandez Date: Wed, 24 Mar 2021 13:18:56 +0000 Subject: [PATCH] partial implementation of user table with generic table and new create user form (#500) * use new data table in user manage page' * remove default empty array hiddenColumns props, was causing render performance problems * remove unused tooltip in hostcontainer * add search to user manage table * add query params to user GET requests * move createUserForm closer to user management page * starting to implement create user modal * starting to add team checking functionality to create user * styling of select team form * changing logic for selectedTeamsForm, simplifying * updated SelectedTeamsForm to handle own state and pass back relevant state to parent * created reusable infobanner component and use it in osquery options page * use infobanner in createuserform * create new Radio component and use in createuserform * create new Radio component and use in createuserform * added new radio buttons to createUserForm * finish custom radio button styling * finish styling of radio in createUserForm * fix and add entities/users#loadAll tests * remove unneeded tests and updated broken ones on UserManagementPage * remove unused modules --- .eslintrc.js | 8 + .../components/DataTable/DataTable.jsx | 2 + .../DataTable}/HeaderCell/HeaderCell.tsx | 0 .../DataTable}/HeaderCell/_styles.scss | 0 .../DataTable}/LinkCell/LinkCell.tsx | 0 .../DataTable}/StatusCell/StatusCell.tsx | 0 .../DataTable}/TextCell/TextCell.tsx | 0 .../components/DataTable/_styles.scss | 0 frontend/components/InfoBanner/InfoBanner.tsx | 22 ++ frontend/components/InfoBanner/_styles.scss | 6 + frontend/components/buttons/Button/index.js | 1 - frontend/components/buttons/Button/index.ts | 1 + .../forms/InviteUserForm/InviteUserForm.jsx | 198 ---------- .../forms/InviteUserForm/_styles.scss | 39 -- .../components/forms/InviteUserForm/index.js | 1 - .../forms/fields/Dropdown/Dropdown.jsx | 5 +- .../components/forms/fields/Radio/Radio.tsx | 40 +++ .../forms/fields/Radio/_styles.scss | 55 +++ .../components/forms/fields/Radio/index.ts | 1 + .../forms/validators/valid_email/index.js | 1 - .../forms/validators/valid_email/index.ts | 1 + .../validators/validate_presence/index.js | 3 - .../validators/validate_presence/index.ts | 3 + frontend/interfaces/team.ts | 7 + frontend/interfaces/{user.js => user.ts} | 12 + frontend/kolide/entities/hosts.js | 1 + frontend/kolide/entities/users.js | 26 +- frontend/kolide/entities/users.tests.js | 16 +- .../OsqueryOptionsPage/OsqueryOptionsPage.jsx | 7 +- .../admin/OsqueryOptionsPage/_styles.scss | 6 +- .../UserManagementPage/UserManagementPage.jsx | 168 +++------ .../UserManagementPage.tests.jsx | 96 +---- .../UserManagementPage/UsersTableConfig.tsx | 76 ++++ .../admin/UserManagementPage/_styles.scss | 37 +- .../CreateUserForm/CreateUserForm.tsx | 338 ++++++++++++++++++ .../components/CreateUserForm/_styles.scss | 70 ++++ .../components/CreateUserForm/index.js | 1 + .../SelectedTeamsForm/SelectedTeamsForm.tsx | 131 +++++++ .../components/SelectedTeamsForm/_styles.scss | 29 ++ .../EditColumnsModal/EditColumnsModal.jsx | 1 + .../HostContainer/HostContainer.jsx | 9 +- .../HostContainer/HostTableConfig.tsx | 12 +- frontend/styles/var/padding.scss | 1 + frontend/test/mocks/user_mocks.js | 10 +- frontend/typings/index.d.ts | 9 + tsconfig.json | 3 + 46 files changed, 980 insertions(+), 473 deletions(-) rename frontend/{pages/hosts/ManageHostsPage => }/components/DataTable/DataTable.jsx (99%) rename frontend/{pages/hosts/ManageHostsPage/components => components/DataTable}/HeaderCell/HeaderCell.tsx (100%) rename frontend/{pages/hosts/ManageHostsPage/components => components/DataTable}/HeaderCell/_styles.scss (100%) rename frontend/{pages/hosts/ManageHostsPage/components => components/DataTable}/LinkCell/LinkCell.tsx (100%) rename frontend/{pages/hosts/ManageHostsPage/components => components/DataTable}/StatusCell/StatusCell.tsx (100%) rename frontend/{pages/hosts/ManageHostsPage/components => components/DataTable}/TextCell/TextCell.tsx (100%) rename frontend/{pages/hosts/ManageHostsPage => }/components/DataTable/_styles.scss (100%) create mode 100644 frontend/components/InfoBanner/InfoBanner.tsx create mode 100644 frontend/components/InfoBanner/_styles.scss delete mode 100644 frontend/components/buttons/Button/index.js create mode 100644 frontend/components/buttons/Button/index.ts delete mode 100644 frontend/components/forms/InviteUserForm/InviteUserForm.jsx delete mode 100644 frontend/components/forms/InviteUserForm/_styles.scss delete mode 100644 frontend/components/forms/InviteUserForm/index.js create mode 100644 frontend/components/forms/fields/Radio/Radio.tsx create mode 100644 frontend/components/forms/fields/Radio/_styles.scss create mode 100644 frontend/components/forms/fields/Radio/index.ts delete mode 100644 frontend/components/forms/validators/valid_email/index.js create mode 100644 frontend/components/forms/validators/valid_email/index.ts delete mode 100644 frontend/components/forms/validators/validate_presence/index.js create mode 100644 frontend/components/forms/validators/validate_presence/index.ts create mode 100644 frontend/interfaces/team.ts rename frontend/interfaces/{user.js => user.ts} (61%) create mode 100644 frontend/pages/admin/UserManagementPage/UsersTableConfig.tsx create mode 100644 frontend/pages/admin/UserManagementPage/components/CreateUserForm/CreateUserForm.tsx create mode 100644 frontend/pages/admin/UserManagementPage/components/CreateUserForm/_styles.scss create mode 100644 frontend/pages/admin/UserManagementPage/components/CreateUserForm/index.js create mode 100644 frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx create mode 100644 frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss create mode 100644 frontend/typings/index.d.ts diff --git a/.eslintrc.js b/.eslintrc.js index 2a2c125547..bf488fe4ff 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -57,6 +57,14 @@ module.exports = { // https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/explicit-module-boundary-types.md#configuring-in-a-mixed-jsts-codebase '@typescript-eslint/explicit-module-boundary-types': 'off', + // Most of the js modules written by us need to be rewritten into TS. Until then + // we use ts-ignore comment to ignore the error TS gives us from not having those modules + // declared (TS7016). This is done on purpose as there is not time to rewrite everything in TS. + '@typescript-eslint/ban-ts-comment': 'off', + + 'no-shadow': 'off', // replaced by ts-eslint rule below + '@typescript-eslint/no-shadow': 'error', + // There is a bug with these rules in our version of jsx-a11y plugin (5.1.1) // To upgrade our version of the plugin we would need to make more changes // with eslint-config-airbnb, so we will just turn off for now. diff --git a/frontend/pages/hosts/ManageHostsPage/components/DataTable/DataTable.jsx b/frontend/components/DataTable/DataTable.jsx similarity index 99% rename from frontend/pages/hosts/ManageHostsPage/components/DataTable/DataTable.jsx rename to frontend/components/DataTable/DataTable.jsx index 9a2cfa36cd..aa80b9aa35 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/DataTable/DataTable.jsx +++ b/frontend/components/DataTable/DataTable.jsx @@ -54,11 +54,13 @@ const DataTable = (props) => { const pageIndexChangeRef = useRef(); const columns = useMemo(() => { + // console.log('Column calc'); return tableColumns; }, [tableColumns]); // The table data needs to be ordered by the order we received from the API. const data = useMemo(() => { + // console.log('Data calc'); return apiOrder.map((id) => { return entityData[id]; }); diff --git a/frontend/pages/hosts/ManageHostsPage/components/HeaderCell/HeaderCell.tsx b/frontend/components/DataTable/HeaderCell/HeaderCell.tsx similarity index 100% rename from frontend/pages/hosts/ManageHostsPage/components/HeaderCell/HeaderCell.tsx rename to frontend/components/DataTable/HeaderCell/HeaderCell.tsx diff --git a/frontend/pages/hosts/ManageHostsPage/components/HeaderCell/_styles.scss b/frontend/components/DataTable/HeaderCell/_styles.scss similarity index 100% rename from frontend/pages/hosts/ManageHostsPage/components/HeaderCell/_styles.scss rename to frontend/components/DataTable/HeaderCell/_styles.scss diff --git a/frontend/pages/hosts/ManageHostsPage/components/LinkCell/LinkCell.tsx b/frontend/components/DataTable/LinkCell/LinkCell.tsx similarity index 100% rename from frontend/pages/hosts/ManageHostsPage/components/LinkCell/LinkCell.tsx rename to frontend/components/DataTable/LinkCell/LinkCell.tsx diff --git a/frontend/pages/hosts/ManageHostsPage/components/StatusCell/StatusCell.tsx b/frontend/components/DataTable/StatusCell/StatusCell.tsx similarity index 100% rename from frontend/pages/hosts/ManageHostsPage/components/StatusCell/StatusCell.tsx rename to frontend/components/DataTable/StatusCell/StatusCell.tsx diff --git a/frontend/pages/hosts/ManageHostsPage/components/TextCell/TextCell.tsx b/frontend/components/DataTable/TextCell/TextCell.tsx similarity index 100% rename from frontend/pages/hosts/ManageHostsPage/components/TextCell/TextCell.tsx rename to frontend/components/DataTable/TextCell/TextCell.tsx diff --git a/frontend/pages/hosts/ManageHostsPage/components/DataTable/_styles.scss b/frontend/components/DataTable/_styles.scss similarity index 100% rename from frontend/pages/hosts/ManageHostsPage/components/DataTable/_styles.scss rename to frontend/components/DataTable/_styles.scss diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx new file mode 100644 index 0000000000..761ce1660c --- /dev/null +++ b/frontend/components/InfoBanner/InfoBanner.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import classNames from 'classnames'; + +const baseClass = 'info-banner'; + +interface IInfoBannerProps { + children: React.ReactNode; + className?: string; +} + +const InfoBanner = (props: IInfoBannerProps): JSX.Element => { + const { children, className } = props; + const wrapperClasses = classNames(baseClass, className); + + return ( +
+ {children} +
+ ); +}; + +export default InfoBanner; diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss new file mode 100644 index 0000000000..5f57ea7802 --- /dev/null +++ b/frontend/components/InfoBanner/_styles.scss @@ -0,0 +1,6 @@ +.info-banner { + padding: 16px; + border-radius: $border-radius; + border: 1px solid #D9D9FE; + background-color: $info; +} diff --git a/frontend/components/buttons/Button/index.js b/frontend/components/buttons/Button/index.js deleted file mode 100644 index 13ca5373f3..0000000000 --- a/frontend/components/buttons/Button/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Button.tsx'; diff --git a/frontend/components/buttons/Button/index.ts b/frontend/components/buttons/Button/index.ts new file mode 100644 index 0000000000..efe8c800c0 --- /dev/null +++ b/frontend/components/buttons/Button/index.ts @@ -0,0 +1 @@ +export { default } from './Button'; diff --git a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx deleted file mode 100644 index 667e7a32d7..0000000000 --- a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import Button from 'components/buttons/Button'; -import InputFieldWithIcon from 'components/forms/fields/InputFieldWithIcon'; -import Checkbox from 'components/forms/fields/Checkbox'; -import userInterface from 'interfaces/user'; -import validatePresence from 'components/forms/validators/validate_presence'; -import validEmail from 'components/forms/validators/valid_email'; - -const baseClass = 'invite-user-form'; - -class InviteUserForm extends Component { - static propTypes = { - serverErrors: PropTypes.shape({ - email: PropTypes.string, - base: PropTypes.string, - }), - invitedBy: userInterface, - onCancel: PropTypes.func, - onSubmit: PropTypes.func, - canUseSSO: PropTypes.bool, - }; - - constructor (props) { - super(props); - - this.state = { - errors: { - admin: null, - email: null, - name: null, - sso_enabled: null, - }, - formData: { - admin: false, - email: '', - name: '', - sso_enabled: false, - }, - }; - } - - componentWillReceiveProps ({ serverErrors }) { - const { errors } = this.state; - - if (this.props.serverErrors !== serverErrors) { - this.setState({ - errors: { - ...errors, - ...serverErrors, - }, - }); - } - } - - onInputChange = (formField) => { - return (value) => { - const { errors, formData } = this.state; - - this.setState({ - errors: { - ...errors, - [formField]: null, - }, - formData: { - ...formData, - [formField]: value, - }, - }); - - return false; - }; - } - - onCheckboxChange = (formField) => { - return (evt) => { - return this.onInputChange(formField)(evt); - }; - }; - - onFormSubmit = (evt) => { - evt.preventDefault(); - const valid = this.validate(); - - if (valid) { - const { formData: { admin, email, name, sso_enabled: ssoEnabled } } = this.state; - const { invitedBy, onSubmit } = this.props; - return onSubmit({ - admin, - email, - invited_by: invitedBy.id, - name, - sso_enabled: ssoEnabled, - }); - } - - return false; - } - - validate = () => { - const { - errors, - formData: { email }, - } = this.state; - - if (!validatePresence(email)) { - this.setState({ - errors: { - ...errors, - email: 'Email field must be completed', - }, - }); - - return false; - } - - if (!validEmail(email)) { - this.setState({ - errors: { - ...errors, - email: `${email} is not a valid email`, - }, - }); - - return false; - } - - return true; - } - - render () { - const { errors, formData: { admin, email, name, ssoEnabled } } = this.state; - const { onCancel, serverErrors } = this.props; - const { onFormSubmit, onInputChange, onCheckboxChange } = this; - const baseError = serverErrors.base; - - return ( -
- {baseError &&
{baseError}
} - - -
-

Admin

- - Enable Admin - -
-
-

Single sign on

- - Enable Single Sign On - -
- -
- - -
- - ); - } -} - -export default InviteUserForm; diff --git a/frontend/components/forms/InviteUserForm/_styles.scss b/frontend/components/forms/InviteUserForm/_styles.scss deleted file mode 100644 index bd1fe18481..0000000000 --- a/frontend/components/forms/InviteUserForm/_styles.scss +++ /dev/null @@ -1,39 +0,0 @@ -.invite-user-form { - margin-top: 24px; - - &__radio { - margin-top: 22px; - - .kolide-checkbox { - margin-top: 5px; - - &__label { - font-size: 16px; - font-weight: $regular; - color: $core-dark-blue-grey; - padding-left: 32px; - } - } - } - - &__role { - color: $core-black; - font-size: $x-small; - font-weight: $bold; - margin: 0; - } - - &__btn-wrap { - display: flex; - flex-direction: row-reverse; - } - - &__btn { - font-size: $small; - height: 38px; - margin-bottom: 5px; - margin-left: 15px; - padding: 0; - width: 120px; - } -} diff --git a/frontend/components/forms/InviteUserForm/index.js b/frontend/components/forms/InviteUserForm/index.js deleted file mode 100644 index 402aee1753..0000000000 --- a/frontend/components/forms/InviteUserForm/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './InviteUserForm'; diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx index e63c49a5b1..4f421b8b03 100644 --- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx +++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx @@ -13,6 +13,7 @@ class Dropdown extends Component { static propTypes = { className: PropTypes.string, clearable: PropTypes.bool, + searchable: PropTypes.bool, disabled: PropTypes.bool, error: PropTypes.string, label: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), @@ -29,6 +30,7 @@ class Dropdown extends Component { static defaultProps = { onChange: noop, clearable: false, + searchable: true, disabled: false, multi: false, name: 'targets', @@ -78,7 +80,7 @@ class Dropdown extends Component { render () { const { handleChange, renderOption } = this; - const { error, className, clearable, disabled, multi, name, options, placeholder, value, wrapperClassName } = this.props; + const { error, className, clearable, disabled, multi, name, options, placeholder, value, wrapperClassName, searchable } = this.props; const formFieldProps = pick(this.props, ['hint', 'label', 'error', 'name']); const selectClasses = classnames(className, `${baseClass}__select`, { @@ -92,6 +94,7 @@ class Dropdown extends Component { clearable={clearable} disabled={disabled} multi={multi} + searchable={searchable} name={`${name}-select`} onChange={handleChange} options={options} diff --git a/frontend/components/forms/fields/Radio/Radio.tsx b/frontend/components/forms/fields/Radio/Radio.tsx new file mode 100644 index 0000000000..01cb9aaa63 --- /dev/null +++ b/frontend/components/forms/fields/Radio/Radio.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import classnames from 'classnames'; + +const baseClass = 'radio'; + +interface IRadioProps { + label: string; + value: string; + id: string; + onChange: (value: string) => void; + checked?: boolean; + name?: string; + className?: string; + disabled?: boolean; +} + +const Radio = (props: IRadioProps): JSX.Element => { + const { className, id, name, value, checked, disabled, label, onChange } = props; + const wrapperClasses = classnames(baseClass, className); + + return ( + + ); +}; + +export default Radio; diff --git a/frontend/components/forms/fields/Radio/_styles.scss b/frontend/components/forms/fields/Radio/_styles.scss new file mode 100644 index 0000000000..ef61533a44 --- /dev/null +++ b/frontend/components/forms/fields/Radio/_styles.scss @@ -0,0 +1,55 @@ +// build with the help of this article, with some of our own modifications +// https://moderncss.dev/pure-css-custom-styled-radio-buttons/ + +.radio { + font-size: $x-small; + display: flex; + align-items: center; + + &__input { + display: flex; + + input { + opacity: 0; + width: 0; + height: 0; + margin: 0; + + & + .radio__control::before { + position: absolute; + content: ""; + width: 10px; + height: 10px; + box-shadow: inset 1em 1em $core-blue; + border-radius: 50%; + top: 5px; + left: 5px; + transition: 180ms transform ease-in-out; + transform: scale(0); + } + + &:checked + .radio__control::before { + transform: scale(1); + } + + &:focus + .radio__control { + border-color: $core-blue; + } + } + } + + &__control { + position: relative; + display: flex; + width: 20px; + height: 20px; + border-radius: 50%; + border: 2px solid $ui-borders; + transform: translateY(-0.05em); + } + + &__label { + margin-left: $pad-xsmall; + line-height: 1; + } +} diff --git a/frontend/components/forms/fields/Radio/index.ts b/frontend/components/forms/fields/Radio/index.ts new file mode 100644 index 0000000000..61c95671a5 --- /dev/null +++ b/frontend/components/forms/fields/Radio/index.ts @@ -0,0 +1 @@ +export { default } from './Radio'; diff --git a/frontend/components/forms/validators/valid_email/index.js b/frontend/components/forms/validators/valid_email/index.js deleted file mode 100644 index e775751de6..0000000000 --- a/frontend/components/forms/validators/valid_email/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './valid_email.ts'; diff --git a/frontend/components/forms/validators/valid_email/index.ts b/frontend/components/forms/validators/valid_email/index.ts new file mode 100644 index 0000000000..68057a1b8d --- /dev/null +++ b/frontend/components/forms/validators/valid_email/index.ts @@ -0,0 +1 @@ +export { default } from './valid_email'; diff --git a/frontend/components/forms/validators/validate_presence/index.js b/frontend/components/forms/validators/validate_presence/index.js deleted file mode 100644 index 009238e4bb..0000000000 --- a/frontend/components/forms/validators/validate_presence/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default (actual) => { - return !!actual; -}; diff --git a/frontend/components/forms/validators/validate_presence/index.ts b/frontend/components/forms/validators/validate_presence/index.ts new file mode 100644 index 0000000000..1a56e4081e --- /dev/null +++ b/frontend/components/forms/validators/validate_presence/index.ts @@ -0,0 +1,3 @@ +export default (actual: any) => { + return !!actual; +}; diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts new file mode 100644 index 0000000000..f23ccc972b --- /dev/null +++ b/frontend/interfaces/team.ts @@ -0,0 +1,7 @@ +interface ITeam { + name: string; + id: number; + role: string; +} + +export default ITeam; diff --git a/frontend/interfaces/user.js b/frontend/interfaces/user.ts similarity index 61% rename from frontend/interfaces/user.js rename to frontend/interfaces/user.ts index 7bb5e4e8c9..2fbda6e672 100644 --- a/frontend/interfaces/user.js +++ b/frontend/interfaces/user.ts @@ -11,3 +11,15 @@ export default PropTypes.shape({ position: PropTypes.string, username: PropTypes.string, }); + +export interface IUser { + admin: boolean, + email: string, + enabled: boolean, + force_password_reset: boolean, + gravatarURL: string, + id: number, + name: string, + position: string, + username: string, +} diff --git a/frontend/kolide/entities/hosts.js b/frontend/kolide/entities/hosts.js index 45e772e9fa..d3026b9263 100644 --- a/frontend/kolide/entities/hosts.js +++ b/frontend/kolide/entities/hosts.js @@ -19,6 +19,7 @@ export default (client) => { loadAll: (page = 0, perPage = 100, selected = '', globalFilter = '', sortBy = []) => { const { HOSTS, LABEL_HOSTS } = endpoints; + // TODO: add this query param logic to client class const pagination = `page=${page}&per_page=${perPage}`; let orderKeyParam = ''; diff --git a/frontend/kolide/entities/users.js b/frontend/kolide/entities/users.js index dd5ac3529d..a4d98dc8cb 100644 --- a/frontend/kolide/entities/users.js +++ b/frontend/kolide/entities/users.js @@ -37,10 +37,31 @@ export default (client) => { return client.authenticatedPost(endpoint, JSON.stringify({ enabled })) .then(response => helpers.addGravatarUrlToResource(response.user)); }, - loadAll: () => { + + // NOTE: this function signature is the same as entities/host#loadAll as this was quicker to just copy + // over. Ideally we'd want to remove the `selected` argument when we have more time, but for now + // is is left unused. + loadAll: (page = 0, perPage = 100, selected = '', globalFilter = '', sortBy = []) => { const { USERS } = endpoints; - return client.authenticatedGet(client._endpoint(USERS)) + // TODO: add this query param logic to client class + const pagination = `page=${page}&per_page=${perPage}`; + + let orderKeyParam = ''; + let orderDirection = ''; + if (sortBy.length !== 0) { + const sortItem = sortBy[0]; + orderKeyParam += `&order_key=${sortItem.id}`; + orderDirection = sortItem.desc ? '&order_direction=desc' : '&order_direction=asc'; + } + + let searchQuery = ''; + if (globalFilter !== '') { + searchQuery = `&query=${globalFilter}`; + } + + const userEndpoint = `${USERS}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}`; + return client.authenticatedGet(client._endpoint(userEndpoint)) .then((response) => { const { users } = response; @@ -91,4 +112,3 @@ export default (client) => { }, }; }; - diff --git a/frontend/kolide/entities/users.tests.js b/frontend/kolide/entities/users.tests.js index fa7da41df2..8099f0aa5c 100644 --- a/frontend/kolide/entities/users.tests.js +++ b/frontend/kolide/entities/users.tests.js @@ -63,6 +63,21 @@ describe('Kolide - API client (users)', () => { expect(request.isDone()).toEqual(true); }); }); + + it('calls the appropriate endpoint with the correct query params when passed multiple arguments', () => { + const request = userMocks.loadAll.validWithParams(bearerToken); + const page = 3; + const perPage = 100; + const selectedFilter = undefined; + const query = 'testQuery'; + const sortBy = [{ id: 'name', desc: true }]; + + Kolide.setBearerToken(bearerToken); + return Kolide.users.loadAll(page, perPage, selectedFilter, query, sortBy) + .then(() => { + expect(request.isDone()).toEqual(true); + }); + }); }); describe('#me', () => { @@ -172,4 +187,3 @@ describe('Kolide - API client (users)', () => { }); }); }); - diff --git a/frontend/pages/admin/OsqueryOptionsPage/OsqueryOptionsPage.jsx b/frontend/pages/admin/OsqueryOptionsPage/OsqueryOptionsPage.jsx index e951b6c64a..d143a22a54 100644 --- a/frontend/pages/admin/OsqueryOptionsPage/OsqueryOptionsPage.jsx +++ b/frontend/pages/admin/OsqueryOptionsPage/OsqueryOptionsPage.jsx @@ -4,10 +4,11 @@ import { connect } from 'react-redux'; import { noop } from 'lodash'; import yaml from 'js-yaml'; +import { renderFlash } from 'redux/nodes/notifications/actions'; import osqueryOptionsActions from 'redux/nodes/osquery/actions'; import validateYaml from 'components/forms/validators/validate_yaml'; import OsqueryOptionsForm from 'components/forms/admin/OsqueryOptionsForm'; -import { renderFlash } from 'redux/nodes/notifications/actions'; +import InfoBanner from 'components/InfoBanner/InfoBanner'; import OpenNewTabIcon from '../../../../assets/images/open-new-tab-12x12@2x.png'; const baseClass = 'osquery-options'; @@ -65,7 +66,7 @@ export class OsqueryOptionsPage extends Component { return (

This file describes options returned to osquery when it checks for configuration.

-
+

See Fleet documentation for an example file that includes the overrides option.

-
+
{ const { currentUser, dispatch } = this.props; const { enableUser, updateAdmin, requirePasswordReset } = userActions; @@ -144,7 +134,7 @@ export class UserManagementPage extends Component { dispatch(inviteActions.silentCreate(formData)) .then(() => { - return this.toggleInviteUserModal(); + return this.toggleCreateUserModal(); }) .catch(() => false); } @@ -152,7 +142,7 @@ export class UserManagementPage extends Component { onInviteCancel = (evt) => { evt.preventDefault(); - return this.toggleInviteUserModal(); + return this.toggleCreateUserModal(); } onToggleEditUser = (user) => { @@ -171,6 +161,12 @@ export class UserManagementPage extends Component { this.setState({ usersEditing: updatedUsersEditing }); } + onSearchQueryChange = (newQuery) => { + this.setState({ + searchQuery: newQuery, + }); + } + goToAppConfigPage = (evt) => { evt.preventDefault(); @@ -180,60 +176,39 @@ export class UserManagementPage extends Component { dispatch(push(ADMIN_SETTINGS)); } - toggleInviteUserModal = () => { - const { showInviteUserModal } = this.state; + toggleCreateUserModal = () => { + const { showCreateUserModal } = this.state; this.setState({ - showInviteUserModal: !showInviteUserModal, + showCreateUserModal: !showCreateUserModal, }); return false; } - renderUserRow = (user, idx, options = { invite: false }) => { - const { currentUser, userErrors } = this.props; - const { invite } = options; - const { onEditUser, onToggleEditUser, onUserActionSelect } = this; - const { usersEditing } = this.state; - const isEditing = includes(usersEditing, user.id); - - return ( - - ); - } - renderModal = () => { const { currentUser, inviteErrors } = this.props; - const { showInviteUserModal } = this.state; - const { onInviteCancel, onInviteUserSubmit, toggleInviteUserModal } = this; + const { showCreateUserModal } = this.state; + const { onInviteCancel, onInviteUserSubmit, toggleCreateUserModal } = this; const ssoEnabledForApp = this.props.config.enable_sso; - if (!showInviteUserModal) { + if (!showCreateUserModal) { return false; } return ( - ); @@ -252,7 +227,7 @@ export class UserManagementPage extends Component { - SMTP is not currently configured in Fleet. The "Invite user" feature requires that SMTP is configured in order to send invitation emails. + SMTP is not currently configured in Fleet. The "Create User" feature requires that SMTP is configured in order to send invitation emails. +
+ + +
- {renderUserTable()} + { return null; }} + /> {renderModal()}
); @@ -327,25 +288,14 @@ export class UserManagementPage extends Component { } const mapStateToProps = (state) => { - const stateEntityGetter = entityGetter(state); const { config } = state.app; const { loading: appConfigLoading } = state.app; const { user: currentUser } = state.auth; - const { entities: users } = stateEntityGetter.get('users'); - const { entities: invites } = stateEntityGetter.get('invites'); - const { errors: inviteErrors, loading: loadingInvites } = state.entities.invites; - const { errors: userErrors, loading: loadingUsers } = state.entities.users; return { appConfigLoading, config, currentUser, - inviteErrors, - invites, - loadingInvites, - loadingUsers, - userErrors, - users, }; }; diff --git a/frontend/pages/admin/UserManagementPage/UserManagementPage.tests.jsx b/frontend/pages/admin/UserManagementPage/UserManagementPage.tests.jsx index 8da1935d42..d0c85d30ff 100644 --- a/frontend/pages/admin/UserManagementPage/UserManagementPage.tests.jsx +++ b/frontend/pages/admin/UserManagementPage/UserManagementPage.tests.jsx @@ -1,10 +1,8 @@ import React from 'react'; import { mount } from 'enzyme'; -import { noop } from 'lodash'; -import * as authActions from 'redux/nodes/auth/actions'; import { connectedComponent, reduxMockStore } from 'test/helpers'; -import ConnectedUserManagementPage, { UserManagementPage } from 'pages/admin/UserManagementPage/UserManagementPage'; +import ConnectedUserManagementPage from 'pages/admin/UserManagementPage/UserManagementPage'; import inviteActions from 'redux/nodes/entities/invites/actions'; import userActions from 'redux/nodes/entities/users/actions'; @@ -35,6 +33,7 @@ const store = { ...currentUser, }, }, + originalOrder: [1], }, invites: { loading: false, @@ -45,6 +44,7 @@ const store = { name: 'Other user', }, }, + originalOrder: [1], }, }, }; @@ -59,52 +59,8 @@ describe('UserManagementPage - component', () => { }); describe('rendering', () => { - it('does not render if invites are loading', () => { - const props = { - dispatch: noop, - config: {}, - currentUser, - invites: [], - loadingInvites: true, - loadingUsers: false, - users: [currentUser], - }; - const page = mount(); - - expect(page.html()).toBeFalsy(); - }); - - it('does not render if users are loading', () => { - const props = { - dispatch: noop, - config: {}, - currentUser, - invites: [], - loadingInvites: false, - loadingUsers: true, - users: [currentUser], - }; - const page = mount(); - - expect(page.html()).toBeFalsy(); - }); - - it('renders user blocks for users and invites', () => { - const mockStore = reduxMockStore(store); - const page = mount(connectedComponent(ConnectedUserManagementPage, { mockStore })); - - expect(page.find('UserRow').length).toEqual(2); - }); - - it('displays a count of the number of users & invites', () => { - const mockStore = reduxMockStore(store); - const page = mount(connectedComponent(ConnectedUserManagementPage, { mockStore })); - const count = page.find('.user-management__user-count'); - expect(count.text()).toContain('2 users'); - }); - it( - 'displays a disabled "Invite user" button if email is not configured', + 'displays a disabled "Create user" button if email is not configured', () => { const notConfiguredStore = { ...store, app: { config: { configured: false } } }; const notConfiguredMockStore = reduxMockStore(notConfiguredStore); @@ -160,48 +116,4 @@ describe('UserManagementPage - component', () => { expect(mockStore.getActions()).toContainEqual(goToAppSettingsAction); }, ); - - it('gets users on mount', () => { - const mockStore = reduxMockStore(store); - - mount(connectedComponent(ConnectedUserManagementPage, { mockStore })); - - expect(userActions.loadAll).toHaveBeenCalled(); - }); - - it('gets invites on mount', () => { - const mockStore = reduxMockStore(store); - - mount(connectedComponent(ConnectedUserManagementPage, { mockStore })); - - expect(inviteActions.loadAll).toHaveBeenCalled(); - }); - - describe('updating a user', () => { - const dispatch = () => Promise.resolve(); - const props = { dispatch, config: {}, currentUser, invites: [], users: [currentUser] }; - const pageNode = mount().instance(); - const updatedAttrs = { name: 'Updated Name' }; - - it('updates the current user with only the updated attributes', () => { - jest.spyOn(authActions, 'updateUser'); - - const updatedUser = { ...currentUser, ...updatedAttrs }; - - pageNode.onEditUser(currentUser, updatedUser); - - expect(authActions.updateUser).toHaveBeenCalledWith(currentUser, updatedAttrs); - }); - - it('updates a different user with only the updated attributes', () => { - jest.spyOn(userActions, 'silentUpdate'); - - const otherUser = { ...currentUser, id: currentUser.id + 1 }; - const updatedUser = { ...otherUser, ...updatedAttrs }; - - pageNode.onEditUser(otherUser, updatedUser); - - expect(userActions.silentUpdate).toHaveBeenCalledWith(otherUser, updatedAttrs); - }); - }); }); diff --git a/frontend/pages/admin/UserManagementPage/UsersTableConfig.tsx b/frontend/pages/admin/UserManagementPage/UsersTableConfig.tsx new file mode 100644 index 0000000000..176be2349f --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/UsersTableConfig.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import HeaderCell from 'components/DataTable/HeaderCell/HeaderCell'; +// import StatusCell from 'components/DataTable/StatusCell/StatusCell'; +import TextCell from 'components/DataTable/TextCell/TextCell'; +import { IUser } from 'interfaces/user'; + +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + } +} + +interface ICellProps { + cell: { + value: string; + }; + row: { + original: IUser; + }; +} + +interface IDataColumn { + title: string; + Header: ((props: IHeaderProps) => JSX.Element) | string; + accessor: string; + Cell: (props: ICellProps) => JSX.Element; + disableHidden?: boolean; + disableSortBy?: boolean; +} + +const usersTableHeaders: IDataColumn[] = [ + { + title: 'Name', + Header: cellProps => , + accessor: 'name', + Cell: cellProps => , + }, + // TODO: need to add this info to API + // { + // title: 'Status', + // Header: 'Status', + // accessor: 'status', + // Cell: cellProps => , + // }, + { + title: 'Email', + Header: cellProps => , + accessor: 'email', + Cell: cellProps => , + }, + // TODO: need to add this info to API + // { + // title: 'Teams', + // Header: cellProps => , + // accessor: 'osquery_version', + // Cell: cellProps => , + // }, + // TODO: need to add this info to API + // { + // title: 'Roles', + // Header: cellProps => , + // accessor: 'primary_ip', + // Cell: cellProps => , + // }, + // TODO: figure out this column accessor + // { + // title: 'Actions', + // Header: cellProps => , + // accessor: 'actions', + // Cell: cellProps => , + // }, +]; + +export default usersTableHeaders; diff --git a/frontend/pages/admin/UserManagementPage/_styles.scss b/frontend/pages/admin/UserManagementPage/_styles.scss index 6ac265fa50..c293093771 100644 --- a/frontend/pages/admin/UserManagementPage/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/_styles.scss @@ -82,9 +82,9 @@ margin: 0; } - &__add-user-wrap { + &__table-controls { display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; } @@ -94,12 +94,43 @@ clear: both; } - &__invite-modal { + &__create-user-modal { &.modal__modal_container { width: 540px; } } + &__edit-columns-button { + display: flex; + align-items: center; + font-size: $x-small; + color: $core-blue; + } + + &__search-input { + position: relative; + color: $core-dark-blue-grey; + width: 344px; + margin-left: $pad-medium; + + .user-management__input-wrapper { + margin-bottom: 0; + } + + .input-field { + padding-left: 42px; + width: 100%; + } + + .kolidecon { + position: absolute; + top: 10px; + left: 12px; + font-size: 20px; + color: $core-medium-blue-grey; + } + } + @include breakpoint(smalldesk) { &__position { display: none; diff --git a/frontend/pages/admin/UserManagementPage/components/CreateUserForm/CreateUserForm.tsx b/frontend/pages/admin/UserManagementPage/components/CreateUserForm/CreateUserForm.tsx new file mode 100644 index 0000000000..4aa8eab4d3 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/CreateUserForm/CreateUserForm.tsx @@ -0,0 +1,338 @@ +import React, { Component, FormEvent } from 'react'; + +import { IUser } from 'interfaces/user'; +import ITeam from 'interfaces/team'; +import Button from 'components/buttons/Button'; +import validatePresence from 'components/forms/validators/validate_presence'; +import validEmail from 'components/forms/validators/valid_email'; + +// ignore TS error for now until these are rewritten in ts. +// @ts-ignore +import InputFieldWithIcon from 'components/forms/fields/InputFieldWithIcon'; +// @ts-ignore +import Checkbox from 'components/forms/fields/Checkbox'; +// @ts-ignore +import Dropdown from 'components/forms/fields/Dropdown'; +import Radio from 'components/forms/fields/Radio'; +import InfoBanner from 'components/InfoBanner/InfoBanner'; +import SelectedTeamsForm from '../SelectedTeamsForm/SelectedTeamsForm'; +import OpenNewTabIcon from '../../../../../../assets/images/open-new-tab-12x12@2x.png'; + +const baseClass = 'create-user-form'; + +enum UserTeamType { + GlobalUser = 'GLOBAL_USER', + AssignTeams = 'ASSIGN_TEAMS', +} + +const globalUserRoles = [ + { + disabled: false, + label: 'admin', + value: 'admin', + }, + { + disabled: false, + label: 'observer', + value: 'observer', + }, + { + disabled: false, + label: 'maintainer', + value: 'maintainer', + }, +]; + +interface IFormData { + admin: boolean; + email: string; + name: string; + sso_enabled: boolean; + global_role?: string; + teams?: ITeam[]; + invited_by?: number; +} + +interface ISubmitData extends IFormData { + created_by: number +} + +interface ICreateUserFormProps { + createdBy: IUser; + onCancel: () => void; + onSubmit: (formData: ISubmitData) => void; + canUseSSO: boolean; + availableTeams: ITeam[]; +} + +interface ICreateUserFormState { + errors: { + admin: boolean | null; + email: string | null; + name: string | null; + sso_enabled: boolean | null; + }; + formData: IFormData, + isGlobalUser: boolean, +} + +class CreateUserForm extends Component { + constructor (props: ICreateUserFormProps) { + super(props); + + this.state = { + errors: { + admin: null, + email: null, + name: null, + sso_enabled: null, + }, + formData: { + admin: false, + email: '', + name: '', + sso_enabled: false, + global_role: undefined, + teams: undefined, + }, + isGlobalUser: false, + }; + } + + onInputChange = (formField: string): (value: string) => void => { + return (value: string) => { + const { errors, formData } = this.state; + + this.setState({ + errors: { + ...errors, + [formField]: null, + }, + formData: { + ...formData, + [formField]: value, + }, + }); + }; + } + + onCheckboxChange = (formField: string): (evt: string) => void => { + return (evt: string) => { + return this.onInputChange(formField)(evt); + }; + }; + + onIsGlobalUserChange = (value: string): void => { + const isGlobalUser = value === UserTeamType.GlobalUser; + this.setState({ + isGlobalUser, + }); + } + + onGlobalUserRoleChange = (value: string): void => { + const { formData } = this.state; + this.setState({ + formData: { + ...formData, + global_role: value, + }, + }); + } + + onSelectedTeamChange = (teams: ITeam[]): void => { + const { formData } = this.state; + this.setState({ + formData: { + ...formData, + teams, + }, + }); + } + + onFormSubmit = (evt: FormEvent): void => { + evt.preventDefault(); + const valid = this.validate(); + if (valid) { + const { formData: { admin, email, name, sso_enabled, global_role, teams } } = this.state; + const { createdBy, onSubmit } = this.props; + return onSubmit({ + admin, + email, + created_by: createdBy.id, + name, + sso_enabled, + global_role, + teams, + }); + } + } + + validate = (): boolean => { + const { + errors, + formData: { email }, + } = this.state; + + if (!validatePresence(email)) { + this.setState({ + errors: { + ...errors, + email: 'Email field must be completed', + }, + }); + + return false; + } + + if (!validEmail(email)) { + this.setState({ + errors: { + ...errors, + email: `${email} is not a valid email`, + }, + }); + + return false; + } + + return true; + } + + renderGlobalRoleForm = () => { + const { onGlobalUserRoleChange } = this; + const { formData: { global_role } } = this.state; + return ( + <> + +

Global users can only be members of the top level team and can manage or observe all users, entities, and settings in Fleet.

+ + Learn more about user permissions + open new tab + +
+

Role

+ + + ); + } + + + renderTeamsForm = (): JSX.Element => { + const { onSelectedTeamChange } = this; + return ( + <> + +

Users can be members of multiple teams and can only manage or observe team-sepcific users, entities, and settings in Fleet.

+ + Learn more about user permissions + open new tab + +
+ + + ); + } + + render (): JSX.Element { + const { errors, formData: { email, name, sso_enabled }, isGlobalUser } = this.state; + const { onCancel, availableTeams } = this.props; + const { onFormSubmit, onInputChange, onCheckboxChange, onIsGlobalUserChange, renderGlobalRoleForm, renderTeamsForm } = this; + + return ( +
+ {/* {baseError &&
{baseError}
} */} + + +
+ + Enable Single Sign On + +
+ +
+
+

Team

+ + +
+
+ {isGlobalUser ? renderGlobalRoleForm() : renderTeamsForm()} +
+
+ +
+ + +
+ + ); + } +} + +export default CreateUserForm; diff --git a/frontend/pages/admin/UserManagementPage/components/CreateUserForm/_styles.scss b/frontend/pages/admin/UserManagementPage/components/CreateUserForm/_styles.scss new file mode 100644 index 0000000000..c917406729 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/CreateUserForm/_styles.scss @@ -0,0 +1,70 @@ +.create-user-form { + margin-top: 24px; + + &__sso-input { + margin-top: 22px; + + .kolide-checkbox { + margin-top: 5px; + + &__label { + font-size: $x-small; + font-weight: $bold; + color: $core-dark-blue-grey; + padding-left: 32px; + } + } + } + + &__label { + color: $core-black; + font-size: $x-small; + font-weight: $bold; + margin-bottom: 4px; + } + + &__user-permissions-info { + display: flex; + flex-direction: column; + margin-bottom: $pad-xlarge; + + p { + margin: 0 0 $pad-medium 0; + } + + a { + color: $core-blue; + font-size: $x-small; + font-weight: $bold; + text-decoration: none; + } + + img { + width: 12px; + height: 12px; + margin-left: 6px; + } + } + + &__selected-teams-container { + margin-bottom: $pad-most; + } + + &__radio-input { + margin-bottom: $pad-medium; + } + + &__btn-wrap { + display: flex; + flex-direction: row-reverse; + } + + &__btn { + font-size: $small; + height: 38px; + margin-bottom: 5px; + margin-left: 15px; + padding: 0; + width: 120px; + } +} diff --git a/frontend/pages/admin/UserManagementPage/components/CreateUserForm/index.js b/frontend/pages/admin/UserManagementPage/components/CreateUserForm/index.js new file mode 100644 index 0000000000..4302dd4680 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/CreateUserForm/index.js @@ -0,0 +1 @@ +export { default } from './CreateUserForm'; diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx new file mode 100644 index 0000000000..c0e412e7e4 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; + +import ITeam from 'interfaces/team'; +// ignore TS error for now until these are rewritten in ts. +// @ts-ignore +import Checkbox from 'components/forms/fields/Checkbox'; +// @ts-ignore +import Dropdown from 'components/forms/fields/Dropdown'; + +interface ITeamCheckboxListItem extends ITeam { + isChecked: boolean | undefined; +} + +interface ISelectedTeamsFormProps { + availableTeams: ITeam[]; + usersCurrentTeams: ITeam[]; + onFormChange: (teams: ITeam[]) => void; +} + +const baseClass = 'selected-teams-form'; + +const roles = [ + { + disabled: false, + label: 'observer', + value: 'observer', + }, + { + disabled: false, + label: 'maintainer', + value: 'maintainer', + }, +]; + +const generateFormListItems = (allTeams: ITeam[], currentTeams: ITeam[]): ITeamCheckboxListItem[] => { + if (currentTeams.length === 0) { + return allTeams.map((team) => { + return { + ...team, + role: 'observer', + isChecked: false, + }; + }); + } + + // TODO: add functionality editing for selected teams. + return []; +}; + +// Handles the generation of the form data. This is eventually passed up to the parent +// so we only want to send the selected teams. The user can change the dropdown of an +// unselected item, but the parent will not track it as it only cares about selected items. +const generateSelectedTeamData = (teamsFormList: ITeamCheckboxListItem[]): ITeam[] => { + return teamsFormList.reduce((selectedTeams: ITeam[], teamItem) => { + if (teamItem.isChecked) { + selectedTeams.push({ + id: teamItem.id, + name: teamItem.name, + role: teamItem.role, + }); + } + return selectedTeams; + }, []); +}; + +// handles the updating of the form items. +// updates either selected state or the dropdown status of an item. +const updateFormState = (prevTeamItems: ITeamCheckboxListItem[], teamId: number, newValue: any, updateType: string): ITeamCheckboxListItem[] => { + const prevItemIndex = prevTeamItems.findIndex(item => item.id === teamId); + const prevItem = prevTeamItems[prevItemIndex]; + + if (updateType === 'checkbox') { + prevItem.isChecked = newValue; + } else { + prevItem.role = newValue; + } + + return [...prevTeamItems]; +}; + +const useSelectedTeamState = (allTeams: ITeam[], currentTeams: ITeam[], formChange: (teams: ITeam[]) => void) => { + const [teamsFormList, setTeamsFormList] = useState(() => { + return generateFormListItems(allTeams, currentTeams); + }); + + const updateSelectedTeams = (teamId: number, newValue: any, updateType: string) => { + setTeamsFormList((prevState) => { + const updatedTeamFormList = updateFormState(prevState, teamId, newValue, updateType); + const selectedTeamsData = generateSelectedTeamData(updatedTeamFormList); + formChange(selectedTeamsData); + return updatedTeamFormList; + }); + }; + + return [teamsFormList, updateSelectedTeams] as const; +}; + +const SelectedTeamsForm = (props: ISelectedTeamsFormProps): JSX.Element => { + const { availableTeams, usersCurrentTeams, onFormChange } = props; + const [teamsFormList, updateSelectedTeams] = useSelectedTeamState(availableTeams, usersCurrentTeams, onFormChange); + + return ( +
+
+ {teamsFormList.map((teamItem) => { + const { isChecked, name, role, id } = teamItem; + return ( +
+ updateSelectedTeams(teamItem.id, newValue, 'checkbox')} + > + {name} + + updateSelectedTeams(teamItem.id, newValue, 'dropdown')} + /> +
+ ); + })} +
+
+ ); +}; + +export default SelectedTeamsForm; diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss new file mode 100644 index 0000000000..f06a260d62 --- /dev/null +++ b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss @@ -0,0 +1,29 @@ +.selected-teams-form { + border: 1px solid $ui-borders; + border-radius: $border-radius; + background-color: $core-light-blue-grey; + padding: $pad-medium; + + &__team-item { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $pad-xsmall; + + &:last-child { + margin-bottom: 0; + } + + .form-field { + margin-bottom: 0; + } + } + + &__role-dropdown { + + .Select-control { + border: none; + width: 130px; + } + } +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx b/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx index eaa1975906..4bfccbc193 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx +++ b/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx @@ -42,6 +42,7 @@ const getHiddenColumns = (columns) => { const EditColumnsModal = (props) => { const { columns, hiddenColumns, onSaveColumns, onCancelColumns } = props; + const [columnItems, updateColumnItems] = useCheckboxListStateManagement(columns, hiddenColumns); return ( diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx index e0ab6fef89..84a8f6c258 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx @@ -1,18 +1,18 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import ReactTooltip from 'react-tooltip'; import labelInterface from 'interfaces/label'; import { getHostTableData } from 'redux/nodes/components/ManageHostsPage/actions'; import Button from 'components/buttons/Button'; import InputField from 'components/forms/fields/InputField'; import KolideIcon from 'components/icons/KolideIcon'; +import DataTable from 'components/DataTable/DataTable'; import Modal from 'components/modals/Modal'; import RoboDogImage from '../../../../../../assets/images/robo-dog-176x144@2x.png'; import EditColumnsIcon from '../../../../../../assets/images/icon-edit-columns-20x20@2x.png'; import { hostDataHeaders, defaultHiddenColumns } from './HostTableConfig'; -import DataTable from '../DataTable/DataTable'; + import EditColumnsModal from '../EditColumnsModal/EditColumnsModal'; const baseClass = 'host-container'; @@ -132,7 +132,7 @@ class HostContainer extends Component { edit columns icon Edit columns -
+
- - Search by hostname, UUID, serial number, or IPv4 -
{ return createRequestMock({ bearerToken, - endpoint: '/api/v1/fleet/users', + endpoint: '/api/v1/fleet/users?page=0&per_page=100', + method: 'get', + response: { users: [userStub] }, + }); + }, + validWithParams: (bearerToken) => { + return createRequestMock({ + bearerToken, + endpoint: '/api/v1/fleet/users?page=3&per_page=100&&order_key=name&order_direction=desc&query=testQuery', method: 'get', response: { users: [userStub] }, }); diff --git a/frontend/typings/index.d.ts b/frontend/typings/index.d.ts new file mode 100644 index 0000000000..7579f52918 --- /dev/null +++ b/frontend/typings/index.d.ts @@ -0,0 +1,9 @@ +/** + * A file that contains the custom typings for fleets own modules and libraries + */ + +// PNG assests +declare module '*.png' { + const value: any; + export = value; +} diff --git a/tsconfig.json b/tsconfig.json index 9d7696c70d..a7377dc6f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,8 @@ ], "exclude": [ "node_modules" + ], + "typeRoots": [ + "./node_modules/@types", "./typings" ] }