mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-30 02:32:58 +05:30
Merge branch '355-new-accounts-api-migration' into 'develop'
New Accounts API migration See merge request elyby/accounts!4
This commit is contained in:
commit
63de8ed548
@ -1,7 +1,8 @@
|
||||
// @flow
|
||||
import type { Account, State as AccountsState } from './reducer';
|
||||
import { getJwtPayload } from 'functions';
|
||||
import { sessionStorage } from 'services/localStorage';
|
||||
import authentication from 'services/api/authentication';
|
||||
import { validateToken, requestToken, logout } from 'services/api/authentication';
|
||||
import { relogin as navigateToLogin } from 'components/auth/actions';
|
||||
import { updateUser, setGuest } from 'components/user/actions';
|
||||
import { setLocale } from 'components/i18n/actions';
|
||||
@ -9,7 +10,6 @@ import { setAccountSwitcher } from 'components/auth/actions';
|
||||
import { getActiveAccount } from 'components/accounts/reducer';
|
||||
import logger from 'services/logger';
|
||||
|
||||
import type { Account, State as AccountsState } from './reducer';
|
||||
import {
|
||||
add,
|
||||
remove,
|
||||
@ -42,75 +42,95 @@ export function authenticate(account: Account | {
|
||||
token: string,
|
||||
refreshToken: ?string,
|
||||
}) {
|
||||
const {token, refreshToken} = account;
|
||||
const { token, refreshToken } = account;
|
||||
const email = account.email || null;
|
||||
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<Account> => {
|
||||
const accountId: number | null = typeof account.id === 'number' ? account.id : null;
|
||||
const knownAccount: ?Account = accountId
|
||||
? getState().accounts.available.find((item) => item.id === accountId)
|
||||
: null;
|
||||
return async (dispatch: Dispatch, getState: () => State): Promise<Account> => {
|
||||
let accountId: number;
|
||||
if (typeof account.id === 'number') {
|
||||
accountId = account.id;
|
||||
} else {
|
||||
accountId = findAccountIdFromToken(token);
|
||||
}
|
||||
|
||||
const knownAccount = getState().accounts.available.find((item) => item.id === accountId);
|
||||
if (knownAccount) {
|
||||
// this account is already available
|
||||
// activate it before validation
|
||||
dispatch(activate(knownAccount));
|
||||
}
|
||||
|
||||
return authentication.validateToken({token, refreshToken})
|
||||
.catch((resp = {}) => {
|
||||
// all the logic to get the valid token was failed,
|
||||
// looks like we have some problems with token
|
||||
// lets redirect to login page
|
||||
if (typeof email === 'string') {
|
||||
// TODO: we should somehow try to find email by token
|
||||
dispatch(relogin(email));
|
||||
}
|
||||
try {
|
||||
const {
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
user,
|
||||
// $FlowFixMe have no idea why it's causes error about missing properties
|
||||
} = await validateToken(accountId, token, refreshToken);
|
||||
const { auth } = getState();
|
||||
const account: Account = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
};
|
||||
dispatch(add(account));
|
||||
dispatch(activate(account));
|
||||
dispatch(updateUser({
|
||||
isGuest: false,
|
||||
...user,
|
||||
}));
|
||||
|
||||
return Promise.reject(resp);
|
||||
})
|
||||
.then(({token, refreshToken, user}) => ({
|
||||
user: {
|
||||
isGuest: false,
|
||||
...user
|
||||
},
|
||||
account: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
token,
|
||||
refreshToken
|
||||
}
|
||||
}))
|
||||
.then(({user, account}) => {
|
||||
const {auth} = getState();
|
||||
// TODO: probably should be moved from here, because it is a side effect
|
||||
logger.setUser(user);
|
||||
|
||||
dispatch(add(account));
|
||||
dispatch(activate(account));
|
||||
dispatch(updateUser(user));
|
||||
if (!newRefreshToken) {
|
||||
// mark user as stranger (user does not want us to remember his account)
|
||||
sessionStorage.setItem(`stranger${account.id}`, 1);
|
||||
}
|
||||
|
||||
// TODO: probably should be moved from here, because it is a side effect
|
||||
logger.setUser(user);
|
||||
if (auth && auth.oauth && auth.oauth.clientId) {
|
||||
// if we authenticating during oauth, we disable account chooser
|
||||
// because user probably has made his choise now
|
||||
// this may happen, when user registers, logs in or uses account
|
||||
// chooser panel during oauth
|
||||
dispatch(setAccountSwitcher(false));
|
||||
}
|
||||
|
||||
if (!account.refreshToken) {
|
||||
// mark user as stranger (user does not want us to remember his account)
|
||||
sessionStorage.setItem(`stranger${account.id}`, 1);
|
||||
}
|
||||
await dispatch(setLocale(user.lang));
|
||||
|
||||
if (auth && auth.oauth && auth.oauth.clientId) {
|
||||
// if we authenticating during oauth, we disable account chooser
|
||||
// because user probably has made his choise now
|
||||
// this may happen, when user registers, logs in or uses account
|
||||
// chooser panel during oauth
|
||||
dispatch(setAccountSwitcher(false));
|
||||
}
|
||||
return account;
|
||||
} catch (resp) {
|
||||
// all the logic to get the valid token was failed,
|
||||
// looks like we have some problems with token
|
||||
// lets redirect to login page
|
||||
if (typeof email === 'string') {
|
||||
// TODO: we should somehow try to find email by token
|
||||
dispatch(relogin(email));
|
||||
}
|
||||
|
||||
return dispatch(setLocale(user.lang))
|
||||
.then(() => account);
|
||||
});
|
||||
throw resp;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function findAccountIdFromToken(token: string): number {
|
||||
const encodedPayloads = token.split('.')[1];
|
||||
const { sub, jti }: { sub: string, jti: number } = JSON.parse(atob(encodedPayloads));
|
||||
// sub has the format "ely|{accountId}", so we must trim "ely|" part
|
||||
if (sub) {
|
||||
return parseInt(sub.substr(4), 10);
|
||||
}
|
||||
|
||||
// In older backend versions identity was stored in jti claim. Some users still have such tokens
|
||||
if (jti) {
|
||||
return jti;
|
||||
}
|
||||
|
||||
throw new Error('payloads is not contains any identity claim');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current user's token exp time. Supposed to be used before performing
|
||||
* any api request
|
||||
@ -203,8 +223,8 @@ export function requestNewToken() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return authentication.requestToken(refreshToken)
|
||||
.then(({ token }) => {
|
||||
return requestToken(refreshToken)
|
||||
.then((token) => {
|
||||
dispatch(updateToken(token));
|
||||
})
|
||||
.catch((resp) => {
|
||||
@ -234,7 +254,7 @@ export function revoke(account: Account) {
|
||||
.finally(() => {
|
||||
// we need to logout user, even in case, when we can
|
||||
// not authenticate him with new account
|
||||
authentication.logout(account)
|
||||
logout(account.token)
|
||||
.catch(() => {
|
||||
// we don't care
|
||||
});
|
||||
@ -268,7 +288,7 @@ export function logoutAll() {
|
||||
const {accounts: {available}} = getState();
|
||||
|
||||
available.forEach((account) =>
|
||||
authentication.logout(account)
|
||||
logout(account.token)
|
||||
.catch(() => {
|
||||
// we don't care
|
||||
})
|
||||
@ -303,7 +323,7 @@ export function logoutStrangers() {
|
||||
available.filter(isStranger)
|
||||
.forEach((account) => {
|
||||
dispatch(remove(account));
|
||||
authentication.logout(account);
|
||||
logout(account.token);
|
||||
});
|
||||
|
||||
if (activeAccount && isStranger(activeAccount)) {
|
||||
|
@ -5,30 +5,33 @@ import { browserHistory } from 'services/history';
|
||||
|
||||
import { InternalServerError } from 'services/request';
|
||||
import { sessionStorage } from 'services/localStorage';
|
||||
import authentication from 'services/api/authentication';
|
||||
import * as authentication from 'services/api/authentication';
|
||||
import {
|
||||
authenticate,
|
||||
revoke,
|
||||
logoutAll,
|
||||
logoutStrangers
|
||||
logoutStrangers,
|
||||
} from 'components/accounts/actions';
|
||||
import {
|
||||
add, ADD,
|
||||
activate, ACTIVATE,
|
||||
remove,
|
||||
reset
|
||||
reset,
|
||||
} from 'components/accounts/actions/pure-actions';
|
||||
import { SET_LOCALE } from 'components/i18n/actions';
|
||||
|
||||
import { updateUser, setUser } from 'components/user/actions';
|
||||
import { setLogin, setAccountSwitcher } from 'components/auth/actions';
|
||||
|
||||
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
|
||||
const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
|
||||
|
||||
const account = {
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token: 'foo',
|
||||
refreshToken: 'bar'
|
||||
token,
|
||||
refreshToken: 'bar',
|
||||
};
|
||||
|
||||
const user = {
|
||||
@ -67,7 +70,7 @@ describe('components/accounts/actions', () => {
|
||||
authentication.validateToken.returns(Promise.resolve({
|
||||
token: account.token,
|
||||
refreshToken: account.refreshToken,
|
||||
user
|
||||
user,
|
||||
}));
|
||||
});
|
||||
|
||||
@ -81,7 +84,29 @@ describe('components/accounts/actions', () => {
|
||||
it('should request user state using token', () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(authentication.validateToken, 'to have a call satisfying', [
|
||||
{token: account.token, refreshToken: account.refreshToken}
|
||||
account.id,
|
||||
account.token,
|
||||
account.refreshToken,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should request user by extracting id from token', () =>
|
||||
authenticate({ token })(dispatch, getState).then(() =>
|
||||
expect(authentication.validateToken, 'to have a call satisfying', [
|
||||
1,
|
||||
token,
|
||||
undefined,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should request user by extracting id from legacy token', () =>
|
||||
authenticate({ token: legacyToken })(dispatch, getState).then(() =>
|
||||
expect(authentication.validateToken, 'to have a call satisfying', [
|
||||
1,
|
||||
legacyToken,
|
||||
undefined,
|
||||
])
|
||||
)
|
||||
);
|
||||
@ -142,7 +167,7 @@ describe('components/accounts/actions', () => {
|
||||
it('rejects when 5xx without logouting', () => {
|
||||
const resp = new InternalServerError(null, {status: 500});
|
||||
|
||||
authentication.validateToken.returns(Promise.reject(resp));
|
||||
authentication.validateToken.rejects(resp);
|
||||
|
||||
return expect(authenticate(account)(dispatch, getState), 'to be rejected with', resp)
|
||||
.then(() => expect(dispatch, 'to have no calls satisfying', [
|
||||
@ -152,10 +177,10 @@ describe('components/accounts/actions', () => {
|
||||
|
||||
it('marks user as stranger, if there is no refreshToken', () => {
|
||||
const expectedKey = `stranger${account.id}`;
|
||||
authentication.validateToken.returns(Promise.resolve({
|
||||
authentication.validateToken.resolves({
|
||||
token: account.token,
|
||||
user
|
||||
}));
|
||||
user,
|
||||
});
|
||||
|
||||
sessionStorage.removeItem(expectedKey);
|
||||
|
||||
@ -247,7 +272,7 @@ describe('components/accounts/actions', () => {
|
||||
it('should call logout api method in background', () =>
|
||||
revoke(account)(dispatch, getState).then(() =>
|
||||
expect(authentication.logout, 'to have a call satisfying', [
|
||||
account
|
||||
account.token
|
||||
])
|
||||
)
|
||||
);
|
||||
@ -298,7 +323,7 @@ describe('components/accounts/actions', () => {
|
||||
it('should call logout api method in background', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(authentication.logout, 'to have a call satisfying', [
|
||||
account2
|
||||
account2.token
|
||||
])
|
||||
)
|
||||
);
|
||||
@ -325,8 +350,8 @@ describe('components/accounts/actions', () => {
|
||||
logoutAll()(dispatch, getState);
|
||||
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[account],
|
||||
[account2]
|
||||
[account.token],
|
||||
[account2.token]
|
||||
]);
|
||||
});
|
||||
|
||||
@ -395,8 +420,8 @@ describe('components/accounts/actions', () => {
|
||||
logoutStrangers()(dispatch, getState);
|
||||
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[foreignAccount],
|
||||
[foreignAccount2]
|
||||
[foreignAccount.token],
|
||||
[foreignAccount2.token]
|
||||
]);
|
||||
});
|
||||
|
||||
@ -458,8 +483,8 @@ describe('components/accounts/actions', () => {
|
||||
|
||||
it('logouts all accounts', () => {
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[foreignAccount],
|
||||
[foreignAccount2],
|
||||
[foreignAccount.token],
|
||||
[foreignAccount2.token],
|
||||
]);
|
||||
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import type { OauthData } from 'services/api/oauth';
|
||||
import type { OAuthResponse } from 'services/api/authentication';
|
||||
import { browserHistory } from 'services/history';
|
||||
import logger from 'services/logger';
|
||||
import localStorage from 'services/localStorage';
|
||||
@ -8,7 +9,11 @@ import history from 'services/history';
|
||||
import { updateUser, acceptRules as userAcceptRules } from 'components/user/actions';
|
||||
import { authenticate, logoutAll } from 'components/accounts/actions';
|
||||
import { getActiveAccount } from 'components/accounts/reducer';
|
||||
import authentication from 'services/api/authentication';
|
||||
import {
|
||||
login as loginEndpoint,
|
||||
forgotPassword as forgotPasswordEndpoint,
|
||||
recoverPassword as recoverPasswordEndpoint,
|
||||
} from 'services/api/authentication';
|
||||
import oauth from 'services/api/oauth';
|
||||
import signup from 'services/api/signup';
|
||||
import dispatchBsod from 'components/ui/bsod/dispatchBsod';
|
||||
@ -76,9 +81,7 @@ export function login({
|
||||
rememberMe?: bool
|
||||
}) {
|
||||
return wrapInLoader((dispatch) =>
|
||||
authentication.login(
|
||||
{login, password, totp, rememberMe}
|
||||
)
|
||||
loginEndpoint({login, password, totp, rememberMe})
|
||||
.then(authHandler(dispatch))
|
||||
.catch((resp) => {
|
||||
if (resp.errors) {
|
||||
@ -120,9 +123,9 @@ export function forgotPassword({
|
||||
captcha: string
|
||||
}) {
|
||||
return wrapInLoader((dispatch, getState) =>
|
||||
authentication.forgotPassword({login, captcha})
|
||||
forgotPasswordEndpoint(login, captcha)
|
||||
.then(({data = {}}) => dispatch(updateUser({
|
||||
maskedEmail: data.emailMask || getState().user.email
|
||||
maskedEmail: data.emailMask || getState().user.email,
|
||||
})))
|
||||
.catch(validationErrorsHandler(dispatch))
|
||||
);
|
||||
@ -138,7 +141,7 @@ export function recoverPassword({
|
||||
newRePassword: string
|
||||
}) {
|
||||
return wrapInLoader((dispatch) =>
|
||||
authentication.recoverPassword({key, newPassword, newRePassword})
|
||||
recoverPasswordEndpoint(key, newPassword, newRePassword)
|
||||
.then(authHandler(dispatch))
|
||||
.catch(validationErrorsHandler(dispatch, '/forgot-password'))
|
||||
);
|
||||
@ -544,9 +547,9 @@ function needActivation() {
|
||||
}
|
||||
|
||||
function authHandler(dispatch) {
|
||||
return (resp) => dispatch(authenticate({
|
||||
return (resp: OAuthResponse) => dispatch(authenticate({
|
||||
token: resp.access_token,
|
||||
refreshToken: resp.refresh_token
|
||||
refreshToken: resp.refresh_token,
|
||||
})).then((resp) => {
|
||||
dispatch(setLogin(null));
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import logger from 'services/logger';
|
||||
import mfa from 'services/api/mfa';
|
||||
import { disable as disableMFA } from 'services/api/mfa';
|
||||
|
||||
import MfaDisableForm from './disableForm/MfaDisableForm';
|
||||
import MfaStatus from './status/MfaStatus';
|
||||
@ -15,6 +16,10 @@ export default class MfaDisable extends Component<{
|
||||
}, {
|
||||
showForm?: bool
|
||||
}> {
|
||||
static contextTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
state = {};
|
||||
|
||||
render() {
|
||||
@ -35,7 +40,7 @@ export default class MfaDisable extends Component<{
|
||||
() => {
|
||||
const data = form.serialize();
|
||||
|
||||
return mfa.disable(data);
|
||||
return disableMFA(this.context.userId, data);
|
||||
}
|
||||
)
|
||||
.then(() => this.props.onComplete())
|
||||
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, FormModel } from 'components/ui/form';
|
||||
import styles from 'components/profile/profileForm.scss';
|
||||
@ -7,7 +8,7 @@ import Stepper from 'components/ui/stepper';
|
||||
import { SlideMotion } from 'components/ui/motion';
|
||||
import { ScrollIntoView } from 'components/ui/scroll';
|
||||
import logger from 'services/logger';
|
||||
import mfa from 'services/api/mfa';
|
||||
import { getSecret, enable as enableMFA } from 'services/api/mfa';
|
||||
|
||||
import Instructions from './instructions';
|
||||
import KeyForm from './keyForm';
|
||||
@ -24,25 +25,31 @@ type Props = {
|
||||
confirmationForm: FormModel,
|
||||
onSubmit: (form: FormModel, sendData: () => Promise<*>) => Promise<*>,
|
||||
onComplete: Function,
|
||||
step: MfaStep
|
||||
step: MfaStep,
|
||||
};
|
||||
|
||||
export default class MfaEnable extends Component<Props, {
|
||||
isLoading: bool,
|
||||
activeStep: MfaStep,
|
||||
secret: string,
|
||||
qrCodeSrc: string
|
||||
}> {
|
||||
interface State {
|
||||
isLoading: bool;
|
||||
activeStep: MfaStep;
|
||||
secret: string;
|
||||
qrCodeSrc: string;
|
||||
}
|
||||
|
||||
export default class MfaEnable extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
confirmationForm: new FormModel(),
|
||||
step: 0
|
||||
step: 0,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
isLoading: false,
|
||||
activeStep: this.props.step,
|
||||
qrCodeSrc: '',
|
||||
secret: ''
|
||||
secret: '',
|
||||
};
|
||||
|
||||
confirmationFormEl: ?Form;
|
||||
@ -124,7 +131,7 @@ export default class MfaEnable extends Component<Props, {
|
||||
if (props.step === 1) {
|
||||
this.setState({isLoading: true});
|
||||
|
||||
mfa.getSecret().then((resp) => {
|
||||
getSecret(this.context.userId).then((resp) => {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
secret: resp.secret,
|
||||
@ -154,7 +161,7 @@ export default class MfaEnable extends Component<Props, {
|
||||
() => {
|
||||
const data = form.serialize();
|
||||
|
||||
return mfa.enable(data);
|
||||
return enableMFA(this.context.userId, data.totp, data.password);
|
||||
}
|
||||
)
|
||||
.then(() => this.props.onComplete())
|
||||
|
@ -16,7 +16,7 @@ export default function Stepper({
|
||||
}: {
|
||||
totalSteps: number,
|
||||
activeStep: number,
|
||||
color: Color
|
||||
color?: Color,
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames(styles.steps, styles[`${color}Steps`])}>
|
||||
|
@ -1,6 +1,14 @@
|
||||
import accounts from 'services/api/accounts';
|
||||
// @flow
|
||||
import type { User, State } from './reducer';
|
||||
import {
|
||||
getInfo as getInfoEndpoint,
|
||||
changeLang as changeLangEndpoint,
|
||||
acceptRules as acceptRulesEndpoint,
|
||||
} from 'services/api/accounts';
|
||||
import { setLocale } from 'components/i18n/actions';
|
||||
|
||||
type Dispatch = (action: Object) => Promise<*>;
|
||||
|
||||
export const UPDATE = 'USER_UPDATE';
|
||||
/**
|
||||
* Merge data into user's state
|
||||
@ -8,10 +16,10 @@ export const UPDATE = 'USER_UPDATE';
|
||||
* @param {object} payload
|
||||
* @return {object} - action definition
|
||||
*/
|
||||
export function updateUser(payload) {
|
||||
export function updateUser(payload: $Shape<User>) { // Temp workaround
|
||||
return {
|
||||
type: UPDATE,
|
||||
payload
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,61 +30,69 @@ export const SET = 'USER_SET';
|
||||
* @param {User} payload
|
||||
* @return {object} - action definition
|
||||
*/
|
||||
export function setUser(payload) {
|
||||
export function setUser(payload: $Shape<User>) {
|
||||
return {
|
||||
type: SET,
|
||||
payload
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const CHANGE_LANG = 'USER_CHANGE_LANG';
|
||||
export function changeLang(lang) {
|
||||
return (dispatch, getState) => dispatch(setLocale(lang))
|
||||
export function changeLang(lang: string) {
|
||||
return (dispatch: Dispatch, getState: () => State) => dispatch(setLocale(lang))
|
||||
.then((lang) => {
|
||||
const {user: {isGuest, lang: oldLang}} = getState();
|
||||
|
||||
if (oldLang !== lang) {
|
||||
!isGuest && accounts.changeLang(lang);
|
||||
|
||||
dispatch({
|
||||
type: CHANGE_LANG,
|
||||
payload: {
|
||||
lang
|
||||
}
|
||||
});
|
||||
const { id, isGuest, lang: oldLang } = getState().user;
|
||||
if (oldLang === lang) {
|
||||
return;
|
||||
}
|
||||
|
||||
!isGuest && changeLangEndpoint(((id: any): number), lang); // hack to tell Flow that it's defined
|
||||
|
||||
dispatch({
|
||||
type: CHANGE_LANG,
|
||||
payload: {
|
||||
lang,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function setGuest() {
|
||||
return (dispatch, getState) => {
|
||||
return (dispatch: Dispatch, getState: () => Object) => {
|
||||
dispatch(setUser({
|
||||
lang: getState().user.lang,
|
||||
isGuest: true
|
||||
isGuest: true,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUserData() {
|
||||
return (dispatch) =>
|
||||
accounts.current()
|
||||
.then((resp) => {
|
||||
dispatch(updateUser({
|
||||
isGuest: false,
|
||||
...resp
|
||||
}));
|
||||
return async (dispatch: Dispatch, getState: () => State) => {
|
||||
// $FlowFixMe
|
||||
const resp = await getInfoEndpoint(getState().user.id);
|
||||
dispatch(updateUser({
|
||||
isGuest: false,
|
||||
...resp,
|
||||
}));
|
||||
dispatch(changeLang(resp.lang));
|
||||
|
||||
return dispatch(changeLang(resp.lang));
|
||||
});
|
||||
return resp;
|
||||
};
|
||||
}
|
||||
|
||||
export function acceptRules() {
|
||||
return (dispatch) =>
|
||||
accounts.acceptRules().then((resp) => {
|
||||
return (dispatch: Dispatch, getState: () => State) => {
|
||||
const { id } = getState().user;
|
||||
if (!id) {
|
||||
throw new Error('user id is should be set at the moment when this action is called');
|
||||
}
|
||||
|
||||
return acceptRulesEndpoint(id).then((resp) => {
|
||||
dispatch(updateUser({
|
||||
shouldAcceptRules: false
|
||||
shouldAcceptRules: false,
|
||||
}));
|
||||
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import sinon from 'sinon';
|
||||
|
||||
import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware';
|
||||
import { browserHistory } from 'services/history';
|
||||
import authentication from 'services/api/authentication';
|
||||
import * as authentication from 'services/api/authentication';
|
||||
import { InternalServerError } from 'services/request';
|
||||
import { updateToken } from 'components/accounts/actions';
|
||||
|
||||
@ -86,7 +86,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
}
|
||||
};
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
authentication.requestToken.returns(Promise.resolve(validToken));
|
||||
|
||||
return middleware.before(data).then((resp) => {
|
||||
expect(resp, 'to satisfy', data);
|
||||
@ -126,7 +126,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
}
|
||||
};
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
authentication.requestToken.returns(Promise.resolve(validToken));
|
||||
|
||||
return middleware.before(data).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
@ -251,7 +251,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
|
||||
restart = sinon.stub().named('restart');
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
authentication.requestToken.returns(Promise.resolve(validToken));
|
||||
});
|
||||
|
||||
function assertNewTokenRequest() {
|
||||
|
@ -18,6 +18,10 @@ export type User = {|
|
||||
shouldAcceptRules?: bool,
|
||||
|};
|
||||
|
||||
export type State = {
|
||||
user: User, // TODO: replace with centralized global state
|
||||
};
|
||||
|
||||
const defaults: User = {
|
||||
id: null,
|
||||
uuid: null,
|
||||
|
@ -1,34 +1,36 @@
|
||||
// @flow
|
||||
import type { RouterHistory, Match } from 'react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import ChangeEmail from 'components/profile/changeEmail/ChangeEmail';
|
||||
|
||||
import accounts from 'services/api/accounts';
|
||||
import { requestEmailChange, setNewEmail, confirmNewEmail } from 'services/api/accounts';
|
||||
|
||||
class ChangeEmailPage extends Component {
|
||||
interface Props {
|
||||
lang: string;
|
||||
email: string;
|
||||
history: RouterHistory;
|
||||
match: {
|
||||
...Match;
|
||||
params: {
|
||||
step: 'step1' | 'step2' | 'step3';
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
class ChangeEmailPage extends Component<Props> {
|
||||
static displayName = 'ChangeEmailPage';
|
||||
|
||||
static propTypes = {
|
||||
email: PropTypes.string.isRequired,
|
||||
lang: PropTypes.string.isRequired,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func
|
||||
}).isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
step: PropTypes.oneOf(['step1', 'step2', 'step3']),
|
||||
code: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
goToProfile: PropTypes.func.isRequired
|
||||
goToProfile: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
const step = this.props.match.params.step;
|
||||
const { step } = this.props.match.params;
|
||||
|
||||
if (step && !/^step[123]$/.test(step)) {
|
||||
// wrong param value
|
||||
@ -37,14 +39,14 @@ class ChangeEmailPage extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {step = 'step1', code} = this.props.match.params;
|
||||
const { step = 'step1', code } = this.props.match.params;
|
||||
|
||||
return (
|
||||
<ChangeEmail
|
||||
onSubmit={this.onSubmit}
|
||||
email={this.props.email}
|
||||
lang={this.props.lang}
|
||||
step={step.slice(-1) * 1 - 1}
|
||||
step={((step.slice(-1): any): number) * 1 - 1}
|
||||
onChangeStep={this.onChangeStep}
|
||||
code={code}
|
||||
/>
|
||||
@ -55,19 +57,20 @@ class ChangeEmailPage extends Component {
|
||||
this.props.history.push(`/profile/change-email/step${++step}`);
|
||||
};
|
||||
|
||||
onSubmit = (step, form) => {
|
||||
onSubmit = (step: number, form) => {
|
||||
return this.context.onSubmit({
|
||||
form,
|
||||
sendData: () => {
|
||||
const { userId } = this.context;
|
||||
const data = form.serialize();
|
||||
|
||||
switch (step) {
|
||||
case 0:
|
||||
return accounts.requestEmailChange(data).catch(handleErrors());
|
||||
return requestEmailChange(userId, data.password).catch(handleErrors());
|
||||
case 1:
|
||||
return accounts.setNewEmail(data).catch(handleErrors('/profile/change-email'));
|
||||
return setNewEmail(userId, data.email, data.key).catch(handleErrors('/profile/change-email'));
|
||||
case 2:
|
||||
return accounts.confirmNewEmail(data).catch(handleErrors('/profile/change-email'));
|
||||
return confirmNewEmail(userId, data.key).catch(handleErrors('/profile/change-email'));
|
||||
default:
|
||||
throw new Error(`Unsupported step ${step}`);
|
||||
}
|
||||
|
@ -1,20 +1,23 @@
|
||||
// @flow
|
||||
import type { User } from 'components/user';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import accounts from 'services/api/accounts';
|
||||
import { changePassword } from 'services/api/accounts';
|
||||
import { FormModel } from 'components/ui/form';
|
||||
import ChangePassword from 'components/profile/changePassword/ChangePassword';
|
||||
|
||||
class ChangePasswordPage extends Component {
|
||||
interface Props {
|
||||
updateUser: (fields: $Shape<User>) => void;
|
||||
}
|
||||
|
||||
class ChangePasswordPage extends Component<Props> {
|
||||
static displayName = 'ChangePasswordPage';
|
||||
|
||||
static propTypes = {
|
||||
updateUser: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
goToProfile: PropTypes.func.isRequired
|
||||
goToProfile: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
form = new FormModel();
|
||||
@ -29,7 +32,7 @@ class ChangePasswordPage extends Component {
|
||||
const {form} = this;
|
||||
return this.context.onSubmit({
|
||||
form,
|
||||
sendData: () => accounts.changePassword(form.serialize())
|
||||
sendData: () => changePassword(this.context.userId, form.serialize()),
|
||||
}).then(() => {
|
||||
this.props.updateUser({
|
||||
passwordChangedAt: Date.now() / 1000
|
||||
@ -43,5 +46,5 @@ import { connect } from 'react-redux';
|
||||
import { updateUser } from 'components/user/actions';
|
||||
|
||||
export default connect(null, {
|
||||
updateUser
|
||||
updateUser,
|
||||
})(ChangePasswordPage);
|
||||
|
@ -1,25 +1,29 @@
|
||||
// @flow
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import accounts from 'services/api/accounts';
|
||||
import { changeUsername } from 'services/api/accounts';
|
||||
import { FormModel } from 'components/ui/form';
|
||||
import ChangeUsername from 'components/profile/changeUsername/ChangeUsername';
|
||||
|
||||
class ChangeUsernamePage extends Component {
|
||||
interface Props {
|
||||
username: string;
|
||||
updateUsername: (username: string) => void;
|
||||
}
|
||||
|
||||
class ChangeUsernamePage extends Component<Props> {
|
||||
static displayName = 'ChangeUsernamePage';
|
||||
|
||||
static propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
updateUsername: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
goToProfile: PropTypes.func.isRequired
|
||||
goToProfile: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
form = new FormModel();
|
||||
|
||||
actualUsername: string;
|
||||
|
||||
componentWillMount() {
|
||||
this.actualUsername = this.props.username;
|
||||
}
|
||||
@ -43,7 +47,7 @@ class ChangeUsernamePage extends Component {
|
||||
};
|
||||
|
||||
onSubmit = () => {
|
||||
const {form} = this;
|
||||
const { form } = this;
|
||||
if (this.actualUsername === this.props.username) {
|
||||
this.context.goToProfile();
|
||||
return Promise.resolve();
|
||||
@ -51,7 +55,10 @@ class ChangeUsernamePage extends Component {
|
||||
|
||||
return this.context.onSubmit({
|
||||
form,
|
||||
sendData: () => accounts.changeUsername(form.serialize())
|
||||
sendData: () => {
|
||||
const { username, password } = form.serialize();
|
||||
return changeUsername(this.context.userId, username, password);
|
||||
},
|
||||
}).then(() => {
|
||||
this.actualUsername = form.value('username');
|
||||
|
||||
@ -64,7 +71,7 @@ import { connect } from 'react-redux';
|
||||
import { updateUser } from 'components/user/actions';
|
||||
|
||||
export default connect((state) => ({
|
||||
username: state.user.username
|
||||
username: state.user.username,
|
||||
}), {
|
||||
updateUsername: (username) => updateUser({username})
|
||||
updateUsername: (username) => updateUser({username}),
|
||||
})(ChangeUsernamePage);
|
||||
|
@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import type { RouterHistory, Match } from 'react-router';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
@ -8,17 +9,18 @@ import type { MfaStep } from 'components/profile/multiFactorAuth';
|
||||
import type { FormModel } from 'components/ui/form';
|
||||
import type { User } from 'components/user';
|
||||
|
||||
class MultiFactorAuthPage extends Component<{
|
||||
user: User,
|
||||
history: {
|
||||
push: (string) => void
|
||||
},
|
||||
interface Props {
|
||||
user: User;
|
||||
history: RouterHistory;
|
||||
match: {
|
||||
...Match;
|
||||
params: {
|
||||
step?: '1' | '2' | '3'
|
||||
}
|
||||
}
|
||||
}> {
|
||||
step?: '1' | '2' | '3';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
class MultiFactorAuthPage extends Component<Props> {
|
||||
static contextTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
goToProfile: PropTypes.func.isRequired
|
||||
@ -26,7 +28,7 @@ class MultiFactorAuthPage extends Component<{
|
||||
|
||||
componentWillMount() {
|
||||
const step = this.props.match.params.step;
|
||||
const {user} = this.props;
|
||||
const { user } = this.props;
|
||||
|
||||
if (step) {
|
||||
if (!/^[1-3]$/.test(step)) {
|
||||
@ -42,7 +44,7 @@ class MultiFactorAuthPage extends Component<{
|
||||
}
|
||||
|
||||
render() {
|
||||
const {user} = this.props;
|
||||
const { user } = this.props;
|
||||
|
||||
return (
|
||||
<MultiFactorAuth
|
||||
|
@ -20,19 +20,24 @@ import styles from './profile.scss';
|
||||
|
||||
import type { FormModel } from 'components/ui/form';
|
||||
|
||||
class ProfilePage extends Component<{
|
||||
onSubmit: ({form: FormModel, sendData: () => Promise<*>}) => void,
|
||||
fetchUserData: () => Promise<*>
|
||||
}> {
|
||||
interface Props {
|
||||
userId: number;
|
||||
onSubmit: ({form: FormModel, sendData: () => Promise<*>}) => void;
|
||||
fetchUserData: () => Promise<*>;
|
||||
}
|
||||
|
||||
class ProfilePage extends Component<Props> {
|
||||
static childContextTypes = {
|
||||
userId: PropTypes.number,
|
||||
onSubmit: PropTypes.func,
|
||||
goToProfile: PropTypes.func
|
||||
goToProfile: PropTypes.func,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
userId: this.props.userId,
|
||||
onSubmit: this.props.onSubmit,
|
||||
goToProfile: () => this.props.fetchUserData().then(this.goToProfile)
|
||||
goToProfile: () => this.props.fetchUserData().then(this.goToProfile),
|
||||
};
|
||||
}
|
||||
|
||||
@ -60,7 +65,9 @@ class ProfilePage extends Component<{
|
||||
goToProfile = () => browserHistory.push('/');
|
||||
}
|
||||
|
||||
export default connect(null, {
|
||||
export default connect((state) => ({
|
||||
userId: state.user.id,
|
||||
}), {
|
||||
fetchUserData,
|
||||
onSubmit: ({form, sendData}: {
|
||||
form: FormModel,
|
||||
|
@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import request from 'services/request';
|
||||
|
||||
type UserResponse = {
|
||||
export type UserResponse = {
|
||||
elyProfileLink: string,
|
||||
email: string,
|
||||
hasMojangUsernameCollision: bool,
|
||||
@ -16,85 +16,63 @@ type UserResponse = {
|
||||
uuid: string,
|
||||
};
|
||||
|
||||
export default {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} [options.token] - an optional token to overwrite headers
|
||||
* in middleware and disable token auto-refresh
|
||||
*
|
||||
* @return {Promise<UserResponse>}
|
||||
*/
|
||||
current(options: { token?: ?string } = {}): Promise<UserResponse> {
|
||||
return request.get('/api/accounts/current', {}, {
|
||||
token: options.token
|
||||
});
|
||||
},
|
||||
export function getInfo(id: number, token?: string): Promise<UserResponse> {
|
||||
return request.get(`/api/v1/accounts/${id}`, {}, {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
changePassword({
|
||||
password = '',
|
||||
newPassword = '',
|
||||
newRePassword = '',
|
||||
logoutAll = true
|
||||
}: {
|
||||
password?: string,
|
||||
newPassword?: string,
|
||||
newRePassword?: string,
|
||||
logoutAll?: bool,
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/accounts/change-password',
|
||||
{password, newPassword, newRePassword, logoutAll}
|
||||
);
|
||||
},
|
||||
export function changePassword(id: number, {
|
||||
password = '',
|
||||
newPassword = '',
|
||||
newRePassword = '',
|
||||
logoutAll = true,
|
||||
}: {
|
||||
password?: string,
|
||||
newPassword?: string,
|
||||
newRePassword?: string,
|
||||
logoutAll?: bool,
|
||||
}): Promise<{ success: bool }> {
|
||||
return request.post(`/api/v1/accounts/${id}/password`, {
|
||||
password,
|
||||
newPassword,
|
||||
newRePassword,
|
||||
logoutAll,
|
||||
});
|
||||
}
|
||||
|
||||
acceptRules() {
|
||||
return request.post('/api/accounts/accept-rules');
|
||||
},
|
||||
export function acceptRules(id: number) {
|
||||
return request.post(`/api/v1/accounts/${id}/rules`);
|
||||
}
|
||||
|
||||
changeUsername({
|
||||
username = '',
|
||||
password = ''
|
||||
}: {
|
||||
username?: string,
|
||||
password?: string,
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/accounts/change-username',
|
||||
{username, password}
|
||||
);
|
||||
},
|
||||
export function changeUsername(id: number, username: ?string, password: ?string) {
|
||||
return request.post(`/api/v1/accounts/${id}/username`, {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
changeLang(lang: string) {
|
||||
return request.post(
|
||||
'/api/accounts/change-lang',
|
||||
{lang}
|
||||
);
|
||||
},
|
||||
export function changeLang(id: number, lang: string) {
|
||||
return request.post(`/api/v1/accounts/${id}/language`, {
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
requestEmailChange({password = ''}: { password?: string }) {
|
||||
return request.post(
|
||||
'/api/accounts/change-email/initialize',
|
||||
{password}
|
||||
);
|
||||
},
|
||||
export function requestEmailChange(id: number, password: string) {
|
||||
return request.post(`/api/v1/accounts/${id}/email-verification`, {
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
setNewEmail({
|
||||
email = '',
|
||||
key = ''
|
||||
}: {
|
||||
email?: string,
|
||||
key?: string,
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/accounts/change-email/submit-new-email',
|
||||
{email, key}
|
||||
);
|
||||
},
|
||||
export function setNewEmail(id: number, email: string, key: string) {
|
||||
return request.post(`/api/v1/accounts/${id}/new-email-verification`, {
|
||||
email,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
confirmNewEmail({key}: { key: string }) {
|
||||
return request.post(
|
||||
'/api/accounts/change-email/confirm-new-email',
|
||||
{key}
|
||||
);
|
||||
}
|
||||
};
|
||||
export function confirmNewEmail(id: number, key: string) {
|
||||
return request.post(`/api/v1/accounts/${id}/email`, {
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
@ -1,159 +1,155 @@
|
||||
// @flow
|
||||
import type { UserResponse } from 'services/api/accounts';
|
||||
import logger from 'services/logger';
|
||||
import request, { InternalServerError } from 'services/request';
|
||||
import accounts from 'services/api/accounts';
|
||||
import { getInfo as getInfoEndpoint } from 'services/api/accounts';
|
||||
|
||||
const authentication = {
|
||||
login({
|
||||
export interface OAuthResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number; // count seconds before expire
|
||||
success: true;
|
||||
}
|
||||
|
||||
export function login({
|
||||
login,
|
||||
password,
|
||||
totp,
|
||||
rememberMe = false,
|
||||
}: {
|
||||
login: string,
|
||||
password?: string,
|
||||
totp?: string,
|
||||
rememberMe: bool,
|
||||
}): Promise<OAuthResponse> {
|
||||
return request.post('/api/authentication/login', {
|
||||
login,
|
||||
password,
|
||||
totp,
|
||||
rememberMe = false
|
||||
}: {
|
||||
login: string,
|
||||
password?: string,
|
||||
totp?: string,
|
||||
rememberMe: bool
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/authentication/login',
|
||||
{login, password, totp, rememberMe},
|
||||
{token: null}
|
||||
);
|
||||
},
|
||||
rememberMe,
|
||||
}, { token: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} [options.token] - an optional token to overwrite headers
|
||||
* in middleware and disable token auto-refresh
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
logout(options: ?{
|
||||
token: string
|
||||
}) {
|
||||
return request.post('/api/authentication/logout', {}, {
|
||||
token: options && options.token
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {string} token - an optional token to overwrite headers
|
||||
* in middleware and disable token auto-refresh
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
export function logout(token?: string): Promise<{success: bool}> {
|
||||
return request.post('/api/authentication/logout', {}, {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
forgotPassword({
|
||||
export function forgotPassword(login: string, captcha: string): Promise<{
|
||||
success: bool,
|
||||
data: {
|
||||
canRepeatIn: number,
|
||||
emailMask: ?string,
|
||||
repeatFrequency: number,
|
||||
},
|
||||
errors: {
|
||||
[key: string]: string,
|
||||
},
|
||||
}> {
|
||||
return request.post('/api/authentication/forgot-password', {
|
||||
login,
|
||||
captcha
|
||||
}: {
|
||||
login: string,
|
||||
captcha: string
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/authentication/forgot-password',
|
||||
{login, captcha},
|
||||
{token: null}
|
||||
);
|
||||
},
|
||||
captcha,
|
||||
}, { token: null });
|
||||
}
|
||||
|
||||
recoverPassword({
|
||||
export function recoverPassword(key: string, newPassword: string, newRePassword: string): Promise<OAuthResponse> {
|
||||
return request.post('/api/authentication/recover-password', {
|
||||
key,
|
||||
newPassword,
|
||||
newRePassword
|
||||
}: {
|
||||
key: string,
|
||||
newPassword: string,
|
||||
newRePassword: string
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/authentication/recover-password',
|
||||
{key, newPassword, newRePassword},
|
||||
{token: null}
|
||||
);
|
||||
},
|
||||
newRePassword,
|
||||
}, { token: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves if token is valid
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.token
|
||||
* @param {string} options.refreshToken
|
||||
*
|
||||
* @return {Promise} - resolves with options.token or with a new token
|
||||
* if it was refreshed. As a side effect the response
|
||||
* will have a `user` field with current user data
|
||||
*/
|
||||
validateToken({token, refreshToken}: {
|
||||
token: string,
|
||||
refreshToken: ?string
|
||||
}) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('token must be a string');
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.then(() => accounts.current({token}))
|
||||
.then((user) => ({token, refreshToken, user}))
|
||||
.catch((resp) =>
|
||||
this.handleTokenError(resp, refreshToken)
|
||||
// TODO: use recursion here
|
||||
.then(({token}) =>
|
||||
accounts.current({token})
|
||||
.then((user) => ({token, refreshToken, user}))
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
handleTokenError(resp: Error | { message: string }, refreshToken: ?string): Promise<{
|
||||
token: string,
|
||||
}> {
|
||||
if (resp instanceof InternalServerError) {
|
||||
// delegate error recovering to the bsod middleware
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
if (refreshToken) {
|
||||
if ([
|
||||
'Token expired',
|
||||
'Incorrect token',
|
||||
'You are requesting with an invalid credential.'
|
||||
].includes(resp.message)) {
|
||||
return authentication.requestToken(refreshToken);
|
||||
}
|
||||
|
||||
logger.error('Unexpected error during token validation', {
|
||||
resp
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
},
|
||||
|
||||
/**
|
||||
* Request new access token using a refreshToken
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
*
|
||||
* @return {Promise} - resolves to {token}
|
||||
*/
|
||||
requestToken(refreshToken: string): Promise<{token: string}> {
|
||||
return request.post(
|
||||
'/api/authentication/refresh-token',
|
||||
{refresh_token: refreshToken}, // eslint-disable-line
|
||||
{token: null}
|
||||
)
|
||||
.then((resp: {access_token: string}) => ({
|
||||
token: resp.access_token
|
||||
}))
|
||||
.catch((resp) => {
|
||||
const errors = resp.errors || {};
|
||||
|
||||
if (errors.refresh_token !== 'error.refresh_token_not_exist') {
|
||||
logger.error('Failed refreshing token: unknown error', {
|
||||
resp
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
});
|
||||
/**
|
||||
* Resolves if token is valid
|
||||
*
|
||||
* @param {number} id
|
||||
* @param {string} token
|
||||
* @param {string} refreshToken
|
||||
*
|
||||
* @return {Promise} - resolves with options.token or with a new token
|
||||
* if it was refreshed. As a side effect the response
|
||||
* will have a `user` field with current user data
|
||||
*
|
||||
*/
|
||||
export async function validateToken(id: number, token: string, refreshToken: ?string): Promise<{
|
||||
token: string,
|
||||
refreshToken: ?string,
|
||||
user: UserResponse,
|
||||
}> {
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('token must be a string');
|
||||
}
|
||||
};
|
||||
|
||||
export default authentication;
|
||||
let user: UserResponse;
|
||||
try {
|
||||
user = await getInfoEndpoint(id, token);
|
||||
} catch (resp) {
|
||||
token = await handleTokenError(resp, refreshToken);
|
||||
user = await getInfoEndpoint(id, token); // TODO: replace with recursive call
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
const recoverableErrors = [
|
||||
'Token expired',
|
||||
'Incorrect token',
|
||||
'You are requesting with an invalid credential.',
|
||||
];
|
||||
|
||||
function handleTokenError(resp: Error | { message: string }, refreshToken: ?string): Promise<string> {
|
||||
if (resp instanceof InternalServerError) {
|
||||
// delegate error recovering to the bsod middleware
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
if (refreshToken) {
|
||||
if (recoverableErrors.includes(resp.message)) {
|
||||
return requestToken(refreshToken);
|
||||
}
|
||||
|
||||
logger.error('Unexpected error during token validation', { resp });
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request new access token using a refreshToken
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
*
|
||||
* @return {Promise} - resolves to token
|
||||
*/
|
||||
export async function requestToken(refreshToken: string): Promise<string> {
|
||||
try {
|
||||
const response: OAuthResponse = await request.post('/api/authentication/refresh-token', {
|
||||
refresh_token: refreshToken, // eslint-disable-line camelcase
|
||||
}, {
|
||||
token: null,
|
||||
});
|
||||
|
||||
return response.access_token;
|
||||
} catch (resp) {
|
||||
const errors = resp.errors || {};
|
||||
if (errors.refresh_token !== 'error.refresh_token_not_exist') {
|
||||
logger.error('Failed refreshing token: unknown error', {
|
||||
resp,
|
||||
});
|
||||
}
|
||||
|
||||
throw resp;
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,34 @@
|
||||
/* eslint-disable camelcase */
|
||||
import expect from 'unexpected';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import request from 'services/request';
|
||||
import authentication from 'services/api/authentication';
|
||||
import accounts from 'services/api/accounts';
|
||||
import * as authentication from 'services/api/authentication';
|
||||
import * as accounts from 'services/api/accounts';
|
||||
|
||||
describe('authentication api', () => {
|
||||
let server;
|
||||
|
||||
beforeEach(() => {
|
||||
server = sinon.createFakeServer({
|
||||
autoRespond: true
|
||||
});
|
||||
|
||||
['get', 'post'].forEach((method) => {
|
||||
server[method] = (url, resp = {}, status = 200, headers = {}) => {
|
||||
server.respondWith(method, url, [
|
||||
status,
|
||||
{ 'Content-Type': 'application/json', ...headers },
|
||||
JSON.stringify(resp)
|
||||
]);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.restore();
|
||||
});
|
||||
|
||||
describe('#login', () => {
|
||||
const params = {
|
||||
login: 'foo',
|
||||
@ -41,50 +64,53 @@ describe('authentication api', () => {
|
||||
});
|
||||
|
||||
describe('#validateToken()', () => {
|
||||
const validTokens = {token: 'foo', refreshToken: 'bar'};
|
||||
const user = {id: 1};
|
||||
const validToken = 'foo';
|
||||
const validRefreshToken = 'bar';
|
||||
const user = { id: 1 };
|
||||
const validateTokenArgs = [user.id, validToken, validRefreshToken];
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(accounts, 'current');
|
||||
|
||||
accounts.current.returns(Promise.resolve(user));
|
||||
sinon.stub(accounts, 'getInfo');
|
||||
accounts.getInfo.returns(Promise.resolve(user));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
accounts.current.restore();
|
||||
accounts.getInfo.restore();
|
||||
});
|
||||
|
||||
it('should request accounts.current', () =>
|
||||
expect(authentication.validateToken(validTokens), 'to be fulfilled')
|
||||
it('should request accounts.getInfo', () =>
|
||||
expect(authentication.validateToken(...validateTokenArgs), 'to be fulfilled')
|
||||
.then(() => {
|
||||
expect(accounts.current, 'to have a call satisfying', [
|
||||
{token: 'foo'}
|
||||
expect(accounts.getInfo, 'to have a call satisfying', [
|
||||
user.id,
|
||||
validToken,
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
it('should resolve with both tokens and user object', () =>
|
||||
expect(authentication.validateToken(validTokens), 'to be fulfilled with', {
|
||||
...validTokens,
|
||||
user
|
||||
expect(authentication.validateToken(...validateTokenArgs), 'to be fulfilled with', {
|
||||
token: validToken,
|
||||
refreshToken: validRefreshToken,
|
||||
user,
|
||||
})
|
||||
);
|
||||
|
||||
it('rejects if token has a bad type', () =>
|
||||
expect(authentication.validateToken({token: {}}),
|
||||
expect(authentication.validateToken(user.id, {}),
|
||||
'to be rejected with', 'token must be a string'
|
||||
)
|
||||
);
|
||||
|
||||
it('should allow empty refreshToken', () =>
|
||||
expect(authentication.validateToken({token: 'foo', refreshToken: null}), 'to be fulfilled')
|
||||
expect(authentication.validateToken(user.id, 'foo', null), 'to be fulfilled')
|
||||
);
|
||||
|
||||
it('rejects if accounts.current request is unexpectedly failed', () => {
|
||||
it('rejects if accounts.getInfo request is unexpectedly failed', () => {
|
||||
const error = 'Something wrong';
|
||||
accounts.current.returns(Promise.reject(error));
|
||||
accounts.getInfo.returns(Promise.reject(error));
|
||||
|
||||
return expect(authentication.validateToken(validTokens),
|
||||
return expect(authentication.validateToken(...validateTokenArgs),
|
||||
'to be rejected with', error
|
||||
);
|
||||
});
|
||||
@ -95,32 +121,42 @@ describe('authentication api', () => {
|
||||
message: 'Token expired',
|
||||
code: 0,
|
||||
status: 401,
|
||||
type: 'yii\\web\\UnauthorizedHttpException'
|
||||
type: 'yii\\web\\UnauthorizedHttpException',
|
||||
};
|
||||
const newToken = 'baz';
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(authentication, 'requestToken');
|
||||
|
||||
accounts.current.onCall(0).returns(Promise.reject(expiredResponse));
|
||||
authentication.requestToken.returns(Promise.resolve({token: newToken}));
|
||||
accounts.getInfo.onCall(0).returns(Promise.reject(expiredResponse));
|
||||
authentication.requestToken.returns(Promise.resolve(newToken));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authentication.requestToken.restore();
|
||||
});
|
||||
|
||||
it('resolves with new token and user object', () =>
|
||||
expect(authentication.validateToken(validTokens),
|
||||
'to be fulfilled with', {...validTokens, token: newToken, user}
|
||||
)
|
||||
);
|
||||
it('resolves with new token and user object', async () => {
|
||||
server.post('/api/authentication/refresh-token', {
|
||||
access_token: newToken,
|
||||
refresh_token: validRefreshToken,
|
||||
success: true,
|
||||
expires_in: 50000
|
||||
});
|
||||
|
||||
|
||||
await expect(authentication.validateToken(...validateTokenArgs),
|
||||
'to be fulfilled with', {token: newToken, refreshToken: validRefreshToken, user}
|
||||
);
|
||||
|
||||
expect(server.requests[0].requestBody, 'to equal', `refresh_token=${validRefreshToken}`);
|
||||
});
|
||||
|
||||
it('rejects if token request failed', () => {
|
||||
const error = 'Something wrong';
|
||||
authentication.requestToken.returns(Promise.reject(error));
|
||||
const error = {error: 'Unexpected error example'};
|
||||
server.post('/api/authentication/refresh-token', error, 500);
|
||||
|
||||
return expect(authentication.validateToken(validTokens),
|
||||
return expect(authentication.validateToken(...validateTokenArgs),
|
||||
'to be rejected with', error
|
||||
);
|
||||
});
|
||||
@ -132,32 +168,35 @@ describe('authentication api', () => {
|
||||
message: 'Incorrect token',
|
||||
code: 0,
|
||||
status: 401,
|
||||
type: 'yii\\web\\UnauthorizedHttpException'
|
||||
type: 'yii\\web\\UnauthorizedHttpException',
|
||||
};
|
||||
const newToken = 'baz';
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(authentication, 'requestToken');
|
||||
|
||||
accounts.current.onCall(0).returns(Promise.reject(expiredResponse));
|
||||
authentication.requestToken.returns(Promise.resolve({token: newToken}));
|
||||
accounts.getInfo.onCall(0).returns(Promise.reject(expiredResponse));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authentication.requestToken.restore();
|
||||
});
|
||||
it('resolves with new token and user object', async () => {
|
||||
server.post('/api/authentication/refresh-token', {
|
||||
access_token: newToken,
|
||||
refresh_token: validRefreshToken,
|
||||
success: true,
|
||||
expires_in: 50000
|
||||
});
|
||||
|
||||
it('resolves with new token and user object', () =>
|
||||
expect(authentication.validateToken(validTokens),
|
||||
'to be fulfilled with', {...validTokens, token: newToken, user}
|
||||
)
|
||||
);
|
||||
|
||||
await expect(authentication.validateToken(...validateTokenArgs),
|
||||
'to be fulfilled with', {token: newToken, refreshToken: validRefreshToken, user}
|
||||
);
|
||||
|
||||
expect(server.requests[0].requestBody, 'to equal', `refresh_token=${validRefreshToken}`);
|
||||
});
|
||||
|
||||
it('rejects if token request failed', () => {
|
||||
const error = 'Something wrong';
|
||||
authentication.requestToken.returns(Promise.reject(error));
|
||||
const error = {error: 'Unexpected error example'};
|
||||
server.post('/api/authentication/refresh-token', error, 500);
|
||||
|
||||
return expect(authentication.validateToken(validTokens),
|
||||
return expect(authentication.validateToken(...validateTokenArgs),
|
||||
'to be rejected with', error
|
||||
);
|
||||
});
|
||||
@ -190,7 +229,7 @@ describe('authentication api', () => {
|
||||
it('overrides token if provided', () => {
|
||||
const token = 'foo';
|
||||
|
||||
authentication.logout({token});
|
||||
authentication.logout(token);
|
||||
|
||||
expect(request.post, 'to have a call satisfying', [
|
||||
'/api/authentication/logout', {}, {token}
|
||||
@ -241,7 +280,7 @@ describe('authentication api', () => {
|
||||
}));
|
||||
|
||||
return expect(authentication.requestToken(refreshToken),
|
||||
'to be fulfilled with', {token}
|
||||
'to be fulfilled with', token,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -1,23 +1,25 @@
|
||||
// @flow
|
||||
import request from 'services/request';
|
||||
import type { Resp } from 'services/request';
|
||||
import request from 'services/request';
|
||||
|
||||
export default {
|
||||
getSecret(): Promise<Resp<{qr: string, secret: string, uri: string}>> {
|
||||
return request.get('/api/two-factor-auth');
|
||||
},
|
||||
export function getSecret(id: number): Promise<Resp<{
|
||||
qr: string,
|
||||
secret: string,
|
||||
uri: string,
|
||||
}>> {
|
||||
return request.get(`/api/v1/accounts/${id}/two-factor-auth`);
|
||||
}
|
||||
|
||||
enable(data: {totp: string, password?: string}): Promise<Resp<*>> {
|
||||
return request.post('/api/two-factor-auth', {
|
||||
totp: data.totp,
|
||||
password: data.password || ''
|
||||
});
|
||||
},
|
||||
export function enable(id: number, totp: string, password?: string): Promise<Resp<*>> {
|
||||
return request.post(`/api/v1/accounts/${id}/two-factor-auth`, {
|
||||
totp,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
disable(data: {totp: string, password?: string}): Promise<Resp<*>> {
|
||||
return request.delete('/api/two-factor-auth', {
|
||||
totp: data.totp,
|
||||
password: data.password || ''
|
||||
});
|
||||
}
|
||||
};
|
||||
export function disable(id: number, totp: string, password?: string): Promise<Resp<*>> {
|
||||
return request.delete(`/api/v1/accounts/${id}/two-factor-auth`, {
|
||||
totp,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user