Forgot password (#162)

* GradientButton components

* Style guide updates

* Display errors and override styles for InputFieldWithIcon

* Envelope Icon

* Login page form submission (#157)

* API client utility

* moves test helpers to the test directory

* Utility to namespace local storage keys

* LoginSuccessfulPage component

* Check icon

* adds auth to redux state

* successful form submission

* Allow tests to load dummy SVG static images & test fixes

* ForgotPassword Page, Form & route

* Email validator
This commit is contained in:
Mike Stone
2016-09-14 16:31:54 -04:00
committed by GitHub
parent 713c248f2e
commit 40af0d29ef
30 changed files with 578 additions and 58 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ test-js:
test: lint test-go test-js
generate: .prefix
go-bindata -pkg=server -o=server/bindata.go frontend/templates/ assets/ assets/images/
go-bindata -pkg=server -o=server/bindata.go frontend/templates/ assets/...
$(shell npm bin)/webpack --progress --colors --bail
generate-dev: .prefix
@@ -0,0 +1,35 @@
import React, { Component, PropTypes } from 'react';
import componentStyles from './styles';
import { loadBackground, removeBackground, resizeBackground } from '../../utilities/backgroundImage';
export class LoginRoutes extends Component {
static propTypes = {
children: PropTypes.element,
};
componentWillMount () {
const { window } = global;
loadBackground();
window.onresize = resizeBackground;
}
componentWillUnmount () {
removeBackground();
}
render () {
const { containerStyles } = componentStyles;
const { children } = this.props;
return (
<div style={containerStyles}>
<img alt="Kolide text logo" src="/assets/images/kolide-logo-text.svg" />
{children}
</div>
);
}
}
export default LoginRoutes;
+1
View File
@@ -0,0 +1 @@
export default from './LoginRoutes';
@@ -0,0 +1,9 @@
export default {
containerStyles: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
paddingTop: '80px',
},
};
@@ -0,0 +1,34 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
class GradientButton extends Component {
static propTypes = {
disabled: PropTypes.bool,
onClick: PropTypes.func,
style: PropTypes.object,
text: PropTypes.string,
type: PropTypes.string,
};
static defaultProps = {
style: {},
};
render () {
const { disabled, onClick, style, text, type } = this.props;
return (
<button
disabled={disabled}
onClick={onClick}
style={[componentStyles(disabled), style]}
type={type}
>
{text}
</button>
);
}
}
export default radium(GradientButton);
@@ -0,0 +1 @@
export default from './GradientButton';
@@ -0,0 +1,32 @@
import styles from '../../../styles';
const { border, color, font, padding } = styles;
export default (disabled) => {
const cursor = disabled ? 'not-allowed' : 'pointer';
return {
backgroundImage: 'linear-gradient(134deg, #7166D9 0%, #C86DD7 100%)',
border: 'none',
cursor,
borderBottomLeftRadius: border.radius.base,
borderBottomRightRadius: border.radius.base,
boxSizing: 'border-box',
color: color.white,
fontSize: font.large,
letterSpacing: '4px',
padding: padding.base,
fontWeight: '300',
textTransform: 'uppercase',
width: '100%',
boxShadow: '0 3px 0 #734893',
position: 'relative',
':active': {
top: '2px',
boxShadow: '0 1px 0 #734893, 0 -2px 0 #D1D9E9',
},
':focus': {
outline: 'none',
},
};
};
@@ -0,0 +1,90 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
import GradientButton from '../../buttons/GradientButton';
import InputFieldWithIcon from '../fields/InputFieldWithIcon';
import validEmail from '../validators/valid_email';
class ForgotPasswordForm extends Component {
static propTypes = {
onSubmit: PropTypes.func,
};
constructor (props) {
super(props);
this.state = {
formData: {
email: null,
},
};
}
onInputFieldChange = (evt) => {
const { value } = evt.target;
this.setState({
error: null,
formData: {
email: value,
},
});
return false;
}
onFormSubmit = (evt) => {
evt.preventDefault();
const { formData } = this.state;
const { onSubmit } = this.props;
if (this.validate()) {
return onSubmit(formData);
}
return false;
}
validate = () => {
const { formData: { email } } = this.state;
if (validEmail(email)) {
return true;
}
this.setState({
error: `${email} is not a valid email`,
});
return false;
}
render () {
const { error, formData: { email } } = this.state;
const { formStyles, inputStyles, submitButtonStyles } = componentStyles;
const { onFormSubmit, onInputFieldChange } = this;
const disabled = !email;
return (
<form onSubmit={onFormSubmit} style={formStyles}>
<InputFieldWithIcon
error={error}
iconName="envelope"
name="email"
onChange={onInputFieldChange}
placeholder="Email Address"
style={inputStyles}
/>
<GradientButton
disabled={disabled}
type="submit"
style={submitButtonStyles}
text="Reset Password"
/>
</form>
);
}
}
export default radium(ForgotPasswordForm);
@@ -0,0 +1,67 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import ForgotPasswordForm from './ForgotPasswordForm';
import { fillInFormInput } from '../../../test/helpers';
const email = 'hi@thegnar.co';
describe('ForgotPasswordForm - component', () => {
afterEach(restoreSpies);
it('renders an InputFieldWithIcon components', () => {
const form = mount(<ForgotPasswordForm onSubmit={noop} />);
expect(form.find('InputFieldWithIcon').length).toEqual(1);
});
it('updates component state when the email field is changed', () => {
const form = mount(<ForgotPasswordForm onSubmit={noop} />);
const emailField = form.find({ name: 'email' });
fillInFormInput(emailField, email);
const { formData } = form.state();
expect(formData).toContain({ email });
});
it('it does not submit the form when the form fields have not been filled out', () => {
const submitSpy = createSpy();
const form = mount(<ForgotPasswordForm onSubmit={submitSpy} />);
const submitBtn = form.find('button');
submitBtn.simulate('submit');
expect(submitBtn.prop('disabled')).toEqual(true);
expect(submitSpy).toNotHaveBeenCalled();
});
it('submits the form data when the form is submitted', () => {
const submitSpy = createSpy();
const form = mount(<ForgotPasswordForm onSubmit={submitSpy} />);
const emailField = form.find({ name: 'email' });
const submitBtn = form.find('button');
fillInFormInput(emailField, email);
submitBtn.simulate('submit');
expect(submitSpy).toHaveBeenCalledWith({ email });
});
it('does not submit the form if the email is not valid', () => {
const submitSpy = createSpy();
const form = mount(<ForgotPasswordForm onSubmit={submitSpy} />);
const emailField = form.find({ name: 'email' });
const submitBtn = form.find('button');
fillInFormInput(emailField, 'invalid-email');
submitBtn.simulate('submit');
expect(submitSpy).toNotHaveBeenCalled();
expect(form.state()).toInclude({
error: 'invalid-email is not a valid email',
});
});
});
@@ -0,0 +1 @@
export default from './ForgotPasswordForm';
@@ -0,0 +1,18 @@
import styles from '../../../styles';
const { border, padding } = styles;
export default {
formStyles: {
width: '100%',
},
inputStyles: {
width: '100%',
},
submitButtonStyles: {
borderTopLeftRadius: border.radius.base,
borderTopRightRadius: border.radius.base,
marginTop: padding.base,
padding: padding.medium,
},
};
@@ -1,8 +1,11 @@
import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';
import radium from 'radium';
import componentStyles from './styles';
import avatar from './avatar.svg';
import componentStyles from './styles';
import GradientButton from '../../buttons/GradientButton';
import InputFieldWithIcon from '../fields/InputFieldWithIcon';
import paths from '../../../router/paths';
class LoginForm extends Component {
static propTypes = {
@@ -54,7 +57,13 @@ class LoginForm extends Component {
}
render () {
const { containerStyles, submitButtonStyles, formStyles } = componentStyles;
const {
containerStyles,
forgotPasswordStyles,
forgotPasswordWrapperStyles,
formStyles,
submitButtonStyles,
} = componentStyles;
const { onInputChange, onFormSubmit } = this;
const canSubmit = this.canSubmit();
@@ -75,13 +84,17 @@ class LoginForm extends Component {
placeholder="Password"
type="password"
/>
<div style={forgotPasswordWrapperStyles}>
<Link style={forgotPasswordStyles} to={paths.FORGOT_PASSWORD}>Forgot Password?</Link>
</div>
</div>
<button
style={submitButtonStyles(canSubmit)}
<GradientButton
disabled={!canSubmit}
onClick={onFormSubmit}
style={submitButtonStyles}
text="Login"
type="submit"
>
Login
</button>
/>
</form>
);
}
+12 -27
View File
@@ -16,35 +16,20 @@ export default {
width: FORM_WIDTH,
minHeight: '350px',
},
forgotPasswordStyles: {
fontSize: font.medium,
textDecoration: 'none',
color: color.lightGrey,
},
forgotPasswordWrapperStyles: {
marginTop: padding.base,
textAlign: 'right',
width: '378px',
},
formStyles: {
boxShadow: '0 5px 30px 0 rgba(0,0,0,0.30)',
},
submitButtonStyles: (canSubmit) => {
const cursor = canSubmit ? 'pointer' : 'not-allowed';
return {
backgroundImage: 'linear-gradient(134deg, #7166D9 0%, #C86DD7 100%)',
border: 'none',
cursor,
borderBottomLeftRadius: border.radius.base,
borderBottomRightRadius: border.radius.base,
boxSizing: 'border-box',
color: color.white,
fontSize: font.large,
letterSpacing: '4px',
padding: padding.base,
fontWeight: '300',
textTransform: 'uppercase',
width: FORM_WIDTH,
boxShadow: '0 3px 0 #734893',
position: 'relative',
':active': {
top: '2px',
boxShadow: '0 1px 0 #734893, 0 -2px 0 #D1D9E9',
},
':focus': {
outline: 'none',
},
};
submitButtonStyles: {
width: FORM_WIDTH,
},
};
@@ -5,14 +5,17 @@ import componentStyles from './styles';
class InputFieldWithIcon extends Component {
static propTypes = {
error: PropTypes.string,
iconName: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
style: PropTypes.object,
type: PropTypes.string,
};
static defaultProps = {
style: {},
type: 'text',
};
@@ -22,31 +25,55 @@ class InputFieldWithIcon extends Component {
}
onInputChange = (evt) => {
evt.preventDefault();
const { value } = evt.target;
const { onChange } = this.props;
this.setState({ value });
return onChange(evt);
};
}
iconVariant = () => {
const { error } = this.props;
const { value } = this.state;
if (error) return 'error';
if (value) return 'colored';
return 'default';
}
renderHeading = () => {
const { error, placeholder } = this.props;
const { value } = this.state;
const { errorStyles, placeholderStyles } = componentStyles;
if (error) {
return <div style={errorStyles}>{error}</div>;
}
return <div style={placeholderStyles(value)}>{placeholder}</div>;
}
render () {
const { iconName, name, placeholder, type } = this.props;
const { containerStyles, iconStyles, inputStyles, placeholderStyles } = componentStyles;
const { error, iconName, name, placeholder, style, type } = this.props;
const { containerStyles, iconStyles, inputErrorStyles, inputStyles } = componentStyles;
const { value } = this.state;
const { onInputChange } = this;
const iconVariant = value ? 'colored' : 'default';
const { iconVariant, onInputChange } = this;
return (
<div style={containerStyles}>
<div style={placeholderStyles(value)}>{placeholder}</div>
{this.renderHeading()}
<input
name={name}
onChange={onInputChange}
placeholder={placeholder}
style={inputStyles(value)}
style={[inputStyles(value), inputErrorStyles(error), style]}
type={type}
/>
<Icon name={iconName} style={iconStyles} variant={iconVariant} />
<Icon name={iconName} style={iconStyles} variant={iconVariant()} />
</div>
);
}
@@ -7,11 +7,25 @@ export default {
marginTop: padding.base,
position: 'relative',
},
errorStyles: {
color: color.red,
fontSize: font.small,
textTransform: 'lowercase',
},
iconStyles: {
position: 'absolute',
right: '6px',
top: '29px',
},
inputErrorStyles: (error) => {
if (error) {
return {
borderBottomColor: color.red,
};
}
return {};
},
inputStyles: (value) => {
const baseStyles = {
borderLeft: 'none',
@@ -0,0 +1,9 @@
const EMAIL_REGEX = /\S+@\S+\.\S+/;
export default (email) => {
if (EMAIL_REGEX.test(email)) {
return true;
}
return false;
};
@@ -0,0 +1,26 @@
import expect from 'expect';
import validEmail from './index';
describe('valid_email - validators', () => {
const validEmails = [
'hi@thegnar.co',
'hi@gnar.dog',
'kolide@gmail.com',
];
const invalidEmails = [
'www.thegnar.co',
'bill@shakespeare',
];
it('returns true for valid emails', () => {
validEmails.forEach((email) => {
expect(validEmail(email)).toEqual(true);
});
});
it('returns false for invalid emails', () => {
invalidEmails.forEach((email) => {
expect(validEmail(email)).toEqual(false);
});
});
});
+2
View File
@@ -1,6 +1,7 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import Check from './svg/Check';
import Envelope from './svg/Envelope';
import KolideLoginBackground from './svg/KolideLoginBackground';
import Lock from './svg/Lock';
import User from './svg/User';
@@ -15,6 +16,7 @@ class Icon extends Component {
static iconNames = {
check: Check,
envelope: Envelope,
kolideLoginBackground: KolideLoginBackground,
lock: Lock,
user: User,
@@ -0,0 +1,59 @@
import React, { Component } from 'react';
import base, { basePropTypes } from '../base';
class Envelope extends Component {
static propTypes = {
...basePropTypes,
};
static variants = {
default: {
fill: '#EAEEFB',
stroke: '#B9C2E4',
},
colored: {
fill: '#EED6FF',
stroke: '#C48DED',
},
error: {
fill: '#FFB5B2',
stroke: '#FF5850',
},
};
render () {
const { alt, style, variant } = this.props;
const color = Envelope.variants[variant];
return (
<svg
width="11px"
height="9px"
viewBox="0 0 11 09"
alt={alt}
style={style}
>
<g>
<defs>
<rect id="path-1" x="0" y="0" width="11" height="8" />
<mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="11" height="8" fill="white">
<use xlinkHref="#path-1" />
</mask>
</defs>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g transform="translate(-714.000000, -594.000000)" stroke={color.stroke}>
<g transform="translate(241.000000, 370.000000)">
<g transform="translate(473.000000, 225.000000)">
<use mask="url(#mask-2)" strokeWidth="2" fill={color.fill} xlinkHref="#path-1" />
<polyline points="0.533320551 0.599345964 5.52216436 3.65714135 10.5638681 0.475880292" />
</g>
</g>
</g>
</g>
</g>
</svg>
);
}
}
export default base(Envelope);
@@ -0,0 +1 @@
export default from './Envelope.svg.jsx';
@@ -0,0 +1,34 @@
import React, { Component } from 'react';
import componentStyles from './styles';
import ForgotPasswordForm from '../../components/forms/ForgotPasswordForm';
class ForgotPasswordPage extends Component {
onSubmit = (formData) => {
console.log('ForgotPasswordPage formData', formData);
}
render () {
const {
containerStyles,
forgotPasswordStyles,
headerStyles,
smallWhiteTabStyles,
textStyles,
whiteTabStyles,
} = componentStyles;
return (
<div style={containerStyles}>
<div style={smallWhiteTabStyles} />
<div style={whiteTabStyles} />
<div style={forgotPasswordStyles}>
<p style={headerStyles}>Forgot Password</p>
<p style={textStyles}>If youve forgotten your password enter your email below and we will email you a link so that you can reset your password.</p>
<ForgotPasswordForm onSubmit={this.onSubmit} />
</div>
</div>
);
}
}
export default ForgotPasswordPage;
@@ -0,0 +1 @@
export default from './ForgotPasswordPage';
@@ -0,0 +1,53 @@
import styles from '../../styles';
const { border, color, font, padding } = styles;
export default {
containerStyles: {
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
},
forgotPasswordStyles: {
alignItems: 'center',
backgroundColor: color.white,
borderTopLeftRadius: border.radius.base,
borderTopRightRadius: border.radius.base,
boxShadow: border.shadow.blur,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
padding: padding.base,
width: '522px',
},
headerStyles: {
fontFamily: "'Oxygen', sans-serif",
fontSize: font.large,
fontWeight: '300',
color: color.mediumGrey,
lineHeight: '32px',
textTransform: 'uppercase',
},
smallWhiteTabStyles: {
backgroundColor: color.white,
borderTopLeftRadius: border.radius.base,
borderTopRightRadius: border.radius.base,
boxShadow: border.shadow.blur,
height: '20px',
marginTop: padding.base,
width: '400px',
},
textStyles: {
color: color.purpleGrey,
fontSize: font.medium,
},
whiteTabStyles: {
backgroundColor: color.white,
borderTopLeftRadius: border.radius.base,
borderTopRightRadius: border.radius.base,
boxShadow: border.shadow.blur,
height: '20px',
width: '460px',
},
};
+4 -7
View File
@@ -38,16 +38,13 @@ export class LoginPage extends Component {
}
render () {
const { containerStyles, formWrapperStyles, whiteTabStyles } = componentStyles;
const { formWrapperStyles, whiteTabStyles } = componentStyles;
const { onSubmit } = this;
return (
<div style={containerStyles}>
<div style={formWrapperStyles}>
<img alt="Kolide text logo" src="/assets/images/kolide-logo-text.svg" />
<div style={whiteTabStyles} />
<LoginForm onSubmit={onSubmit} />
</div>
<div style={formWrapperStyles}>
<div style={whiteTabStyles} />
<LoginForm onSubmit={onSubmit} />
</div>
);
}
-6
View File
@@ -3,12 +3,6 @@ import styles from '../../styles';
const { color, padding } = styles;
export default {
containerStyles: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
paddingTop: '80px',
},
formWrapperStyles: {
alignItems: 'center',
display: 'flex',
+7 -2
View File
@@ -4,9 +4,11 @@ import { Provider } from 'react-redux';
import radium from 'radium';
import { syncHistoryWithStore } from 'react-router-redux';
import App from '../components/App';
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
import HomePage from '../pages/HomePage';
import LoginPage from '../pages/LoginPage';
import LoginSuccessfulPage from '../pages/LoginSuccessfulPage';
import LoginRoutes from '../components/LoginRoutes';
import store from '../redux/store';
const history = syncHistoryWithStore(browserHistory, store);
@@ -16,8 +18,11 @@ const routes = (
<Router history={history}>
<Route path="/" component={radium(App)}>
<IndexRoute component={radium(HomePage)} />
<Route path="login" component={radium(LoginPage)} />
<Route path="login_successful" component={radium(LoginSuccessfulPage)} />
<Route component={radium(LoginRoutes)}>
<Route path="login" component={radium(LoginPage)} />
<Route path="login_successful" component={radium(LoginSuccessfulPage)} />
<Route path="forgot_password" component={radium(ForgotPasswordPage)} />
</Route>
</Route>
</Router>
</Provider>
+5
View File
@@ -0,0 +1,5 @@
export default {
FORGOT_PASSWORD: '/forgot_password',
HOME: '/',
LOGIN: '/login',
};
+3
View File
@@ -2,4 +2,7 @@ export default {
radius: {
base: '4px',
},
shadow: {
blur: '0 0 30px 0 rgba(0,0,0,0.30)',
},
};
+3
View File
@@ -7,7 +7,10 @@ export default {
grey,
lightGrey: '#B4B4B4',
logoPurple: '#9651CA',
mediumGrey: '#6F737F',
primary: grey,
purple: '#c38dec',
purpleGrey: '#858495',
red: '#FF5850',
white: '#FFF',
};
+1
View File
@@ -2,6 +2,7 @@ import { pxToRem } from './helpers';
export default {
auto: 'auto',
medium: pxToRem(16),
base: pxToRem(18),
half: pxToRem(9),
none: 0,