#104: add AcceptRules auth panel

This commit is contained in:
SleepWalker 2016-08-02 21:59:29 +03:00
parent 7ab904d52a
commit cb1a4b7d55
17 changed files with 269 additions and 2 deletions

View File

@ -33,6 +33,7 @@ const contexts = [
['login', 'password', 'forgotPassword', 'recoverPassword'], ['login', 'password', 'forgotPassword', 'recoverPassword'],
['register', 'activation', 'resendActivation'], ['register', 'activation', 'resendActivation'],
['changePassword'], ['changePassword'],
['acceptRules'],
['permissions'] ['permissions']
]; ];

View File

@ -0,0 +1,15 @@
# How to add new auth panel
To add new panel you need to:
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
* add new actions to `components/auth/actions` and api endpoints to `services/api`
* create panel component at `components/auth/[panelId]`
* add new context in `components/auth/PanelTransition`
* connect component to `routes`
* whatever else you need
# TODO
This flow must be simplified

View File

@ -0,0 +1,6 @@
{
"title": "Accept new rules",
"accept": "Accept",
"decline": "Decline",
"description": "We have updated our {link}. In order to get access to accounts.ely.by service, you need to accept them."
}

View File

@ -0,0 +1,17 @@
import factory from 'components/auth/factory';
import Body from './AcceptRulesBody';
import messages from './AcceptRules.intl.json';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'darkBlue',
autoFocus: true,
label: messages.accept
},
links: {
label: messages.decline
}
});

View File

@ -0,0 +1,38 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router';
import icons from 'components/ui/icons.scss';
import BaseAuthBody from 'components/auth/BaseAuthBody';
import registerMessages from 'components/auth/register/Register.intl.json';
import styles from './acceptRules.scss';
import messages from './AcceptRules.intl.json';
export default class AcceptRulesBody extends BaseAuthBody {
static displayName = 'AcceptRulesBody';
static panelId = 'acceptRules';
render() {
return (
<div>
{this.renderErrors()}
<div className={styles.security}>
<span className={icons.lock} />
</div>
<p className={styles.descriptionText}>
<Message {...messages.description} values={{
link: (
<Link to="/rules" target="_blank">
<Message {...registerMessages.termsOfService} />
</Link>
)
}} />
</p>
</div>
);
}
}

View File

@ -0,0 +1,16 @@
@import '~components/ui/colors.scss';
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}
// TODO: вынести иконки такого типа в какую-то внешнюю структуру?
.security {
color: #fff;
font-size: 90px;
line-height: 1;
margin-bottom: 15px;
}

View File

@ -1,6 +1,6 @@
import { routeActions } from 'react-router-redux'; import { routeActions } from 'react-router-redux';
import { updateUser, logout as logoutUser, changePassword as changeUserPassword, authenticate } from 'components/user/actions'; import { updateUser, logout as logoutUser, changePassword as changeUserPassword, acceptRules as userAcceptRules, authenticate } from 'components/user/actions';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import oauth from 'services/api/oauth'; import oauth from 'services/api/oauth';
import signup from 'services/api/signup'; import signup from 'services/api/signup';
@ -46,6 +46,13 @@ export function changePassword({
); );
} }
export function acceptRules() {
return wrapInLoader((dispatch) =>
dispatch(userAcceptRules())
.catch(validationErrorsHandler(dispatch))
);
}
export function forgotPassword({ export function forgotPassword({
login = '' login = ''
}) { }) {

View File

@ -4,7 +4,7 @@ const KEY_USER = 'user';
export default class User { export default class User {
/** /**
* @param {Object|string|undefined} data plain object or jwt token or empty to load from storage * @param {object|string|undefined} data plain object or jwt token or empty to load from storage
* *
* @return {User} * @return {User}
*/ */
@ -30,6 +30,7 @@ export default class User {
goal: null, // the goal with wich user entered site goal: null, // the goal with wich user entered site
isGuest: true, isGuest: true,
isActive: false, isActive: false,
shouldAcceptRules: false, // whether user need to review updated rules
shouldChangePassword: false, // TODO: нужно ещё пробросить причину необходимости смены shouldChangePassword: false, // TODO: нужно ещё пробросить причину необходимости смены
passwordChangedAt: null, passwordChangedAt: null,
hasMojangUsernameCollision: false, hasMojangUsernameCollision: false,

View File

@ -89,6 +89,19 @@ export function changePassword({
; ;
} }
export function acceptRules() {
return (dispatch) =>
accounts.acceptRules()
.then((resp) => {
dispatch(updateUser({
shouldAcceptRules: false
}));
return resp;
})
;
}
let middlewareAdded = false; let middlewareAdded = false;
export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth
return (dispatch, getState) => { return (dispatch, getState) => {

View File

@ -21,6 +21,7 @@ import Activation from 'components/auth/activation/Activation';
import ResendActivation from 'components/auth/resendActivation/ResendActivation'; import ResendActivation from 'components/auth/resendActivation/ResendActivation';
import Password from 'components/auth/password/Password'; import Password from 'components/auth/password/Password';
import ChangePassword from 'components/auth/changePassword/ChangePassword'; import ChangePassword from 'components/auth/changePassword/ChangePassword';
import AcceptRules from 'components/auth/acceptRules/AcceptRules';
import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword'; import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword';
import RecoverPassword from 'components/auth/recoverPassword/RecoverPassword'; import RecoverPassword from 'components/auth/recoverPassword/RecoverPassword';
import Finish from 'components/auth/finish/Finish'; import Finish from 'components/auth/finish/Finish';
@ -60,6 +61,7 @@ export default function routesFactory(store) {
<Route path="/resend-activation" components={new ResendActivation()} {...startAuthFlow} /> <Route path="/resend-activation" components={new ResendActivation()} {...startAuthFlow} />
<Route path="/oauth/permissions" components={new Permissions()} {...startAuthFlow} /> <Route path="/oauth/permissions" components={new Permissions()} {...startAuthFlow} />
<Route path="/oauth/finish" component={Finish} {...startAuthFlow} /> <Route path="/oauth/finish" component={Finish} {...startAuthFlow} />
<Route path="/accept-rules" components={new AcceptRules()} {...startAuthFlow} />
<Route path="/change-password" components={new ChangePassword()} {...startAuthFlow} /> <Route path="/change-password" components={new ChangePassword()} {...startAuthFlow} />
<Route path="/forgot-password" components={new ForgotPassword()} {...startAuthFlow} /> <Route path="/forgot-password" components={new ForgotPassword()} {...startAuthFlow} />
<Route path="/recover-password(/:key)" components={new RecoverPassword()} {...startAuthFlow} /> <Route path="/recover-password(/:key)" components={new RecoverPassword()} {...startAuthFlow} />

View File

@ -17,6 +17,10 @@ export default {
); );
}, },
acceptRules() {
return request.post('/api/accounts/accept-rules');
},
changeUsername({ changeUsername({
username = '', username = '',
password = '' password = ''

View File

@ -0,0 +1,23 @@
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
export default class AcceptRulesState extends AbstractState {
enter(context) {
const {user} = context.getState();
if (user.shouldAcceptRules) {
context.navigate('/accept-rules');
} else {
context.setState(new CompleteState());
}
}
resolve(context) {
context.run('acceptRules')
.then(() => context.setState(new CompleteState()));
}
reject(context) {
context.run('logout');
}
}

View File

@ -143,6 +143,7 @@ export default class AuthFlow {
case '/': case '/':
case '/login': case '/login':
case '/password': case '/password':
case '/accept-rules':
case '/change-password': case '/change-password':
case '/oauth/permissions': case '/oauth/permissions':
case '/oauth/finish': case '/oauth/finish':

View File

@ -3,6 +3,7 @@ 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 AcceptRulesState from './AcceptRulesState';
import FinishState from './FinishState'; import FinishState from './FinishState';
export default class CompleteState extends AbstractState { export default class CompleteState extends AbstractState {
@ -19,6 +20,8 @@ export default class CompleteState extends AbstractState {
context.setState(new LoginState()); context.setState(new LoginState());
} else if (!user.isActive) { } else if (!user.isActive) {
context.setState(new ActivationState()); context.setState(new ActivationState());
} else if (user.shouldAcceptRules) {
context.setState(new AcceptRulesState());
} else if (user.shouldChangePassword) { } else if (user.shouldChangePassword) {
context.setState(new ChangePasswordState()); context.setState(new ChangePasswordState());
} else if (auth.oauth && auth.oauth.clientId) { } else if (auth.oauth && auth.oauth.clientId) {

View File

@ -0,0 +1,88 @@
import AcceptRulesState from 'services/authFlow/AcceptRulesState';
import CompleteState from 'services/authFlow/CompleteState';
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
describe('AcceptRulesState', () => {
let state;
let context;
let mock;
beforeEach(() => {
state = new AcceptRulesState();
const data = bootstrap();
context = data.context;
mock = data.mock;
});
afterEach(() => {
mock.verify();
});
describe('#enter', () => {
it('should navigate to /accept-rules', () => {
context.getState.returns({
user: {
shouldAcceptRules: true,
isGuest: false
}
});
expectNavigate(mock, '/accept-rules');
state.enter(context);
});
it('should transition to complete state if rules accepted', () => {
context.getState.returns({
user: {
shouldAcceptRules: false,
isGuest: false
}
});
expectState(mock, CompleteState);
state.enter(context);
});
});
describe('#resolve', () => {
it('should call acceptRules', () => {
expectRun(mock, 'acceptRules').returns({then() {}});
state.resolve(context);
});
it('should transition to complete state on success', () => {
const promise = Promise.resolve();
mock.expects('run').returns(promise);
expectState(mock, CompleteState);
state.resolve(context);
return promise;
});
it('should NOT transition to complete state on fail', () => {
const promise = Promise.reject();
mock.expects('run').returns(promise);
mock.expects('setState').never();
state.resolve(context);
return promise.catch(mock.verify.bind(mock));
});
});
describe('#reject', () => {
it('should logout', () => {
expectRun(mock, 'logout');
state.reject(context);
});
});
});

View File

@ -5,6 +5,7 @@ import AbstractState from 'services/authFlow/AbstractState';
import OAuthState from 'services/authFlow/OAuthState'; import OAuthState from 'services/authFlow/OAuthState';
import RegisterState from 'services/authFlow/RegisterState'; import RegisterState from 'services/authFlow/RegisterState';
import AcceptRulesState from 'services/authFlow/AcceptRulesState';
import RecoverPasswordState from 'services/authFlow/RecoverPasswordState'; import RecoverPasswordState from 'services/authFlow/RecoverPasswordState';
import ForgotPasswordState from 'services/authFlow/ForgotPasswordState'; import ForgotPasswordState from 'services/authFlow/ForgotPasswordState';
import ActivationState from 'services/authFlow/ActivationState'; import ActivationState from 'services/authFlow/ActivationState';
@ -178,6 +179,7 @@ describe('AuthFlow', () => {
'/login': LoginState, '/login': LoginState,
'/password': LoginState, '/password': LoginState,
'/change-password': LoginState, '/change-password': LoginState,
'/accept-rules': LoginState,
'/oauth/permissions': LoginState, '/oauth/permissions': LoginState,
'/oauth/finish': LoginState, '/oauth/finish': LoginState,
'/oauth2/v1': OAuthState, '/oauth2/v1': OAuthState,

View File

@ -4,6 +4,7 @@ import CompleteState from 'services/authFlow/CompleteState';
import LoginState from 'services/authFlow/LoginState'; import LoginState from 'services/authFlow/LoginState';
import ActivationState from 'services/authFlow/ActivationState'; import ActivationState from 'services/authFlow/ActivationState';
import ChangePasswordState from 'services/authFlow/ChangePasswordState'; import ChangePasswordState from 'services/authFlow/ChangePasswordState';
import AcceptRulesState from 'services/authFlow/AcceptRulesState';
import FinishState from 'services/authFlow/FinishState'; import FinishState from 'services/authFlow/FinishState';
import PermissionsState from 'services/authFlow/PermissionsState'; import PermissionsState from 'services/authFlow/PermissionsState';
@ -96,6 +97,35 @@ describe('CompleteState', () => {
state.enter(context); state.enter(context);
}); });
it('should transition to accept-rules if shouldAcceptRules', () => {
context.getState.returns({
user: {
shouldAcceptRules: true,
isActive: true,
isGuest: false
},
auth: {}
});
expectState(mock, AcceptRulesState);
state.enter(context);
});
it('should transition to activation with higher priority than shouldAcceptRules', () => {
context.getState.returns({
user: {
shouldAcceptRules: true,
isGuest: false
},
auth: {}
});
expectState(mock, ActivationState);
state.enter(context);
});
it('should transition to finish state if code is present', () => { it('should transition to finish state if code is present', () => {
context.getState.returns({ context.getState.returns({
user: { user: {