diff options
Diffstat (limited to 'web-ui/src')
38 files changed, 1545 insertions, 90 deletions
diff --git a/web-ui/src/account_recovery/account_recovery.html b/web-ui/src/account_recovery/account_recovery.html new file mode 100644 index 00000000..35054455 --- /dev/null +++ b/web-ui/src/account_recovery/account_recovery.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="icon" type="image/png" href="public/images/favicon.png" /> + <meta charset="utf-8"/> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> + <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0, maximum-scale=1.0"/> + <title>Pixelated</title> + </head> + <body> + <div id="root"/> + <script type="text/javascript" src="/public/account_recovery.js"></script> + </body> +</html> diff --git a/web-ui/src/account_recovery/account_recovery.js b/web-ui/src/account_recovery/account_recovery.js new file mode 100644 index 00000000..19b7c19c --- /dev/null +++ b/web-ui/src/account_recovery/account_recovery.js @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import a11y from 'react-a11y'; + +import App from 'src/common/app'; +import PageWrapper from './page'; + +require('es6-promise').polyfill(); + +if (process.env.NODE_ENV === 'development') a11y(React); + +render( + <App child={<PageWrapper />} />, + document.getElementById('root') +); diff --git a/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.js b/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.js new file mode 100644 index 00000000..5b9da350 --- /dev/null +++ b/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.js @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; +import { translate } from 'react-i18next'; + +import InputField from 'src/common/input_field/input_field'; +import SubmitButton from 'src/common/submit_button/submit_button'; + +import './admin_recovery_code_form.scss'; + +export const AdminRecoveryCodeForm = ({ t, next }) => ( + <form className='account-recovery-form admin-code' onSubmit={next}> + <img + className='account-recovery-progress' + src='/public/images/account-recovery/step_1.svg' + alt={t('account-recovery.admin-form.image-description')} + /> + <h1>{t('account-recovery.admin-form.title')}</h1> + <img + className='admin-code-image' + src='/public/images/account-recovery/admins_contact.svg' + alt='' + /> + <ul> + <li>{t('account-recovery.admin-form.tip1')}</li> + <li>{t('account-recovery.admin-form.tip2')}</li> + <li>{t('account-recovery.admin-form.tip3')}</li> + </ul> + <InputField name='admin-code' label={t('account-recovery.admin-form.input-label')} /> + <SubmitButton buttonText={t('account-recovery.button-next')} /> + </form> +); + +AdminRecoveryCodeForm.propTypes = { + t: React.PropTypes.func.isRequired, + next: React.PropTypes.func.isRequired +}; + +export default translate('', { wait: true })(AdminRecoveryCodeForm); diff --git a/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.scss b/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.scss new file mode 100644 index 00000000..6dcbcc67 --- /dev/null +++ b/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.scss @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +.admin-code-image { + margin: 1em 0; + align-self: center; + height: 2.7em; +} diff --git a/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.spec.js b/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.spec.js new file mode 100644 index 00000000..73c4c1e0 --- /dev/null +++ b/web-ui/src/account_recovery/admin_recovery_code_form/admin_recovery_code_form.spec.js @@ -0,0 +1,38 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import { AdminRecoveryCodeForm } from './admin_recovery_code_form'; + +describe('AdminRecoveryCodeForm', () => { + let adminRecoveryCodeForm; + let mockNext; + + beforeEach(() => { + const mockTranslations = key => key; + mockNext = expect.createSpy(); + adminRecoveryCodeForm = shallow( + <AdminRecoveryCodeForm t={mockTranslations} next={mockNext} /> + ); + }); + + it('renders title for admin recovery code', () => { + expect(adminRecoveryCodeForm.find('h1').text()).toEqual('account-recovery.admin-form.title'); + }); + + it('renders tips for retrieving recovery code', () => { + expect(adminRecoveryCodeForm.find('li').length).toEqual(3); + }); + + it('renders input field for admin code', () => { + expect(adminRecoveryCodeForm.find('InputField').props().name).toEqual('admin-code'); + }); + + it('renders button for next step', () => { + expect(adminRecoveryCodeForm.find('SubmitButton').props().buttonText).toEqual('account-recovery.button-next'); + }); + + it('submits form to next step', () => { + adminRecoveryCodeForm.find('form').simulate('submit'); + expect(mockNext).toHaveBeenCalled(); + }); +}); diff --git a/web-ui/src/account_recovery/backup_account_step/backup_account_step.js b/web-ui/src/account_recovery/backup_account_step/backup_account_step.js new file mode 100644 index 00000000..891b45ec --- /dev/null +++ b/web-ui/src/account_recovery/backup_account_step/backup_account_step.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ +import React from 'react'; +import { translate } from 'react-i18next'; +import LinkButton from 'src/common/link_button/link_button'; + +export const BackupAccountStep = ({ t }) => ( + <div className='account-recovery-form backup-account'> + <img + className='account-recovery-progress' + src='/public/images/account-recovery/step_4.svg' + alt={t('account-recovery.backup-account-step.image-description')} + /> + <h1>{t('account-recovery.backup-account-step.title')}</h1> + <LinkButton + buttonText={t('account-recovery.backup-account-step.buttonText')} + href='/backup-account' + /> + </div> +); + +BackupAccountStep.propTypes = { + t: React.PropTypes.func.isRequired +}; + +export default translate('', { wait: true })(BackupAccountStep); diff --git a/web-ui/src/account_recovery/backup_account_step/backup_account_step.spec.js b/web-ui/src/account_recovery/backup_account_step/backup_account_step.spec.js new file mode 100644 index 00000000..38a5e560 --- /dev/null +++ b/web-ui/src/account_recovery/backup_account_step/backup_account_step.spec.js @@ -0,0 +1,27 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import LinkButton from 'src/common/link_button/link_button'; +import { BackupAccountStep } from './backup_account_step'; + +describe('BackupAccountStep', () => { + let backupAccountStep; + + beforeEach(() => { + const mockTranslations = key => key; + backupAccountStep = shallow(<BackupAccountStep t={mockTranslations} />); + }); + + it('renders title for backup account step', () => { + expect(backupAccountStep.find('h1').text()).toEqual('account-recovery.backup-account-step.title'); + }); + + it('renders submit button with given href', () => { + expect(backupAccountStep.find(LinkButton).props().href).toEqual('/backup-account'); + }); + + it('renders submit button with given button text', () => { + expect(backupAccountStep.find(LinkButton).props().buttonText) + .toEqual('account-recovery.backup-account-step.buttonText'); + }); +}); diff --git a/web-ui/src/account_recovery/new_password_form/new_password_form.js b/web-ui/src/account_recovery/new_password_form/new_password_form.js new file mode 100644 index 00000000..5e1e72c9 --- /dev/null +++ b/web-ui/src/account_recovery/new_password_form/new_password_form.js @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import 'isomorphic-fetch'; +import React from 'react'; +import { translate } from 'react-i18next'; +import validator from 'validator'; + +import { submitForm } from 'src/common/util'; +import InputField from 'src/common/input_field/input_field'; +import SubmitButton from 'src/common/submit_button/submit_button'; +import BackLink from 'src/common/back_link/back_link'; + +import './new_password_form.scss'; + +export class NewPasswordForm extends React.Component { + + constructor(props) { + super(props); + this.state = { + submitButtonDisabled: true, + password: '', + errorPassword: '', + confirmPassword: '', + errorConfirmPassword: '' + }; + } + + submitHandler = (event) => { + event.preventDefault(); + submitForm(event, '/account-recovery', { + userCode: this.props.userCode, + password: this.state.password, + confirmPassword: this.state.confirmPassword + }).then(() => this.props.next()); + } + + handleChangePassword = (event) => { + this.setState({ password: event.target.value }); + this.validatePassword(event.target.value, this.state.confirmPassword); + }; + + handleChangeConfirmPassword = (event) => { + this.setState({ confirmPassword: event.target.value }); + this.validatePassword(this.state.password, event.target.value); + }; + + validatePassword = (password, confirmPassword) => { + const emptyPassword = validator.isEmpty(password); + const validPassword = validator.isLength(password, { min: 8, max: 9999 }); + const emptyConfirmPassword = validator.isEmpty(confirmPassword); + const validConfirmPassword = confirmPassword === password; + + const t = this.props.t; + + this.setState({ + errorPassword: !emptyPassword && !validPassword ? t('account-recovery.new-password-form.error.invalid-password') : '', + errorConfirmPassword: !emptyConfirmPassword && !validConfirmPassword ? t('account-recovery.new-password-form.error.invalid-confirm-password') : '', + submitButtonDisabled: !validPassword || !validConfirmPassword + }); + }; + + render() { + const { t, previous } = this.props; + return ( + <form className='account-recovery-form new-password' onSubmit={this.submitHandler}> + <img + className='account-recovery-progress' + src='/public/images/account-recovery/step_3.svg' + alt={t('account-recovery.new-password-form.image-description')} + /> + <h1>{t('account-recovery.new-password-form.title')}</h1> + <InputField + type='password' name='new-password' value={this.state.password} + label={t('account-recovery.new-password-form.input-label1')} + errorText={this.state.errorPassword} onChange={this.handleChangePassword} + /> + <InputField + type='password' name='confirm-password' value={this.state.confirmPassword} + label={t('account-recovery.new-password-form.input-label2')} + errorText={this.state.errorConfirmPassword} onChange={this.handleChangeConfirmPassword} + /> + <SubmitButton buttonText={t('account-recovery.button-next')} disabled={this.state.submitButtonDisabled} /> + <BackLink text={t('account-recovery.back')} onClick={previous} /> + </form> + ); + } +} + +NewPasswordForm.propTypes = { + t: React.PropTypes.func.isRequired, + next: React.PropTypes.func.isRequired, + previous: React.PropTypes.func.isRequired, + userCode: React.PropTypes.string.isRequired +}; + +export default translate('', { wait: true })(NewPasswordForm); diff --git a/web-ui/src/account_recovery/new_password_form/new_password_form.scss b/web-ui/src/account_recovery/new_password_form/new_password_form.scss new file mode 100644 index 00000000..77623262 --- /dev/null +++ b/web-ui/src/account_recovery/new_password_form/new_password_form.scss @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +.new-password { + .input-field-group:first-of-type { + margin-bottom: 0; + } +} diff --git a/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js b/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js new file mode 100644 index 00000000..c29487a7 --- /dev/null +++ b/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js @@ -0,0 +1,161 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import fetchMock from 'fetch-mock'; +import { NewPasswordForm } from './new_password_form'; + +describe('NewPasswordForm', () => { + let newPasswordForm; + let mockPrevious; + let mockNext; + let mockTranslations; + + beforeEach(() => { + mockTranslations = key => key; + mockPrevious = expect.createSpy(); + newPasswordForm = shallow( + <NewPasswordForm t={mockTranslations} previous={mockPrevious} userCode='def234' /> + ); + }); + + it('renders title for new password form', () => { + expect(newPasswordForm.find('h1').text()).toEqual('account-recovery.new-password-form.title'); + }); + + it('renders input for new password', () => { + expect(newPasswordForm.find('InputField').at(0).props().type).toEqual('password'); + expect(newPasswordForm.find('InputField').at(0).props().label).toEqual('account-recovery.new-password-form.input-label1'); + }); + + it('renders input to confirm new password', () => { + expect(newPasswordForm.find('InputField').at(1).props().type).toEqual('password'); + expect(newPasswordForm.find('InputField').at(1).props().label).toEqual('account-recovery.new-password-form.input-label2'); + }); + + it('renders submit button', () => { + expect(newPasswordForm.find('SubmitButton').props().buttonText).toEqual('account-recovery.button-next'); + }); + + it('returns to previous step on link click', () => { + newPasswordForm.find('BackLink').simulate('click'); + expect(mockPrevious).toHaveBeenCalled(); + }); + + describe('Submit', () => { + beforeEach((done) => { + mockNext = expect.createSpy().andCall(() => done()); + newPasswordForm = shallow( + <NewPasswordForm t={mockTranslations} previous={mockPrevious} userCode='def234' next={mockNext} /> + ); + fetchMock.post('/account-recovery', 200); + newPasswordForm.find('InputField[name="new-password"]').simulate('change', { target: { value: '123' } }); + newPasswordForm.find('InputField[name="confirm-password"]').simulate('change', { target: { value: '456' } }); + newPasswordForm.find('form').simulate('submit', { preventDefault: expect.createSpy() }); + }); + + it('posts to account recovery', () => { + expect(fetchMock.called('/account-recovery')).toBe(true, 'POST was not called'); + }); + + it('sends user code as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"userCode":"def234"'); + }); + + it('sends password as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"password":"123"'); + }); + + it('sends confirm password as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"confirmPassword":"456"'); + }); + }); + + describe('Password validation', () => { + let newPasswordFormInstance; + + beforeEach(() => { + newPasswordFormInstance = newPasswordForm.instance(); + }); + + it('verifies initial state', () => { + expect(newPasswordFormInstance.state.errorPassword).toEqual(''); + expect(newPasswordFormInstance.state.errorConfirmPassword).toEqual(''); + expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(true); + }); + + context('with valid fields', () => { + beforeEach(() => { + newPasswordFormInstance.validatePassword('12345678', '12345678'); + }); + + it('does not set error in state', () => { + expect(newPasswordFormInstance.state.errorPassword).toEqual(''); + expect(newPasswordFormInstance.state.errorConfirmPassword).toEqual(''); + }); + + it('enables submit button', () => { + expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(false); + }); + }); + + context('with invalid password', () => { + beforeEach(() => { + newPasswordFormInstance.validatePassword('1234', ''); + }); + + it('sets password error in state', () => { + expect(newPasswordFormInstance.state.errorPassword).toEqual('account-recovery.new-password-form.error.invalid-password'); + }); + + it('disables submit button', () => { + expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(true); + }); + }); + + context('with invalid confirm password', () => { + beforeEach(() => { + newPasswordFormInstance.validatePassword('12345678', '1234'); + }); + + it('sets confirm password error in state', () => { + expect(newPasswordFormInstance.state.errorConfirmPassword).toEqual('account-recovery.new-password-form.error.invalid-confirm-password'); + }); + + it('disables submit button', () => { + expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(true); + }); + }); + + context('with empty fields', () => { + it('does not set error in state if both empty', () => { + newPasswordFormInstance.validatePassword('', ''); + expect(newPasswordFormInstance.state.errorPassword).toEqual(''); + expect(newPasswordFormInstance.state.errorConfirmPassword).toEqual(''); + }); + + it('does not set confirm password error in state if empty', () => { + newPasswordFormInstance.validatePassword('12345678', ''); + expect(newPasswordFormInstance.state.errorConfirmPassword).toEqual(''); + }); + + it('sets confirm password error in state if not empty', () => { + newPasswordFormInstance.validatePassword('', '12345678'); + expect(newPasswordFormInstance.state.errorConfirmPassword).toEqual('account-recovery.new-password-form.error.invalid-confirm-password'); + }); + + it('disables submit button if empty confirm password', () => { + newPasswordFormInstance.validatePassword('12345678', ''); + expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(true); + }); + + it('disables submit button if empty password', () => { + newPasswordFormInstance.validatePassword('', '12345678'); + expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(true); + }); + }); + + it('calls next handler on success', () => { + expect(mockNext).toHaveBeenCalled(); + }); + }); +}); diff --git a/web-ui/src/account_recovery/page.js b/web-ui/src/account_recovery/page.js new file mode 100644 index 00000000..94927a16 --- /dev/null +++ b/web-ui/src/account_recovery/page.js @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; +import { translate } from 'react-i18next'; +import DocumentTitle from 'react-document-title'; +import Header from 'src/common/header/header'; +import AdminRecoveryCodeForm from 'src/account_recovery/admin_recovery_code_form/admin_recovery_code_form'; +import UserRecoveryCodeForm from 'src/account_recovery/user_recovery_code_form/user_recovery_code_form'; +import NewPasswordForm from 'src/account_recovery/new_password_form/new_password_form'; +import BackupAccountStep from 'src/account_recovery/backup_account_step/backup_account_step'; +import Footer from 'src/common/footer/footer'; + +import 'font-awesome/scss/font-awesome.scss'; +import './page.scss'; + + +export class Page extends React.Component { + + constructor(props) { + super(props); + this.state = { step: 0, userCode: '' }; + } + + nextStep = (event) => { + if (event) { + event.preventDefault(); + } + this.setState({ step: this.state.step + 1 }); + } + + previousStep = () => { + this.setState({ step: this.state.step - 1 }); + } + + saveUserCode = (event) => { + this.setState({ userCode: event.target.value }); + } + + steps = () => ({ + 0: <AdminRecoveryCodeForm next={this.nextStep} />, + 1: + (<UserRecoveryCodeForm + previous={this.previousStep} + next={this.nextStep} + saveCode={this.saveUserCode} + />), + 2: + (<NewPasswordForm + previous={this.previousStep} + userCode={this.state.userCode} + next={this.nextStep} + />), + 3: <BackupAccountStep /> + }) + + mainContent = () => this.steps()[this.state.step]; + + render() { + const t = this.props.t; + return ( + <DocumentTitle title={t('account-recovery.page-title')}> + <div className='page'> + <Header /> + <section> + <div className='container'> + {this.mainContent()} + </div> + </section> + <Footer /> + </div> + </DocumentTitle> + ); + } +} + +Page.propTypes = { + t: React.PropTypes.func.isRequired +}; + +export default translate('', { wait: true })(Page); diff --git a/web-ui/src/account_recovery/page.scss b/web-ui/src/account_recovery/page.scss new file mode 100644 index 00000000..378bf63c --- /dev/null +++ b/web-ui/src/account_recovery/page.scss @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +@import "~scss/vendor/reset"; +@import "~scss/base/colors"; +@import "~scss/base/fonts"; + +html, body, #root { + height: 100%; +} + +body, #root { + min-height: 100%; +} + +a { + text-decoration: none; +} + +.container { + background: $white; + margin: 3% auto; + box-shadow: 0 2px 3px 0 $shadow; + width: 84%; + padding: 6% 5%; +} + +.page { + font-family: "Open Sans", "Microsoft YaHei", "Hiragino Sans GB", "Hiragino Sans GB W3", "微软雅黑", "Helvetica Neue", Arial, sans-serif; + background: $dark_blue; /* For browsers that do not support gradients */ + background: -webkit-linear-gradient(left top, $dark_blue, $middle_blue); /* For Safari 5.1 to 6.0 */ + background: -o-linear-gradient(bottom right, $dark_blue, $middle_blue); /* For Opera 11.1 to 12.0 */ + background: -moz-linear-gradient(bottom right, $dark_blue, $middle_blue); /* For Firefox 3.6 to 15 */ + background: linear-gradient(to bottom right, $dark_blue, $middle_blue); /* Standard syntax */ + color: $dark_grey_text; + min-height: 100%; + display: flex; + flex-direction: column; +} + +section { + flex: 1 0 auto; +} + +h1 { + font-size: 1.3em; + font-weight: 600; + text-align: center; +} + +p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.account-recovery-form { + display: flex; + flex-direction: column; + + .account-recovery-progress { + align-self: center; + width: 100%; + } + + .input-field-group { + margin-top: 0; + } +} + +@media only screen and (min-width : 500px) { + body { + font-size: 1.3em; + } + + .account-recovery-form { + align-items: center; + + .account-recovery-progress { + margin-bottom: 1.4em; + width: 80%; + } + + h1 { + width: 80%; + } + } +} + +@media only screen and (min-width : 960px) { + .container { + width: 60%; + max-width: 700px; + padding: 3em; + } + + .account-recovery-form { + h1 { + max-width: 80%; + width: auto; + } + + .input-field-group, .submit-button { + width: 60%; + align-self: center; + } + } +} diff --git a/web-ui/src/account_recovery/page.spec.js b/web-ui/src/account_recovery/page.spec.js new file mode 100644 index 00000000..8e4ccc33 --- /dev/null +++ b/web-ui/src/account_recovery/page.spec.js @@ -0,0 +1,91 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; + +import { Page } from 'src/account_recovery/page'; +import Header from 'src/common/header/header'; +import Footer from 'src/common/footer/footer'; + +import AdminRecoveryCodeFormWrapper from './admin_recovery_code_form/admin_recovery_code_form'; +import UserRecoveryCodeFormWrapper from './user_recovery_code_form/user_recovery_code_form'; +import NewPasswordFormWrapper from './new_password_form/new_password_form'; +import BackupAccountStepWrapper from './backup_account_step/backup_account_step'; + +describe('Account Recovery Page', () => { + let page; + let pageInstance; + + beforeEach(() => { + const mockTranslations = key => key; + page = shallow(<Page t={mockTranslations} />); + pageInstance = page.instance(); + }); + + it('renders account recovery page title', () => { + expect(page.props().title).toEqual('account-recovery.page-title'); + }); + + it('renders header without logout button', () => { + expect(page.find(Header).props().renderLogout).toEqual(false); + }); + + it('renders footer', () => { + expect(page.find(Footer).length).toEqual(1); + }); + + it('saves user code', () => { + pageInstance.saveUserCode({ target: { value: '123' } }); + expect(pageInstance.state.userCode).toEqual('123'); + }); + + it('prevents default event before next', () => { + const eventSpy = expect.createSpy(); + pageInstance.nextStep({ preventDefault: eventSpy }); + + expect(eventSpy).toHaveBeenCalled(); + }); + + context('main content', () => { + it('renders admin recovery code form as default form', () => { + expect(page.find(AdminRecoveryCodeFormWrapper).length).toEqual(1); + expect(page.find(UserRecoveryCodeFormWrapper).length).toEqual(0); + expect(page.find(NewPasswordFormWrapper).length).toEqual(0); + }); + + it('renders user recovery code form when admin code submitted', () => { + pageInstance.nextStep(); + + expect(page.find(UserRecoveryCodeFormWrapper).length).toEqual(1); + }); + + it('returns to admin code form on user code form back link', () => { + pageInstance.nextStep(); + pageInstance.previousStep(); + + expect(page.find(AdminRecoveryCodeFormWrapper).length).toEqual(1); + }); + + it('renders new password form when user code submitted', () => { + pageInstance.nextStep(); + pageInstance.nextStep(); + + expect(page.find(NewPasswordFormWrapper).length).toEqual(1); + }); + + it('returns to user code form on new password form back link', () => { + pageInstance.nextStep(); + pageInstance.nextStep(); + pageInstance.previousStep(); + + expect(page.find(UserRecoveryCodeFormWrapper).length).toEqual(1); + }); + + it('renders backup account form after submitting new password', () => { + pageInstance.nextStep(); + pageInstance.nextStep(); + pageInstance.nextStep(); + + expect(page.find(BackupAccountStepWrapper).length).toEqual(1); + }); + }); +}); diff --git a/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.js b/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.js new file mode 100644 index 00000000..c39c894d --- /dev/null +++ b/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.js @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; +import { translate } from 'react-i18next'; + +import InputField from 'src/common/input_field/input_field'; +import SubmitButton from 'src/common/submit_button/submit_button'; +import BackLink from 'src/common/back_link/back_link'; + +import './user_recovery_code_form.scss'; + +export const UserRecoveryCodeForm = ({ t, previous, next, saveCode }) => ( + <form className='account-recovery-form user-code' onSubmit={next}> + <img + className='account-recovery-progress' + src='/public/images/account-recovery/step_2.svg' + alt={t('account-recovery.user-form.image-description')} + /> + <h1>{t('account-recovery.user-form.title')}</h1> + <div className='user-code-form-content'> + <img + className='user-code-image' + src='/public/images/account-recovery/codes.svg' + alt='' + /> + <p>{t('account-recovery.user-form.description')}</p> + </div> + <InputField + name='user-code' label={t('account-recovery.user-form.input-label')} + onChange={saveCode} + /> + <SubmitButton buttonText={t('account-recovery.button-next')} /> + <BackLink text={t('account-recovery.back')} onClick={previous} /> + </form> +); + +UserRecoveryCodeForm.propTypes = { + t: React.PropTypes.func.isRequired, + previous: React.PropTypes.func.isRequired, + next: React.PropTypes.func.isRequired, + saveCode: React.PropTypes.func.isRequired +}; + +export default translate('', { wait: true })(UserRecoveryCodeForm); diff --git a/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.scss b/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.scss new file mode 100644 index 00000000..55f5621b --- /dev/null +++ b/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.scss @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +.user-code-form-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.user-code-image { + margin: 1em 0; + align-self: center; + height: 4em; +} + +@media only screen and (min-width : 500px) { + .user-code-form-content { + flex-direction: row; + width: 80%; + } + + .user-code-image { + margin-right: 1.6em; + } +} diff --git a/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.spec.js b/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.spec.js new file mode 100644 index 00000000..386c3a19 --- /dev/null +++ b/web-ui/src/account_recovery/user_recovery_code_form/user_recovery_code_form.spec.js @@ -0,0 +1,55 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import { UserRecoveryCodeForm } from './user_recovery_code_form'; + +describe('UserRecoveryCodeForm', () => { + let userRecoveryCodeForm; + let mockNext; + let mockPrevious; + let mockSaveCode; + + beforeEach(() => { + const mockTranslations = key => key; + mockNext = expect.createSpy(); + mockPrevious = expect.createSpy(); + mockSaveCode = expect.createSpy(); + userRecoveryCodeForm = shallow( + <UserRecoveryCodeForm + t={mockTranslations} next={mockNext} + previous={mockPrevious} saveCode={mockSaveCode} + /> + ); + }); + + it('renders title for user recovery code', () => { + expect(userRecoveryCodeForm.find('h1').text()).toEqual('account-recovery.user-form.title'); + }); + + it('renders description', () => { + expect(userRecoveryCodeForm.find('p').text()).toEqual('account-recovery.user-form.description'); + }); + + it('renders input for user code', () => { + expect(userRecoveryCodeForm.find('InputField').props().label).toEqual('account-recovery.user-form.input-label'); + }); + + it('renders submit button', () => { + expect(userRecoveryCodeForm.find('SubmitButton').props().buttonText).toEqual('account-recovery.button-next'); + }); + + it('submits form to next step', () => { + userRecoveryCodeForm.find('form').simulate('submit'); + expect(mockNext).toHaveBeenCalled(); + }); + + it('returns to previous step on link click', () => { + userRecoveryCodeForm.find('BackLink').simulate('click'); + expect(mockPrevious).toHaveBeenCalled(); + }); + + it('saves code on input change', () => { + userRecoveryCodeForm.find('InputField').simulate('change', '123'); + expect(mockSaveCode).toHaveBeenCalledWith('123'); + }); +}); diff --git a/web-ui/src/backup_account/backup_email/backup_email.js b/web-ui/src/backup_account/backup_email/backup_email.js index 09863950..ac64f02e 100644 --- a/web-ui/src/backup_account/backup_email/backup_email.js +++ b/web-ui/src/backup_account/backup_email/backup_email.js @@ -18,10 +18,12 @@ import 'isomorphic-fetch'; import React from 'react'; import { translate } from 'react-i18next'; +import validator from 'validator'; + +import { submitForm } from 'src/common/util'; import SubmitButton from 'src/common/submit_button/submit_button'; import InputField from 'src/common/input_field/input_field'; -import validator from 'validator'; -import browser from 'helpers/browser'; +import BackLink from 'src/common/back_link/back_link'; import './backup_email.scss'; @@ -29,7 +31,7 @@ export class BackupEmail extends React.Component { constructor(props) { super(props); - this.state = { error: '', submitButtonDisabled: true }; + this.state = { error: '', submitButtonDisabled: true, backupEmail: '' }; } validateEmail = (event) => { @@ -40,21 +42,20 @@ export class BackupEmail extends React.Component { error: !emptyEmail && !validEmail ? t('backup-account.backup-email.error.invalid-email') : '', submitButtonDisabled: !validEmail || emptyEmail }); - } + }; submitHandler = (event) => { - event.preventDefault(); + submitForm(event, '/backup-account', { + backupEmail: this.state.backupEmail + }).then((response) => { + if (response.ok) this.props.onSubmit('success'); + else this.props.onSubmit('error'); + }); + }; - fetch('/backup-account', { - credentials: 'same-origin', - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - csrftoken: [browser.getCookie('XSRF-TOKEN')] - }) - }).then(() => this.props.onSubmit('success')); + handleChange = (event) => { + this.setState({ backupEmail: event.target.value }); + this.validateEmail(event); } render() { @@ -70,14 +71,12 @@ export class BackupEmail extends React.Component { <h1>{t('backup-account.backup-email.title')}</h1> <p>{t('backup-account.backup-email.paragraph1')}</p> <p>{t('backup-account.backup-email.paragraph2')}</p> - <InputField name='email' label={t('backup-account.backup-email.input-label')} errorText={this.state.error} onChange={this.validateEmail} /> + <InputField name='email' value={this.state.backupEmail} label={t('backup-account.backup-email.input-label')} errorText={this.state.error} onChange={this.handleChange} /> <SubmitButton buttonText={t('backup-account.backup-email.button')} disabled={this.state.submitButtonDisabled} /> - <div className='link-content'> - <a href='/' className='link'> - <i className='fa fa-angle-left' aria-hidden='true' /> - <span>{t('back-to-inbox')}</span> - </a> - </div> + <BackLink + href='/' + text={t('back-to-inbox')} + /> </form> </div> ); diff --git a/web-ui/src/backup_account/backup_email/backup_email.spec.js b/web-ui/src/backup_account/backup_email/backup_email.spec.js index 48199738..a34afa06 100644 --- a/web-ui/src/backup_account/backup_email/backup_email.spec.js +++ b/web-ui/src/backup_account/backup_email/backup_email.spec.js @@ -3,12 +3,12 @@ import expect from 'expect'; import React from 'react'; import fetchMock from 'fetch-mock'; import { BackupEmail } from 'src/backup_account/backup_email/backup_email'; -import browser from 'helpers/browser'; describe('BackupEmail', () => { let backupEmail; let mockOnSubmit; let mockTranslations; + let backupEmailInstance; beforeEach(() => { mockOnSubmit = expect.createSpy(); @@ -30,8 +30,6 @@ describe('BackupEmail', () => { }); describe('Email validation', () => { - let backupEmailInstance; - beforeEach(() => { backupEmailInstance = backupEmail.instance(); }); @@ -84,42 +82,64 @@ describe('BackupEmail', () => { }); }); + describe('Email changing handler', () => { + beforeEach(() => { + backupEmailInstance = backupEmail.instance(); + }); + + it('sets user backup email in the state', () => { + backupEmailInstance.handleChange({ target: { value: 'test@test.com' } }); + expect(backupEmailInstance.state.backupEmail).toEqual('test@test.com'); + }); + }); + describe('Submit', () => { let preventDefaultSpy; - beforeEach((done) => { - mockOnSubmit = expect.createSpy().andCall(() => done()); + beforeEach(() => { preventDefaultSpy = expect.createSpy(); - expect.spyOn(browser, 'getCookie').andReturn('abc123'); + }); - backupEmail = shallow(<BackupEmail t={mockTranslations} onSubmit={mockOnSubmit} />); + context('on success', () => { + beforeEach((done) => { + mockOnSubmit = expect.createSpy().andCall(() => done()); - fetchMock.post('/backup-account', 204); - backupEmail.find('form').simulate('submit', { preventDefault: preventDefaultSpy }); - }); + fetchMock.post('/backup-account', 204); + backupEmail = shallow(<BackupEmail t={mockTranslations} onSubmit={mockOnSubmit} />); - it('posts backup email', () => { - expect(fetchMock.called('/backup-account')).toBe(true, 'Backup account POST was not called'); - }); + backupEmail.find('InputField').simulate('change', { target: { value: 'test@test.com' } }); + backupEmail.find('form').simulate('submit', { preventDefault: preventDefaultSpy }); + }); - it('sends csrftoken as content', () => { - expect(fetchMock.lastOptions('/backup-account').body).toContain('"csrftoken":["abc123"]'); - }); + it('posts backup email', () => { + expect(fetchMock.called('/backup-account')).toBe(true, 'Backup account POST was not called'); + }); - it('sends content-type header', () => { - expect(fetchMock.lastOptions('/backup-account').headers['Content-Type']).toEqual('application/json'); - }); + it('sends user email as content', () => { + expect(fetchMock.lastOptions('/backup-account').body).toContain('"backupEmail":"test@test.com"'); + }); - it('sends same origin headers', () => { - expect(fetchMock.lastOptions('/backup-account').credentials).toEqual('same-origin'); + it('calls onSubmit from props with success', () => { + expect(mockOnSubmit).toHaveBeenCalledWith('success'); + }); }); - it('prevents default call to refresh page', () => { - expect(preventDefaultSpy).toHaveBeenCalled(); - }); + context('on error', () => { + beforeEach((done) => { + mockOnSubmit = expect.createSpy().andCall(() => done()); + + fetchMock.post('/backup-account', 500); + backupEmail = shallow(<BackupEmail t={mockTranslations} onSubmit={mockOnSubmit} />); + backupEmail.find('form').simulate('submit', { preventDefault: preventDefaultSpy }); + }); - it('calls onSubmit from props when success', () => { - expect(mockOnSubmit).toHaveBeenCalledWith('success'); + it('calls onSubmit from props with error', () => { + expect(mockOnSubmit).toHaveBeenCalledWith('error'); + }); }); }); + + afterEach(() => { + fetchMock.restore(); + }); }); diff --git a/web-ui/src/backup_account/confirmation/confirmation.js b/web-ui/src/backup_account/confirmation/confirmation.js index 41637dab..49b0d19c 100644 --- a/web-ui/src/backup_account/confirmation/confirmation.js +++ b/web-ui/src/backup_account/confirmation/confirmation.js @@ -18,6 +18,7 @@ import React from 'react'; import { translate } from 'react-i18next'; import SubmitButton from 'src/common/submit_button/submit_button'; +import BackLink from 'src/common/back_link/back_link'; import './confirmation.scss'; @@ -29,12 +30,10 @@ export const Confirmation = ({ t }) => ( <form action='/'> <SubmitButton buttonText={t('backup-account.confirmation.button')} type='submit' /> </form> - <div className='link-content'> - <a href='/backup-account' className='link'> - <i className='fa fa-angle-left' aria-hidden='true' /> - <span>{t('backup-account.confirmation.retry-button')}</span> - </a> - </div> + <BackLink + href='/backup-account' + text={t('backup-account.confirmation.retry-button')} + /> </div> ); diff --git a/web-ui/src/backup_account/confirmation/confirmation.spec.js b/web-ui/src/backup_account/confirmation/confirmation.spec.js index 291d156d..7a6f38ca 100644 --- a/web-ui/src/backup_account/confirmation/confirmation.spec.js +++ b/web-ui/src/backup_account/confirmation/confirmation.spec.js @@ -20,10 +20,10 @@ describe('Confirmation', () => { }); it('renders confirmation retry button', () => { - expect(page.find('a').text()).toEqual('backup-account.confirmation.retry-button'); + expect(page.find('BackLink').props().text).toEqual('backup-account.confirmation.retry-button'); }); it('retries button redirects to backup account', () => { - expect(page.find('a').props().href).toEqual('/backup-account'); + expect(page.find('BackLink').props().href).toEqual('/backup-account'); }); }); diff --git a/web-ui/src/backup_account/page.js b/web-ui/src/backup_account/page.js index 49e4b316..e7663205 100644 --- a/web-ui/src/backup_account/page.js +++ b/web-ui/src/backup_account/page.js @@ -22,6 +22,7 @@ import Footer from 'src/common/footer/footer'; import Header from 'src/common/header/header'; import BackupEmail from 'src/backup_account/backup_email/backup_email'; import Confirmation from 'src/backup_account/confirmation/confirmation'; +import SnackbarNotification from 'src/common/snackbar_notification/snackbar_notification'; import 'font-awesome/scss/font-awesome.scss'; import './page.scss'; @@ -36,22 +37,30 @@ export class Page extends React.Component { saveBackupEmail = (status) => { this.setState({ status }); - } + }; mainContent = () => { if (this.state.status === 'success') return <Confirmation />; return <BackupEmail onSubmit={this.saveBackupEmail} />; }; + showSnackbarOnError = (t) => { + if (this.state.status === 'error') { + return <SnackbarNotification message={t('backup-account.error.submit-error')} isError />; + } + return undefined; // To satisfy eslint error - consistent-return + }; + render() { const t = this.props.t; return ( <DocumentTitle title={t('backup-account.page-title')}> <div className='page'> - <Header /> + <Header renderLogout /> <section> {this.mainContent()} </section> + {this.showSnackbarOnError(t)} <Footer /> </div> </DocumentTitle> diff --git a/web-ui/src/backup_account/page.scss b/web-ui/src/backup_account/page.scss index 71e3f074..d4f1f887 100644 --- a/web-ui/src/backup_account/page.scss +++ b/web-ui/src/backup_account/page.scss @@ -64,20 +64,6 @@ p { margin-bottom: 0.5em; } -.link { - color: $dark_blue; - font-style: italic; - font-size: 0.8em; - - .fa { - font-size: 1.6em; - position: relative; - top: 3px; - margin-right: 0.3em; - } - -} - @media only screen and (min-width : 500px) { body { font-size: 1.3em; diff --git a/web-ui/src/backup_account/page.spec.js b/web-ui/src/backup_account/page.spec.js index bd7bb884..8c014ee4 100644 --- a/web-ui/src/backup_account/page.spec.js +++ b/web-ui/src/backup_account/page.spec.js @@ -4,6 +4,8 @@ import React from 'react'; import { Page } from 'src/backup_account/page'; import BackupEmail from 'src/backup_account/backup_email/backup_email'; import Confirmation from 'src/backup_account/confirmation/confirmation'; +import SnackbarNotification from 'src/common/snackbar_notification/snackbar_notification'; +import Header from 'src/common/header/header'; describe('BackupAccount', () => { let page; @@ -17,6 +19,10 @@ describe('BackupAccount', () => { expect(page.props().title).toEqual('backup-account.page-title'); }); + it('renders header with logout button', () => { + expect(page.find(Header).props().renderLogout).toEqual(true); + }); + describe('save backup email', () => { let pageInstance; @@ -41,5 +47,29 @@ describe('BackupAccount', () => { pageInstance.saveBackupEmail('success'); expect(page.find(Confirmation).length).toEqual(1); }); + + context('on submit error', () => { + beforeEach(() => { + pageInstance.saveBackupEmail('error'); + }); + + it('returns snackbar component on error', () => { + const snackbar = pageInstance.showSnackbarOnError(pageInstance.props.t); + expect(snackbar).toEqual(<SnackbarNotification message='backup-account.error.submit-error' isError />); + }); + + it('returns nothing when there is no error', () => { + pageInstance.saveBackupEmail('success'); + const snackbar = pageInstance.showSnackbarOnError(pageInstance.props.t); + expect(snackbar).toEqual(undefined); + }); + + it('renders snackbar notification on error', () => { + const snackbar = page.find(SnackbarNotification); + expect(snackbar).toExist(); + expect(snackbar.props().message).toEqual('backup-account.error.submit-error'); + expect(snackbar.props().isError).toEqual(true); + }); + }); }); }); diff --git a/web-ui/src/common/back_link/back_link.js b/web-ui/src/common/back_link/back_link.js new file mode 100644 index 00000000..bb5ffbea --- /dev/null +++ b/web-ui/src/common/back_link/back_link.js @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; + +import './back_link.scss'; + +const icon = <i className='fa fa-angle-left' aria-hidden='true' />; + +const button = (text, options) => ( + <button className='link' {...options}>{icon}<span>{text}</span></button> +); + +const link = (text, options) => ( + <a className='link' {...options}>{icon}<span>{text}</span></a> +); + +const BackLink = ({ text, ...other }) => ( + <div className='link-content'> + { other.href ? link(text, other) : button(text, other) } + </div> +); + +BackLink.propTypes = { + text: React.PropTypes.string.isRequired +}; + +export default BackLink; diff --git a/web-ui/src/common/back_link/back_link.scss b/web-ui/src/common/back_link/back_link.scss new file mode 100644 index 00000000..5541d9d9 --- /dev/null +++ b/web-ui/src/common/back_link/back_link.scss @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +@import "~scss/base/colors"; + +.link { + color: $dark_blue; + font-style: italic; + font-size: 0.8em; + margin: 0; + padding: 0; + border: 0; + background: transparent; + + .fa { + font-size: 1.6em; + position: relative; + top: 3px; + margin-right: 0.3em; + } +} diff --git a/web-ui/src/common/back_link/back_link.spec.js b/web-ui/src/common/back_link/back_link.spec.js new file mode 100644 index 00000000..5f49a6f9 --- /dev/null +++ b/web-ui/src/common/back_link/back_link.spec.js @@ -0,0 +1,41 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import BackLink from 'src/common/back_link/back_link'; + +describe('BackLink', () => { + context('as link', () => { + let backLink; + + beforeEach(() => { + backLink = shallow(<BackLink text='Back to inbox' href='/' />); + }); + + it('renders link with text', () => { + expect(backLink.find('a').text()).toEqual('Back to inbox'); + }); + + it('adds link action', () => { + expect(backLink.find('a').props().href).toEqual('/'); + }); + }); + + context('as button', () => { + let backLink; + let mockClick; + + beforeEach(() => { + mockClick = expect.createSpy(); + backLink = shallow(<BackLink text='Back to inbox' onClick={mockClick} />); + }); + + it('renders button with text', () => { + expect(backLink.find('button').text()).toEqual('Back to inbox'); + }); + + it('adds button click event', () => { + backLink.find('button').simulate('click'); + expect(mockClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/web-ui/src/common/header/header.js b/web-ui/src/common/header/header.js index 715d54c6..3ad924c0 100644 --- a/web-ui/src/common/header/header.js +++ b/web-ui/src/common/header/header.js @@ -19,7 +19,7 @@ import React from 'react'; import Logout from 'src/common/logout/logout'; import './header.scss'; -export const Header = () => ( +export const Header = ({ renderLogout }) => ( <header className='header-wrapper'> <div className='header-content'> <a href='/'> @@ -30,10 +30,18 @@ export const Header = () => ( /> </a> <div className='header-icons'> - <Logout /> + { renderLogout ? <Logout /> : <div /> } </div> </div> </header> ); +Header.propTypes = { + renderLogout: React.PropTypes.bool +}; + +Header.defaultProps = { + renderLogout: false +}; + export default Header; diff --git a/web-ui/src/common/header/header.spec.js b/web-ui/src/common/header/header.spec.js index 81a952c7..0c11713b 100644 --- a/web-ui/src/common/header/header.spec.js +++ b/web-ui/src/common/header/header.spec.js @@ -11,11 +11,16 @@ describe('Header', () => { header = shallow(<Header />); }); - it('renders the header containing the logout button', () => { - expect(header.find('header').find(Logout)).toExist(); - }); - it('renders the header pixelated logo', () => { expect(header.find('header').find('img').props().alt).toEqual('Pixelated'); }); + + it('renders the header containing the logout button when renderLogout is true', () => { + header = shallow(<Header renderLogout />); + expect(header.find('header').find(Logout).length).toEqual(1); + }); + + it('hides logout button when renderLogout is false', () => { + expect(header.find('header').find(Logout).length).toEqual(0); + }); }); diff --git a/web-ui/src/common/link_button/link_button.js b/web-ui/src/common/link_button/link_button.js new file mode 100644 index 00000000..e7fd80b0 --- /dev/null +++ b/web-ui/src/common/link_button/link_button.js @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; +import FlatButton from 'material-ui/FlatButton'; + +import './link_button.scss'; + +const labelStyle = { + textTransform: 'none', + color: 'inherit', + fontSize: 'inherit', + width: '100%', + padding: '0' +}; + +const linkButtonStyle = { + color: 'inherit', + borderRadius: '0', + minHeight: '36px', + height: 'auto', + lineHeight: '20px', + padding: '12px 0' +}; + +const LinkButton = ({ buttonText, href }) => ( + <div className='link-button'> + <FlatButton + href={href} + containerElement='a' + label={buttonText} + labelStyle={labelStyle} + hoverColor={'#ff9c00'} + style={linkButtonStyle} + /> + </div> +); + +LinkButton.propTypes = { + buttonText: React.PropTypes.string.isRequired, + href: React.PropTypes.string.isRequired +}; + +export default LinkButton; diff --git a/web-ui/src/common/link_button/link_button.scss b/web-ui/src/common/link_button/link_button.scss new file mode 100644 index 00000000..3b4a534c --- /dev/null +++ b/web-ui/src/common/link_button/link_button.scss @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +@import "~scss/base/colors"; + +.link-button > a { + width: 100%; +} + +.link-button { + width: 100%; + margin-top: 2em; + border: 2px solid $light_orange; + border-radius: 2px; + font-size: 1em; + color: $light_orange; + &:hover { + color: $white; + } +} + +@media only screen and (min-width : 500px) { + .link-button { + width: 70%; + align-self: center; + } +} + +@media only screen and (min-width : 960px) { + .link-button { + width: 300px; + margin-bottom: 1em; + font-size: 0.8em; + } +} diff --git a/web-ui/src/common/link_button/link_button.spec.js b/web-ui/src/common/link_button/link_button.spec.js new file mode 100644 index 00000000..945afffe --- /dev/null +++ b/web-ui/src/common/link_button/link_button.spec.js @@ -0,0 +1,20 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import LinkButton from 'src/common/link_button/link_button'; + +describe('LinkButton', () => { + let linkButton; + + beforeEach(() => { + linkButton = shallow(<LinkButton buttonText='Go To Link' href='/some-link' />); + }); + + it('renders link button with given button text', () => { + expect(linkButton.find('FlatButton').props().label).toEqual('Go To Link'); + }); + + it('renders link button with given href', () => { + expect(linkButton.find('FlatButton').props().href).toEqual('/some-link'); + }); +}); diff --git a/web-ui/src/common/snackbar_notification/snackbar_notification.js b/web-ui/src/common/snackbar_notification/snackbar_notification.js new file mode 100644 index 00000000..8a7e5094 --- /dev/null +++ b/web-ui/src/common/snackbar_notification/snackbar_notification.js @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +import React from 'react'; +import Snackbar from 'material-ui/Snackbar'; +import { red500, blue500 } from 'material-ui/styles/colors'; + +import './snackbar_notification.scss'; + +const notificationStyle = () => ({ + top: 0, + left: 'auto', + bottom: 'auto', + alignSelf: 'center', + transform: 'translate3d(0, 0px, 0)' +}); + +const contentStyle = { + textAlign: 'center' +}; + +const getStyleByType = (isError) => { + const style = { height: 'auto' }; + style.backgroundColor = isError ? red500 : blue500; + return style; +}; + +const SnackbarNotification = ({ message, isError = false, autoHideDuration = 5000 }) => ( + <Snackbar + id='snackbar' + open + bodyStyle={getStyleByType(isError)} + message={message} + autoHideDuration={autoHideDuration} + contentStyle={contentStyle} + style={notificationStyle()} + /> +); + +SnackbarNotification.propTypes = { + message: React.PropTypes.string.isRequired, + isError: React.PropTypes.bool, + autoHideDuration: React.PropTypes.number +}; + +SnackbarNotification.defaultProps = { + isError: false, + autoHideDuration: 5000 +}; + +export default SnackbarNotification; diff --git a/web-ui/src/common/snackbar_notification/snackbar_notification.scss b/web-ui/src/common/snackbar_notification/snackbar_notification.scss new file mode 100644 index 00000000..e37ba4ae --- /dev/null +++ b/web-ui/src/common/snackbar_notification/snackbar_notification.scss @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2017 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + */ + +//TODO: Refer to Issue - https://github.com/callemall/material-ui/issues/3860 +#snackbar > div { + line-height: 20px !important; + padding: 24px !important; +} diff --git a/web-ui/src/common/snackbar_notification/snackbar_notification.spec.js b/web-ui/src/common/snackbar_notification/snackbar_notification.spec.js new file mode 100644 index 00000000..24b79e71 --- /dev/null +++ b/web-ui/src/common/snackbar_notification/snackbar_notification.spec.js @@ -0,0 +1,31 @@ +import { shallow } from 'enzyme'; +import expect from 'expect'; +import React from 'react'; +import SnackbarNotification from 'src/common/snackbar_notification/snackbar_notification'; +import Snackbar from 'material-ui/Snackbar'; +import { red500 } from 'material-ui/styles/colors'; + +describe('SnackbarNotification', () => { + let snackbarNotification; + + beforeEach(() => { + snackbarNotification = shallow(<SnackbarNotification message={'Error Message'} isError />); + }); + + it('renders snackbar with error message', () => { + expect(snackbarNotification.find(Snackbar).props().message).toEqual('Error Message'); + }); + + it('renders snackbar with open as true', () => { + expect(snackbarNotification.find(Snackbar).props().open).toEqual(true); + }); + + it('renders snackbar with error body style', () => { + expect(snackbarNotification.find(Snackbar).props().bodyStyle) + .toEqual({ height: 'auto', backgroundColor: red500 }); + }); + + it('renders snackbar with default auto-hide duration', () => { + expect(snackbarNotification.find(Snackbar).props().autoHideDuration).toEqual(5000); + }); +}); diff --git a/web-ui/src/common/submit_button/submit_button.js b/web-ui/src/common/submit_button/submit_button.js index 1224c7bd..f77a5596 100644 --- a/web-ui/src/common/submit_button/submit_button.js +++ b/web-ui/src/common/submit_button/submit_button.js @@ -30,7 +30,7 @@ const buttonStyle = { height: '48px' }; -const SubmitButton = ({ buttonText, disabled = false }) => ( +const SubmitButton = ({ buttonText, disabled = false, ...other }) => ( <div className='submit-button'> <RaisedButton type='submit' @@ -41,6 +41,7 @@ const SubmitButton = ({ buttonText, disabled = false }) => ( overlayStyle={buttonStyle} fullWidth primary + {...other} /> </div> ); diff --git a/web-ui/src/common/util.js b/web-ui/src/common/util.js index effb3d9c..c70a8444 100644 --- a/web-ui/src/common/util.js +++ b/web-ui/src/common/util.js @@ -1,8 +1,27 @@ +import browser from 'helpers/browser'; + export const hasQueryParameter = (param) => { const decodedUri = decodeURIComponent(window.location.search.substring(1)); return !(decodedUri.split('&').indexOf(param) < 0); }; +export const submitForm = (event, url, body = {}) => { + event.preventDefault(); + + return fetch(url, { + credentials: 'same-origin', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + csrftoken: [browser.getCookie('XSRF-TOKEN')], + ...body + }) + }); +}; + export default { - hasQueryParameter + hasQueryParameter, + submitForm }; diff --git a/web-ui/src/common/util.spec.js b/web-ui/src/common/util.spec.js index 805d9dd5..a79859a0 100644 --- a/web-ui/src/common/util.spec.js +++ b/web-ui/src/common/util.spec.js @@ -1,4 +1,7 @@ import expect from 'expect'; +import fetchMock from 'fetch-mock'; + +import browser from 'helpers/browser'; import Util from 'src/common/util'; describe('Utils', () => { @@ -17,4 +20,36 @@ describe('Utils', () => { expect(Util.hasQueryParameter('error')).toBe(false); }); }); + + describe('submitForm', () => { + const event = {}; + + beforeEach(() => { + event.preventDefault = expect.createSpy(); + expect.spyOn(browser, 'getCookie').andReturn('abc123'); + + fetchMock.post('/some-url', 200); + Util.submitForm(event, '/some-url', { userCode: '123' }); + }); + + it('sends csrftoken as content', () => { + expect(fetchMock.lastOptions('/some-url').body).toContain('"csrftoken":["abc123"]'); + }); + + it('sends body as content', () => { + expect(fetchMock.lastOptions('/some-url').body).toContain('"userCode":"123"'); + }); + + it('sends content-type header', () => { + expect(fetchMock.lastOptions('/some-url').headers['Content-Type']).toEqual('application/json'); + }); + + it('sends same origin headers', () => { + expect(fetchMock.lastOptions('/some-url').credentials).toEqual('same-origin'); + }); + + it('prevents default call to refresh page', () => { + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); }); diff --git a/web-ui/src/login/opensans.css b/web-ui/src/login/opensans.css index 8795bdf7..74f98822 100644 --- a/web-ui/src/login/opensans.css +++ b/web-ui/src/login/opensans.css @@ -2,68 +2,68 @@ font-family: 'Open Sans'; font-style: normal; font-weight: 300; - src: local("Open Sans Light"), local("OpenSans-Light"), url("/assets/fonts/OpenSans-Light.woff") format("woff"); + src: local("Open Sans Light"), local("OpenSans-Light"), url("fonts/OpenSans-Light.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 400; - src: local("Open Sans"), local("OpenSans"), url("/assets/fonts/OpenSans.woff") format("woff"); + src: local("Open Sans"), local("OpenSans"), url("fonts/OpenSans.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 600; - src: local("Open Sans Semibold"), local("OpenSans-Semibold"), url("/assets/fonts/OpenSans-Semibold.woff") format("woff"); + src: local("Open Sans Semibold"), local("OpenSans-Semibold"), url("fonts/OpenSans-Semibold.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 700; - src: local("Open Sans Bold"), local("OpenSans-Bold"), url("/assets/fonts/OpenSans-Bold.woff") format("woff"); + src: local("Open Sans Bold"), local("OpenSans-Bold"), url("fonts/OpenSans-Bold.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 800; - src: local("Open Sans Extrabold"), local("OpenSans-Extrabold"), url("/assets/fonts/OpenSans-Extrabold.woff") format("woff"); + src: local("Open Sans Extrabold"), local("OpenSans-Extrabold"), url("fonts/OpenSans-Extrabold.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: italic; font-weight: 300; - src: local("Open Sans Light Italic"), local("OpenSansLight-Italic"), url("/assets/fonts/OpenSansLight-Italic.woff") format("woff"); + src: local("Open Sans Light Italic"), local("OpenSansLight-Italic"), url("fonts/OpenSansLight-Italic.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: italic; font-weight: 400; - src: local("Open Sans Italic"), local("OpenSans-Italic"), url("/assets/fonts/OpenSans-Italic.woff") format("woff"); + src: local("Open Sans Italic"), local("OpenSans-Italic"), url("fonts/OpenSans-Italic.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: italic; font-weight: 600; - src: local("Open Sans Semibold Italic"), local("OpenSans-SemiboldItalic"), url("/assets/fonts/OpenSans-SemiboldItalic.woff ") format("woff"); + src: local("Open Sans Semibold Italic"), local("OpenSans-SemiboldItalic"), url("fonts/OpenSans-SemiboldItalic.woff ") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: italic; font-weight: 700; - src: local("Open Sans Bold Italic"), local("OpenSans-BoldItalic"), url("/assets/fonts/OpenSans-BoldItalic.woff") format("woff"); + src: local("Open Sans Bold Italic"), local("OpenSans-BoldItalic"), url("fonts/OpenSans-BoldItalic.woff") format("woff"); } @font-face { font-family: 'Open Sans'; font-style: italic; font-weight: 800; - src: local("Open Sans Extrabold Italic"), local("OpenSans-ExtraboldItalic"), url("/assets/fonts/OpenSans-ExtraboldItalic.woff") format("woff"); + src: local("Open Sans Extrabold Italic"), local("OpenSans-ExtraboldItalic"), url("fonts/OpenSans-ExtraboldItalic.woff") format("woff"); } |