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
This commit is contained in:
Gabe Hernandez 2021-03-24 13:18:56 +00:00 committed by Zach Wasserman
parent 6df3dfbf6d
commit d0ded91d0b
46 changed files with 980 additions and 473 deletions

View file

@ -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.

View file

@ -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];
});

View file

@ -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 (
<div className={wrapperClasses}>
{children}
</div>
);
};
export default InfoBanner;

View file

@ -0,0 +1,6 @@
.info-banner {
padding: 16px;
border-radius: $border-radius;
border: 1px solid #D9D9FE;
background-color: $info;
}

View file

@ -1 +0,0 @@
export { default } from './Button.tsx';

View file

@ -0,0 +1 @@
export { default } from './Button';

View file

@ -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 (
<form onSubmit={onFormSubmit} className={baseClass}>
{baseError && <div className="form__base-error">{baseError}</div>}
<InputFieldWithIcon
autofocus
error={errors.name}
name="name"
onChange={onInputChange('name')}
placeholder="Name"
value={name}
/>
<InputFieldWithIcon
error={errors.email}
name="email"
onChange={onInputChange('email')}
placeholder="Email"
value={email}
/>
<div className={`${baseClass}__radio`}>
<p className={`${baseClass}__role`}>Admin</p>
<Checkbox
name="admin"
onChange={onCheckboxChange('admin')}
value={admin}
wrapperClassName={`${baseClass}__invite-admin`}
>
Enable Admin
</Checkbox>
</div>
<div className={`${baseClass}__radio`}>
<p className={`${baseClass}__role`}>Single sign on</p>
<Checkbox
name="sso_enabled"
onChange={onCheckboxChange('sso_enabled')}
value={ssoEnabled}
disabled={!this.props.canUseSSO}
wrapperClassName={`${baseClass}__invite-admin`}
>
Enable Single Sign On
</Checkbox>
</div>
<div className={`${baseClass}__btn-wrap`}>
<Button className={`${baseClass}__btn`} type="submit" variant="brand">
Invite
</Button>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
type="input"
variant="inverse"
>
Cancel
</Button>
</div>
</form>
);
}
}
export default InviteUserForm;

View file

@ -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;
}
}

View file

@ -1 +0,0 @@
export { default } from './InviteUserForm';

View file

@ -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}

View file

@ -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 (
<label htmlFor={id} className={wrapperClasses}>
<span className={`${baseClass}__input`}>
<input
type="radio"
id={id}
disabled={disabled}
name={name}
value={value}
checked={checked}
onChange={event => onChange(event.target.value)}
/>
<span className={`${baseClass}__control`} />
</span>
<span className={`${baseClass}__label`}>{label}</span>
</label>
);
};
export default Radio;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from './Radio';

View file

@ -1 +0,0 @@
export { default } from './valid_email.ts';

View file

@ -0,0 +1 @@
export { default } from './valid_email';

View file

@ -1,3 +0,0 @@
export default (actual) => {
return !!actual;
};

View file

@ -0,0 +1,3 @@
export default (actual: any) => {
return !!actual;
};

View file

@ -0,0 +1,7 @@
interface ITeam {
name: string;
id: number;
role: string;
}
export default ITeam;

View file

@ -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,
}

View file

@ -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 = '';

View file

@ -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) => {
},
};
};

View file

@ -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)', () => {
});
});
});

View file

@ -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 (
<div className={`${baseClass} body-wrap`}>
<p className={`${baseClass}__page-description`}>This file describes options returned to osquery when it checks for configuration.</p>
<div className={`${baseClass}__info-banner`}>
<InfoBanner className={`${baseClass}__config-docs`}>
<p>See Fleet documentation for an example file that includes the overrides option.</p>
<a
href="https://github.com/fleetdm/fleet/blob/master/docs/1-Using-Fleet/2-fleetctl-CLI.md#osquery-configuration-options"
@ -75,7 +76,7 @@ export class OsqueryOptionsPage extends Component {
Go to Fleet docs
<img src={OpenNewTabIcon} alt="open new tab" />
</a>
</div>
</InfoBanner>
<div className={`${baseClass}__form-wrapper`}>
<OsqueryOptionsForm
formData={formData}

View file

@ -14,14 +14,10 @@
padding-bottom: $pad-medium;
}
&__info-banner {
&__config-docs {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px;
border-radius: $border-radius;
border: 1px solid #D9D9FE;
background-color: $info;
margin-bottom: 16px;
p {

View file

@ -5,20 +5,22 @@ import { concat, includes, difference } from 'lodash';
import { push } from 'react-router-redux';
import Button from 'components/buttons/Button';
import InputField from 'components/forms/fields/InputField';
import KolideIcon from 'components/icons/KolideIcon';
import configInterface from 'interfaces/config';
import deepDifference from 'utilities/deep_difference';
import entityGetter from 'redux/utilities/entityGetter';
import inviteActions from 'redux/nodes/entities/invites/actions';
import inviteInterface from 'interfaces/invite';
import InviteUserForm from 'components/forms/InviteUserForm';
import Modal from 'components/modals/Modal';
import paths from 'router/paths';
import { renderFlash } from 'redux/nodes/notifications/actions';
import WarningBanner from 'components/WarningBanner';
import { updateUser } from 'redux/nodes/auth/actions';
import userActions from 'redux/nodes/entities/users/actions';
import UserRow from 'components/UserRow';
import userInterface from 'interfaces/user';
import DataTable from 'components/DataTable/DataTable';
import CreateUserForm from './components/CreateUserForm';
import usersTableHeaders from './UsersTableConfig';
const baseClass = 'user-management';
@ -32,35 +34,23 @@ export class UserManagementPage extends Component {
base: PropTypes.string,
email: PropTypes.string,
}),
invites: PropTypes.arrayOf(inviteInterface),
loadingInvites: PropTypes.bool,
loadingUsers: PropTypes.bool,
userErrors: PropTypes.shape({
base: PropTypes.string,
name: PropTypes.string,
username: PropTypes.string,
}),
users: PropTypes.arrayOf(userInterface),
};
constructor (props) {
super(props);
this.state = {
showInviteUserModal: false,
showCreateUserModal: false,
usersEditing: [],
searchQuery: '',
};
}
componentWillMount () {
const { dispatch } = this.props;
dispatch(userActions.loadAll());
dispatch(inviteActions.loadAll());
return false;
}
onUserActionSelect = (user, action) => {
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 (
<UserRow
isEditing={isEditing}
isInvite={invite}
isCurrentUser={currentUser.id === user.id}
key={`${user.email}-${idx}-${invite ? 'invite' : 'user'}`}
onEditUser={onEditUser}
onSelect={onUserActionSelect}
onToggleEditUser={onToggleEditUser}
user={user}
userErrors={userErrors}
/>
);
}
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 (
<Modal
title="Invite new user"
onExit={toggleInviteUserModal}
className={`${baseClass}__invite-modal`}
title="Create new user"
onExit={toggleCreateUserModal}
className={`${baseClass}__create-user-modal`}
>
<InviteUserForm
<CreateUserForm
serverErrors={inviteErrors}
invitedBy={currentUser}
createdBy={currentUser}
onCancel={onInviteCancel}
onSubmit={onInviteUserSubmit}
canUseSSO={ssoEnabledForApp}
availableTeams={currentUser.teams}
/>
</Modal>
);
@ -252,7 +227,7 @@ export class UserManagementPage extends Component {
<WarningBanner
shouldShowWarning={!config.configured}
>
<span>SMTP is not currently configured in Fleet. The &quot;Invite user&quot; feature requires that SMTP is configured in order to send invitation emails.</span>
<span>SMTP is not currently configured in Fleet. The &quot;Create User&quot; feature requires that SMTP is configured in order to send invitation emails.</span>
<Button
className={`${baseClass}__config-button`}
onClick={goToAppConfigPage}
@ -265,61 +240,47 @@ export class UserManagementPage extends Component {
);
}
renderUserTable = () => {
const { invites, users } = this.props;
const { renderUserRow } = this;
return (
<div className={`${baseClass}__wrapper`}>
<table className={`${baseClass}__table`}>
<thead>
<tr>
<th>Username</th>
<th>Status</th>
<th>Full Name</th>
<th>Email</th>
<th>Role</th>
<th className={`${baseClass}__position`}>Position</th>
<th />
</tr>
</thead>
<tbody>
{users.map((user, idx) => {
return renderUserRow(user, idx);
})}
{invites.map((user, idx) => {
return renderUserRow(user, idx, { invite: true });
})}
</tbody>
</table>
</div>
);
}
render () {
const { renderModal, renderSmtpWarning, renderUserTable, toggleInviteUserModal } = this;
const { config, loadingInvites, loadingUsers, users, invites } = this.props;
const resourcesCount = users.length + invites.length;
if (loadingInvites || loadingUsers) {
return false;
}
const { renderModal, renderSmtpWarning, toggleCreateUserModal, onSearchQueryChange } = this;
const { config } = this.props;
const { searchQuery } = this.state;
return (
<div className={`${baseClass} body-wrap`}>
<p className={`${baseClass}__page-description`}>Invite new users, customize user permissions, and disable users in Fleet.</p>
<p className={`${baseClass}__page-description`}>Create new users, customize user permissions, and remove users from Fleet.</p>
{renderSmtpWarning()}
<div className={`${baseClass}__add-user-wrap`}>
<p className={`${baseClass}__user-count`}>{resourcesCount} users</p>
{/* TODO: find a way to move these controls into the table component */}
<div className={`${baseClass}__table-controls`}>
<Button
className={'button button--brand'}
className={`${baseClass}__create-user-button`}
disabled={!config.configured}
onClick={toggleInviteUserModal}
onClick={toggleCreateUserModal}
title={config.configured ? 'Add User' : 'Email must be configured to add users'}
>
Invite user
Create user
</Button>
<div className={`${baseClass}__search-input`}>
<InputField
placeholder="Search"
name=""
onChange={onSearchQueryChange}
value={searchQuery}
inputWrapperClass={`${baseClass}__input-wrapper`}
/>
<KolideIcon name="search" />
</div>
</div>
{renderUserTable()}
<DataTable
searchQuery={searchQuery}
tableColumns={usersTableHeaders}
hiddenColumns={[]}
pageSize={100}
defaultSortHeader={'name'}
resultsName={'rows'}
fetchDataAction={userActions.loadAll}
entity={'users'}
emptyComponent={() => { return null; }}
/>
{renderModal()}
</div>
);
@ -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,
};
};

View file

@ -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(<UserManagementPage {...props} />);
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(<UserManagementPage {...props} />);
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(<UserManagementPage {...props} />).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);
});
});
});

View file

@ -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 => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'name',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
// TODO: need to add this info to API
// {
// title: 'Status',
// Header: 'Status',
// accessor: 'status',
// Cell: cellProps => <StatusCell value={cellProps.cell.value} />,
// },
{
title: 'Email',
Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
accessor: 'email',
Cell: cellProps => <TextCell value={cellProps.cell.value} />,
},
// TODO: need to add this info to API
// {
// title: 'Teams',
// Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
// accessor: 'osquery_version',
// Cell: cellProps => <TextCell value={cellProps.cell.value} />,
// },
// TODO: need to add this info to API
// {
// title: 'Roles',
// Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
// accessor: 'primary_ip',
// Cell: cellProps => <TextCell value={cellProps.cell.value} />,
// },
// TODO: figure out this column accessor
// {
// title: 'Actions',
// Header: cellProps => <HeaderCell value={cellProps.column.title} isSortedDesc={cellProps.column.isSortedDesc} />,
// accessor: 'actions',
// Cell: cellProps => <TextCell value={cellProps.cell.value} formatter={humanHostLastSeen} />,
// },
];
export default usersTableHeaders;

View file

@ -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;

View file

@ -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 <ICreateUserFormProps, ICreateUserFormState> {
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 (
<>
<InfoBanner className={`${baseClass}__user-permissions-info`}>
<p>Global users can only be members of the top level team and can manage or observe all users, entities, and settings in Fleet.</p>
<a
href="https://github.com/fleetdm/fleet/blob/master/docs/1-Using-Fleet/2-fleetctl-CLI.md#osquery-configuration-options"
target="_blank"
rel="noreferrer"
>
Learn more about user permissions
<img src={OpenNewTabIcon} alt="open new tab" />
</a>
</InfoBanner>
<p className={`${baseClass}__label`}>Role</p>
<Dropdown
value={global_role || 'admin'}
className={`${baseClass}__global-role-dropdown`}
options={globalUserRoles}
searchable={false}
onChange={onGlobalUserRoleChange}
/>
</>
);
}
renderTeamsForm = (): JSX.Element => {
const { onSelectedTeamChange } = this;
return (
<>
<InfoBanner className={`${baseClass}__user-permissions-info`}>
<p>Users can be members of multiple teams and can only manage or observe team-sepcific users, entities, and settings in Fleet.</p>
<a
href="https://github.com/fleetdm/fleet/blob/master/docs/1-Using-Fleet/2-fleetctl-CLI.md#osquery-configuration-options"
target="_blank"
rel="noreferrer"
>
Learn more about user permissions
<img src={OpenNewTabIcon} alt="open new tab" />
</a>
</InfoBanner>
<SelectedTeamsForm
availableTeams={[{ name: 'Test Team', id: 1, role: 'admin' }, { name: 'Test Team 2', id: 2, role: 'admin' }]}
usersCurrentTeams={[]}
onFormChange={onSelectedTeamChange}
/>
</>
);
}
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 (
<form onSubmit={onFormSubmit} className={baseClass}>
{/* {baseError && <div className="form__base-error">{baseError}</div>} */}
<InputFieldWithIcon
autofocus
error={errors.name}
name="name"
onChange={onInputChange('name')}
placeholder="Full Name"
value={name}
/>
<InputFieldWithIcon
error={errors.email}
name="email"
onChange={onInputChange('email')}
placeholder="Email"
value={email}
/>
<div className={`${baseClass}__sso-input`}>
<Checkbox
name="sso_enabled"
onChange={onCheckboxChange('sso_enabled')}
value={sso_enabled}
disabled={!this.props.canUseSSO}
wrapperClassName={`${baseClass}__invite-admin`}
>
Enable Single Sign On
</Checkbox>
</div>
<div className={`${baseClass}__selected-teams-container`}>
<div className={`${baseClass}__team-radios`}>
<p className={`${baseClass}__label`}>Team</p>
<Radio
className={`${baseClass}__radio-input`}
label={'Global user'}
id={'global-user'}
checked={isGlobalUser}
value={UserTeamType.GlobalUser}
name={'userTeamType'}
onChange={onIsGlobalUserChange}
/>
<Radio
className={`${baseClass}__radio-input`}
label={'Assign teams'}
id={'assign-teams'}
checked={!isGlobalUser}
value={UserTeamType.AssignTeams}
name={'userTeamType'}
onChange={onIsGlobalUserChange}
/>
</div>
<div className={`${baseClass}__teams-form-container`}>
{isGlobalUser ? renderGlobalRoleForm() : renderTeamsForm()}
</div>
</div>
<div className={`${baseClass}__btn-wrap`}>
<Button
className={`${baseClass}__btn`}
type="button"
variant="brand"
onClick={() => { return null; }}
>
Create
</Button>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="inverse"
>
Cancel
</Button>
</div>
</form>
);
}
}
export default CreateUserForm;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from './CreateUserForm';

View file

@ -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 (
<div className={baseClass}>
<div className={`${baseClass}__team-select-items`}>
{teamsFormList.map((teamItem) => {
const { isChecked, name, role, id } = teamItem;
return (
<div key={id} className={`${baseClass}__team-item`}>
<Checkbox
value={isChecked}
name={name}
onChange={(newValue: boolean) => updateSelectedTeams(teamItem.id, newValue, 'checkbox')}
>
{name}
</Checkbox>
<Dropdown
value={role}
className={`${baseClass}__role-dropdown`}
options={roles}
searchable={false}
onChange={(newValue: string) => updateSelectedTeams(teamItem.id, newValue, 'dropdown')}
/>
</div>
);
})}
</div>
</div>
);
};
export default SelectedTeamsForm;

View file

@ -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;
}
}
}

View file

@ -42,6 +42,7 @@ const getHiddenColumns = (columns) => {
const EditColumnsModal = (props) => {
const { columns, hiddenColumns, onSaveColumns, onCancelColumns } = props;
const [columnItems, updateColumnItems] = useCheckboxListStateManagement(columns, hiddenColumns);
return (

View file

@ -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 {
<img src={EditColumnsIcon} alt="edit columns icon" />
Edit columns
</Button>
<div data-for="search" className={`${baseClass}__search-input`}>
<div className={`${baseClass}__search-input`}>
<InputField
placeholder="Search hostname, UUID, serial number, or IPv4"
name=""
@ -142,9 +142,6 @@ class HostContainer extends Component {
/>
<KolideIcon name="search" />
</div>
<ReactTooltip place="bottom" type="dark" effect="solid" id="search" backgroundColor="#3e4771">
<span className={`${baseClass}__tooltip-text`}>Search by hostname, UUID, serial number, or IPv4</span>
</ReactTooltip>
</div>
<DataTable
selectedFilter={selectedFilter}

View file

@ -1,11 +1,11 @@
import React from 'react';
import { IHost } from '../../../../../interfaces/host';
import HeaderCell from '../HeaderCell/HeaderCell';
import LinkCell from '../LinkCell/LinkCell';
import StatusCell from '../StatusCell/StatusCell';
import TextCell from '../TextCell/TextCell';
import { humanHostMemory, humanHostUptime, humanHostLastSeen, humanHostDetailUpdated } from '../../../../../kolide/helpers';
import { IHost } from 'interfaces/host';
import HeaderCell from 'components/DataTable/HeaderCell/HeaderCell';
import LinkCell from 'components/DataTable/LinkCell/LinkCell';
import StatusCell from 'components/DataTable/StatusCell/StatusCell';
import TextCell from 'components/DataTable/TextCell/TextCell';
import { humanHostMemory, humanHostUptime, humanHostLastSeen, humanHostDetailUpdated } from 'kolide/helpers';
interface IHeaderProps {
column: {

View file

@ -4,6 +4,7 @@ $pad-small: px-to-rem(13);
$pad-medium: px-to-rem(16);
$pad-base: px-to-rem(18);
$pad-large: px-to-rem(24);
$pad-xlarge: px-to-rem(32);
$pad-half: px-to-rem(9);
$pad-none: 0;
$pad-most: px-to-rem(40);

View file

@ -59,7 +59,15 @@ export default {
valid: (bearerToken) => {
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] },
});

9
frontend/typings/index.d.ts vendored Normal file
View file

@ -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;
}

View file

@ -11,5 +11,8 @@
],
"exclude": [
"node_modules"
],
"typeRoots": [
"./node_modules/@types", "./typings"
]
}