Merge branch 'master' into profile

This commit is contained in:
SleepWalker
2016-03-16 08:04:06 +02:00
43 changed files with 568 additions and 188 deletions

View File

@ -18,11 +18,11 @@
"history": "^1.17.0", "history": "^1.17.0",
"intl-format-cache": "^2.0.4", "intl-format-cache": "^2.0.4",
"intl-messageformat": "^1.1.0", "intl-messageformat": "^1.1.0",
"react": "^0.14.0", "react": "^15.0.0-rc.1",
"react-dom": "^0.14.3", "react-dom": "^15.0.0-rc.1",
"react-height": "^2.0.3", "react-height": "^2.0.3",
"react-helmet": "^2.3.1", "react-helmet": "^2.3.1",
"react-intl": "=2.0.0-beta-2", "react-intl": "=v2.0.0-rc-1",
"react-motion": "^0.4.0", "react-motion": "^0.4.0",
"react-redux": "^4.0.0", "react-redux": "^4.0.0",
"react-router": "^2.0.0", "react-router": "^2.0.0",

View File

@ -3,30 +3,33 @@
*/ */
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import AuthError from './AuthError'; import AuthError from 'components/auth/authError/AuthError';
import { userShape } from 'components/user/User';
export default class BaseAuthBody extends Component { export default class BaseAuthBody extends Component {
static propTypes = { static contextTypes = {
clearErrors: PropTypes.func.isRequired, clearErrors: PropTypes.func.isRequired,
resolve: PropTypes.func.isRequired, resolve: PropTypes.func.isRequired,
reject: PropTypes.func.isRequired, reject: PropTypes.func.isRequired,
auth: PropTypes.shape({ auth: PropTypes.shape({
error: PropTypes.string error: PropTypes.string,
}) scopes: PropTypes.array
}),
user: userShape
}; };
renderErrors() { renderErrors() {
return this.props.auth.error return this.context.auth.error
? <AuthError error={this.props.auth.error} onClose={this.onClearErrors} /> ? <AuthError error={this.context.auth.error} onClose={this.onClearErrors} />
: '' : ''
; ;
} }
onFormSubmit() { onFormSubmit() {
this.props.resolve(this.serialize()); this.context.resolve(this.serialize());
} }
onClearErrors = this.props.clearErrors; onClearErrors = this.context.clearErrors;
form = {}; form = {};
@ -39,6 +42,35 @@ export default class BaseAuthBody extends Component {
}; };
} }
/**
* Fixes some issues with scroll, when input beeing focused
*
* When an element is focused, by default browsers will scroll its parents to display
* focused item to user. This behavior may cause unexpected visual effects, when
* you animating apearing of an input (e.g. transform) and auto focusing it. In
* that case the browser will scroll the parent container so that input will be
* visible.
* This method will fix that issue by finding parent with overflow: hidden and
* reseting its scrollLeft value to 0.
*
* Usage:
* <input autoFocus onFocus={this.fixAutoFocus} />
*
* @param {Object} event
*/
fixAutoFocus = (event) => {
let el = event.target;
while (el.parentNode) {
el = el.parentNode;
if (getComputedStyle(el).overflow === 'hidden') {
el.scrollLeft = 0;
break;
}
}
};
serialize() { serialize() {
return Object.keys(this.form).reduce((acc, key) => { return Object.keys(this.form).reduce((acc, key) => {
acc[key] = this.form[key].getValue(); acc[key] = this.form[key].getValue();

View File

@ -10,6 +10,7 @@ import {helpLinks as helpLinksStyles} from 'components/auth/helpLinks.scss';
import panelStyles from 'components/ui/panel.scss'; import panelStyles from 'components/ui/panel.scss';
import icons from 'components/ui/icons.scss'; import icons from 'components/ui/icons.scss';
import authFlow from 'services/authFlow'; import authFlow from 'services/authFlow';
import { userShape } from 'components/user/User';
import * as actions from './actions'; import * as actions from './actions';
@ -21,6 +22,7 @@ class PanelTransition extends Component {
static displayName = 'PanelTransition'; static displayName = 'PanelTransition';
static propTypes = { static propTypes = {
// context props
auth: PropTypes.shape({ auth: PropTypes.shape({
error: PropTypes.string, error: PropTypes.string,
login: PropTypes.shape({ login: PropTypes.shape({
@ -28,15 +30,45 @@ class PanelTransition extends Component {
password: PropTypes.string password: PropTypes.string
}) })
}).isRequired, }).isRequired,
user: userShape.isRequired,
setError: React.PropTypes.func.isRequired, setError: React.PropTypes.func.isRequired,
clearErrors: React.PropTypes.func.isRequired, clearErrors: React.PropTypes.func.isRequired,
resolve: React.PropTypes.func.isRequired,
reject: React.PropTypes.func.isRequired,
// local props
path: PropTypes.string.isRequired, path: PropTypes.string.isRequired,
Title: PropTypes.element.isRequired, Title: PropTypes.element,
Body: PropTypes.element.isRequired, Body: PropTypes.element,
Footer: PropTypes.element.isRequired, Footer: PropTypes.element,
Links: PropTypes.element.isRequired Links: PropTypes.element,
children: PropTypes.element
}; };
static childContextTypes = {
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
login: PropTypes.string,
password: PropTypes.string
})
}),
user: userShape,
clearErrors: React.PropTypes.func,
resolve: PropTypes.func,
reject: PropTypes.func
};
getChildContext() {
return {
auth: this.props.auth,
user: this.props.user,
clearErrors: this.props.clearErrors,
resolve: this.props.resolve,
reject: this.props.reject
};
}
state = { state = {
height: {}, height: {},
contextHeight: 0 contextHeight: 0
@ -70,6 +102,12 @@ class PanelTransition extends Component {
const {path, Title, Body, Footer, Links} = this.props; const {path, Title, Body, Footer, Links} = this.props;
if (this.props.children) {
return this.props.children;
} else if (!Title || !Body || !Footer || !Links) {
throw new Error('Title, Body, Footer and Links are required');
}
return ( return (
<TransitionMotion <TransitionMotion
styles={[ styles={[
@ -91,7 +129,7 @@ class PanelTransition extends Component {
const contentHeight = { const contentHeight = {
overflow: 'hidden', overflow: 'hidden',
height: forceHeight ? common.switchContextHeightSpring : 'auto' height: forceHeight ? common.style.switchContextHeightSpring : 'auto'
}; };
const bodyHeight = { const bodyHeight = {
@ -141,6 +179,7 @@ class PanelTransition extends Component {
/** /**
* @param {Object} config * @param {Object} config
* @param {string} config.key
* @param {Object} [options] * @param {Object} [options]
* @param {Object} [options.isLeave=false] - true, if this is a leave transition * @param {Object} [options.isLeave=false] - true, if this is a leave transition
* *
@ -155,7 +194,7 @@ class PanelTransition extends Component {
'/password': 1, '/password': 1,
'/activation': 1, '/activation': 1,
'/oauth/permissions': -1, '/oauth/permissions': -1,
'/password-change': 1, '/change-password': 1,
'/forgot-password': 1 '/forgot-password': 1
}; };
const sign = map[key]; const sign = map[key];
@ -177,7 +216,7 @@ class PanelTransition extends Component {
'/register': not('/activation') ? 'Y' : 'X', '/register': not('/activation') ? 'Y' : 'X',
'/activation': not('/register') ? 'Y' : 'X', '/activation': not('/register') ? 'Y' : 'X',
'/oauth/permissions': 'Y', '/oauth/permissions': 'Y',
'/password-change': 'Y', '/change-password': 'Y',
'/forgot-password': not('/password') && not('/login') ? 'Y' : 'X' '/forgot-password': not('/password') && not('/login') ? 'Y' : 'X'
}; };
@ -235,7 +274,7 @@ class PanelTransition extends Component {
<div key={`header${key}`} style={style}> <div key={`header${key}`} style={style}>
{hasBackButton ? backButton : null} {hasBackButton ? backButton : null}
<div style={scrollStyle}> <div style={scrollStyle}>
{React.cloneElement(Title, this.props)} {Title}
</div> </div>
</div> </div>
); );
@ -263,7 +302,6 @@ class PanelTransition extends Component {
return ( return (
<ReactHeight key={`body${key}`} style={style} onHeightReady={this.onUpdateHeight}> <ReactHeight key={`body${key}`} style={style} onHeightReady={this.onUpdateHeight}>
{React.cloneElement(Body, { {React.cloneElement(Body, {
...this.props,
ref: (body) => { ref: (body) => {
this.body = body; this.body = body;
} }
@ -279,7 +317,7 @@ class PanelTransition extends Component {
return ( return (
<div key={`footer${key}`} style={style}> <div key={`footer${key}`} style={style}>
{React.cloneElement(Footer, this.props)} {Footer}
</div> </div>
); );
} }
@ -291,15 +329,15 @@ class PanelTransition extends Component {
return ( return (
<div key={`links${key}`} style={style}> <div key={`links${key}`} style={style}>
{React.cloneElement(Links, this.props)} {Links}
</div> </div>
); );
} }
/** /**
* @param {string} key * @param {string} key
* @param {Object} props * @param {Object} style
* @param {number} props.opacitySpring * @param {number} style.opacitySpring
* *
* @return {Object} * @return {Object}
*/ */

View File

@ -1,6 +1,6 @@
import { routeActions } from 'react-router-redux'; import { routeActions } from 'react-router-redux';
import { updateUser, logout as logoutUser, authenticate } from 'components/user/actions'; import { updateUser, logout as logoutUser, changePassword as changeUserPassword, authenticate } from 'components/user/actions';
import request from 'services/request'; import request from 'services/request';
export function login({login = '', password = '', rememberMe = false}) { export function login({login = '', password = '', rememberMe = false}) {
@ -32,7 +32,7 @@ export function login({login = '', password = '', rememberMe = false}) {
username: login, username: login,
email: login email: login
})); }));
} else { } else if (resp.errors) {
if (resp.errors.login === LOGIN_REQUIRED && password) { if (resp.errors.login === LOGIN_REQUIRED && password) {
dispatch(logout()); dispatch(logout());
} }
@ -46,6 +46,25 @@ export function login({login = '', password = '', rememberMe = false}) {
; ;
} }
export function changePassword({
password = '',
newPassword = '',
newRePassword = ''
}) {
return (dispatch) =>
dispatch(changeUserPassword({password, newPassword, newRePassword}))
.catch((resp) => {
if (resp.errors) {
const errorMessage = resp.errors[Object.keys(resp.errors)[0]];
dispatch(setError(errorMessage));
throw new Error(errorMessage);
}
// TODO: log unexpected errors
})
;
}
export function register({ export function register({
email = '', email = '',
username = '', username = '',
@ -67,9 +86,11 @@ export function register({
dispatch(routeActions.push('/activation')); dispatch(routeActions.push('/activation'));
}) })
.catch((resp) => { .catch((resp) => {
const errorMessage = resp.errors[Object.keys(resp.errors)[0]]; if (resp.errors) {
dispatch(setError(errorMessage)); const errorMessage = resp.errors[Object.keys(resp.errors)[0]];
throw new Error(errorMessage); dispatch(setError(errorMessage));
throw new Error(errorMessage);
}
// TODO: log unexpected errors // TODO: log unexpected errors
}) })
@ -151,6 +172,7 @@ export function oAuthComplete(params = {}) {
if (resp.statusCode === 401 && resp.error === 'access_denied') { if (resp.statusCode === 401 && resp.error === 'access_denied') {
// user declined permissions // user declined permissions
return { return {
success: false,
redirectUri: resp.redirectUri redirectUri: resp.redirectUri
}; };
} }
@ -168,6 +190,18 @@ export function oAuthComplete(params = {}) {
error.acceptRequired = true; error.acceptRequired = true;
throw error; throw error;
} }
})
.then((resp) => {
if (resp.redirectUri === 'static_page' || resp.redirectUri === 'static_page_with_code') {
resp.displayCode = resp.redirectUri === 'static_page_with_code';
dispatch(setOAuthCode({
success: resp.success,
code: resp.code,
displayCode: resp.displayCode
}));
}
return resp;
}); });
}; };
} }
@ -224,6 +258,18 @@ export function setOAuthRequest(oauth) {
}; };
} }
export const SET_OAUTH_RESULT = 'set_oauth_result';
export function setOAuthCode(oauth) {
return {
type: SET_OAUTH_RESULT,
payload: {
success: oauth.success,
code: oauth.code,
displayCode: oauth.displayCode
}
};
}
export const SET_SCOPES = 'set_scopes'; export const SET_SCOPES = 'set_scopes';
export function setScopes(scopes) { export function setScopes(scopes) {
if (!(scopes instanceof Array)) { if (!(scopes instanceof Array)) {

View File

@ -1,4 +1,4 @@
import React, { PropTypes } from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
@ -6,20 +6,12 @@ import Helmet from 'react-helmet';
import buttons from 'components/ui/buttons.scss'; import buttons from 'components/ui/buttons.scss';
import { Input } from 'components/ui/Form'; import { Input } from 'components/ui/Form';
import BaseAuthBody from './BaseAuthBody'; import BaseAuthBody from 'components/auth/BaseAuthBody';
import styles from './activation.scss'; import styles from './activation.scss';
import messages from './Activation.messages'; import messages from './Activation.messages';
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'ActivationBody';
...BaseAuthBody.propTypes,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
login: PropTypes.stirng
})
})
};
render() { render() {
return ( return (
@ -31,7 +23,7 @@ class Body extends BaseAuthBody {
<div className={styles.descriptionText}> <div className={styles.descriptionText}>
<Message {...messages.activationMailWasSent} values={{ <Message {...messages.activationMailWasSent} values={{
email: (<b>{this.props.user.email}</b>) email: (<b>{this.context.user.email}</b>)
}} /> }} />
</div> </div>
</div> </div>
@ -40,6 +32,7 @@ class Body extends BaseAuthBody {
color="blue" color="blue"
className={styles.activationCodeInput} className={styles.activationCodeInput}
autoFocus autoFocus
onFocus={this.fixAutoFocus}
required required
placeholder={messages.enterTheCode} placeholder={messages.enterTheCode}
/> />

View File

@ -11,8 +11,8 @@ export default class AppInfo extends Component {
static displayName = 'AppInfo'; static displayName = 'AppInfo';
static propTypes = { static propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string,
description: PropTypes.string.isRequired, description: PropTypes.string,
onGoToAuth: PropTypes.func.isRequired onGoToAuth: PropTypes.func.isRequired
}; };

View File

@ -71,6 +71,10 @@ export default class AuthError extends Component {
'error.you_must_accept_rules': () => this.errorsMap['error.rulesAgreement_required'](), 'error.you_must_accept_rules': () => this.errorsMap['error.rulesAgreement_required'](),
'error.key_required': () => <Message {...messages.keyRequired} />, 'error.key_required': () => <Message {...messages.keyRequired} />,
'error.key_is_required': () => this.errorsMap['error.key_required'](), 'error.key_is_required': () => this.errorsMap['error.key_required'](),
'error.key_not_exists': () => <Message {...messages.keyNotExists} /> 'error.key_not_exists': () => <Message {...messages.keyNotExists} />,
'error.newPassword_required': () => <Message {...messages.newPasswordRequired} />,
'error.newRePassword_required': () => <Message {...messages.newRePasswordRequired} />,
'error.newRePassword_does_not_match': () => <Message {...messages.passwordsDoesNotMatch} />
}; };
} }

View File

@ -31,6 +31,16 @@ export default defineMessages({
defaultMessage: 'Please enter password' defaultMessage: 'Please enter password'
}, },
newPasswordRequired: {
id: 'newPasswordRequired',
defaultMessage: 'Please enter new password'
},
newRePasswordRequired: {
id: 'newRePasswordRequired',
defaultMessage: 'Please repeat new password'
},
usernameRequired: { usernameRequired: {
id: 'usernameRequired', id: 'usernameRequired',
defaultMessage: 'Username is required' defaultMessage: 'Username is required'

View File

@ -2,21 +2,17 @@ import React, { PropTypes } from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { Link } from 'react-router';
import buttons from 'components/ui/buttons.scss'; import buttons from 'components/ui/buttons.scss';
import { Input } from 'components/ui/Form'; import { Input } from 'components/ui/Form';
import BaseAuthBody from 'components/auth/BaseAuthBody';
import BaseAuthBody from './BaseAuthBody';
import passwordChangedMessages from './PasswordChange.messages';
import icons from 'components/ui/icons.scss'; import icons from 'components/ui/icons.scss';
import styles from './passwordChange.scss';
import messages from './ChangePassword.messages';
import styles from './changePassword.scss';
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'ChangePasswordBody';
...BaseAuthBody.propTypes
};
render() { render() {
return ( return (
@ -28,49 +24,66 @@ class Body extends BaseAuthBody {
</div> </div>
<p className={styles.descriptionText}> <p className={styles.descriptionText}>
<Message {...passwordChangedMessages.changePasswordMessage} /> <Message {...messages.changePasswordMessage} />
</p> </p>
<Input {...this.bindField('password')}
icon="key"
color="darkBlue"
type="password"
autoFocus
onFocus={this.fixAutoFocus}
required
placeholder={messages.currentPassword}
/>
<Input {...this.bindField('newPassword')} <Input {...this.bindField('newPassword')}
icon="key" icon="key"
color="darkBlue" color="darkBlue"
autoFocus type="password"
required required
placeholder={passwordChangedMessages.newPassword} placeholder={messages.newPassword}
/> />
<Input {...this.bindField('newRePassword')} <Input {...this.bindField('newRePassword')}
icon="key" icon="key"
color="darkBlue" color="darkBlue"
type="password"
required required
placeholder={passwordChangedMessages.newRePassword} placeholder={messages.newRePassword}
/> />
</div> </div>
); );
} }
} }
export default function PasswordChange() { export default function ChangePassword() {
return { const componentsMap = {
Title: () => ( // TODO: separate component for PageTitle Title: () => ( // TODO: separate component for PageTitle
<Message {...passwordChangedMessages.changePasswordTitle}> <Message {...messages.changePasswordTitle}>
{(msg) => <span>{msg}<Helmet title={msg} /></span>} {(msg) => <span>{msg}<Helmet title={msg} /></span>}
</Message> </Message>
), ),
Body, Body,
Footer: () => ( Footer: () => (
<button className={buttons.darkBlue} type="submit"> <button className={buttons.darkBlue} type="submit">
<Message {...passwordChangedMessages.change} /> <Message {...messages.change} />
</button> </button>
), ),
Links: (props) => ( Links: (props, context) => (
<a href="#" onClick={(event) => { <a href="#" onClick={(event) => {
event.preventDefault(); event.preventDefault();
props.reject(); context.reject();
}}> }}>
<Message {...passwordChangedMessages.skipThisStep} /> <Message {...messages.skipThisStep} />
</a> </a>
) )
}; };
componentsMap.Links.contextTypes = {
reject: PropTypes.func.isRequired
};
return componentsMap;
} }

View File

@ -17,6 +17,10 @@ export default defineMessages({
id: 'change', id: 'change',
defaultMessage: 'Change' defaultMessage: 'Change'
}, },
currentPassword: {
id: 'currentPassword',
defaultMessage: 'Enter current password'
},
newPassword: { newPassword: {
id: 'newPassword', id: 'newPassword',
defaultMessage: 'Enter new password' defaultMessage: 'Enter new password'

View File

@ -0,0 +1,122 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { FormattedMessage as Message } from 'react-intl';
import classNames from 'classnames';
import Helmet from 'react-helmet';
import buttons from 'components/ui/buttons.scss';
import messages from './Finish.messages';
import styles from './finish.scss';
class Finish extends Component {
static displayName = 'Finish';
static propTypes = {
appName: PropTypes.string.isRequired,
code: PropTypes.string.isRequired,
displayCode: PropTypes.bool,
success: PropTypes.bool
};
state = {
isCopySupported: document.queryCommandSupported && document.queryCommandSupported('copy')
};
render() {
const {appName, code, state, displayCode, success} = this.props;
const {isCopySupported} = this.state;
const authData = JSON.stringify({
auth_code: code,
state: state
});
history.pushState(null, null, `#${authData}`);
return (
<div className={styles.finishPage}>
<Helmet title={authData} />
{success ? (
<div>
<div className={styles.successBackground}></div>
<div className={styles.greenTitle}>
<Message {...messages.authForAppSuccessful} values={{
appName: (<span className={styles.appName}>{appName}</span>)
}} />
</div>
{displayCode ? (
<div>
<div className={styles.description}>
<Message {...messages.passCodeToApp} values={{appName}} />
</div>
<div className={styles.codeContainer}>
<div className={styles.code} ref={this.setCode}>{code}</div>
</div>
{isCopySupported ? (
<button
className={classNames(buttons.smallButton, buttons.green)}
onClick={this.handleCopyClick}
>
<Message {...messages.copy} />
</button>
) : (
''
)}
</div>
) : (
<div className={styles.description}>
<Message {...messages.waitAppReaction} />
</div>
)}
</div>
) : (
<div>
<div className={styles.failBackground}></div>
<div className={styles.redTitle}>
<Message {...messages.authForAppFailed} values={{
appName: (<span className={styles.appName}>{appName}</span>)
}} />
</div>
<div className={styles.description}>
<Message {...messages.waitAppReaction} />
</div>
</div>
)}
</div>
);
}
handleCopyClick = (event) => {
event.preventDefault();
// http://stackoverflow.com/a/987376/5184751
try {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this.code);
selection.removeAllRanges();
selection.addRange(range);
const successful = document.execCommand('copy');
selection.removeAllRanges();
// TODO: было бы ещё неплохо сделать какую-то анимацию, вроде "Скопировано",
// ибо сейчас после клика как-то неубедительно, скопировалось оно или нет
console.log('Copying text command was ' + (successful ? 'successful' : 'unsuccessful'));
} catch (err) {}
};
setCode = (el) => {
this.code = el;
};
}
export default connect(({auth}) => ({
appName: auth.client.name,
code: auth.oauth.code,
displayCode: auth.oauth.displayCode,
state: auth.oauth.state,
success: auth.oauth.success
}))(Finish);

View File

@ -0,0 +1,29 @@
import { defineMessages } from 'react-intl';
export default defineMessages({
authForAppSuccessful: {
id: 'authForAppSuccessful',
defaultMessage: 'Authorization for {appName} was successfully completed'
// defaultMessage: 'Авторизация для {appName} успешно выполнена'
},
authForAppFailed: {
id: 'authForAppFailed',
defaultMessage: 'Authorization for {appName} was failed'
// defaultMessage: 'Авторизация для {appName} не удалась'
},
waitAppReaction: {
id: 'waitAppReaction',
defaultMessage: 'Please, wait till your application response'
// defaultMessage: 'Пожалуйста, дождитесь реакции вашего приложения'
},
passCodeToApp: {
id: 'passCodeToApp',
defaultMessage: 'To complete authorization process, please, provide the following code to {appName}'
// defaultMessage: 'Чтобы завершить процесс авторизации, пожалуйста, передай {appName} этот код'
},
copy: {
id: 'copy',
defaultMessage: 'Copy'
// defaultMessage: 'Скопировать'
}
});

View File

@ -0,0 +1,76 @@
@import '~components/ui/colors.scss';
@import '~components/ui/fonts.scss';
.finishPage {
font-family: $font-family-title;
position: relative;
max-width: 515px;
padding-top: 40px;
margin: 0 auto;
text-align: center;
}
.iconBackground {
position: absolute;
top: -15px;
transform: translateX(-50%);
font-size: 200px;
color: #e0d9cf;
z-index: -1;
}
.successBackground {
composes: checkmark from 'components/ui/icons.scss';
@extend .iconBackground;
}
.failBackground {
composes: close from 'components/ui/icons.scss';
@extend .iconBackground;
}
.title {
font-size: 22px;
margin-bottom: 10px;
}
.greenTitle {
composes: title;
color: $green;
.appName {
color: darker($green);
}
}
.redTitle {
composes: title;
color: $red;
.appName {
color: darker($red);
}
}
.description {
font-size: 18px;
margin-bottom: 10px;
}
.codeContainer {
margin-bottom: 5px;
margin-top: 35px;
}
.code {
$border: 5px solid darker($green);
display: inline-block;
border-right: $border;
border-left: $border;
padding: 5px 10px;
word-break: break-all;
text-align: center;
}

View File

@ -1,4 +1,4 @@
import React, { PropTypes } from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
@ -6,22 +6,13 @@ import Helmet from 'react-helmet';
import buttons from 'components/ui/buttons.scss'; import buttons from 'components/ui/buttons.scss';
import { Input } from 'components/ui/Form'; import { Input } from 'components/ui/Form';
import BaseAuthBody from './BaseAuthBody'; import BaseAuthBody from 'components/auth/BaseAuthBody';
import messages from './ForgotPassword.messages'; import messages from './ForgotPassword.messages';
import styles from './forgotPassword.scss'; import styles from './forgotPassword.scss';
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'ForgotPasswordBody';
...BaseAuthBody.propTypes,
//login: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
email: PropTypes.stirng
})
})
};
// Если юзер вводил своё мыло во время попытки авторизации, то почему бы его сюда автоматически не подставить? // Если юзер вводил своё мыло во время попытки авторизации, то почему бы его сюда автоматически не подставить?
render() { render() {
@ -37,6 +28,7 @@ class Body extends BaseAuthBody {
icon="envelope" icon="envelope"
color="lightViolet" color="lightViolet"
autoFocus autoFocus
onFocus={this.fixAutoFocus}
required required
placeholder={messages.accountEmail} placeholder={messages.accountEmail}
/> />
@ -49,7 +41,6 @@ class Body extends BaseAuthBody {
onFormSubmit() { onFormSubmit() {
// TODO: обработчик отправки письма с инструкцией по смене аккаунта // TODO: обработчик отправки письма с инструкцией по смене аккаунта
//this.props.login(this.serialize());
} }
} }

View File

@ -1,4 +1,4 @@
import React, { PropTypes } from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
@ -7,20 +7,12 @@ import { Link } from 'react-router';
import buttons from 'components/ui/buttons.scss'; import buttons from 'components/ui/buttons.scss';
import { Input } from 'components/ui/Form'; import { Input } from 'components/ui/Form';
import BaseAuthBody from './BaseAuthBody'; import BaseAuthBody from 'components/auth/BaseAuthBody';
import passwordMessages from 'components/auth/password/Password.messages';
import messages from './Login.messages'; import messages from './Login.messages';
import passwordMessages from './Password.messages';
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'LoginBody';
...BaseAuthBody.propTypes,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
login: PropTypes.stirng
})
})
};
render() { render() {
return ( return (
@ -30,6 +22,7 @@ class Body extends BaseAuthBody {
<Input {...this.bindField('login')} <Input {...this.bindField('login')}
icon="envelope" icon="envelope"
autoFocus autoFocus
onFocus={this.fixAutoFocus}
required required
placeholder={messages.emailOrUsername} placeholder={messages.emailOrUsername}
/> />

View File

@ -1,4 +1,4 @@
import React, { PropTypes } from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
@ -8,24 +8,15 @@ import buttons from 'components/ui/buttons.scss';
import icons from 'components/ui/icons.scss'; import icons from 'components/ui/icons.scss';
import { Input, Checkbox } from 'components/ui/Form'; import { Input, Checkbox } from 'components/ui/Form';
import BaseAuthBody from './BaseAuthBody'; import BaseAuthBody from 'components/auth/BaseAuthBody';
import styles from './password.scss'; import styles from './password.scss';
import messages from './Password.messages'; import messages from './Password.messages';
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'PasswordBody';
...BaseAuthBody.propTypes,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
login: PropTypes.stirng,
password: PropTypes.stirng
})
})
};
render() { render() {
const {user} = this.props; const {user} = this.context;
return ( return (
<div> <div>
@ -46,6 +37,7 @@ class Body extends BaseAuthBody {
icon="key" icon="key"
type="password" type="password"
autoFocus autoFocus
onFocus={this.fixAutoFocus}
required required
placeholder={messages.accountPassword} placeholder={messages.accountPassword}
/> />

View File

@ -7,22 +7,16 @@ import buttons from 'components/ui/buttons.scss';
import icons from 'components/ui/icons.scss'; import icons from 'components/ui/icons.scss';
import { PanelBodyHeader } from 'components/ui/Panel'; import { PanelBodyHeader } from 'components/ui/Panel';
import BaseAuthBody from './BaseAuthBody'; import BaseAuthBody from 'components/auth/BaseAuthBody';
import styles from './permissions.scss'; import styles from './permissions.scss';
import messages from './Permissions.messages'; import messages from './Permissions.messages';
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'PermissionsBody';
...BaseAuthBody.propTypes,
auth: PropTypes.shape({
error: PropTypes.string,
scopes: PropTypes.array.isRequired
})
};
render() { render() {
const {user} = this.props; const {user} = this.context;
const scopes = this.props.auth.scopes; const scopes = this.context.auth.scopes;
return ( return (
<div> <div>
@ -61,7 +55,7 @@ class Body extends BaseAuthBody {
} }
export default function Permissions() { export default function Permissions() {
return { const componentsMap = {
Title: () => ( // TODO: separate component for PageTitle Title: () => ( // TODO: separate component for PageTitle
<Message {...messages.permissionsTitle}> <Message {...messages.permissionsTitle}>
{(msg) => <span>{msg}<Helmet title={msg} /></span>} {(msg) => <span>{msg}<Helmet title={msg} /></span>}
@ -73,14 +67,20 @@ export default function Permissions() {
<Message {...messages.approve} /> <Message {...messages.approve} />
</button> </button>
), ),
Links: (props) => ( Links: (props, context) => (
<a href="#" onClick={(event) => { <a href="#" onClick={(event) => {
event.preventDefault(); event.preventDefault();
props.reject(); context.reject();
}}> }}>
<Message {...messages.decline} /> <Message {...messages.decline} />
</a> </a>
) )
}; };
componentsMap.Links.contextTypes = {
reject: PropTypes.func.isRequired
};
return componentsMap;
} }

View File

@ -1,6 +1,6 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { ERROR, SET_CLIENT, SET_OAUTH, SET_SCOPES } from './actions'; import { ERROR, SET_CLIENT, SET_OAUTH, SET_OAUTH_RESULT, SET_SCOPES } from './actions';
export default combineReducers({ export default combineReducers({
error, error,
@ -56,6 +56,14 @@ function oauth(
state: payload.state state: payload.state
}; };
case SET_OAUTH_RESULT:
return {
...state,
success: payload.success,
code: payload.code,
displayCode: payload.displayCode
};
default: default:
return state; return state;
} }

View File

@ -1,4 +1,4 @@
import React, { PropTypes } from 'react'; import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
@ -6,27 +6,14 @@ import Helmet from 'react-helmet';
import buttons from 'components/ui/buttons.scss'; import buttons from 'components/ui/buttons.scss';
import { Input, Checkbox } from 'components/ui/Form'; import { Input, Checkbox } from 'components/ui/Form';
import BaseAuthBody from './BaseAuthBody'; import BaseAuthBody from 'components/auth/BaseAuthBody';
import activationMessages from 'components/auth/activation/Activation.messages';
import messages from './Register.messages'; import messages from './Register.messages';
import activationMessages from './Activation.messages';
// TODO: password and username can be validate for length and sameness // TODO: password and username can be validate for length and sameness
class Body extends BaseAuthBody { class Body extends BaseAuthBody {
static propTypes = { static displayName = 'RegisterBody';
...BaseAuthBody.propTypes,
register: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
register: PropTypes.shape({
email: PropTypes.string,
username: PropTypes.stirng,
password: PropTypes.stirng,
rePassword: PropTypes.stirng,
rulesAgreement: PropTypes.boolean
})
})
};
render() { render() {
return ( return (
@ -38,6 +25,7 @@ class Body extends BaseAuthBody {
color="blue" color="blue"
type="text" type="text"
autoFocus autoFocus
onFocus={this.fixAutoFocus}
required required
placeholder={messages.yourNickname} placeholder={messages.yourNickname}
/> />

View File

@ -14,7 +14,7 @@ export class Input extends Component {
id: PropTypes.string id: PropTypes.string
}), }),
icon: PropTypes.string, icon: PropTypes.string,
color: PropTypes.oneOf(['green', 'blue', 'red']) color: PropTypes.oneOf(['green', 'blue', 'red', 'lightViolet', 'darkBlue'])
}; };
static contextTypes = { static contextTypes = {

View File

@ -24,7 +24,6 @@ export function logout() {
return setUser({isGuest: true}); return setUser({isGuest: true});
} }
export function fetchUserData() { export function fetchUserData() {
return (dispatch) => return (dispatch) =>
request.get('/api/accounts/current') request.get('/api/accounts/current')
@ -45,6 +44,26 @@ export function fetchUserData() {
}); });
} }
export function changePassword({
password = '',
newPassword = '',
newRePassword = ''
}) {
return (dispatch) =>
request.post(
'/api/accounts/change-password',
{password, newPassword, newRePassword}
)
.then((resp) => {
dispatch(updateUser({
shouldChangePassword: false
}));
return resp;
})
;
}
export function authenticate(token) { export function authenticate(token) {
if (!token || token.split('.').length !== 3) { if (!token || token.split('.').length !== 3) {

View File

@ -22,19 +22,6 @@ import routesFactory from 'routes';
import 'index.scss'; import 'index.scss';
// TODO: временная мера против Intl, который беспощадно спамит консоль
if (process.env.NODE_ENV !== 'production') {
const originalConsoleError = console.error;
if (console.error === originalConsoleError) {
console.error = (...args) => {
if (args[0].indexOf('[React Intl] Missing message:') === 0) {
return;
}
originalConsoleError.call(console, ...args);
};
}
}
const reducer = combineReducers({ const reducer = combineReducers({
...reducers, ...reducers,
routing: routeReducer routing: routeReducer

View File

@ -2,7 +2,7 @@ import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import AppInfo from 'components/auth/AppInfo'; import AppInfo from 'components/auth/appInfo/AppInfo';
import PanelTransition from 'components/auth/PanelTransition'; import PanelTransition from 'components/auth/PanelTransition';
import styles from './auth.scss'; import styles from './auth.scss';

View File

@ -4,17 +4,9 @@ import { connect } from 'react-redux';
import ProfilePage from 'pages/profile/ProfilePage'; import ProfilePage from 'pages/profile/ProfilePage';
import authFlow from 'services/authFlow';
class IndexPage extends Component { class IndexPage extends Component {
displayName = 'IndexPage'; displayName = 'IndexPage';
componentWillMount() {
if (this.props.user.isGuest) {
authFlow.login();
}
}
render() { render() {
return ( return (
<div> <div>

View File

@ -8,14 +8,15 @@ import AuthPage from 'pages/auth/AuthPage';
import { authenticate } from 'components/user/actions'; import { authenticate } from 'components/user/actions';
import OAuthInit from 'components/auth/OAuthInit'; import OAuthInit from 'components/auth/OAuthInit';
import Register from 'components/auth/Register'; import Register from 'components/auth/register/Register';
import Login from 'components/auth/Login'; import Login from 'components/auth/login/Login';
import Permissions from 'components/auth/Permissions'; import Permissions from 'components/auth/permissions/Permissions';
import Activation from 'components/auth/Activation'; import Activation from 'components/auth/activation/Activation';
import Password from 'components/auth/Password'; import Password from 'components/auth/password/Password';
import Logout from 'components/auth/Logout'; import Logout from 'components/auth/Logout';
import PasswordChange from 'components/auth/PasswordChange'; import ChangePassword from 'components/auth/changePassword/ChangePassword';
import ForgotPassword from 'components/auth/ForgotPassword'; import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword';
import Finish from 'components/auth/finish/Finish';
import authFlow from 'services/authFlow'; import authFlow from 'services/authFlow';
@ -34,7 +35,7 @@ export default function routesFactory(store) {
return ( return (
<Route path="/" component={RootPage}> <Route path="/" component={RootPage}>
<IndexRoute component={IndexPage} /> <IndexRoute component={IndexPage} {...onEnter} />
<Route path="oauth" component={OAuthInit} {...onEnter} /> <Route path="oauth" component={OAuthInit} {...onEnter} />
<Route path="logout" component={Logout} {...onEnter} /> <Route path="logout" component={Logout} {...onEnter} />
@ -45,7 +46,8 @@ export default function routesFactory(store) {
<Route path="/register" components={new Register()} {...onEnter} /> <Route path="/register" components={new Register()} {...onEnter} />
<Route path="/activation" components={new Activation()} {...onEnter} /> <Route path="/activation" components={new Activation()} {...onEnter} />
<Route path="/oauth/permissions" components={new Permissions()} {...onEnter} /> <Route path="/oauth/permissions" components={new Permissions()} {...onEnter} />
<Route path="/password-change" components={new PasswordChange()} {...onEnter} /> <Route path="/oauth/finish" component={Finish} {...onEnter} />
<Route path="/change-password" components={new ChangePassword()} {...onEnter} />
<Route path="/forgot-password" components={new ForgotPassword()} {...onEnter} /> <Route path="/forgot-password" components={new ForgotPassword()} {...onEnter} />
</Route> </Route>
</Route> </Route>

View File

@ -23,7 +23,6 @@ export default class AuthFlow {
const {routing} = this.getState(); const {routing} = this.getState();
if (routing.location.pathname !== route) { if (routing.location.pathname !== route) {
this.ignoreRequest = true; // TODO: remove me
if (this.replace) { if (this.replace) {
this.replace(route); this.replace(route);
} }
@ -62,10 +61,10 @@ export default class AuthFlow {
throw new Error('State is required'); throw new Error('State is required');
} }
if (this.state instanceof state.constructor) { // if (this.state instanceof state.constructor) {
// already in this state // // already in this state
return; // return;
} // }
this.state && this.state.leave(this); this.state && this.state.leave(this);
this.state = state; this.state = state;
@ -74,9 +73,10 @@ export default class AuthFlow {
handleRequest(path, replace) { handleRequest(path, replace) {
this.replace = replace; this.replace = replace;
if (this.ignoreRequest) {
this.ignoreRequest = false; if (path === '/') {
return; // reset oauth data if user tried to navigate to index route
this.run('setOAuthRequest', {});
} }
switch (path) { switch (path) {
@ -92,11 +92,13 @@ export default class AuthFlow {
this.setState(new ForgotPasswordState()); this.setState(new ForgotPasswordState());
break; break;
case '/':
case '/login': case '/login':
case '/password': case '/password':
case '/activation': case '/activation':
case '/password-change': case '/change-password':
case '/oauth/permissions': case '/oauth/permissions':
case '/oauth/finish':
this.setState(new LoginState()); this.setState(new LoginState());
break; break;

View File

@ -3,7 +3,12 @@ import CompleteState from './CompleteState';
export default class ChangePasswordState extends AbstractState { export default class ChangePasswordState extends AbstractState {
enter(context) { enter(context) {
context.navigate('/password-change'); context.navigate('/change-password');
}
resolve(context, payload) {
context.run('changePassword', payload)
.then(() => context.setState(new CompleteState()));
} }
reject(context) { reject(context) {

View File

@ -3,8 +3,18 @@ import LoginState from './LoginState';
import PermissionsState from './PermissionsState'; import PermissionsState from './PermissionsState';
import ActivationState from './ActivationState'; import ActivationState from './ActivationState';
import ChangePasswordState from './ChangePasswordState'; import ChangePasswordState from './ChangePasswordState';
import FinishState from './FinishState';
export default class CompleteState extends AbstractState { export default class CompleteState extends AbstractState {
constructor(options = {}) {
super(options);
if ('accept' in options) {
this.isPermissionsAccepted = options.accept;
this.isUserReviewedPermissions = true;
}
}
enter(context) { enter(context) {
const {auth, user} = context.getState(); const {auth, user} = context.getState();
@ -14,17 +24,33 @@ export default class CompleteState extends AbstractState {
context.setState(new ActivationState()); context.setState(new ActivationState());
} else if (user.shouldChangePassword) { } else if (user.shouldChangePassword) {
context.setState(new ChangePasswordState()); context.setState(new ChangePasswordState());
} else if (auth.oauth) { } else if (auth.oauth && auth.oauth.clientId) {
context.run('oAuthComplete').then((resp) => { if (auth.oauth.code) {
location.href = resp.redirectUri; context.setState(new FinishState());
}, (resp) => { } else {
// TODO let data = {};
if (resp.unauthorized) { if (this.isUserReviewedPermissions) {
context.setState(new LoginState()); data.accept = this.isPermissionsAccepted;
} else if (resp.acceptRequired) {
context.setState(new PermissionsState());
} }
}); context.run('oAuthComplete', data).then((resp) => {
switch (resp.redirectUri) {
case 'static_page':
case 'static_page_with_code':
context.setState(new FinishState());
break;
default:
location.href = resp.redirectUri;
break;
}
}, (resp) => {
// TODO
if (resp.unauthorized) {
context.setState(new LoginState());
} else if (resp.acceptRequired) {
context.setState(new PermissionsState());
}
});
}
} else { } else {
context.navigate('/'); context.navigate('/');
} }

View File

@ -0,0 +1,7 @@
import AbstractState from './AbstractState';
export default class CompleteState extends AbstractState {
enter(context) {
context.navigate('/oauth/finish');
}
}

View File

@ -1,4 +1,5 @@
import AbstractState from './AbstractState'; import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
export default class PermissionsState extends AbstractState { export default class PermissionsState extends AbstractState {
enter(context) { enter(context) {
@ -14,8 +15,8 @@ export default class PermissionsState extends AbstractState {
} }
process(context, accept) { process(context, accept) {
context.run('oAuthComplete', { context.setState(new CompleteState({
accept accept
}).then((resp) => location.href = resp.redirectUri); }));
} }
} }