* AppSettingsPage at /admin/settings

* Adds App Settings to site nav items

* SMTP not configured warning

* Creates AppConfigForm

* Avatar preview

* API client to update app config

* Creates OrgLogoIcon component

* Hide username/password when no auth type
This commit is contained in:
Mike Stone 2016-12-23 13:40:16 -05:00 коммит произвёл GitHub
Родитель f092c614cf
Коммит ee6832c743
48 изменённых файлов: 1267 добавлений и 84 удалений

Просмотреть файл

@ -39,7 +39,7 @@ class Button extends React.Component<IButtonProps, IButtonState> {
render () {
const { handleClick } = this;
const { className, disabled, size, tabIndex, text, type, variant } = this.props;
const fullClassName = classnames(`${baseClass}--${variant}`, className, {
const fullClassName = classnames(baseClass, `${baseClass}--${variant}`, className, {
[baseClass]: variant !== 'unstyled',
[`${baseClass}--disabled`]: disabled,
[`${baseClass}--${size}`]: size,

Просмотреть файл

@ -121,6 +121,7 @@ $base-class: 'button';
background-color: transparent;
border: 0;
box-shadow: none;
cursor: pointer;
margin: 0;
padding: 0;
height: auto;
@ -131,6 +132,10 @@ $base-class: 'button';
top: 0;
}
&:focus {
outline: none;
}
&:hover {
background-color: transparent;
box-shadow: none;

Просмотреть файл

@ -0,0 +1,35 @@
import React, { PropTypes } from 'react';
import classnames from 'classnames';
const Slider = ({ onChange, value }) => {
const baseClass = 'slider-wrap';
const sliderBtnClass = classnames(
baseClass,
{ [`${baseClass}--active`]: value }
);
const sliderDotClass = classnames(
`${baseClass}__dot`,
{ [`${baseClass}__dot--active`]: value }
);
const handleClick = (evt) => {
evt.preventDefault();
return onChange(!value);
};
return (
<button className={`button button--unstyled ${sliderBtnClass}`} onClick={handleClick}>
<div className={sliderDotClass} />
</button>
);
};
Slider.propTypes = {
onChange: PropTypes.func,
value: PropTypes.bool,
};
export default Slider;

Просмотреть файл

@ -0,0 +1,37 @@
.slider-wrap {
&:hover {
background-color: $text-medium;
}
@include transition(background-color 400ms ease-in-out);
background-color: $text-medium;
border-radius: 12px;
border: 1px solid #eaeaea;
cursor: pointer;
display: inline-block;
height: 22px;
min-width: 40px;
position: relative;
width: 40px;
&--active {
background-color: $brand;
&:hover {
background-color: $brand;
}
}
&__dot {
@include transition(left 300ms ease-in-out);
@include size(14px);
@include position(absolute, 0 null null 5px);
margin-top: 3px;
border-radius: 50%;
background-color: $white;
&--active {
left: 21px;
}
}
}

Просмотреть файл

@ -0,0 +1,167 @@
.app-config-form {
&__section {
@include clearfix;
.smtp-options {
font-size: 15px;
font-weight: $bold;
line-height: 3.2;
letter-spacing: 0.6px;
color: $text-dark;
padding-left: 15px;
em {
font-style: normal;
}
&--configured {
em {
color: $success;
}
}
&--notconfigured {
em {
color: $alert;
}
}
}
}
&__inputs {
width: 60%;
float: left;
padding: 0 40px 0 0;
box-sizing: border-box;
.input-field {
width: 100%;
}
&--smtp {
margin: 0 0 $pad-base;
.input-field__wrapper {
width: 18%;
float: right;
&:first-child {
float: left;
width: 78%;
}
}
.kolide-checkbox {
clear: left;
display: block;
}
}
}
&__details {
float: right;
width: 40%;
p {
font-size: 15px;
font-weight: $normal;
line-height: 1.6;
letter-spacing: 0.5px;
color: $text-dark;
&.app-config-form__note {
font-size: 13px;
color: $text-medium;
}
}
.hint {
color: $text-medium;
&--brand {
color: $brand;
}
}
}
&__avatar-preview {
text-align: center;
img {
border-radius: 80px;
height: 160px;
width: 160px;
}
p {
color: $link;
font-size: 18px;
font-weight: $bold;
margin-top: 0;
}
}
&__show-options {
color: $text-dark;
}
&__smtp-section {
border-radius: 2px;
background-color: $bg-medium;
border: solid 1px $accent-medium;
padding: 30px;
.input-field__wrapper {
@include clearfix;
&:last-child {
margin: 0;
}
}
.input-field__label {
float: left;
font-size: 15px;
font-weight: $bold;
letter-spacing: 0.6px;
text-align: right;
color: $text-dark;
width: 30%;
margin: 0;
line-height: 40px;
@media (min-width: $medium-width + 1) and (max-width: 1195px) {
line-height: 18px;
}
}
.input-field,
.Select,
.slide-wrapper {
width: 60%;
float: right;
}
.slide-wrapper {
.slider-option {
font-size: 18px;
font-weight: $light;
letter-spacing: 1.3px;
text-align: left;
vertical-align: text-bottom;
&--off {
color: $text-light;
}
&--on {
color: $brand;
}
}
.button {
margin: 0 10px;
}
}
}
}

Просмотреть файл

@ -22,7 +22,15 @@ export default (WrappedComponent, { fields, validate = defaultValidate }) => {
const { errors, formData } = props;
if (!errors) {
this.state = { errors: {}, formData };
return false;
}
this.state = { errors, formData };
return false;
}
componentWillReceiveProps (nextProps) {
@ -81,9 +89,13 @@ export default (WrappedComponent, { fields, validate = defaultValidate }) => {
const { errors } = this.state;
const { errors: serverErrors } = this.props;
if (serverErrors) {
return errors[fieldName] || serverErrors[fieldName];
}
return errors[fieldName];
}
getFields = () => {
const { getError, getValue, onFieldChange } = this;
const fieldProps = {};

Просмотреть файл

@ -5,10 +5,10 @@ const baseClass = 'form-field';
class FormField extends Component {
static propTypes = {
children: PropTypes.element,
children: PropTypes.node,
className: PropTypes.string,
error: PropTypes.string,
hint: PropTypes.oneOfType([PropTypes.array, PropTypes.string]),
hint: PropTypes.oneOfType([PropTypes.array, PropTypes.node, PropTypes.string]),
label: PropTypes.oneOfType([PropTypes.array, PropTypes.string]),
name: PropTypes.string,
type: PropTypes.string,

Просмотреть файл

@ -4,87 +4,41 @@ import { mount } from 'enzyme';
import { noop } from 'lodash';
import AdminDetails from 'components/forms/RegistrationForm/AdminDetails';
import { fillInFormInput } from 'test/helpers';
import { fillInFormInput, itBehavesLikeAFormInputElement } from 'test/helpers';
describe('AdminDetails - form', () => {
afterEach(restoreSpies);
let form = mount(<AdminDetails handleSubmit={noop} />);
describe('username input', () => {
it('renders an input field', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const usernameField = form.find({ name: 'username' });
expect(usernameField.length).toEqual(1);
});
it('updates state when the field changes', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const usernameField = form.find({ name: 'username' }).find('input');
fillInFormInput(usernameField, 'Gnar');
expect(form.state().formData).toInclude({ username: 'Gnar' });
itBehavesLikeAFormInputElement(form, 'username');
});
});
describe('password input', () => {
it('renders an input field', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const passwordField = form.find({ name: 'password' });
expect(passwordField.length).toEqual(1);
});
it('updates state when the field changes', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const passwordField = form.find({ name: 'password' }).find('input');
fillInFormInput(passwordField, 'p@ssw0rd');
expect(form.state().formData).toInclude({ password: 'p@ssw0rd' });
itBehavesLikeAFormInputElement(form, 'password');
});
});
describe('password confirmation input', () => {
it('renders an input field', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const passwordConfirmationField = form.find({ name: 'password_confirmation' });
expect(passwordConfirmationField.length).toEqual(1);
});
it('updates state when the field changes', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const passwordConfirmationField = form.find({ name: 'password_confirmation' }).find('input');
fillInFormInput(passwordConfirmationField, 'p@ssw0rd');
expect(form.state().formData).toInclude({ password_confirmation: 'p@ssw0rd' });
itBehavesLikeAFormInputElement(form, 'password_confirmation');
});
});
describe('email input', () => {
it('renders an input field', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const emailField = form.find({ name: 'email' });
expect(emailField.length).toEqual(1);
});
it('updates state when the field changes', () => {
const form = mount(<AdminDetails handleSubmit={noop} />);
const emailField = form.find({ name: 'email' }).find('input');
fillInFormInput(emailField, 'hi@gnar.dog');
expect(form.state().formData).toInclude({ email: 'hi@gnar.dog' });
itBehavesLikeAFormInputElement(form, 'email');
});
});
describe('submitting the form', () => {
it('validates the email field', () => {
const onSubmitSpy = createSpy();
const form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
const submitBtn = form.find('Button');
@ -101,7 +55,7 @@ describe('AdminDetails - form', () => {
it('validates the email field', () => {
const onSubmitSpy = createSpy();
const form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
const emailField = form.find({ name: 'email' }).find('input');
const submitBtn = form.find('Button');
@ -114,7 +68,7 @@ describe('AdminDetails - form', () => {
it('validates the password fields match', () => {
const onSubmitSpy = createSpy();
const form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
const passwordConfirmationField = form.find({ name: 'password_confirmation' }).find('input');
const passwordField = form.find({ name: 'password' }).find('input');
const submitBtn = form.find('Button');
@ -131,7 +85,7 @@ describe('AdminDetails - form', () => {
it('submits the form when valid', () => {
const onSubmitSpy = createSpy();
const form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
form = mount(<AdminDetails handleSubmit={onSubmitSpy} />);
const emailField = form.find({ name: 'email' }).find('input');
const passwordConfirmationField = form.find({ name: 'password_confirmation' }).find('input');
const passwordField = form.find({ name: 'password' }).find('input');

Просмотреть файл

@ -73,9 +73,12 @@
color: $text-dark;
margin: 30px 0 0;
label {
.kolide-checkbox {
display: block;
position: relative;
&__label {
padding: 0;
}
}
p {

Просмотреть файл

@ -0,0 +1,228 @@
import React, { Component, PropTypes } from 'react';
import Button from 'components/buttons/Button';
import Checkbox from 'components/forms/fields/Checkbox';
import Dropdown from 'components/forms/fields/Dropdown';
import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field';
import Icon from 'components/icons/Icon';
import InputField from 'components/forms/fields/InputField';
import OrgLogoIcon from 'components/icons/OrgLogoIcon';
import Slider from 'components/forms/fields/Slider';
import validate from 'components/forms/admin/AppConfigForm/validate';
const authMethodOptions = [
{ label: 'Plain', value: 'authmethod_plain' },
{ label: 'Cram MD5', value: 'authmethod_cram_md5' },
];
const authTypeOptions = [
{ label: 'Username and Password', value: 'authtype_username_password' },
{ label: 'None', value: 'authtype_none' },
];
const baseClass = 'app-config-form';
const formFields = [
'authentication_method', 'authentication_type', 'domain', 'enable_ssl_tls', 'enable_start_tls',
'kolide_server_url', 'org_logo_url', 'org_name', 'password', 'port', 'sender_address',
'server', 'user_name', 'verify_ssl_certs',
];
const Header = ({ showAdvancedOptions }) => {
const CaratIcon = <Icon name={showAdvancedOptions ? 'downcarat' : 'upcarat'} />;
return <span>Advanced Options {CaratIcon} <small>You normally dont need to change these settings they are for special setups.</small></span>;
};
Header.propTypes = { showAdvancedOptions: PropTypes.bool.isRequired };
class AppConfigForm extends Component {
static propTypes = {
fields: PropTypes.shape({
authentication_method: formFieldInterface.isRequired,
authentication_type: formFieldInterface.isRequired,
domain: formFieldInterface.isRequired,
enable_ssl_tls: formFieldInterface.isRequired,
enable_start_tls: formFieldInterface.isRequired,
kolide_server_url: formFieldInterface.isRequired,
org_logo_url: formFieldInterface.isRequired,
org_name: formFieldInterface.isRequired,
password: formFieldInterface.isRequired,
port: formFieldInterface.isRequired,
sender_address: formFieldInterface.isRequired,
server: formFieldInterface.isRequired,
user_name: formFieldInterface.isRequired,
verify_ssl_certs: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func,
smtpConfigured: PropTypes.bool.isRequired,
};
constructor (props) {
super(props);
this.state = { showAdvancedOptions: false };
}
onToggleAdvancedOptions = (evt) => {
evt.preventDefault();
const { showAdvancedOptions } = this.state;
this.setState({ showAdvancedOptions: !showAdvancedOptions });
return false;
}
renderAdvancedOptions = () => {
const { fields } = this.props;
const { showAdvancedOptions } = this.state;
if (!showAdvancedOptions) {
return false;
}
return (
<div>
<div className={`${baseClass}__inputs`}>
<div className={`${baseClass}__smtp-section`}>
<InputField {...fields.domain} label="Domain" />
<Slider {...fields.verify_ssl_certs} label="Verify SSL Certs?" />
<Slider {...fields.enable_start_tls} label="Enable STARTTLS?" />
</div>
</div>
<div className={`${baseClass}__details`}>
<p><strong>Domain</strong> - If you need to specify a HELO domain, you can do it here <em className="hint hint--brand">(Default: <strong>Blank</strong>)</em></p>
<p><strong>Verify SSL Certs</strong> - Turn this off (not recommended) if you use a self-signed certificate <em className="hint hint--brand">(Default: <strong>On</strong>)</em></p>
<p><strong>Enable STARTTLS</strong> - Detects if STARTTLS is enabled in your SMTP server and starts to use it. <em className="hint hint--brand">(Default: <strong>On</strong>)</em></p>
</div>
</div>
);
}
renderSmtpSection = () => {
const { fields } = this.props;
if (fields.authentication_type.value === 'authtype_none') {
return false;
}
return (
<div className={`${baseClass}__smtp-section`}>
<InputField
{...fields.user_name}
label="SMTP Username"
/>
<InputField
{...fields.password}
label="SMTP Password"
/>
<Dropdown
{...fields.authentication_method}
label="Auth Method"
options={authMethodOptions}
placeholder=""
/>
</div>
);
}
render () {
const { fields, handleSubmit, smtpConfigured } = this.props;
const { onToggleAdvancedOptions, renderAdvancedOptions, renderSmtpSection } = this;
const { showAdvancedOptions } = this.state;
return (
<form className={baseClass} onSubmit={handleSubmit}>
<div className={`${baseClass}__section`}>
<h2>Organization Info</h2>
<div className={`${baseClass}__inputs`}>
<InputField
{...fields.org_name}
label="Organization Name"
/>
<InputField
{...fields.org_logo_url}
label="Organization Avatar"
/>
</div>
<div className={`${baseClass}__details ${baseClass}__avatar-preview`}>
<OrgLogoIcon src={fields.org_logo_url.value} />
<p>Avatar Preview</p>
</div>
</div>
<div className={`${baseClass}__section`}>
<h2>Kolide Web Address</h2>
<div className={`${baseClass}__inputs`}>
<InputField
{...fields.kolide_server_url}
label="Kolide App URL"
hint={<span>Include base path only (eg. no <code>/v1</code>)</span>}
/>
</div>
<div className={`${baseClass}__details`}>
<p>What base URL should <strong>osqueryd</strong> clients user to connect and register with <strong>Kolide</strong>?</p>
<p className={`${baseClass}__note`}><strong>Note:</strong>Please ensure the URL you choose is accessible to all endpoints that need to communicate with Kolide, otherwise they will not be able to correctly register.</p>
<Button text="SEND TEST" variant="inverse" />
</div>
</div>
<div className={`${baseClass}__section`}>
<h2>SMTP Options <small className={`smtp-options smtp-options--${smtpConfigured ? 'configured' : 'notconfigured'}`}>STATUS: <em>{smtpConfigured ? 'CONFIGURED' : 'NOT CONFIGURED'}</em></small></h2>
<div className={`${baseClass}__inputs`}>
<InputField
{...fields.sender_address}
label="Sender Address"
/>
</div>
<div className={`${baseClass}__details`}>
<p>The address email recipients will see all messages that are sent from the <strong>Kolide</strong> application.</p>
</div>
<div className={`${baseClass}__inputs ${baseClass}__inputs--smtp`}>
<InputField
{...fields.server}
label="SMTP Server"
/>
<InputField
{...fields.port}
label="&nbsp;"
/>
<Checkbox
{...fields.enable_ssl_tls}
>
User SSL/TLS to connect (recommended)
</Checkbox>
</div>
<div className={`${baseClass}__details`}>
<p>The hostname / IP address and corresponding port of your organization&apos;s SMTP server.</p>
</div>
<div className={`${baseClass}__inputs`}>
<Dropdown
{...fields.authentication_type}
label="Authentication Type"
options={authTypeOptions}
/>
{renderSmtpSection()}
</div>
<div className={`${baseClass}__details`}>
<p>If your mail server requires authentication, you need to specify the authentication type here.</p>
<p><strong>No Authentication</strong> - Select this if your SMTP is open.</p>
<p><strong>Username & Password</strong> - Select this if your SMTP server requires username and password before use.</p>
</div>
</div>
<div className={`${baseClass}__section`}>
<h2><a href="#advancedOptions" onClick={onToggleAdvancedOptions} className={`${baseClass}__show-options`}><Header showAdvancedOptions={showAdvancedOptions} /></a></h2>
{renderAdvancedOptions()}
</div>
<Button
text="UPDATE SETTINGS"
type="submit"
variant="brand"
/>
</form>
);
}
}
export default Form(AppConfigForm, {
fields: formFields,
validate,
});

Просмотреть файл

@ -0,0 +1,79 @@
import React from 'react';
import expect from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import AppConfigForm from 'components/forms/admin/AppConfigForm';
import { itBehavesLikeAFormInputElement } from 'test/helpers';
describe('AppConfigForm - form', () => {
const form = mount(<AppConfigForm handleSubmit={noop} />);
describe('Organization Name input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'org_name');
});
});
describe('Organization Avatar input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'org_logo_url');
});
});
describe('Kolide App URL input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'kolide_server_url');
});
});
describe('Sender Address input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'sender_address');
});
});
describe('SMTP Server input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'server');
});
});
describe('Port input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'port');
});
});
describe('Enable SSL/TLS input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'enable_ssl_tls', 'Checkbox');
});
});
describe('SMTP user name input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'user_name');
});
});
describe('SMTP user password input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'password');
});
});
describe('Advanced options', () => {
it('does not render advanced options by default', () => {
expect(form.find({ name: 'domain' }).length).toEqual(0);
expect(form.find('Slider').length).toEqual(0);
});
it('renders advanced options when "Advanced Options" is clicked', () => {
form.find('.app-config-form__show-options').simulate('click');
expect(form.find({ name: 'domain' }).length).toEqual(1);
expect(form.find('Slider').length).toEqual(2);
});
});
});

Просмотреть файл

@ -0,0 +1 @@
export default from './AppConfigForm';

Просмотреть файл

@ -0,0 +1,41 @@
import { size, some } from 'lodash';
export default (formData) => {
const errors = {};
const {
authentication_type: authType,
kolide_server_url: kolideServerUrl,
sender_address: smtpSenderAddress,
server: smtpServer,
user_name: smtpUserName,
password: smtpPassword,
} = formData;
if (!kolideServerUrl) {
errors.kolide_server_url = 'Kolide Server URL must be present';
}
if (some([smtpSenderAddress, smtpPassword, smtpServer, smtpUserName])) {
if (!smtpSenderAddress) {
errors.sender_address = 'SMTP Sender Address must be present';
}
if (!smtpServer) {
errors.server = 'SMTP Server must be present';
}
if (authType !== 'authtype_none') {
if (!smtpUserName) {
errors.user_name = 'SMTP Username must be present';
}
if (!smtpPassword) {
errors.password = 'SMTP Password must be present';
}
}
}
const valid = !size(errors);
return { valid, errors };
};

Просмотреть файл

@ -8,6 +8,7 @@
visibility: hidden;
margin: 0;
position: absolute;
z-index: -1;
&:checked + .kolide-checkbox__tick {
&::after {
@ -36,6 +37,7 @@
position: absolute;
left: 0;
display: inline-block;
float: left;
&::after {
@include transition(border 75ms ease-in-out, background 75ms ease-in-out);

Просмотреть файл

@ -26,6 +26,7 @@ class Dropdown extends Component {
static defaultProps = {
onChange: noop,
clearable: false,
name: 'targets',
placeholder: 'Select One...',
};
@ -68,7 +69,7 @@ class Dropdown extends Component {
<Select
className={`${baseClass}__select ${className}`}
clearable={clearable}
name={`${name}-select` || 'targets'}
name={`${name}-select`}
onChange={handleChange}
options={options}
placeholder={placeholder}

Просмотреть файл

@ -4,7 +4,8 @@ import { pick } from 'lodash';
import FormField from 'components/forms/FormField';
const Slider = ({ onChange, value, inactiveText = 'Off', activeText = 'On' }) => {
const Slider = (props) => {
const { onChange, value, inactiveText = 'Off', activeText = 'On' } = props;
const baseClass = 'kolide-slider';
const sliderBtnClass = classnames(
@ -23,7 +24,7 @@ const Slider = ({ onChange, value, inactiveText = 'Off', activeText = 'On' }) =>
return onChange(!value);
};
const formFieldProps = pick(this.props, ['hint', 'label', 'error', 'name']);
const formFieldProps = pick(props, ['hint', 'label', 'error', 'name']);
return (
<FormField {...formFieldProps} type="slider">

Просмотреть файл

@ -251,7 +251,7 @@ class QueryForm extends Component {
<Dropdown
options={platformOptions}
onChange={onFieldChange('platform')}
value={platform.value}
value={platform}
/>
);
}

Просмотреть файл

@ -0,0 +1,56 @@
import React, { Component, PropTypes } from 'react';
import kolideLogo from '../../../../assets/images/kolide-logo.svg';
class OrgLogoIcon extends Component {
static propTypes = {
src: PropTypes.string.isRequired,
};
static defaultProps = {
src: kolideLogo,
};
constructor (props) {
super(props);
this.state = { imageSrc: kolideLogo };
}
componentWillMount () {
const { src } = this.props;
this.setState({ imageSrc: src });
return false;
}
componentWillReceiveProps (nextProps) {
const { src } = nextProps;
this.setState({ imageSrc: src });
return false;
}
onError = () => {
this.setState({ imageSrc: kolideLogo });
return false;
}
render () {
const { imageSrc } = this.state;
const { onError } = this;
return (
<img
alt="Organization Logo"
onError={onError}
src={imageSrc}
/>
);
}
}
export default OrgLogoIcon;

Просмотреть файл

@ -0,0 +1,20 @@
import React from 'react';
import expect from 'expect';
import { mount } from 'enzyme';
import kolideLogo from '../../../../assets/images/kolide-logo.svg';
import OrgLogoIcon from './OrgLogoIcon';
describe('OrgLogoIcon - component', () => {
it('renders the Kolide Logo by default', () => {
const component = mount(<OrgLogoIcon />);
expect(component.state('imageSrc')).toEqual(kolideLogo);
});
it('renders the image source when it is valid', () => {
const component = mount(<OrgLogoIcon src="/assets/images/avatar.svg" />);
expect(component.state('imageSrc')).toEqual('/assets/images/avatar.svg');
});
});

Просмотреть файл

@ -0,0 +1 @@
export default from './OrgLogoIcon';

Просмотреть файл

@ -4,10 +4,27 @@ export default (admin) => {
icon: 'admin',
name: 'Admin',
location: {
regex: /^\/admin\/users/,
regex: /^\/admin/,
pathname: '/admin/users',
},
subItems: [],
subItems: [
{
icon: 'admin',
name: 'Manage Users',
location: {
regex: /\/admin\/users/,
pathname: '/admin/users',
},
},
{
icon: 'user-settings',
name: 'App Settings',
location: {
regex: /\/admin\/settings/,
pathname: '/admin/settings',
},
},
],
},
];

Просмотреть файл

@ -1,6 +1,19 @@
import { PropTypes } from 'react';
export default PropTypes.shape({
authentication_method: PropTypes.string,
authentication_type: PropTypes.string,
configured: PropTypes.bool,
domain: PropTypes.string,
enable_ssl_tls: PropTypes.bool,
enable_start_tls: PropTypes.bool,
kolide_server_url: PropTypes.string,
org_logo_url: PropTypes.string,
org_name: PropTypes.string,
password: PropTypes.string,
port: PropTypes.number,
sender_address: PropTypes.string,
server: PropTypes.string,
user_name: PropTypes.string,
verify_sll_certs: PropTypes.bool,
});

Просмотреть файл

@ -4,6 +4,6 @@ export default PropTypes.shape({
error: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.any,
value: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
});

Просмотреть файл

@ -32,6 +32,21 @@ const filterTarget = (targetType) => {
};
};
export const formatConfigDataForServer = (config) => {
const orgInfoAttrs = ['org_logo_url', 'org_name'];
const serverSettingsAttrs = ['kolide_server_url'];
const smtpSettingsAttrs = [
'authentication_method', 'authentication_type', 'email_enabled', 'enable_ssl_tls',
'enable_start_tls', 'password', 'port', 'sender_address', 'server', 'user_name', 'verify_ssl_certs',
];
return {
org_info: pick(config, orgInfoAttrs),
server_settings: pick(config, serverSettingsAttrs),
smtp_settings: pick(config, smtpSettingsAttrs),
};
};
export const formatSelectedTargetsForApi = (selectedTargets) => {
const targets = selectedTargets || [];
const hosts = flatMap(targets, filterTarget('hosts'));
@ -56,4 +71,4 @@ const setupData = (formData) => {
};
};
export default { addGravatarUrlToResource, formatSelectedTargetsForApi, labelSlug, setupData };
export default { addGravatarUrlToResource, formatConfigDataForServer, formatSelectedTargetsForApi, labelSlug, setupData };

Просмотреть файл

@ -1,5 +1,7 @@
import expect from 'expect';
import { omit } from 'lodash';
import { configStub } from 'test/stubs';
import helpers from 'kolide/helpers';
const label1 = { id: 1, target_type: 'labels' };
@ -15,6 +17,31 @@ describe('Kolide API - helpers', () => {
});
});
describe('#formatConfigDataForServer', () => {
const { formatConfigDataForServer } = helpers;
const config = {
org_name: 'Kolide',
org_logo_url: '0.0.0.0:8080/logo.png',
kolide_server_url: '',
configured: false,
sender_address: '',
server: '',
port: 587,
authentication_type: 'authtype_username_password',
user_name: '',
password: '',
enable_ssl_tls: true,
authentication_method: 'authmethod_plain',
verify_ssl_certs: true,
enable_start_tls: true,
email_enabled: false,
};
it('splits config into categories for the server', () => {
expect(formatConfigDataForServer(config)).toEqual(omit(configStub, ['smtp_settings.configured']));
});
});
describe('#formatSelectedTargetsForApi', () => {
const { formatSelectedTargetsForApi } = helpers;

Просмотреть файл

@ -80,8 +80,7 @@ class Kolide extends Base {
getConfig = () => {
const { CONFIG } = endpoints;
return this.authenticatedGet(this.endpoint(CONFIG))
.then((response) => { return response.org_info; });
return this.authenticatedGet(this.endpoint(CONFIG));
}
getInvites = () => {
@ -330,6 +329,13 @@ class Kolide extends Base {
return Base.post(this.endpoint(SETUP), JSON.stringify(setupData));
}
updateConfig = (formData) => {
const { CONFIG } = endpoints;
const configData = helpers.formatConfigDataForServer(formData);
return this.authenticatedPatch(this.endpoint(CONFIG), JSON.stringify(configData));
}
updateQuery = ({ id: queryID }, updateParams) => {
const { QUERIES } = endpoints;
const updateQueryEndpoint = `${this.baseURL}${QUERIES}/${queryID}`;

Просмотреть файл

@ -1,8 +1,9 @@
import expect from 'expect';
import nock from 'nock';
import Kolide from './index';
import mocks from '../test/mocks';
import Kolide from 'kolide';
import helpers from 'kolide/helpers';
import mocks from 'test/mocks';
const {
invalidForgotPasswordRequest,
@ -29,6 +30,7 @@ const {
validRevokeInviteRequest,
validRunQueryRequest,
validSetupRequest,
validUpdateConfigRequest,
validUpdateQueryRequest,
validUpdateUserRequest,
validUser,
@ -463,6 +465,39 @@ describe('Kolide - API client', () => {
});
});
describe('#updateConfig', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
const formData = {
org_name: 'Kolide',
org_logo_url: '0.0.0.0:8080/logo.png',
kolide_server_url: '',
configured: false,
sender_address: '',
server: '',
port: 587,
authentication_type: 'authtype_username_password',
user_name: '',
password: '',
enable_ssl_tls: true,
authentication_method: 'authmethod_plain',
verify_ssl_certs: true,
enable_start_tls: true,
email_enabled: false,
};
const configData = helpers.formatConfigDataForServer(formData);
const request = validUpdateConfigRequest(bearerToken, configData);
Kolide.setBearerToken(bearerToken);
Kolide.updateConfig(formData)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
describe('#updateQuery', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';

Просмотреть файл

@ -0,0 +1,78 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { size } from 'lodash';
import AppConfigForm from 'components/forms/admin/AppConfigForm';
import configInterface from 'interfaces/config';
import { renderFlash } from 'redux/nodes/notifications/actions';
import SmtpWarning from 'pages/Admin/AppSettingsPage/SmtpWarning';
import { updateConfig } from 'redux/nodes/app/actions';
export const baseClass = 'app-settings';
class AppSettingsPage extends Component {
static propTypes = {
appConfig: configInterface,
dispatch: PropTypes.func.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
};
constructor (props) {
super(props);
this.state = { showSmtpWarning: true };
}
onDismissSmtpWarning = () => {
this.setState({ showSmtpWarning: false });
return false;
}
onFormSubmit = (formData) => {
const { dispatch } = this.props;
dispatch(updateConfig(formData))
.then(() => {
dispatch(renderFlash('success', 'Settings updated!'));
});
return false;
}
render () {
const { appConfig, error } = this.props;
const { onDismissSmtpWarning, onFormSubmit } = this;
const { showSmtpWarning } = this.state;
const { configured: smtpConfigured } = appConfig;
const shouldShowWarning = !smtpConfigured && showSmtpWarning;
if (!size(appConfig)) {
return false;
}
return (
<div className={`${baseClass} body-wrap`}>
<h1>App Settings</h1>
<SmtpWarning
onDismiss={onDismissSmtpWarning}
shouldShowWarning={shouldShowWarning}
/>
<AppConfigForm
formData={appConfig}
errors={error}
handleSubmit={onFormSubmit}
smtpConfigured={smtpConfigured}
/>
</div>
);
}
}
const mapStateToProps = ({ app }) => {
const { config: appConfig, error } = app;
return { appConfig, error };
};
export default connect(mapStateToProps)(AppSettingsPage);

Просмотреть файл

@ -0,0 +1,58 @@
import expect from 'expect';
import { mount } from 'enzyme';
import AppSettingsPage from 'pages/Admin/AppSettingsPage';
import testHelpers from 'test/helpers';
const { connectedComponent, reduxMockStore } = testHelpers;
describe('AppSettingsPage - component', () => {
it('renders', () => {
const mockStore = reduxMockStore({ app: { config: {} } });
const page = mount(connectedComponent(AppSettingsPage, { mockStore }));
expect(page.find('AppSettingsPage').length).toEqual(1);
});
it('renders a warning if SMTP has not been configured', () => {
const storeWithoutSMTPConfig = { app: { config: { configured: false } } };
const mockStore = reduxMockStore(storeWithoutSMTPConfig);
const page = mount(
connectedComponent(AppSettingsPage, { mockStore })
).find('AppSettingsPage');
const smtpWarning = page.find('SmtpWarning');
expect(smtpWarning.length).toEqual(1);
expect(smtpWarning.find('Icon').length).toEqual(1);
expect(smtpWarning.text()).toInclude('Email is not currently configured in Kolide');
});
it('dismisses the smtp warning when "DISMISS" is clicked', () => {
const storeWithoutSMTPConfig = { app: { config: { configured: false } } };
const mockStore = reduxMockStore(storeWithoutSMTPConfig);
const page = mount(
connectedComponent(AppSettingsPage, { mockStore })
).find('AppSettingsPage');
const smtpWarning = page.find('SmtpWarning');
const dismissButton = smtpWarning.find('Button').first();
dismissButton.simulate('click');
expect(page.find('SmtpWarning').html()).toNotExist();
});
it('does not render a warning if SMTP has been configured', () => {
const storeWithoutSMTPConfig = { app: { config: { configured: true } } };
const mockStore = reduxMockStore(storeWithoutSMTPConfig);
const page = mount(
connectedComponent(AppSettingsPage, { mockStore })
).find('AppSettingsPage');
expect(page.find('SmtpWarning').html()).toNotExist();
});
});

Просмотреть файл

@ -0,0 +1,31 @@
import React, { PropTypes } from 'react';
import Button from 'components/buttons/Button';
import Icon from 'components/icons/Icon';
const baseClass = 'smtp-warning';
const SmtpWarning = ({ onDismiss, shouldShowWarning }) => {
if (!shouldShowWarning) {
return false;
}
return (
<div className={baseClass}>
<div className={`${baseClass}__icon-wrap`}>
<Icon name="warning-filled" />
<span className={`${baseClass}__label`}>Warning!</span>
</div>
<span className={`${baseClass}__text`}>Email is not currently configured in Kolide. Many features rely on email to work.</span>
<Button onClick={onDismiss} text="Dismiss" variant="unstyled" />
<Button text="Resolve" variant="unstyled" />
</div>
);
};
SmtpWarning.propTypes = {
onDismiss: PropTypes.func.isRequired,
shouldShowWarning: PropTypes.bool.isRequired,
};
export default SmtpWarning;

Просмотреть файл

@ -0,0 +1,77 @@
.app-settings {
padding: 30px;
h1 {
font-size: 24px;
font-weight: $light;
line-height: 1.33;
letter-spacing: 1px;
color: $text-dark;
margin: 0 0 22px;
}
h2 {
font-size: 20px;
font-weight: $normal;
line-height: 2.4;
letter-spacing: 0.8px;
color: $text-dark;
border-bottom: dashed 1px $accent-dark;
margin: 0 0 15px;
a {
text-decoration: none;
}
.kolidecon {
font-size: 8px;
margin: 0 20px;
vertical-align: 4px;
}
small {
font-size: 15px;
font-weight: $normal;
line-height: 1.6;
letter-spacing: 0.5px;
color: $text-dark;
}
}
}
.smtp-warning {
@include display(flex);
@include justify-content(space-between);
@include align-items(flex-start);
background-color: $alert;
color: $white;
padding: 15px 20px;
border-radius: 2px;
font-size: 15px;
margin: 0 0 25px;
&__icon-wrap {
min-width: 130px;
line-height: 20px;
}
&__label {
font-weight: $bold;
margin-left: 15px;
text-transform: uppercase;
}
&__text {
@include flex-grow(1);
line-height: 20px;
}
.button {
color: $white;
font-size: 15px;
text-decoration: underline;
font-weight: $light;
letter-spacing: 0.6px;
margin-left: 15px;
}
}

Просмотреть файл

@ -0,0 +1 @@
export default from './AppSettingsPage';

Просмотреть файл

@ -3,7 +3,7 @@ import classnames from 'classnames';
import Avatar from '../../../../components/Avatar';
import Dropdown from '../../../../components/forms/fields/Dropdown';
import EditUserForm from '../../../../components/forms/Admin/EditUserForm';
import EditUserForm from '../../../../components/forms/admin/EditUserForm';
import userInterface from '../../../../interfaces/user';
import { userStatusLabel } from './helpers';

Просмотреть файл

@ -1,4 +1,7 @@
import Kolide from '../../../kolide';
import Kolide from 'kolide';
import formatApiErrors from 'utilities/format_api_errors';
import { frontendFormattedConfig } from 'redux/nodes/app/helpers';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
export const CONFIG_START = 'CONFIG_START';
@ -25,9 +28,11 @@ export const getConfig = () => {
return Kolide.getConfig()
.then((config) => {
dispatch(configSuccess(config));
const formattedConfig = frontendFormattedConfig(config);
return config;
dispatch(configSuccess(formattedConfig));
return formattedConfig;
})
.catch((error) => {
dispatch(configFailure(error));
@ -36,3 +41,24 @@ export const getConfig = () => {
});
};
};
export const updateConfig = (configData) => {
return (dispatch) => {
dispatch(loadConfig);
return Kolide.updateConfig(configData)
.then((config) => {
const formattedConfig = frontendFormattedConfig(config);
dispatch(configSuccess(formattedConfig));
return formattedConfig;
})
.catch((error) => {
const formattedErrors = formatApiErrors(error);
dispatch(configFailure(formattedErrors));
return false;
});
};
};

Просмотреть файл

@ -1,9 +1,11 @@
import expect from 'expect';
import { CONFIG_START, CONFIG_SUCCESS, getConfig } from './actions';
import Kolide from '../../../kolide';
import { reduxMockStore } from '../../../test/helpers';
import { validGetConfigRequest } from '../../../test/mocks';
import { CONFIG_START, CONFIG_SUCCESS, getConfig, updateConfig } from 'redux/nodes/app/actions';
import { configStub } from 'test/stubs';
import { frontendFormattedConfig } from 'redux/nodes/app/helpers';
import Kolide from 'kolide';
import { reduxMockStore } from 'test/helpers';
import { validGetConfigRequest, validUpdateConfigRequest } from 'test/mocks';
describe('App - actions', () => {
describe('getConfig action', () => {
@ -39,4 +41,39 @@ describe('App - actions', () => {
.catch(done);
});
});
describe('updateConfig action', () => {
const store = reduxMockStore({});
const configFormData = frontendFormattedConfig(configStub);
it('calls the api update config endpoint', (done) => {
const bearerToken = 'abc123';
const request = validUpdateConfigRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
store.dispatch(updateConfig(configFormData))
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
it('dispatches CONFIG_START & CONFIG_SUCCESS actions', (done) => {
const bearerToken = 'abc123';
validUpdateConfigRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
store.dispatch(updateConfig(configFormData))
.then(() => {
const actions = store.getActions()
.map((action) => { return action.type; });
expect(actions).toInclude(CONFIG_START);
expect(actions).toInclude(CONFIG_SUCCESS);
done();
})
.catch(done);
});
});
});

Просмотреть файл

@ -0,0 +1,15 @@
export const frontendFormattedConfig = (config) => {
const {
org_info: orgInfo,
server_settings: serverSettings,
smtp_settings: smtpSettings,
} = config;
return {
...orgInfo,
...serverSettings,
...smtpSettings,
};
};
export default { frontendFormattedConfig };

Просмотреть файл

@ -0,0 +1,24 @@
import expect from 'expect';
import { configStub } from 'test/stubs';
import helpers from 'redux/nodes/app/helpers';
describe('redux app node - helpers', () => {
describe('#frontendFormattedConfig', () => {
const { frontendFormattedConfig } = helpers;
it('returns a flattened config object', () => {
const {
org_info: orgInfo,
server_settings: serverSettings,
smtp_settings: smtpSettings,
} = configStub;
expect(frontendFormattedConfig(configStub)).toEqual({
...orgInfo,
...serverSettings,
...smtpSettings,
});
});
});
});

Просмотреть файл

@ -3,6 +3,7 @@ import { browserHistory, IndexRoute, Route, Router } from 'react-router';
import { Provider } from 'react-redux';
import { syncHistoryWithStore } from 'react-router-redux';
import AdminAppSettingsPage from 'pages/Admin/AppSettingsPage';
import AdminUserManagementPage from 'pages/Admin/UserManagementPage';
import AllPacksPage from 'pages/packs/AllPacksPage';
import App from 'components/App';
@ -40,6 +41,7 @@ const routes = (
<IndexRoute component={HomePage} />
<Route path="admin" component={AuthenticatedAdminRoutes}>
<Route path="users" component={AdminUserManagementPage} />
<Route path="settings" component={AdminAppSettingsPage} />
</Route>
<Route path="hosts">
<Route path="new" component={NewHostPage} />

Просмотреть файл

@ -17,7 +17,7 @@ export const reduxMockStore = (store = {}) => {
};
export const connectedComponent = (ComponentClass, options = {}) => {
const { props = {}, mockStore = reduxMockStore() } = options;
const { mockStore = reduxMockStore(), props = {} } = options;
return (
<Provider store={mockStore}>

Просмотреть файл

@ -295,6 +295,16 @@ export const validSetupRequest = (formData) => {
.reply(200, {});
};
export const validUpdateConfigRequest = (bearerToken, configData) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.patch('/api/v1/kolide/config', JSON.stringify(configData))
.reply(200, {});
};
export const validUpdateQueryRequest = (bearerToken, query, formData) => {
return nock('http://localhost:8080', {
reqHeaders: {
@ -338,6 +348,7 @@ export default {
validRevokeInviteRequest,
validRunQueryRequest,
validSetupRequest,
validUpdateConfigRequest,
validUpdateQueryRequest,
validUpdateUserRequest,
validUser,

Просмотреть файл

@ -6,6 +6,30 @@ export const adminUserStub = {
username: 'gnardog',
};
export const configStub = {
org_info: {
org_name: 'Kolide',
org_logo_url: '0.0.0.0:8080/logo.png',
},
server_settings: {
kolide_server_url: '',
},
smtp_settings: {
configured: false,
sender_address: '',
server: '',
port: 587,
authentication_type: 'authtype_username_password',
user_name: '',
password: '',
enable_ssl_tls: true,
authentication_method: 'authmethod_plain',
verify_ssl_certs: true,
enable_start_tls: true,
email_enabled: false,
},
};
export const packStub = {
created_at: '0001-01-01T00:00:00Z',
updated_at: '0001-01-01T00:00:00Z',
@ -57,6 +81,7 @@ export const userStub = {
export default {
adminUserStub,
configStub,
packStub,
queryStub,
scheduledQueryStub,

Просмотреть файл

@ -0,0 +1,14 @@
export default (error) => {
if (!error.response || !error.response.errors) {
return undefined;
}
const { errors: errorsArray } = error.response;
const result = {};
errorsArray.forEach((errorObject) => {
result[errorObject.name] = errorObject.reason;
});
return result;
};

Просмотреть файл

@ -0,0 +1,27 @@
import expect from 'expect';
import formatApiErrors from 'utilities/format_api_errors';
describe('formatApiErrors', () => {
const errorStub = {
response: {
errors: [
{
name: 'email',
reason: 'is not the correct format',
},
{
name: 'kolide_server_url',
reason: 'must be present',
},
],
},
};
it('formats errors for the Form HOC', () => {
expect(formatApiErrors(errorStub)).toEqual({
email: 'is not the correct format',
kolide_server_url: 'must be present',
});
});
});

Просмотреть файл

@ -0,0 +1 @@
export default from './format_api_errors';