License features (#1134)

* API client to create and get an app license

* Fixes unhandled promise rejection errors in redux config

* License Page and Form

* Adds getLicense action

* Adds License key area to App Settings Form

* Use license.token instead of license.license

* Implement API client

* Adds key icon to License Form

* Adds License Success component

* Render License Success on License Page when there is a license

* Adds persistent flash actions and reducer to redux

* Adds nag message middleware

* Moves FlashMessage component to flash_message directory

* Adds Persistent Flash component

* Renders Persistent Flash component from Core Layout

* Adds Kyle's styles

* Change license validation message

* Finishing touches for app config form license area

* Handle revoked licenses

* License Page hits setup endpoint

* Display server errors on license form

* Changes 0 allowed hosts to unlimited

* Trims JWT token before sending to the server

* GET setup page after submitting license
This commit is contained in:
Mike Stone 2017-02-09 22:16:51 -05:00 committed by Victor Vrantchan
parent ec1c3b3b94
commit e565e03130
58 changed files with 2017 additions and 48 deletions

34
assets/images/key.svg Normal file
View file

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">
<defs>
<style>
.cls-1 {
fill: #ed9700;
}
.cls-2 {
fill: #ffce00;
}
.cls-3 {
fill: #ffc200;
}
</style>
</defs>
<title>key</title>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M30.67,1.33A13.29,13.29,0,0,0,17.82,18.18l-3.15,3.15L16,22.67h-.17L1,37.33v5.33L2.67,44H8l1.33-1.17L8,39h2.67L12,37.33V35h3V32h5l2.67-2.67L24,30.67l3.15-3.15A13.33,13.33,0,1,0,30.67,1.33ZM33.33,16a4,4,0,1,1,4-4A4,4,0,0,1,33.33,16Z"/>
<path class="cls-2" d="M29.33,0A13.29,13.29,0,0,0,16.48,16.85L13.33,20l1.34,1.34h0L0,36v5.33L1.33,43H6.67L8,41.33,6.67,37H9.33L11,36V33h2V31h5.67l2.67-2.83,1.33,1.25,3.15-3.19A13.35,13.35,0,1,0,29.33,0ZM32,14.67a4,4,0,1,1,4-4A4,4,0,0,1,32,14.67Z"/>
<g>
<polygon class="cls-1" points="11 33 11 36 13.33 33 11 33"/>
<polygon class="cls-1" points="13 31 13 33.33 16 31 13 31"/>
<polygon class="cls-1" points="6.67 37 7.33 39 9.33 37 6.67 37"/>
<polygon class="cls-1" points="16 24 0.01 40 0 41.33 0.67 42 17.33 25.33 16 24"/>
<polygon class="cls-3" points="16 31 18.67 31 21.33 28.17 20 26.92 16 31"/>
<polygon class="cls-3" points="4 43 6.67 43 8 41.5 7.33 39.58 4 43"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="43px" height="40px" viewBox="0 0 43 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
<title>sign-up-pencil</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Kolide-Pre-Setup-2" transform="translate(-520.000000, -633.000000)">
<g id="Group-4" transform="translate(478.000000, 145.000000)">
<g id="Group-2" transform="translate(18.000000, 466.000000)">
<g id="sign-up-pencil" transform="translate(24.000000, 22.000000)">
<path d="M25,0.0005 L25,0.0005 L1.499,0.0005 C0.671,0.0005 0,0.6705 0,1.4985 L0,38.5015 C0,39.3295 0.671,40.0005 1.499,40.0005 L30.502,40.0005 C31.329,40.0005 32,39.3295 32,38.5015 L32,7.0005 L32,7.0005 L25,7.0005 L25,0.0005 Z" id="Fill-1" fill="#4A90E2"></path>
<polygon id="Fill-3" fill="#205492" points="25 7 32 7 25 0"></polygon>
<path d="M20,7 L4,7 C3.448,7 3,6.552 3,6 C3,5.448 3.448,5 4,5 L20,5 C20.552,5 21,5.448 21,6 C21,6.552 20.552,7 20,7" id="Fill-5" fill="#FFFFFF"></path>
<path d="M26,12 L4,12 C3.448,12 3,11.552 3,11 C3,10.448 3.448,10 4,10 L26,10 C26.553,10 27,10.448 27,11 C27,11.552 26.553,12 26,12" id="Fill-7" fill="#FFFFFF"></path>
<path d="M18,17 L4,17 C3.448,17 3,16.552 3,16 C3,15.448 3.448,15 4,15 L18,15 C18.552,15 19,15.448 19,16 C19,16.552 18.552,17 18,17" id="Fill-9" fill="#FFFFFF"></path>
<path d="M10,35 L4,35 C3.448,35 3,34.553 3,34 C3,33.447 3.448,33 4,33 L10,33 C10.552,33 11,33.447 11,34 C11,34.553 10.552,35 10,35" id="Fill-11" fill="#FFFFFF"></path>
<polygon id="Fill-13" fill="#FFFFFF" points="15 29 21 35 15 35"></polygon>
<polygon id="Fill-15" fill="#FFAD00" points="16 28 22 34 38 18 32 12"></polygon>
<path d="M41.584,11.5845 L38.416,8.4155 C37.634,7.6335 36.366,7.6335 35.584,8.4155 L33,11.0005 L39,17.0005 L41.584,14.4155 C42.366,13.6335 42.366,12.3665 41.584,11.5845" id="Fill-17" fill="#FF5850"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -41,9 +41,9 @@ export default (WrappedComponent) => {
}
render () {
const { isLoadingUser } = this.props;
const { currentUser, isLoadingUser } = this.props;
if (isLoadingUser) {
if (isLoadingUser || currentUser) {
return false;
}

View file

@ -0,0 +1,43 @@
import React from 'react';
import moment from 'moment';
import Icon from 'components/icons/Icon';
import licenseInterface from 'interfaces/license';
import key from '../../../assets/images/key.svg';
const baseClass = 'license-success';
const LicenseSuccess = ({ license }) => {
const { allowed_hosts: allowedHosts, expiry } = license;
const expiryMoment = moment(expiry);
const timeToExpiration = expiryMoment.toNow(true);
const hostText = allowedHosts.count === 1 ? 'Host' : 'Hosts';
return (
<div className={baseClass}>
<div className={`${baseClass}__container`}>
<h2><Icon name="success-check" />License Upload Successful!</h2>
<h3>
<img alt="Kolide License" className={`${baseClass}__key-img`} src={key} />
Kolide License Details:
</h3>
<h4>Current License Level:</h4>
<ul>
<li><Icon name="single-host" />{allowedHosts}&nbsp;{hostText}</li>
{timeToExpiration && <li><Icon name="clock" />Expires in {timeToExpiration}</li>}
</ul>
<a href="/setup" className="button button--success">
SETUP KOLIDE
</a>
</div>
</div>
);
};
LicenseSuccess.propTypes = {
license: licenseInterface.isRequired,
};
export default LicenseSuccess;

View file

@ -0,0 +1,18 @@
import React from 'react';
import expect from 'expect';
import { mount } from 'enzyme';
import { licenseStub } from 'test/stubs';
import LicenseSuccess from 'components/LicenseSuccess';
const defaultProps = {
license: licenseStub(),
};
describe('LicenseSuccess - component', () => {
describe('rendering', () => {
it('renders', () => {
expect(mount(<LicenseSuccess {...defaultProps} />).length).toEqual(1, 'Expected LicenseSuccess component to render');
});
});
});

View file

@ -0,0 +1,87 @@
.license-success {
@include display(flex);
@include align-content(center);
@include justify-content(center);
@include flex-grow(1);
padding: 20px;
h2 {
@include display(flex);
border-bottom: 1px solid $accent-medium;
color: #48c586;
font-size: 24px;
font-weight: $bold;
line-height: 50px;
margin-top: 0;
padding-bottom: 15px;
i {
font-size: 50px;
margin-right: 15px;
}
}
h3 {
@include display(flex);
color: $link;
font-size: 24px;
font-weight: $bold;
line-height: 43px;
margin-top: 0;
img {
height: 43px;
margin-right: 15px;
}
}
h4 {
color: #48c586;
font-size: 16px;
font-weight: $bold;
}
&__container {
@include align-self(center);
@include size(500px auto);
border-radius: 4px;
background-color: $white;
box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.3);
box-sizing: border-box;
padding: 25px 35px;
margin-top: -55px;
}
ul {
padding-left: 0;
text-decoration: none;
li {
@include display(flex);
font-size: 24px;
line-height: 48px;
margin-bottom: 30px;
i {
font-size: 48px;
margin-right: 18px;
}
}
}
a {
background-color: #48c586;
border-radius: 4px;
box-shadow: 0 6px 6px 0 rgba(32, 36, 50, 0.24), 0 6px 0 0 #1d9056;
display: block;
font-size: 24px;
font-weight: $bold;
height: 60px;
line-height: 60px;
&:hover {
background-color: #48c586;
}
}
}

View file

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

View file

@ -65,6 +65,12 @@ $base-class: 'button';
@include button-variant($alert);
}
&--muted {
@include button-variant(#eceef1, null, $footer-accent);
color: $footer-accent;
}
&--warning {
@include button-variant($warning);
}

View file

@ -0,0 +1,25 @@
import React, { PropTypes } from 'react';
import classnames from 'classnames';
import Icon from 'components/icons/Icon';
const baseClass = 'persistent-flash';
const PersistentFlash = ({ message }) => {
const klass = classnames(baseClass, `${baseClass}--error`);
return (
<div className={klass}>
<div className={`${baseClass}__content`}>
<Icon name="warning-filled" /> <span>{message}</span>
</div>
</div>
);
};
PersistentFlash.propTypes = {
message: PropTypes.string.isRequired,
};
export default PersistentFlash;

View file

@ -0,0 +1,42 @@
.wrapper {
> div {
&.persistent-flash {
height: 50px;
min-height: 50px;
max-height: 50px;
}
}
}
.persistent-flash {
@include display(flex);
@include align-items(center);
@include justify-content(center);
@include position(fixed, 0 0 null $nav-width);
color: $white;
padding: $pad-half;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
background-color: #3d4758;
min-width: $min-width;
@include breakpoint(smalldesk) {
left: $nav-tablet-width;
}
&__content {
@include flex-grow(1);
text-align: center;
i {
color: $alert;
font-size: 24px;
}
span {
margin-left: 15px;
margin-right: 15px;
}
}
}

View file

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

View file

@ -17,10 +17,101 @@
}
}
&__license-btn {
width: 200px;
&--reset {
margin-right: 23px;
}
}
&__license-detail {
&-icon {
i {
color: rgba(32, 36, 50, 0.54);
font-size: 48px;
margin-right: 23px;
}
}
&-text-wrapper {
i {
color: $alert;
font-size: 20px;
}
}
&-text {
color: rgba(32, 37, 50, 0.5);
font-size: 20px;
margin: 0;
}
&-warning {
color: $alert;
font-size: 15px;
font-weight: $bold;
margin: 0;
}
}
&__license-form {
float: right;
a {
float: right;
font-size: 15px;
line-height: 25px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h3 {
@include display(inline-flex);
color: #858495;
font-size: 20px;
font-weight: $normal;
line-height: 25px;
margin-top: 0;
img {
height: 25px;
margin-right: 14px;
}
}
textarea {
font-family: SourceCodePro, $monospace;
resize: none;
}
}
&__license-info {
float: left;
&-row {
@include display(flex);
margin-bottom: 25px;
}
}
&__section {
@include clearfix;
margin: 0 0 30px;
.app-config-form__license-input {
@include size(424px 160px);
background-color: $bg-medium;
border: 1px solid rgba(73, 143, 226, 0.24);
border-radius: 4px;
font-size: 15px;
}
.smtp-options {
font-size: 15px;
font-weight: $bold;

View file

@ -0,0 +1,64 @@
import React, { Component, PropTypes } from 'react';
import Button from 'components/buttons/Button';
import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field';
import InputField from 'components/forms/fields/InputField';
import validate from 'components/forms/LicenseForm/validate';
import freeTrial from '../../../../assets/images/sign-up-pencil.svg';
import key from '../../../../assets/images/key.svg';
const fields = ['license'];
const baseClass = 'license-form';
class LicenseForm extends Component {
static propTypes = {
baseError: PropTypes.string,
fields: PropTypes.shape({
license: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
};
render () {
const { baseError, fields: formFields, handleSubmit } = this.props;
return (
<form className={baseClass} onSubmit={handleSubmit}>
<div className={`${baseClass}__container`}>
<h2>
<img alt="Kolide License" className={`${baseClass}__key-img`} src={key} />
Kolide License
</h2>
{baseError && <div className="form__base-error">{baseError}</div>}
<InputField
{...formFields.license}
hint={<p className={`${baseClass}__help-text`}>Found under <a href="https://www.kolide.co/account">Account Settings</a> at Kolide.co</p>}
inputClassName={`${baseClass}__input`}
label="Enter License File"
type="textarea"
/>
<Button block className={`${baseClass}__upload-btn`} type="submit">
UPLOAD LICENSE
</Button>
<p className="form-field__label">Don&apos;t have a license?</p>
<p className={`${baseClass}__free-trial-text`}>Start a free trial of Kolide today!</p>
<a
className={`${baseClass}__free-trial-btn button button--unstyled`}
href="https://www.kolide.co/register"
>
<img
alt="Free trial"
src={freeTrial}
className={`${baseClass}__free-trial-img`}
/>
<span>Sign up for Free Kolide Trial</span>
</a>
</div>
</form>
);
}
}
export default Form(LicenseForm, { fields, validate });

View file

@ -0,0 +1,59 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import { fillInFormInput, itBehavesLikeAFormInputElement } from 'test/helpers';
import LicenseForm from 'components/forms/LicenseForm';
const defaultProps = {
handleSubmit: noop,
};
describe('LicenseForm - component', () => {
describe('rendering', () => {
it('renders', () => {
expect(mount(<LicenseForm {...defaultProps} />).length).toEqual(1);
});
});
describe('license input', () => {
const Form = mount(<LicenseForm {...defaultProps} />);
it('renders an input field', () => {
itBehavesLikeAFormInputElement(Form, 'license', 'textarea');
});
});
describe('submitting the form', () => {
afterEach(restoreSpies);
it('calls the handleSubmit prop when valid', () => {
const spy = createSpy();
const props = { handleSubmit: spy };
const Form = mount(<LicenseForm {...props} />);
const LicenseField = Form.find({ name: 'license' }).find('textarea');
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
fillInFormInput(LicenseField, jwtToken);
Form.simulate('submit');
expect(spy).toHaveBeenCalledWith({ license: jwtToken });
});
it('does not submit when invalid', () => {
const spy = createSpy();
const props = { handleSubmit: spy };
const Form = mount(<LicenseForm {...props} />);
const LicenseField = Form.find({ name: 'license' }).find('textarea');
const jwtToken = 'invalid.token';
fillInFormInput(LicenseField, jwtToken);
Form.simulate('submit');
expect(spy).toNotHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,99 @@
.license-form {
@include display(flex);
@include align-content(center);
@include justify-content(center);
@include flex-grow(1);
textarea {
resize: none;
}
&__key-img {
height: 43px;
margin-right: 15px;
}
h2 {
@include display(flex);
color: $link;
font-size: 24px;
font-weight: $bold;
line-height: 43px;
margin-top: 0;
}
&__container {
@include align-self(center);
@include size(500px auto);
border-radius: 4px;
background-color: $white;
box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.3);
box-sizing: border-box;
padding: 25px 35px;
margin-top: -55px;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__free-trial-btn {
@include display(flex);
@include align-items(center);
background-color: $white;
border: 1px solid $accent-light;
border-radius: 2px;
box-shadow: 0 3px 12px 0 rgba(49, 49, 93, 0.1), 0 3px 3px 0 rgba(0, 0, 0, 0.07);
height: 90px;
padding-left: 24px;
text-align: left;
text-transform: none;
&:hover {
background-color: $bg-light;
box-shadow: 0 3px 12px 0 rgba(49, 49, 93, 0.1), 0 3px 3px 0 rgba(0, 0, 0, 0.07);
}
span {
color: $link;
font-size: 18px;
margin-left: 26px;
}
}
&__free-trial-text {
font-size: 15px;
margin-top: 0;
}
&__help-text {
font-size: 15px;
line-height: 24px;
margin-top: 0;
span {
color: $link;
}
}
&__input {
background-color: $bg-light;
border-radius: 4px;
box-shadow: inset 0 0 5px 0 rgba(32, 36, 50, 0.24);
font-family: SourceCodePro, $monospace;
line-height: 28px;
width: 100%;
}
&__upload-btn {
@include button-variant(#48c586);
font-size: 18px;
height: 60px;
}
}

View file

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

View file

@ -0,0 +1,19 @@
import { size, trim } from 'lodash';
import validJwtToken from 'components/forms/validators/valid_jwt_token';
export default ({ license }) => {
const errors = {};
if (!license) {
errors.license = 'License must be present';
}
if (license && !validJwtToken(trim(license))) {
errors.license = 'License syntax is not valid. Please ensure you have entered the entire license. Please contact support@kolide.co if you need assistance';
}
const valid = !size(errors);
return { errors, valid };
};

View file

@ -0,0 +1,31 @@
import expect from 'expect';
import validate from 'components/forms/LicenseForm/validate';
describe('LicenseForm - validation', () => {
it('is valid given a valid license', () => {
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
const { errors, valid } = validate({ license: jwtToken });
expect(valid).toEqual(true);
expect(errors).toEqual({});
});
it('is not valid if the license is blank', () => {
const jwtToken = '';
const { errors, valid } = validate({ license: jwtToken });
expect(valid).toEqual(false);
expect(errors).toEqual({ license: 'License must be present' });
});
it('is not valid if the license is invalid', () => {
const jwtToken = 'KFBR392';
const { errors, valid } = validate({ license: jwtToken });
expect(valid).toEqual(false);
expect(errors).toEqual({
license: 'License syntax is not valid. Please ensure you have entered the entire license. Please contact support@kolide.co if you need assistance',
});
});
});

View file

@ -1,4 +1,5 @@
import React, { Component, PropTypes } from 'react';
import moment from 'moment';
import Button from 'components/buttons/Button';
import Checkbox from 'components/forms/fields/Checkbox';
@ -7,10 +8,13 @@ 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 licenseInterface from 'interfaces/license';
import OrgLogoIcon from 'components/icons/OrgLogoIcon';
import Slider from 'components/forms/fields/Slider';
import validate from 'components/forms/admin/AppConfigForm/validate';
import key from '../../../../../assets/images/key.svg';
const authMethodOptions = [
{ label: 'Plain', value: 'authmethod_plain' },
{ label: 'Cram MD5', value: 'authmethod_cram_md5' },
@ -22,8 +26,8 @@ const authTypeOptions = [
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', 'osquery_enroll_secret', 'password', 'port', 'sender_address',
'server', 'user_name', 'verify_ssl_certs',
'kolide_server_url', 'license', 'org_logo_url', 'org_name', 'osquery_enroll_secret', 'password',
'port', 'sender_address', 'server', 'user_name', 'verify_ssl_certs',
];
const Header = ({ showAdvancedOptions }) => {
const CaratIcon = <Icon name={showAdvancedOptions ? 'downcarat' : 'upcarat'} />;
@ -42,6 +46,7 @@ class AppConfigForm extends Component {
enable_ssl_tls: formFieldInterface.isRequired,
enable_start_tls: formFieldInterface.isRequired,
kolide_server_url: formFieldInterface.isRequired,
license: formFieldInterface.isRequired,
org_logo_url: formFieldInterface.isRequired,
org_name: formFieldInterface.isRequired,
password: formFieldInterface.isRequired,
@ -51,7 +56,9 @@ class AppConfigForm extends Component {
user_name: formFieldInterface.isRequired,
verify_ssl_certs: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func,
handleSubmit: PropTypes.func.isRequired,
handleUpdateLicense: PropTypes.func.isRequired,
license: licenseInterface.isRequired,
smtpConfigured: PropTypes.bool.isRequired,
};
@ -61,6 +68,14 @@ class AppConfigForm extends Component {
this.state = { revealSecret: false, showAdvancedOptions: false };
}
onResetLicense = (evt) => {
evt.preventDefault();
const { fields, license } = this.props;
return fields.license.onChange(license.token);
}
onToggleAdvancedOptions = (evt) => {
evt.preventDefault();
@ -81,6 +96,14 @@ class AppConfigForm extends Component {
return false;
}
onUpdateLicense = (evt) => {
evt.preventDefault();
const { fields, handleUpdateLicense } = this.props;
return handleUpdateLicense(fields.license.value);
}
renderAdvancedOptions = () => {
const { fields } = this.props;
const { showAdvancedOptions } = this.state;
@ -137,9 +160,13 @@ class AppConfigForm extends Component {
}
render () {
const { fields, handleSubmit, smtpConfigured } = this.props;
const { onToggleAdvancedOptions, onToggleRevealSecret, renderAdvancedOptions, renderSmtpSection } = this;
const { fields, handleSubmit, license, smtpConfigured } = this.props;
const { onToggleAdvancedOptions, onResetLicense, onToggleRevealSecret, onUpdateLicense, renderAdvancedOptions, renderSmtpSection } = this;
const { revealSecret, showAdvancedOptions } = this.state;
const expiryMoment = license && moment(license.expiry);
const hostWarning = license.hosts > license.allowed_hosts;
const expiryWarning = expiryMoment && expiryMoment.diff(moment(), 'days') <= 2;
const timeToExpiration = expiryMoment.toNow(true);
return (
<form className={baseClass} onSubmit={handleSubmit}>
@ -160,6 +187,57 @@ class AppConfigForm extends Component {
<p>Avatar Preview</p>
</div>
</div>
<div className={`${baseClass}__section`}>
<h2>Kolide License</h2>
<div className={`${baseClass}__license-info`}>
<div className={`${baseClass}__license-info-row`}>
<div className={`${baseClass}__license-detail-icon`}><Icon name="business" /></div>
<div className={`${baseClass}__license-detail-text-wrapper`}>
<p className={`${baseClass}__license-detail-text`}>{license.organization}</p>
{hostWarning && <p className={`${baseClass}__license-detail-warning`}>Exceeding Host Limit</p>}
</div>
</div>
<div className={`${baseClass}__license-info-row`}>
<div className={`${baseClass}__license-detail-icon`}><Icon name="single-host" /></div>
<div className={`${baseClass}__license-detail-text-wrapper`}>
<p className={`${baseClass}__license-detail-text`}>{license.hosts}/{license.allowed_hosts} Hosts {hostWarning && <Icon name="warning-filled" />}</p>
{hostWarning && <p className={`${baseClass}__license-detail-warning`}>Exceeding Host Limit</p>}
</div>
</div>
<div className={`${baseClass}__license-info-row`}>
<div className={`${baseClass}__license-detail-icon`}><Icon name="clock" /></div>
<div className={`${baseClass}__license-detail-text-wrapper`}>
<p className={`${baseClass}__license-detail-text`}>{timeToExpiration} {expiryWarning && <Icon name="warning-filled" />}</p>
{expiryWarning && <p className={`${baseClass}__license-detail-warning`}>Subscription Expiring Soon!</p>}
</div>
</div>
</div>
<div className={`${baseClass}__license-form`}>
<h3><img alt="License String" src={key} />License String</h3>
<a href="https://www.kolide.co/account" rel="noopener noreferrer" target="_blank">
View License Options <Icon name="external-link" />
</a>
<InputField
{...fields.license}
inputClassName={`${baseClass}__license-input`}
type="textarea"
/>
<Button
className={`${baseClass}__license-btn ${baseClass}__license-btn--reset`}
onClick={onResetLicense}
variant="muted"
>
CANCEL
</Button>
<Button
className={`${baseClass}__license-btn ${baseClass}__license-btn--save`}
onClick={onUpdateLicense}
variant="success"
>
SAVE CHANGES
</Button>
</div>
</div>
<div className={`${baseClass}__section`}>
<h2>Kolide Web Address</h2>
<div className={`${baseClass}__inputs`}>

View file

@ -5,9 +5,23 @@ import { noop } from 'lodash';
import AppConfigForm from 'components/forms/admin/AppConfigForm';
import { itBehavesLikeAFormInputElement } from 'test/helpers';
import { licenseStub } from 'test/stubs';
describe('AppConfigForm - form', () => {
const form = mount(<AppConfigForm handleSubmit={noop} smtpConfigured={false} />);
const defaultProps = {
formData: { org_name: 'Kolide' },
handleSubmit: noop,
handleUpdateLicense: noop,
license: licenseStub(),
smtpConfigured: false,
};
const form = mount(<AppConfigForm {...defaultProps} />);
describe('License input', () => {
it('renders an input field', () => {
itBehavesLikeAFormInputElement(form, 'license', 'textarea');
});
});
describe('Organization Name input', () => {
it('renders an input field', () => {

View file

@ -1,6 +1,7 @@
import { size, some } from 'lodash';
import { size, some, trim } from 'lodash';
import APP_CONSTANTS from 'app_constants';
import validJwtToken from 'components/forms/validators/valid_jwt_token';
const { APP_SETTINGS } = APP_CONSTANTS;
@ -9,6 +10,7 @@ export default (formData) => {
const {
authentication_type: authType,
kolide_server_url: kolideServerUrl,
license,
org_name: orgName,
password: smtpPassword,
sender_address: smtpSenderAddress,
@ -21,6 +23,14 @@ export default (formData) => {
errors.kolide_server_url = 'Kolide Server URL must be present';
}
if (!license) {
errors.license = 'License must be present';
}
if (license && !validJwtToken(trim(license))) {
errors.license = 'License is not a valid JWT token';
}
if (!orgName) {
errors.org_name = 'Organization Name must be present';
}

View file

@ -7,6 +7,7 @@ describe('AppConfigForm - validations', () => {
org_name: 'The Gnar Co.',
authentication_type: 'username_password',
kolide_server_url: 'https://gnar.dog',
license: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ',
sender_address: 'hi@gnar.dog',
server: '192.168.99.100',
port: 1025,
@ -18,6 +19,34 @@ describe('AppConfigForm - validations', () => {
expect(validate(validFormData)).toEqual({ valid: true, errors: {} });
});
it('validates presense of the license field', () => {
const invalidFormData = {
...validFormData,
license: '',
};
expect(validate(invalidFormData)).toEqual({
valid: false,
errors: {
license: 'License must be present',
},
});
});
it('validates the license is a JWT token', () => {
const invalidFormData = {
...validFormData,
license: 'KFBR392',
};
expect(validate(invalidFormData)).toEqual({
valid: false,
errors: {
license: 'License is not a valid JWT token',
},
});
});
it('validates presence of the org_name field', () => {
const invalidFormData = {
...validFormData,

View file

@ -0,0 +1,5 @@
const JWT_REGEX = /^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/;
export default (jwtToken) => {
return JWT_REGEX.test(jwtToken);
};

View file

@ -0,0 +1,10 @@
import { PropTypes } from 'react';
export default PropTypes.shape({
allowed_hosts: PropTypes.oneOfType([
PropTypes.string, PropTypes.number,
]),
expiry: PropTypes.string,
hosts: PropTypes.number,
license: PropTypes.string,
});

View file

@ -12,6 +12,7 @@ export default {
LABEL_HOSTS: (id) => {
return `/v1/kolide/labels/${id}/hosts`;
},
LICENSE: '/v1/kolide/license',
LOGIN: '/v1/kolide/login',
LOGOUT: '/v1/kolide/logout',
ME: '/v1/kolide/me',
@ -24,6 +25,7 @@ export default {
return `/v1/kolide/packs/${pack.id}/scheduled`;
},
SETUP: '/v1/setup',
SETUP_LICENSE: '/v1/license',
STATUS_LABEL_COUNTS: '/v1/kolide/host_summary',
TARGETS: '/v1/kolide/targets',
USERS: '/v1/kolide/users',

View file

@ -63,6 +63,12 @@ export const formatSelectedTargetsForApi = (selectedTargets, appendID = false) =
return { hosts, labels };
};
const parseLicense = (license) => {
const allowedHosts = license.allowed_hosts === 0 ? 'Unlimited' : license.allowed_hosts;
return { ...license, allowed_hosts: allowedHosts };
};
const setupData = (formData) => {
const orgInfo = pick(formData, ORG_INFO_ATTRS);
const adminInfo = pick(formData, ADMIN_ATTRS);
@ -79,4 +85,11 @@ const setupData = (formData) => {
};
};
export default { addGravatarUrlToResource, formatConfigDataForServer, formatSelectedTargetsForApi, labelSlug, setupData };
export default {
addGravatarUrlToResource,
formatConfigDataForServer,
formatSelectedTargetsForApi,
labelSlug,
parseLicense,
setupData,
};

View file

@ -1,7 +1,7 @@
import expect from 'expect';
import { omit } from 'lodash';
import { configStub } from 'test/stubs';
import { configStub, licenseStub } from 'test/stubs';
import helpers from 'kolide/helpers';
const label1 = { id: 1, target_type: 'labels' };
@ -10,6 +10,25 @@ const host1 = { id: 6, target_type: 'hosts' };
const host2 = { id: 5, target_type: 'hosts' };
describe('Kolide API - helpers', () => {
describe('#parseLicense', () => {
const { parseLicense } = helpers;
const validLicense = licenseStub();
it('returns Unlimited when allowed_hosts is 0', () => {
const unlimitedLicense = { ...validLicense, allowed_hosts: 0 };
expect(parseLicense(unlimitedLicense)).toEqual({
...validLicense,
allowed_hosts: 'Unlimited',
});
});
it('returns the allowed_hosts attribute when not 0', () => {
const limitedLicense = { ...validLicense, allowed_hosts: 2 };
expect(parseLicense(limitedLicense)).toEqual(limitedLicense);
});
});
describe('#labelSlug', () => {
it('creates a slug for the label', () => {
expect(helpers.labelSlug({ display_text: 'All Hosts' })).toEqual('all-hosts');

View file

@ -1,4 +1,4 @@
import { get, omit } from 'lodash';
import { get, omit, trim } from 'lodash';
import { appendTargetTypeToTargets } from 'redux/nodes/entities/targets/helpers';
import Base from 'kolide/base';
@ -120,6 +120,28 @@ class Kolide extends Base {
},
}
license = {
setup: (jwtToken) => {
const { SETUP_LICENSE } = endpoints;
return this.authenticatedPost(this.endpoint(SETUP_LICENSE), JSON.stringify({ license: trim(jwtToken) }))
.then(response => helpers.parseLicense(response.license));
},
create: (jwtToken) => {
const { LICENSE } = endpoints;
return this.authenticatedPost(this.endpoint(LICENSE), JSON.stringify({ license: trim(jwtToken) }))
.then(response => helpers.parseLicense(response.license));
},
load: () => {
const { LICENSE } = endpoints;
return this.authenticatedGet(this.endpoint(LICENSE))
.then(response => helpers.parseLicense(response.license));
},
}
queries = {
run: ({ query, selected }) => {
const { RUN_QUERY } = endpoints;

View file

@ -4,13 +4,22 @@ import nock from 'nock';
import Kolide from 'kolide';
import helpers from 'kolide/helpers';
import mocks from 'test/mocks';
import { configOptionStub, hostStub, packStub, queryStub, userStub, labelStub } from 'test/stubs';
import {
configOptionStub,
hostStub,
labelStub,
licenseStub,
packStub,
queryStub,
userStub,
} from 'test/stubs';
const {
invalidForgotPasswordRequest,
invalidResetPasswordRequest,
validChangePasswordRequest,
validCreateLabelRequest,
validCreateLicenseRequest,
validCreatePackRequest,
validCreateQueryRequest,
validCreateScheduledQueryRequest,
@ -25,6 +34,7 @@ const {
validGetConfigRequest,
validGetHostsRequest,
validGetInvitesRequest,
validGetLicenseRequest,
validGetQueriesRequest,
validGetQueryRequest,
validGetScheduledQueriesRequest,
@ -38,6 +48,7 @@ const {
validRevokeInviteRequest,
validRunQueryRequest,
validSetupRequest,
validSetupLicenseRequest,
validStatusLabelsGetCountsRequest,
validUpdateAdminRequest,
validUpdateConfigOptionsRequest,
@ -121,6 +132,119 @@ describe('Kolide - API client', () => {
});
});
describe('license', () => {
const validLicense = licenseStub();
describe('#create', () => {
it('calls the correct endpoint with the correct parameters', (done) => {
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
const request = validCreateLicenseRequest(bearerToken, jwtToken);
Kolide.setBearerToken(bearerToken);
Kolide.license.create(jwtToken)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(() => {
expect(request.isDone()).toEqual(true);
done();
});
});
it('changes 0 allowed_hosts to Unlimited', (done) => {
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
const unlimitedHosts = { ...validLicense, allowed_hosts: 0 };
validCreateLicenseRequest(bearerToken, jwtToken, unlimitedHosts);
Kolide.setBearerToken(bearerToken);
Kolide.license.create(jwtToken)
.then((response) => {
expect(response.allowed_hosts).toEqual('Unlimited', 'Expected there to be unlimited allowed hosts');
done();
})
.catch((response) => {
expect(response.allowed_hosts).toEqual('Unlimited', 'Expected there to be unlimited allowed hosts');
done();
});
});
});
describe('#load', () => {
it('calls the correct endpoint with the correct parameters', (done) => {
const request = validGetLicenseRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
Kolide.license.load()
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(() => {
expect(request.isDone()).toEqual(true);
done();
});
});
it('changes 0 allowed_hosts to Unlimited', (done) => {
const unlimitedHosts = { ...validLicense, allowed_hosts: 0 };
validGetLicenseRequest(bearerToken, unlimitedHosts);
Kolide.setBearerToken(bearerToken);
Kolide.license.load()
.then((response) => {
expect(response.allowed_hosts).toEqual('Unlimited', 'Expected there to be unlimited allowed hosts');
done();
})
.catch((response) => {
expect(response.allowed_hosts).toEqual('Unlimited', 'Expected there to be unlimited allowed hosts');
done();
});
});
});
describe('#setup', () => {
it('calls the correct endpoint with the correct parameters', (done) => {
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
const request = validSetupLicenseRequest(bearerToken, jwtToken);
Kolide.setBearerToken(bearerToken);
Kolide.license.setup(jwtToken)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(() => {
expect(request.isDone()).toEqual(true);
done();
});
});
it('changes 0 allowed_hosts to Unlimited', (done) => {
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
const unlimitedHosts = { ...validLicense, allowed_hosts: 0 };
validSetupLicenseRequest(bearerToken, jwtToken, unlimitedHosts);
Kolide.setBearerToken(bearerToken);
Kolide.license.setup(jwtToken)
.then((response) => {
expect(response.allowed_hosts).toEqual('Unlimited', 'Expected there to be unlimited allowed hosts');
done();
})
.catch((response) => {
expect(response.allowed_hosts).toEqual('Unlimited', 'Expected there to be unlimited allowed hosts');
done();
});
});
});
});
describe('configOptions', () => {
it('#loadAll', (done) => {
const request = validGetConfigOptionsRequest(bearerToken);

View file

@ -5,7 +5,8 @@ import { logoutUser } from 'redux/nodes/auth/actions';
import { push } from 'react-router-redux';
import configInterface from 'interfaces/config';
import FlashMessage from 'components/FlashMessage';
import FlashMessage from 'components/flash_messages/FlashMessage';
import PersistentFlash from 'components/flash_messages/PersistentFlash';
import SiteNavHeader from 'components/side_panels/SiteNavHeader';
import SiteNavSidePanel from 'components/side_panels/SiteNavSidePanel';
import userInterface from 'interfaces/user';
@ -20,6 +21,10 @@ export class CoreLayout extends Component {
user: userInterface,
fullWidthFlash: PropTypes.bool,
notifications: notificationInterface,
persistentFlash: PropTypes.shape({
showFlash: PropTypes.bool.isRequired,
message: PropTypes.string.isRequired,
}).isRequired,
};
onLogoutUser = () => {
@ -70,7 +75,7 @@ export class CoreLayout extends Component {
}
render () {
const { fullWidthFlash, notifications, children, config, user } = this.props;
const { fullWidthFlash, notifications, children, config, persistentFlash, user } = this.props;
const { onRemoveFlash, onUndoActionClick } = this;
if (!user) return false;
@ -96,6 +101,7 @@ export class CoreLayout extends Component {
/>
</nav>
<div className="core-wrapper">
{persistentFlash.showFlash && <PersistentFlash message={persistentFlash.message} />}
<FlashMessage
fullWidth={fullWidthFlash}
notification={notifications}
@ -114,14 +120,16 @@ const mapStateToProps = (state) => {
app: { config },
auth: { user },
notifications,
persistentFlash,
} = state;
const fullWidthFlash = !user;
return {
notifications,
fullWidthFlash,
config,
fullWidthFlash,
notifications,
persistentFlash,
user,
};
};

View file

@ -11,7 +11,15 @@ const {
} = helpers;
describe('CoreLayout - layouts', () => {
const store = { app: { config: {} }, auth: { user: userStub }, notifications: {} };
const store = {
app: { config: {} },
auth: { user: userStub },
notifications: {},
persistentFlash: {
showFlash: false,
message: '',
},
};
const mockStore = reduxMockStore(store);
it('renders the FlashMessage component when notifications are present', () => {
@ -25,6 +33,10 @@ describe('CoreLayout - layouts', () => {
isVisible: true,
message: 'nice jerb!',
},
persistentFlash: {
showFlash: false,
message: '',
},
};
const mockStoreWithNotifications = reduxMockStore(storeWithNotifications);
const componentWithFlash = connectedComponent(CoreLayout, {
@ -43,4 +55,23 @@ describe('CoreLayout - layouts', () => {
expect(appWithFlash.find('FlashMessage').html()).toExist();
expect(appWithoutFlash.find('FlashMessage').html()).toNotExist();
});
it('renders the PersistentFlash component when showFlash is true', () => {
const storeWithPersistentFlash = {
...store,
persistentFlash: {
showFlash: true,
message: 'This is the flash message',
},
};
const mockStoreWithPersistentFlash = reduxMockStore(storeWithPersistentFlash);
const Layout = connectedComponent(CoreLayout, {
mockStore: mockStoreWithPersistentFlash,
});
const MountedLayout = mount(Layout);
expect(MountedLayout.find('PersistentFlash').length).toEqual(1, 'Expected the Persistent Flash to be on the page');
});
});

View file

@ -1,10 +1,12 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { size } from 'lodash';
import { omit, size } from 'lodash';
import AppConfigForm from 'components/forms/admin/AppConfigForm';
import configInterface from 'interfaces/config';
import { createLicense, getLicense } from 'redux/nodes/auth/actions';
import deepDifference from 'utilities/deep_difference';
import licenseInterface from 'interfaces/license';
import { renderFlash } from 'redux/nodes/notifications/actions';
import SmtpWarning from 'components/SmtpWarning';
import { updateConfig } from 'redux/nodes/app/actions';
@ -16,6 +18,8 @@ class AppSettingsPage extends Component {
appConfig: configInterface,
dispatch: PropTypes.func.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
license: licenseInterface,
loadingLicense: PropTypes.bool,
};
constructor (props) {
@ -24,6 +28,14 @@ class AppSettingsPage extends Component {
this.state = { showSmtpWarning: true };
}
componentWillMount () {
const { dispatch } = this.props;
dispatch(getLicense());
return false;
}
onDismissSmtpWarning = () => {
this.setState({ showSmtpWarning: false });
@ -32,7 +44,8 @@ class AppSettingsPage extends Component {
onFormSubmit = (formData) => {
const { appConfig, dispatch } = this.props;
const diff = deepDifference(formData, appConfig);
const appConfigFormData = omit(formData, ['license']);
const diff = deepDifference(appConfigFormData, appConfig);
dispatch(updateConfig(diff))
.then(() => {
@ -51,17 +64,37 @@ class AppSettingsPage extends Component {
return false;
}
onUpdateLicense = (license) => {
const { dispatch, license: licenseProp } = this.props;
if (license === licenseProp.token) {
return false;
}
dispatch(createLicense({ license }))
.then(() => {
dispatch(renderFlash('success', 'License updated!'));
return false;
})
.catch(() => false);
return false;
}
render () {
const { appConfig, error } = this.props;
const { onDismissSmtpWarning, onFormSubmit } = this;
const { appConfig, error, license, loadingLicense } = this.props;
const { onDismissSmtpWarning, onFormSubmit, onUpdateLicense } = this;
const { showSmtpWarning } = this.state;
const { configured: smtpConfigured } = appConfig;
const shouldShowWarning = !smtpConfigured && showSmtpWarning;
if (!size(appConfig)) {
if (!size(appConfig) || loadingLicense) {
return false;
}
const formData = { ...appConfig, license: license.token };
return (
<div className={`${baseClass} body-wrap`}>
<h1>App Settings</h1>
@ -70,9 +103,11 @@ class AppSettingsPage extends Component {
shouldShowWarning={shouldShowWarning}
/>
<AppConfigForm
formData={appConfig}
serverErrors={error}
formData={formData}
handleSubmit={onFormSubmit}
handleUpdateLicense={onUpdateLicense}
license={license}
serverErrors={error}
smtpConfigured={smtpConfigured}
/>
</div>
@ -80,10 +115,11 @@ class AppSettingsPage extends Component {
}
}
const mapStateToProps = ({ app }) => {
const mapStateToProps = ({ app, auth }) => {
const { config: appConfig, error } = app;
const { license, loading: loadingLicense } = auth;
return { appConfig, error };
return { appConfig, error, license, loadingLicense };
};
export default connect(mapStateToProps)(AppSettingsPage);

View file

@ -2,21 +2,26 @@ import expect from 'expect';
import { mount } from 'enzyme';
import AppSettingsPage from 'pages/Admin/AppSettingsPage';
import { flatConfigStub, licenseStub } from 'test/stubs';
import testHelpers from 'test/helpers';
const { connectedComponent, reduxMockStore } = testHelpers;
const baseStore = {
app: { config: flatConfigStub },
auth: { license: licenseStub },
};
const storeWithoutSMTPConfig = { ...baseStore, app: { config: { ...flatConfigStub, configured: false } } };
const storeWithSMTPConfig = { ...baseStore, app: { config: { ...flatConfigStub, configured: true } } };
describe('AppSettingsPage - component', () => {
it('renders', () => {
const mockStore = reduxMockStore({ app: { config: {} } });
const mockStore = reduxMockStore(baseStore);
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 })
@ -30,8 +35,6 @@ describe('AppSettingsPage - component', () => {
});
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 })
@ -46,9 +49,7 @@ describe('AppSettingsPage - component', () => {
});
it('does not render a warning if SMTP has been configured', () => {
const storeWithoutSMTPConfig = { app: { config: { configured: true } } };
const mockStore = reduxMockStore(storeWithoutSMTPConfig);
const mockStore = reduxMockStore(storeWithSMTPConfig);
const page = mount(
connectedComponent(AppSettingsPage, { mockStore })
).find('AppSettingsPage');

View file

@ -0,0 +1,82 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { setupLicense } from 'redux/nodes/auth/actions';
import EnsureUnauthenticated from 'components/EnsureUnauthenticated';
import Footer from 'components/Footer';
import LicenseForm from 'components/forms/LicenseForm';
import licenseInterface from 'interfaces/license';
import LicenseSuccess from 'components/LicenseSuccess';
import { showBackgroundImage } from 'redux/nodes/app/actions';
import kolideLogo from '../../../assets/images/kolide-logo-condensed.svg';
const baseClass = 'license-page';
class LicensePage extends Component {
static propTypes = {
dispatch: PropTypes.func,
errors: PropTypes.shape({
base: PropTypes.string,
license: PropTypes.string,
}),
license: licenseInterface.isRequired,
};
componentWillMount () {
const { dispatch } = this.props;
dispatch(showBackgroundImage);
return false;
}
handleSubmit = ({ license }) => {
const { dispatch } = this.props;
dispatch(setupLicense({ license }))
.catch(() => false);
return false;
}
render () {
const { handleSubmit } = this;
const { errors, license } = this.props;
if (license.token) {
return (
<div className={baseClass}>
<img
alt="Kolide"
src={kolideLogo}
className={`${baseClass}__logo`}
/>
<LicenseSuccess license={license} />
<Footer />
</div>
);
}
return (
<div className={baseClass}>
<img
alt="Kolide"
src={kolideLogo}
className={`${baseClass}__logo`}
/>
<LicenseForm handleSubmit={handleSubmit} serverErrors={errors} />
<Footer />
</div>
);
}
}
const mapStateToProps = (state) => {
const { errors, license } = state.auth;
return { errors, license };
};
const ConnectedComponent = connect(mapStateToProps)(LicensePage);
export default EnsureUnauthenticated(ConnectedComponent);

View file

@ -0,0 +1,123 @@
import expect, { spyOn, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import helpers from 'test/helpers';
import Kolide from 'kolide';
import LicensePage from 'pages/LicensePage';
import stubs from 'test/stubs';
const {
connectedComponent,
reduxMockStore,
} = helpers;
const {
licenseStub,
userStub,
} = stubs;
describe('LicensePage - component', () => {
describe('rendering', () => {
it('renders the license success content when a license is present', () => {
const store = {
auth: {
license: licenseStub(),
loading: false,
user: null,
},
};
const Component = connectedComponent(LicensePage, {
mockStore: reduxMockStore(store),
});
expect(mount(Component).find('LicenseForm').length).toEqual(0, 'Expected the LicenseForm to not be on the page when a license is present');
expect(mount(Component).find('LicenseSuccess').length).toEqual(1, 'Expected the LicenseSuccess component to be on the page when a license is present');
});
it('renders when not authenticated', () => {
const store = {
auth: {
license: {},
loading: false,
user: null,
},
};
const Component = connectedComponent(LicensePage, {
mockStore: reduxMockStore(store),
});
expect(mount(Component).find('LicensePage').length).toEqual(1);
});
it('does not render when a user is logged in', () => {
const store = {
auth: {
license: {},
loading: false,
user: userStub,
},
};
const Component = connectedComponent(LicensePage, {
mockStore: reduxMockStore(store),
});
expect(mount(Component).find('LicensePage').length).toEqual(0);
});
it('does not render when loading the user', () => {
const store = {
auth: {
license: {},
loading: true,
user: null,
},
};
const Component = connectedComponent(LicensePage, {
mockStore: reduxMockStore(store),
});
expect(mount(Component).find('LicensePage').length).toEqual(0);
});
it('renders a LicenseForm when a license is not present', () => {
const store = {
auth: {
license: {},
loading: false,
user: null,
},
};
const Component = connectedComponent(LicensePage, {
mockStore: reduxMockStore(store),
});
expect(mount(Component).find('LicenseForm').length).toEqual(1, 'Expected the LicenseForm to be on the page');
});
});
describe('submitting the form', () => {
afterEach(restoreSpies);
it('calls the Kolide setup license endpoint', () => {
spyOn(Kolide.license, 'setup').andReturn(Promise.resolve());
const store = {
auth: {
license: {},
loading: false,
user: null,
},
};
const Component = connectedComponent(LicensePage, {
mockStore: reduxMockStore(store),
});
const Form = mount(Component).find('LicenseForm');
const LicenseField = Form.find({ name: 'license' }).find('textarea');
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
helpers.fillInFormInput(LicenseField, jwtToken);
Form.simulate('submit');
expect(Kolide.license.setup).toHaveBeenCalledWith(jwtToken);
});
});
});

View file

@ -0,0 +1,20 @@
.license-page {
@include display(flex);
@include justify-content(center);
@include flex-direction(column);
min-height: calc(100vh - #{$footer-height});
position: relative;
&__logo {
@include position(absolute, 15px null null 15px);
width: 200px;
@include breakpoint(tablet) {
width: 125px;
position: static;
display: block;
margin: 15px 0 0 15px;
}
}
}

View file

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

View file

@ -0,0 +1,12 @@
import moment from 'moment';
const shouldNagUser = ({ license }) => {
const { allowed_hosts: allowedHosts, expiry, hosts, revoked } = license;
const hostsOverenrolled = hosts > allowedHosts;
const licenseExpired = moment().isAfter(moment(expiry));
return hostsOverenrolled || licenseExpired || revoked;
};
export default { shouldNagUser };

View file

@ -0,0 +1,41 @@
import expect from 'expect';
import helpers from 'redux/middlewares/nag_message/helpers';
import { licenseStub } from 'test/stubs';
const validLicense = licenseStub();
describe('Nag message middleware - helpers', () => {
describe('#shouldNagUser', () => {
const { shouldNagUser } = helpers;
it('returns true when there are more hosts than allowed hosts', () => {
const overusedLicense = {
...validLicense,
allowed_hosts: 2,
hosts: 3,
};
expect(shouldNagUser({ license: overusedLicense })).toEqual(true);
});
it('returns true when the license is expired', () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const expiredLicense = { ...validLicense, expiry: yesterday.toISOString() };
expect(shouldNagUser({ license: expiredLicense })).toEqual(true, 'Expected the expired license to return true');
});
it('returns true when the license is revoked', () => {
const revokedLicense = { ...validLicense, revoked: true };
expect(shouldNagUser({ license: revokedLicense })).toEqual(true, 'Expected the revoked license to return true');
});
it('returns false when the license is valid', () => {
expect(shouldNagUser({ license: validLicense })).toEqual(false, 'Expected the valid license to return false');
});
});
});

View file

@ -0,0 +1,20 @@
/* eslint-disable no-unused-vars */
import actions from 'redux/nodes/persistent_flash/actions';
import helpers from 'redux/middlewares/nag_message/helpers';
import { LICENSE_FAILURE, LICENSE_SUCCESS } from 'redux/nodes/auth/actions';
const nagMessageMiddleware = store => next => (action) => {
const { type, payload } = action;
if (type === LICENSE_SUCCESS) {
if (helpers.shouldNagUser(payload)) {
store.dispatch(actions.showPersistentFlash('Please upgrade your Kolide license'));
} else {
store.dispatch(actions.hidePersistentFlash);
}
}
return next(action);
};
export default nagMessageMiddleware;

View file

@ -0,0 +1,69 @@
import expect from 'expect';
import { licenseSuccess } from 'redux/nodes/auth/actions';
import { licenseStub } from 'test/stubs';
import { reduxMockStore } from 'test/helpers';
const validLicense = licenseStub();
describe('nag_message - middleware', () => {
it('dispatches a persistent flash message when a license is overused', () => {
const mockStore = reduxMockStore();
const overusedLicense = {
...validLicense,
allowed_hosts: 2,
hosts: 3,
};
const expectedNagMessageAction = {
type: 'SHOW_PERSISTENT_FLASH',
payload: {
message: 'Please upgrade your Kolide license',
},
};
mockStore.dispatch(licenseSuccess(overusedLicense));
expect(mockStore.getActions()).toInclude(expectedNagMessageAction);
});
it('dispatches a persistent flash message when a license is expired', () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const mockStore = reduxMockStore();
const expiredLicense = { ...validLicense, expiry: yesterday.toISOString() };
const expectedNagMessageAction = {
type: 'SHOW_PERSISTENT_FLASH',
payload: {
message: 'Please upgrade your Kolide license',
},
};
mockStore.dispatch(licenseSuccess(expiredLicense));
expect(mockStore.getActions()).toInclude(expectedNagMessageAction);
});
it('dispatches a persistent flash message when a license is revoked', () => {
const mockStore = reduxMockStore();
const revokedLicense = { ...validLicense, revoked: true };
const expectedNagMessageAction = {
type: 'SHOW_PERSISTENT_FLASH',
payload: {
message: 'Please upgrade your Kolide license',
},
};
mockStore.dispatch(licenseSuccess(revokedLicense));
expect(mockStore.getActions()).toInclude(expectedNagMessageAction);
});
it('dispatches an action to clear persistent flash message when the license is valid', () => {
const mockStore = reduxMockStore();
const expectedNagMessageAction = { type: 'HIDE_PERSISTENT_FLASH' };
mockStore.dispatch(licenseSuccess(validLicense));
expect(mockStore.getActions()).toInclude(expectedNagMessageAction);
});
});

View file

@ -4,6 +4,9 @@ import Kolide from 'kolide';
import userActions from 'redux/nodes/entities/users/actions';
export const CLEAR_AUTH_ERRORS = 'CLEAR_AUTH_ERRORS';
export const LICENSE_REQUEST = 'LICENSE_REQUEST';
export const LICENSE_SUCCESS = 'LICENSE_SUCCESS';
export const LICENSE_FAILURE = 'LICENSE_FAILURE';
export const LOGIN_REQUEST = 'LOGIN_REQUEST';
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';
@ -17,6 +20,74 @@ export const PERFORM_REQUIRED_PASSWORD_RESET_REQUEST = 'PERFORM_REQUIRED_PASSWOR
export const PERFORM_REQUIRED_PASSWORD_RESET_SUCCESS = 'PERFORM_REQUIRED_PASSWORD_RESET_SUCCESS';
export const PERFORM_REQUIRED_PASSWORD_RESET_FAILURE = 'PERFORM_REQUIRED_PASSWORD_RESET_FAILURE';
export const licenseFailure = (errors) => {
return {
type: LICENSE_FAILURE,
payload: { errors },
};
};
export const licenseRequest = { type: LICENSE_REQUEST };
export const licenseSuccess = (license) => {
return {
type: LICENSE_SUCCESS,
payload: { license },
};
};
export const setupLicense = ({ license }) => {
return (dispatch) => {
dispatch(licenseRequest);
return Kolide.license.setup(license)
.then((response) => {
dispatch(licenseSuccess(response));
return response;
})
.catch((response) => {
const errorsObject = formatErrorResponse(response);
dispatch(licenseFailure(errorsObject));
throw response;
});
};
};
export const createLicense = ({ license }) => {
return (dispatch) => {
dispatch(licenseRequest);
return Kolide.license.create(license)
.then((response) => {
dispatch(licenseSuccess(response));
return response;
})
.catch((response) => {
const errorsObject = formatErrorResponse(response);
dispatch(licenseFailure(errorsObject));
throw response;
});
};
};
export const getLicense = () => {
return (dispatch) => {
dispatch(licenseRequest);
return Kolide.license.load()
.then((license) => {
dispatch(licenseSuccess(license));
return license;
})
.catch((response) => {
const errorsObject = formatErrorResponse(response);
dispatch(licenseFailure(errorsObject));
throw response;
});
};
};
export const clearAuthErrors = { type: CLEAR_AUTH_ERRORS };
export const loginRequest = { type: LOGIN_REQUEST };
export const loginSuccess = ({ user, token }) => {

View file

@ -7,6 +7,12 @@ import { reduxMockStore } from 'test/helpers';
import { userStub } from 'test/stubs';
import {
createLicense,
getLicense,
setupLicense,
LICENSE_FAILURE,
LICENSE_REQUEST,
LICENSE_SUCCESS,
performRequiredPasswordReset,
PERFORM_REQUIRED_PASSWORD_RESET_REQUEST,
PERFORM_REQUIRED_PASSWORD_RESET_FAILURE,
@ -18,6 +24,288 @@ const store = { entities: { invites: {}, users: {} } };
const user = { ...userStub, id: 1, email: 'zwass@kolide.co', force_password_reset: false };
describe('Auth - actions', () => {
describe('#createLicense', () => {
const license = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
afterEach(restoreSpies);
describe('successful request', () => {
beforeEach(() => {
spyOn(Kolide.default.license, 'create').andReturn(Promise.resolve({ license }));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(createLicense({ license }))
.then(() => {
expect(Kolide.default.license.create).toHaveBeenCalledWith(license);
})
.catch(() => {
expect(Kolide.default.license.create).toHaveBeenCalledWith(license);
});
});
it('dispatches the correct actions', () => {
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: LICENSE_REQUEST },
{ type: 'HIDE_PERSISTENT_FLASH' },
{
type: LICENSE_SUCCESS,
payload: { license: { license } },
},
];
return mockStore.dispatch(createLicense({ license }))
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
});
});
});
describe('unsuccessful request', () => {
const errors = [
{
name: 'base',
reason: 'Unable to create license',
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to create license',
errors,
},
};
beforeEach(() => {
spyOn(Kolide.default.license, 'create').andReturn(Promise.reject(errorResponse));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(createLicense({ license }))
.then(() => {
expect(Kolide.default.license.create).toHaveBeenCalledWith(license);
})
.catch(() => {
expect(Kolide.default.license.create).toHaveBeenCalledWith(license);
});
});
it('dispatches the correct actions', () => {
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: LICENSE_REQUEST },
{
type: LICENSE_FAILURE,
payload: { errors: { base: 'Unable to create license', http_status: 422 } },
},
];
return mockStore.dispatch(createLicense({ license }))
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
});
});
});
});
describe('#setupLicense', () => {
const license = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
afterEach(restoreSpies);
describe('successful request', () => {
beforeEach(() => {
spyOn(Kolide.default.license, 'setup').andReturn(Promise.resolve({ license }));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(setupLicense({ license }))
.then(() => {
expect(Kolide.default.license.setup).toHaveBeenCalledWith(license);
})
.catch(() => {
expect(Kolide.default.license.setup).toHaveBeenCalledWith(license);
});
});
it('dispatches the correct actions', () => {
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: LICENSE_REQUEST },
{ type: 'HIDE_PERSISTENT_FLASH' },
{
type: LICENSE_SUCCESS,
payload: { license: { license } },
},
];
return mockStore.dispatch(setupLicense({ license }))
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
});
});
});
describe('unsuccessful request', () => {
const errors = [
{
name: 'base',
reason: 'Unable to create license',
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to create license',
errors,
},
};
beforeEach(() => {
spyOn(Kolide.default.license, 'setup').andReturn(Promise.reject(errorResponse));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(setupLicense({ license }))
.then(() => {
expect(Kolide.default.license.setup).toHaveBeenCalledWith(license);
})
.catch(() => {
expect(Kolide.default.license.create).toHaveBeenCalledWith(license);
});
});
it('dispatches the correct actions', () => {
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: LICENSE_REQUEST },
{
type: LICENSE_FAILURE,
payload: { errors: { base: 'Unable to create license', http_status: 422 } },
},
];
return mockStore.dispatch(setupLicense({ license }))
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
});
});
});
});
describe('#getLicense', () => {
const license = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
afterEach(restoreSpies);
describe('successful request', () => {
beforeEach(() => {
spyOn(Kolide.default.license, 'load').andReturn(Promise.resolve({ license }));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(getLicense())
.then(() => {
expect(Kolide.default.license.load).toHaveBeenCalled();
})
.catch(() => {
expect(Kolide.default.license.load).toHaveBeenCalled();
});
});
it('dispatches the correct actions', () => {
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: LICENSE_REQUEST },
{ type: 'HIDE_PERSISTENT_FLASH' },
{
type: LICENSE_SUCCESS,
payload: { license: { license } },
},
];
return mockStore.dispatch(getLicense())
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
});
});
});
describe('unsuccessful request', () => {
const errors = [
{
name: 'base',
reason: 'Unable to get license',
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to get license',
errors,
},
};
beforeEach(() => {
spyOn(Kolide.default.license, 'load').andReturn(Promise.reject(errorResponse));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(getLicense())
.then(() => {
expect(Kolide.default.license.load).toHaveBeenCalled();
})
.catch(() => {
expect(Kolide.default.license.load).toHaveBeenCalled();
});
});
it('dispatches the correct actions', () => {
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: LICENSE_REQUEST },
{
type: LICENSE_FAILURE,
payload: { errors: { base: 'Unable to get license', http_status: 422 } },
},
];
return mockStore.dispatch(getLicense())
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
});
});
});
});
describe('dispatching the perform required password reset action', () => {
describe('successful request', () => {
beforeEach(() => {

View file

@ -1,5 +1,8 @@
import {
CLEAR_AUTH_ERRORS,
LICENSE_FAILURE,
LICENSE_REQUEST,
LICENSE_SUCCESS,
LOGIN_FAILURE,
LOGIN_REQUEST,
LOGIN_SUCCESS,
@ -15,6 +18,7 @@ import {
} from './actions';
export const initialState = {
license: {},
loading: false,
errors: {},
user: null,
@ -27,6 +31,7 @@ const reducer = (state = initialState, action) => {
...state,
errors: {},
};
case LICENSE_REQUEST:
case LOGIN_REQUEST:
case LOGOUT_REQUEST:
case UPDATE_USER_REQUEST:
@ -34,12 +39,19 @@ const reducer = (state = initialState, action) => {
...state,
loading: true,
};
case LICENSE_SUCCESS:
return {
...state,
loading: false,
license: action.payload.license,
};
case LOGIN_SUCCESS:
return {
...state,
loading: false,
user: action.payload.user,
};
case LICENSE_FAILURE:
case LOGIN_FAILURE:
return {
...state,

View file

@ -157,13 +157,15 @@ describe('reduxConfig', () => {
const { actions, reducer } = config;
it('calls the createFunc', () => {
mockStore.dispatch(actions.create());
mockStore.dispatch(actions.create())
.catch(() => false);
expect(createFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.create());
mockStore.dispatch(actions.create())
.catch(() => false);
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map((action) => { return action.type; });
@ -344,9 +346,8 @@ describe('reduxConfig', () => {
});
});
describe('unprocessable entitiy', () => {
describe('unprocessable entity', () => {
const mockStore = reduxMockStore(store);
const errors = [
{ name: 'first_name',
reason: 'is not valid',
@ -374,7 +375,8 @@ describe('reduxConfig', () => {
const { actions, reducer } = config;
it('calls the updateFunc', () => {
mockStore.dispatch(actions.update(user));
mockStore.dispatch(actions.update(user))
.catch(() => false);
expect(updateFunc).toHaveBeenCalledWith(user);
});
@ -573,13 +575,15 @@ describe('reduxConfig', () => {
const { actions, reducer } = config;
it('calls the createFunc', () => {
mockStore.dispatch(actions.destroy());
mockStore.dispatch(actions.destroy())
.catch(() => false);
expect(destroyFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.destroy());
mockStore.dispatch(actions.destroy())
.catch(() => false);
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map((action) => { return action.type; });
@ -762,13 +766,15 @@ describe('reduxConfig', () => {
const { actions, reducer } = config;
it('calls the loadFunc', () => {
mockStore.dispatch(actions.load());
mockStore.dispatch(actions.load())
.catch(() => false);
expect(loadFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.load());
mockStore.dispatch(actions.load())
.catch(() => false);
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map((action) => { return action.type; });
@ -880,13 +886,15 @@ describe('reduxConfig', () => {
const { actions, reducer } = config;
it('calls the loadAllFunc', () => {
mockStore.dispatch(actions.loadAll());
mockStore.dispatch(actions.loadAll())
.catch(() => false);
expect(loadAllFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.loadAll());
mockStore.dispatch(actions.loadAll())
.catch(() => false);
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map((action) => { return action.type; });

View file

@ -0,0 +1,22 @@
// Action Types
const HIDE_PERSISTENT_FLASH = 'HIDE_PERSISTENT_FLASH';
const SHOW_PERSISTENT_FLASH = 'SHOW_PERSISTENT_FLASH';
export const ACTION_TYPES = {
HIDE_PERSISTENT_FLASH,
SHOW_PERSISTENT_FLASH,
};
// Actions
const hidePersistentFlash = { type: HIDE_PERSISTENT_FLASH };
const showPersistentFlash = (message) => {
return {
type: SHOW_PERSISTENT_FLASH,
payload: { message },
};
};
export default {
hidePersistentFlash,
showPersistentFlash,
};

View file

@ -0,0 +1,23 @@
import { ACTION_TYPES } from 'redux/nodes/persistent_flash/actions';
export const initialState = {
showFlash: false,
message: '',
};
export default (state = initialState, { type, payload }) => {
switch (type) {
case ACTION_TYPES.HIDE_PERSISTENT_FLASH:
return {
showFlash: false,
message: '',
};
case ACTION_TYPES.SHOW_PERSISTENT_FLASH:
return {
showFlash: true,
message: payload.message,
};
default:
return state;
}
};

View file

@ -0,0 +1,32 @@
import expect from 'expect';
import actions from 'redux/nodes/persistent_flash/actions';
import reducer, { initialState } from 'redux/nodes/persistent_flash/reducer';
describe('persistent_flash - reducer', () => {
it('sets the initial state', () => {
const nextState = reducer(undefined, { type: 'SOME_ACTION' });
expect(nextState).toEqual(initialState);
});
it('shows the flash and sets the message when showPersistentFlash is dispatched', () => {
const message = 'This is the flash message';
const action = actions.showPersistentFlash(message);
expect(reducer(initialState, action)).toEqual({
showFlash: true,
message,
});
});
it('hides the flash and removes the message when hidePersistentFlash is dispatched', () => {
const currentState = { showFlash: true, message: 'something' };
const action = actions.hidePersistentFlash;
expect(reducer(currentState, action)).toEqual({
showFlash: false,
message: '',
});
});
});

View file

@ -7,6 +7,7 @@ import auth from './nodes/auth/reducer';
import components from './nodes/components/reducer';
import entities from './nodes/entities/reducer';
import notifications from './nodes/notifications/reducer';
import persistentFlash from './nodes/persistent_flash/reducer';
import redirectLocation from './nodes/redirectLocation/reducer';
export default combineReducers({
@ -16,6 +17,7 @@ export default combineReducers({
entities,
loadingBar: loadingBarReducer,
notifications,
persistentFlash,
redirectLocation,
routing: routerReducer,
});

View file

@ -5,6 +5,7 @@ import { routerMiddleware } from 'react-router-redux';
import thunkMiddleware from 'redux-thunk';
import authMiddleware from './middlewares/auth';
import nagMessageMiddleware from './middlewares/nag_message';
import redirectMiddleware from './middlewares/redirect';
import reducers from './reducers';
@ -14,6 +15,7 @@ const appliedMiddleware = applyMiddleware(
thunkMiddleware,
routerMiddleware(browserHistory),
authMiddleware,
nagMessageMiddleware,
redirectMiddleware,
loadingBarMiddleware({
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAILURE'],

View file

@ -13,6 +13,7 @@ import ConfigOptionsPage from 'pages/config/ConfigOptionsPage';
import ConfirmInvitePage from 'pages/ConfirmInvitePage';
import CoreLayout from 'layouts/CoreLayout';
import EditPackPage from 'pages/packs/EditPackPage';
import LicensePage from 'pages/LicensePage';
import LoginRoutes from 'components/LoginRoutes';
import LogoutPage from 'pages/LogoutPage';
import ManageHostsPage from 'pages/hosts/ManageHostsPage';
@ -34,6 +35,7 @@ const routes = (
<Router history={history}>
<Route path="/" component={App}>
<Route path="setup" component={RegistrationPage} />
<Route path="license" component={LicensePage} />
<Route path="login" component={LoginRoutes}>
<Route path="invites/:invite_token" component={ConfirmInvitePage} />
<Route path="forgot" />

View file

@ -8,10 +8,12 @@ export default {
FORGOT_PASSWORD: '/login/forgot',
HOME: '/',
KOLIDE_500: '/500',
LICENSE: '/license',
LOGIN: '/login',
LOGOUT: '/logout',
MANAGE_HOSTS: '/hosts/manage',
NEW_PACK: '/packs/new',
NEW_QUERY: '/queries/new',
RESET_PASSWORD: '/login/reset',
SETUP: '/setup',
};

View file

@ -6,6 +6,7 @@ import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import authMiddleware from 'redux/middlewares/auth';
import nagMessageMiddleware from 'redux/middlewares/nag_message';
import redirectMiddleware from 'redux/middlewares/redirect';
export const fillInFormInput = (inputComponent, value) => {
@ -13,7 +14,7 @@ export const fillInFormInput = (inputComponent, value) => {
};
export const reduxMockStore = (store = {}) => {
const middlewares = [thunk, authMiddleware, redirectMiddleware];
const middlewares = [thunk, authMiddleware, nagMessageMiddleware, redirectMiddleware];
const mockStore = configureStore(middlewares);
return mockStore(store);
@ -45,7 +46,8 @@ export const itBehavesLikeAFormDropdownElement = (form, inputName) => {
};
export const itBehavesLikeAFormInputElement = (form, inputName, inputType = 'InputField', inputText = 'some text') => {
const inputField = form.find({ name: inputName }).find('input');
const Input = form.find({ name: inputName });
const inputField = inputType === 'textarea' ? Input.find('textarea') : Input.find('input');
expect(inputField.length).toEqual(1);

View file

@ -34,6 +34,53 @@ export const validCreateLabelRequest = (bearerToken, labelParams) => {
.reply(201, { label: { ...labelParams, display_text: labelParams.name } });
};
export const validCreateLicenseRequest = (bearerToken, jwtToken, response = stubs.licenseStub()) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.post('/api/v1/kolide/license', JSON.stringify({ license: jwtToken }))
.reply(201, {
license: {
...response,
token: jwtToken,
},
});
};
export const validSetupLicenseRequest = (bearerToken, jwtToken, response = stubs.licenseStub()) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.post('/api/v1/license', JSON.stringify({ license: jwtToken }))
.reply(201, {
license: {
...response,
license: jwtToken,
},
});
};
export const validGetLicenseRequest = (bearerToken, response = stubs.licenseStub()) => {
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.get('/api/v1/kolide/license')
.reply(200, {
license: {
...response,
license: jwtToken,
},
});
};
export const validCreatePackRequest = (bearerToken, packParams) => {
return nock('http://localhost:8080', {
reqHeaders: {
@ -447,6 +494,7 @@ export default {
invalidResetPasswordRequest,
validChangePasswordRequest,
validCreateLabelRequest,
validCreateLicenseRequest,
validCreatePackRequest,
validCreateQueryRequest,
validCreateScheduledQueryRequest,
@ -461,6 +509,7 @@ export default {
validGetConfigRequest,
validGetHostsRequest,
validGetInvitesRequest,
validGetLicenseRequest,
validGetQueriesRequest,
validGetQueryRequest,
validGetScheduledQueriesRequest,
@ -474,6 +523,7 @@ export default {
validRevokeInviteRequest,
validRunQueryRequest,
validSetupRequest,
validSetupLicenseRequest,
validStatusLabelsGetCountsRequest,
validUpdateAdminRequest,
validUpdateConfigOptionsRequest,

View file

@ -38,6 +38,25 @@ export const configStub = {
},
};
export const flatConfigStub = {
org_name: 'Kolide',
org_logo_url: '0.0.0.0:8080/logo.png',
kolide_server_url: '',
configured: false,
domain: '',
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 hostStub = {
created_at: '2017-01-10T19:18:55Z',
updated_at: '2017-01-10T20:13:52Z',
@ -117,6 +136,21 @@ export const labelStub = {
target_type: 'labels',
};
export const licenseStub = () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ',
expiry: tomorrow.toISOString(),
allowed_hosts: 100,
hosts: 70,
evaluation: false,
revoked: false,
organization: 'Kolide',
};
};
export const packStub = {
created_at: '0001-01-01T00:00:00Z',
updated_at: '0001-01-01T00:00:00Z',
@ -173,8 +207,10 @@ export const userStub = {
export default {
adminUserStub,
configStub,
flatConfigStub,
hostStub,
labelStub,
licenseStub,
packStub,
queryStub,
scheduledQueryStub,