mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
6df3dfbf6d
commit
d0ded91d0b
46 changed files with 980 additions and 473 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
22
frontend/components/InfoBanner/InfoBanner.tsx
Normal file
22
frontend/components/InfoBanner/InfoBanner.tsx
Normal 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;
|
||||
6
frontend/components/InfoBanner/_styles.scss
Normal file
6
frontend/components/InfoBanner/_styles.scss
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.info-banner {
|
||||
padding: 16px;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid #D9D9FE;
|
||||
background-color: $info;
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Button.tsx';
|
||||
1
frontend/components/buttons/Button/index.ts
Normal file
1
frontend/components/buttons/Button/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Button';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './InviteUserForm';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
40
frontend/components/forms/fields/Radio/Radio.tsx
Normal file
40
frontend/components/forms/fields/Radio/Radio.tsx
Normal 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;
|
||||
55
frontend/components/forms/fields/Radio/_styles.scss
Normal file
55
frontend/components/forms/fields/Radio/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/components/forms/fields/Radio/index.ts
Normal file
1
frontend/components/forms/fields/Radio/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Radio';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './valid_email.ts';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './valid_email';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default (actual) => {
|
||||
return !!actual;
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default (actual: any) => {
|
||||
return !!actual;
|
||||
};
|
||||
7
frontend/interfaces/team.ts
Normal file
7
frontend/interfaces/team.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
interface ITeam {
|
||||
name: string;
|
||||
id: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export default ITeam;
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 "Invite user" feature requires that SMTP is configured in order to send invitation emails.</span>
|
||||
<span>SMTP is not currently configured in Fleet. The "Create User" 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
76
frontend/pages/admin/UserManagementPage/UsersTableConfig.tsx
Normal file
76
frontend/pages/admin/UserManagementPage/UsersTableConfig.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CreateUserForm';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ const getHiddenColumns = (columns) => {
|
|||
|
||||
const EditColumnsModal = (props) => {
|
||||
const { columns, hiddenColumns, onSaveColumns, onCancelColumns } = props;
|
||||
|
||||
const [columnItems, updateColumnItems] = useCheckboxListStateManagement(columns, hiddenColumns);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
9
frontend/typings/index.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -11,5 +11,8 @@
|
|||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types", "./typings"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue