Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace

This commit is contained in:
SleepWalker
2019-12-07 21:02:00 +02:00
parent d8d2df0702
commit f9d3bb4e20
404 changed files with 758 additions and 742 deletions

34
packages/app/App.tsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react';
import { hot } from 'react-hot-loader/root';
import { Provider as ReduxProvider } from 'react-redux';
import { Router, Route, Switch } from 'react-router-dom';
import { IntlProvider } from 'app/components/i18n';
import { Store } from 'redux';
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import RootPage from 'app/pages/root/RootPage';
import SuccessOauthPage from 'app/pages/auth/SuccessOauthPage';
const App = ({
store,
browserHistory,
}: {
store: Store;
browserHistory: any;
}) => (
<ReduxProvider store={store}>
<IntlProvider>
<Router history={browserHistory}>
<Switch>
<Route path="/oauth2/code/success" component={SuccessOauthPage} />
<AuthFlowRoute
path="/oauth2/:version(v\d+)/:clientId?"
component={() => null}
/>
<Route path="/" component={RootPage} />
</Switch>
</Router>
</IntlProvider>
</ReduxProvider>
);
export default hot(App);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { omit, debounce } from 'app/functions';
/**
* MeasureHeight is a component that allows you to measure the height of elements wrapped.
*
* Each time the height changed, the `onMeasure` prop will be called.
* On each component update the `shouldMeasure` prop is being called and depending of
* the value returned will be decided whether to call `onMeasure`.
* By default `shouldMeasure` will compare the old and new values of the `state` prop.
* Both `shouldMeasure` and `state` can be used to reduce the amount of measures, which
* will reduce the count of forced reflows in browser.
*
* Usage:
* <MeasureHeight
* state={theValueToInvalidateCurrentMeasure}
* onMeasure={this.onUpdateContextHeight}
* >
* <div>some content here</div>
* <div>which may be multiple children</div>
* </MeasureHeight>
*/
type ChildState = any;
export default class MeasureHeight extends React.PureComponent<
{
shouldMeasure: (prevState: ChildState, newState: ChildState) => boolean;
onMeasure: (height: number) => void;
state: ChildState;
} & React.HTMLAttributes<HTMLDivElement>
> {
static defaultProps = {
shouldMeasure: (prevState: ChildState, newState: ChildState) =>
prevState !== newState,
onMeasure: () => {},
};
el: HTMLDivElement | null = null;
componentDidMount() {
// we want to measure height immediately on first mount to avoid ui laggs
this.measure();
window.addEventListener('resize', this.enqueueMeasurement);
}
componentDidUpdate(prevProps: typeof MeasureHeight.prototype.props) {
if (this.props.shouldMeasure(prevProps.state, this.props.state)) {
this.enqueueMeasurement();
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.enqueueMeasurement);
}
render() {
const props = omit(this.props, ['shouldMeasure', 'onMeasure', 'state']);
return <div {...props} ref={(el: HTMLDivElement) => (this.el = el)} />;
}
measure = () => {
requestAnimationFrame(() => {
this.el && this.props.onMeasure(this.el.offsetHeight);
});
};
enqueueMeasurement = debounce(this.measure);
}

View File

@ -0,0 +1,5 @@
{
"addAccount": "Add account",
"goToEly": "Go to Ely.by profile",
"logout": "Log out"
}

View File

@ -0,0 +1,198 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import loader from 'app/services/loader';
import { SKIN_DARK, COLOR_WHITE, Skin } from 'app/components/ui';
import { Button } from 'app/components/ui/form';
import { authenticate, revoke } from 'app/components/accounts/actions';
import { getActiveAccount, Account } from 'app/components/accounts/reducer';
import { RootState } from 'app/reducers';
import styles from './accountSwitcher.scss';
import messages from './AccountSwitcher.intl.json';
interface Props {
switchAccount: (account: Account) => Promise<Account>;
removeAccount: (account: Account) => Promise<void>;
// called after each action performed
onAfterAction: () => void;
// called after switching an account. The active account will be passed as arg
onSwitch: (account: Account) => void;
accounts: RootState['accounts'];
skin: Skin;
// whether active account should be expanded and shown on the top
highlightActiveAccount: boolean;
// whether to show logout icon near each account
allowLogout: boolean;
// whether to show add account button
allowAdd: boolean;
}
export class AccountSwitcher extends React.Component<Props> {
static defaultProps: Partial<Props> = {
skin: SKIN_DARK,
highlightActiveAccount: true,
allowLogout: true,
allowAdd: true,
onAfterAction() {},
onSwitch() {},
};
render() {
const {
accounts,
skin,
allowAdd,
allowLogout,
highlightActiveAccount,
} = this.props;
const activeAccount = getActiveAccount({ accounts });
if (!activeAccount) {
throw new Error('Can not find active account');
}
let { available } = accounts;
if (highlightActiveAccount) {
available = available.filter(account => account.id !== activeAccount.id);
}
return (
<div
className={classNames(
styles.accountSwitcher,
styles[`${skin}AccountSwitcher`],
)}
>
{highlightActiveAccount ? (
<div className={styles.item}>
<div
className={classNames(
styles.accountIcon,
styles.activeAccountIcon,
styles.accountIcon1,
)}
/>
<div className={styles.activeAccountInfo}>
<div className={styles.activeAccountUsername}>
{activeAccount.username}
</div>
<div
className={classNames(
styles.accountEmail,
styles.activeAccountEmail,
)}
>
{activeAccount.email}
</div>
<div className={styles.links}>
<div className={styles.link}>
<a
href={`http://ely.by/u${activeAccount.id}`}
target="_blank"
>
<Message {...messages.goToEly} />
</a>
</div>
<div className={styles.link}>
<a
className={styles.link}
onClick={this.onRemove(activeAccount)}
href="#"
>
<Message {...messages.logout} />
</a>
</div>
</div>
</div>
</div>
) : null}
{available.map((account, index) => (
<div
className={classNames(styles.item, styles.accountSwitchItem)}
key={account.id}
onClick={this.onSwitch(account)}
>
<div
className={classNames(
styles.accountIcon,
styles[
`accountIcon${(index % 7) + (highlightActiveAccount ? 2 : 1)}`
],
)}
/>
{allowLogout ? (
<div
className={styles.logoutIcon}
onClick={this.onRemove(account)}
/>
) : (
<div className={styles.nextIcon} />
)}
<div className={styles.accountInfo}>
<div className={styles.accountUsername}>{account.username}</div>
<div className={styles.accountEmail}>{account.email}</div>
</div>
</div>
))}
{allowAdd ? (
<Link to="/login" onClick={this.props.onAfterAction}>
<Button
color={COLOR_WHITE}
block
small
className={styles.addAccount}
label={
<Message {...messages.addAccount}>
{message => (
<span>
<div className={styles.addIcon} />
{message}
</span>
)}
</Message>
}
/>
</Link>
) : null}
</div>
);
}
onSwitch = (account: Account) => (event: React.MouseEvent) => {
event.preventDefault();
loader.show();
this.props
.switchAccount(account)
.finally(() => this.props.onAfterAction())
.then(() => this.props.onSwitch(account))
// we won't sent any logs to sentry, because an error should be already
// handled by external logic
.catch(error => console.warn('Error switching account', { error }))
.finally(() => loader.hide());
};
onRemove = (account: Account) => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.props.removeAccount(account).then(() => this.props.onAfterAction());
};
}
export default connect(
({ accounts }: RootState) => ({
accounts,
}),
{
switchAccount: authenticate,
removeAccount: revoke,
},
)(AccountSwitcher);

View File

@ -0,0 +1,225 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
//@import '~app/components/ui/panel.scss';
$bodyLeftRightPadding: 20px;
$lightBorderColor: #eee;
.accountSwitcher {
text-align: left;
}
.accountInfo {
}
.accountUsername,
.accountEmail {
overflow: hidden;
text-overflow: ellipsis;
}
.lightAccountSwitcher {
background: #fff;
color: #444;
min-width: 205px;
$border: 1px solid $lightBorderColor;
border-left: $border;
border-right: $border;
border-bottom: 7px solid darker($green);
.item {
padding: 15px;
border-bottom: 1px solid $lightBorderColor;
}
.accountSwitchItem {
cursor: pointer;
transition: 0.25s;
&:hover {
background-color: $whiteButtonLight;
}
&:active {
background-color: $whiteButtonDark;
}
}
.accountIcon {
font-size: 27px;
width: 20px;
text-align: center;
}
.activeAccountIcon {
font-size: 40px;
}
.activeAccountInfo {
margin-left: 29px;
}
.activeAccountUsername {
font-family: $font-family-title;
font-size: 20px;
color: $green;
}
.activeAccountEmail {
}
.links {
margin-top: 6px;
}
.link {
font-size: 12px;
margin-bottom: 3px;
white-space: nowrap;
&:last-of-type {
margin-bottom: 0;
}
}
.accountInfo {
margin-left: 29px;
margin-right: 25px;
}
.accountUsername {
font-family: $font-family-title;
font-size: 14px;
color: #666;
}
.accountEmail {
font-size: 10px;
color: #999;
}
.addAccount {
}
}
.darkAccountSwitcher {
background: $black;
$border: 1px solid lighter($black);
.item {
padding: 15px 20px;
border-top: 1px solid lighter($black);
transition: 0.25s;
cursor: pointer;
&:hover {
background-color: lighter($black);
}
&:active {
background-color: darker($black);
}
&:last-of-type {
border-bottom: $border;
}
}
.accountIcon {
font-size: 35px;
}
.accountInfo {
margin-left: 30px;
margin-right: 26px;
}
.accountUsername {
font-family: $font-family-title;
color: #fff;
}
.accountEmail {
color: #666;
font-size: 12px;
}
}
.accountIcon {
composes: minecraft-character from '~app/components/ui/icons.scss';
float: left;
&1 {
color: $green;
}
&2 {
color: $blue;
}
&3 {
color: $violet;
}
&4 {
color: $orange;
}
&5 {
color: $dark_blue;
}
&6 {
color: $light_violet;
}
&7 {
color: $red;
}
}
.addIcon {
composes: plus from '~app/components/ui/icons.scss';
color: $green;
position: relative;
bottom: 1px;
margin-right: 3px;
}
.nextIcon {
composes: arrowRight from '~app/components/ui/icons.scss';
position: relative;
float: right;
font-size: 24px;
color: #4e4e4e;
line-height: 35px;
left: 0;
transition: color 0.25s, left 0.5s;
.item:hover & {
color: #aaa;
left: 5px;
}
}
.logoutIcon {
composes: exit from '~app/components/ui/icons.scss';
color: #cdcdcd;
float: right;
line-height: 27px;
transition: 0.25s;
&:hover {
color: #777;
}
}

View File

@ -0,0 +1,497 @@
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import { browserHistory } from 'app/services/history';
import { InternalServerError } from 'app/services/request';
import { sessionStorage } from 'app/services/localStorage';
import * as authentication from 'app/services/api/authentication';
import {
authenticate,
revoke,
logoutAll,
logoutStrangers,
} from 'app/components/accounts/actions';
import {
add,
ADD,
activate,
ACTIVATE,
remove,
reset,
} from 'app/components/accounts/actions/pure-actions';
import { SET_LOCALE } from 'app/components/i18n/actions';
import { updateUser, setUser } from 'app/components/user/actions';
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
import { Dispatch, RootState } from 'app/reducers';
import { Account } from './reducer';
const token =
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
const legacyToken =
'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
const account = {
id: 1,
username: 'username',
email: 'email@test.com',
token,
refreshToken: 'bar',
};
const user = {
id: 1,
username: 'username',
email: 'email@test.com',
lang: 'be',
};
describe('components/accounts/actions', () => {
let dispatch: Dispatch;
let getState: () => RootState;
beforeEach(() => {
dispatch = sinon
.spy(arg => (typeof arg === 'function' ? arg(dispatch, getState) : arg))
.named('store.dispatch');
getState = sinon.stub().named('store.getState');
(getState as any).returns({
accounts: {
available: [],
active: null,
},
auth: {
credentials: {},
},
user: {},
});
sinon
.stub(authentication, 'validateToken')
.named('authentication.validateToken');
sinon.stub(browserHistory, 'push').named('browserHistory.push');
sinon.stub(authentication, 'logout').named('authentication.logout');
(authentication.logout as any).returns(Promise.resolve());
(authentication.validateToken as any).returns(
Promise.resolve({
token: account.token,
refreshToken: account.refreshToken,
user,
}),
);
});
afterEach(() => {
(authentication.validateToken as any).restore();
(authentication.logout as any).restore();
(browserHistory.push as any).restore();
});
describe('#authenticate()', () => {
it('should request user state using token', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
account.id,
account.token,
account.refreshToken,
]),
));
it('should request user by extracting id from token', () =>
authenticate({ token } as Account)(
dispatch,
getState,
undefined,
).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 } as Account)(
dispatch,
getState,
undefined,
).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
1,
legacyToken,
undefined,
]),
));
it(`dispatches ${ADD} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [add(account)]),
));
it(`dispatches ${ACTIVATE} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it(`dispatches ${SET_LOCALE} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
{ type: SET_LOCALE, payload: { locale: 'be' } },
]),
));
it('should update user state', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
updateUser({ ...user, isGuest: false }),
]),
));
it('resolves with account', () =>
authenticate(account)(dispatch, getState, undefined).then(resp =>
expect(resp, 'to equal', account),
));
it('rejects when bad auth data', () => {
(authentication.validateToken as any).returns(Promise.reject({}));
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected',
).then(() => {
expect(dispatch, 'to have a call satisfying', [
setLogin(account.email),
]);
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
});
});
it('rejects when 5xx without logouting', () => {
const resp = new InternalServerError('500', { status: 500 });
(authentication.validateToken as any).rejects(resp);
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected with',
resp,
).then(() =>
expect(dispatch, 'to have no calls satisfying', [
{ payload: { isGuest: true } },
]),
);
});
it('marks user as stranger, if there is no refreshToken', () => {
const expectedKey = `stranger${account.id}`;
(authentication.validateToken as any).resolves({
token: account.token,
user,
});
sessionStorage.removeItem(expectedKey);
return authenticate(account)(dispatch, getState, undefined).then(() => {
expect(sessionStorage.getItem(expectedKey), 'not to be null');
sessionStorage.removeItem(expectedKey);
});
});
describe('when user authenticated during oauth', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
available: [],
active: null,
},
user: {},
auth: {
oauth: {
clientId: 'ely.by',
prompt: [],
},
},
});
});
it('should dispatch setAccountSwitcher', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
setAccountSwitcher(false),
]),
));
});
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should activate account before auth api call', () => {
(authentication.validateToken as any).returns(
Promise.reject({ error: 'foo' }),
);
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected with',
{ error: 'foo' },
).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
);
});
});
});
describe('#revoke()', () => {
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should dispatch reset action', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [reset()]),
));
it('should call logout api method in background', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [
account.token,
]),
));
it('should update user state', () =>
revoke(account)(dispatch, getState, undefined).then(
() =>
expect(dispatch, 'to have a call satisfying', [
setUser({ isGuest: true }),
]),
// expect(dispatch, 'to have calls satisfying', [
// [remove(account)],
// [expect.it('to be a function')]
// // [logout()] // TODO: this is not a plain action. How should we simplify its testing?
// ])
));
});
describe('when multiple accounts available', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
user,
});
});
it('should switch to the next account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should remove current account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [remove(account2)]),
));
it('should call logout api method in background', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [
account2.token,
]),
));
});
});
describe('#logoutAll()', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
auth: {
credentials: {},
},
user,
});
});
it('should call logout api method for each account', () => {
logoutAll()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [
[account.token],
[account2.token],
]);
});
it('should dispatch reset', () => {
logoutAll()(dispatch, getState, undefined);
expect(dispatch, 'to have a call satisfying', [reset()]);
});
it('should redirect to /login', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
}));
it('should change user to guest', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(dispatch, 'to have a call satisfying', [
setUser({
lang: user.lang,
isGuest: true,
}),
]);
}));
});
describe('#logoutStrangers', () => {
const foreignAccount = {
...account,
id: 2,
refreshToken: null,
};
const foreignAccount2 = {
...foreignAccount,
id: 3,
};
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [account, foreignAccount, foreignAccount2],
},
user,
});
});
it('should remove stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount)]);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount2)]);
});
it('should logout stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
});
it('should activate another account if available', () =>
logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should not activate another account if active account is already not a stranger', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account, foreignAccount],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'not to have calls satisfying', [activate(account)]),
);
});
it('should not dispatch if no strangers', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'was not called'),
);
});
describe('when all accounts are strangers', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [foreignAccount, foreignAccount2],
},
auth: {
credentials: {},
},
user,
});
logoutStrangers()(dispatch, getState, undefined);
});
it('logouts all accounts', () => {
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
expect(dispatch, 'to have a call satisfying', [
setUser({ isGuest: true }),
]);
expect(dispatch, 'to have a call satisfying', [reset()]);
});
});
describe('when a stranger has a mark in sessionStorage', () => {
const key = `stranger${foreignAccount.id}`;
beforeEach(() => {
sessionStorage.setItem(key, 1);
logoutStrangers()(dispatch, getState, undefined);
});
afterEach(() => {
sessionStorage.removeItem(key);
});
it('should not log out', () =>
expect(dispatch, 'not to have calls satisfying', [
{ payload: foreignAccount },
]));
});
});
});

View File

@ -0,0 +1,354 @@
import { getJwtPayloads } from 'app/functions';
import { sessionStorage } from 'app/services/localStorage';
import {
validateToken,
requestToken,
logout,
} from 'app/services/api/authentication';
import { relogin as navigateToLogin } from 'app/components/auth/actions';
import { updateUser, setGuest } from 'app/components/user/actions';
import { setLocale } from 'app/components/i18n/actions';
import { setAccountSwitcher } from 'app/components/auth/actions';
import { getActiveAccount } from 'app/components/accounts/reducer';
import logger from 'app/services/logger';
import { ThunkAction } from 'app/reducers';
import { Account } from './reducer';
import {
add,
remove,
activate,
reset,
updateToken,
} from './actions/pure-actions';
export { updateToken, activate, remove };
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @returns {Function}
*/
export function authenticate(
account:
| Account
| {
token: string;
refreshToken: string | null;
},
): ThunkAction<Promise<Account>> {
const { token, refreshToken } = account;
const email = 'email' in account ? account.email : null;
return async (dispatch, getState) => {
let accountId: number;
if ('id' in account && 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));
}
try {
const {
token: newToken,
refreshToken: newRefreshToken,
user,
} = await validateToken(accountId, token, refreshToken);
const { auth } = getState();
const newAccount: Account = {
id: user.id,
username: user.username,
email: user.email,
token: newToken,
refreshToken: newRefreshToken,
};
dispatch(add(newAccount));
dispatch(activate(newAccount));
dispatch(
updateUser({
isGuest: false,
...user,
}),
);
// TODO: probably should be moved from here, because it is a side effect
logger.setUser(user);
if (!newRefreshToken) {
// mark user as stranger (user does not want us to remember his account)
sessionStorage.setItem(`stranger${newAccount.id}`, 1);
}
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));
}
await dispatch(setLocale(user.lang));
return newAccount;
} 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));
}
throw resp;
}
};
}
function findAccountIdFromToken(token: string): number {
const { sub, jti } = getJwtPayloads(token);
// 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
*
* @see components/user/middlewares/refreshTokenMiddleware
*
* @returns {Function}
*/
export function ensureToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { token } = getActiveAccount(getState()) || {};
try {
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time desynchronization problem
const { exp } = getJwtPayloads(token as any);
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
return dispatch(requestNewToken());
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token,
});
dispatch(relogin());
return Promise.reject(new Error('Invalid token'));
}
return Promise.resolve();
};
}
/**
* Checks whether request `error` is an auth error and tries recover from it by
* requesting a new auth token
*
* @see components/user/middlewares/refreshTokenMiddleware
*
* @param {object} error
*
* @returns {Function}
*/
export function recoverFromTokenError(
error: {
status: number;
message: string;
} | void,
): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
if (error && error.status === 401) {
const activeAccount = getActiveAccount(getState());
if (activeAccount && activeAccount.refreshToken) {
if (
[
'Token expired',
'Incorrect token',
'You are requesting with an invalid credential.',
].includes(error.message)
) {
// request token and retry
return dispatch(requestNewToken());
}
logger.error('Unknown unauthorized response', {
error,
});
}
// user's access token is outdated and we have no refreshToken
// or something unexpected happend
// in both cases we resetting all the user's state
dispatch(relogin());
}
return Promise.reject(error);
};
}
/**
* Requests new token and updates state. In case, when token can not be updated,
* it will redirect user to login page
*
* @returns {Function}
*/
export function requestNewToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { refreshToken } = getActiveAccount(getState()) || {};
if (!refreshToken) {
dispatch(relogin());
return Promise.resolve();
}
return requestToken(refreshToken)
.then(token => {
dispatch(updateToken(token));
})
.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
dispatch(relogin());
return Promise.reject(resp);
});
};
}
/**
* Remove one account from current user's account list
*
* @param {Account} account
*
* @returns {Function}
*/
export function revoke(account: Account): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const accountToReplace: Account | null =
getState().accounts.available.find(({ id }) => id !== account.id) || null;
if (accountToReplace) {
await dispatch(authenticate(accountToReplace))
.finally(() => {
// we need to logout user, even in case, when we can
// not authenticate him with new account
logout(account.token).catch(() => {
// we don't care
});
dispatch(remove(account));
})
.catch(() => {
// we don't care
});
return;
}
return dispatch(logoutAll());
};
}
export function relogin(email?: string): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!email && activeAccount) {
email = activeAccount.email;
}
dispatch(navigateToLogin(email || null));
};
}
export function logoutAll(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
dispatch(setGuest());
const {
accounts: { available },
} = getState();
available.forEach(account =>
logout(account.token).catch(() => {
// we don't care
}),
);
dispatch(reset());
dispatch(relogin());
return Promise.resolve();
};
}
/**
* Logouts accounts, that was marked as "do not remember me"
*
* We detecting foreign accounts by the absence of refreshToken. The account
* won't be removed, until key `stranger${account.id}` is present in sessionStorage
*
* @returns {Function}
*/
export function logoutStrangers(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const {
accounts: { available },
} = getState();
const activeAccount = getActiveAccount(getState());
const isStranger = ({ refreshToken, id }: Account) =>
!refreshToken && !sessionStorage.getItem(`stranger${id}`);
if (available.some(isStranger)) {
const accountToReplace = available.find(account => !isStranger(account));
if (accountToReplace) {
available.filter(isStranger).forEach(account => {
dispatch(remove(account));
logout(account.token);
});
if (activeAccount && isStranger(activeAccount)) {
await dispatch(authenticate(accountToReplace));
return;
}
} else {
await dispatch(logoutAll());
return;
}
}
return Promise.resolve();
};
}

View File

@ -0,0 +1,78 @@
import {
Account,
AddAction,
RemoveAction,
ActivateAction,
UpdateTokenAction,
ResetAction,
} from '../reducer';
export const ADD = 'accounts:add';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function add(account: Account): AddAction {
return {
type: ADD,
payload: account,
};
}
export const REMOVE = 'accounts:remove';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function remove(account: Account): RemoveAction {
return {
type: REMOVE,
payload: account,
};
}
export const ACTIVATE = 'accounts:activate';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function activate(account: Account): ActivateAction {
return {
type: ACTIVATE,
payload: account,
};
}
export const RESET = 'accounts:reset';
/**
* @private
*
* @returns {object} - action definition
*/
export function reset(): ResetAction {
return {
type: RESET,
};
}
export const UPDATE_TOKEN = 'accounts:updateToken';
/**
* @param {string} token
*
* @returns {object} - action definition
*/
export function updateToken(token: string): UpdateTokenAction {
return {
type: UPDATE_TOKEN,
payload: token,
};
}

View File

@ -0,0 +1,2 @@
export { State as AccountsState, Account } from './reducer';
export { default as AccountSwitcher } from './AccountSwitcher';

View File

@ -0,0 +1,162 @@
import expect from 'app/test/unexpected';
import { updateToken } from './actions';
import {
add,
remove,
activate,
reset,
ADD,
REMOVE,
ACTIVATE,
UPDATE_TOKEN,
RESET,
} from './actions/pure-actions';
import accounts, { Account } from './reducer';
const account: Account = {
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
} as Account;
describe('Accounts reducer', () => {
let initial;
beforeEach(() => {
initial = accounts(undefined, {} as any);
});
it('should be empty', () =>
expect(accounts(undefined, {} as any), 'to equal', {
active: null,
available: [],
}));
it('should return last state if unsupported action', () =>
expect(accounts({ state: 'foo' } as any, {} as any), 'to equal', {
state: 'foo',
}));
describe(ACTIVATE, () => {
it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', {
active: account.id,
});
});
});
describe(ADD, () => {
it('adds an account', () =>
expect(accounts(initial, add(account)), 'to satisfy', {
available: [account],
}));
it('should replace if account was added for the second time', () => {
const outdatedAccount = {
...account,
someShit: true,
};
const updatedAccount = {
...account,
token: 'newToken',
};
expect(
accounts(
{ ...initial, available: [outdatedAccount] },
add(updatedAccount),
),
'to satisfy',
{
available: [updatedAccount],
},
);
});
it('should sort accounts by username', () => {
const newAccount = {
...account,
id: 2,
username: 'abc',
};
expect(
accounts({ ...initial, available: [account] }, add(newAccount)),
'to satisfy',
{
available: [newAccount, account],
},
);
});
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
add(),
),
'to throw',
'Invalid or empty payload passed for accounts.add',
);
});
});
describe(REMOVE, () => {
it('should remove an account', () =>
expect(
accounts({ ...initial, available: [account] }, remove(account)),
'to equal',
initial,
));
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
remove(),
),
'to throw',
'Invalid or empty payload passed for accounts.remove',
);
});
});
describe(RESET, () => {
it('should reset accounts state', () =>
expect(
accounts({ ...initial, available: [account] }, reset()),
'to equal',
initial,
));
});
describe(UPDATE_TOKEN, () => {
it('should update token', () => {
const newToken = 'newToken';
expect(
accounts(
{ active: account.id, available: [account] },
updateToken(newToken),
),
'to satisfy',
{
active: account.id,
available: [
{
...account,
token: newToken,
},
],
},
);
});
});
});

View File

@ -0,0 +1,136 @@
export type Account = {
id: number;
username: string;
email: string;
token: string;
refreshToken: string | null;
};
export type State = {
active: number | null;
available: Account[];
};
export type AddAction = { type: 'accounts:add'; payload: Account };
export type RemoveAction = { type: 'accounts:remove'; payload: Account };
export type ActivateAction = { type: 'accounts:activate'; payload: Account };
export type UpdateTokenAction = {
type: 'accounts:updateToken';
payload: string;
};
export type ResetAction = { type: 'accounts:reset' };
type Action =
| AddAction
| RemoveAction
| ActivateAction
| UpdateTokenAction
| ResetAction;
export function getActiveAccount(state: { accounts: State }): Account | null {
const accountId = state.accounts.active;
return (
state.accounts.available.find(account => account.id === accountId) || null
);
}
export function getAvailableAccounts(state: {
accounts: State;
}): Array<Account> {
return state.accounts.available;
}
export default function accounts(
state: State = {
active: null,
available: [],
},
action: Action,
): State {
switch (action.type) {
case 'accounts:add': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
state.available = state.available
.filter(account => account.id !== payload.id)
.concat(payload);
state.available.sort((account1, account2) => {
if (account1.username === account2.username) {
return 0;
}
return account1.username > account2.username ? 1 : -1;
});
return state;
}
case 'accounts:activate': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
return {
available: state.available.map(account => {
if (account.id === payload.id) {
return { ...payload };
}
return { ...account };
}),
active: payload.id,
};
}
case 'accounts:reset':
return {
active: null,
available: [],
};
case 'accounts:remove': {
if (!action.payload || !action.payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove');
}
const { payload } = action;
return {
...state,
available: state.available.filter(account => account.id !== payload.id),
};
}
case 'accounts:updateToken': {
if (typeof action.payload !== 'string') {
throw new Error('payload must be a jwt token');
}
const { payload } = action;
return {
...state,
available: state.available.map(account => {
if (account.id === state.active) {
return {
...account,
token: payload,
};
}
return { ...account };
}),
};
}
}
return state;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import Helmet from 'react-helmet';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
export default function AuthTitle({ title }: { title: MessageDescriptor }) {
return (
<Message {...title}>
{msg => (
<span>
{msg}
<Helmet title={msg} />
</span>
)}
</Message>
);
}

View File

@ -0,0 +1,67 @@
/**
* Helps with form fields binding, form serialization and errors rendering
*/
import PropTypes from 'prop-types';
import React from 'react';
import AuthError from 'app/components/auth/authError/AuthError';
import { userShape } from 'app/components/user/User';
import { FormModel } from 'app/components/ui/form';
import { RouteComponentProps } from 'react-router-dom';
export default class BaseAuthBody extends React.Component<
// TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<{ [key: string]: any }>
> {
static contextTypes = {
clearErrors: PropTypes.func.isRequired,
resolve: PropTypes.func.isRequired,
requestRedraw: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]),
scopes: PropTypes.array,
}).isRequired,
user: userShape,
};
autoFocusField: string | null = '';
componentWillReceiveProps(nextProps, nextContext) {
if (nextContext.auth.error !== this.context.auth.error) {
this.form.setErrors(nextContext.auth.error || {});
}
}
renderErrors() {
const error = this.form.getFirstError();
return error && <AuthError error={error} onClose={this.onClearErrors} />;
}
onFormSubmit() {
this.context.resolve(this.serialize());
}
onClearErrors = this.context.clearErrors;
form = new FormModel({
renderErrors: false,
});
bindField = this.form.bindField.bind(this.form);
serialize() {
return this.form.serialize();
}
autoFocus() {
const fieldId = this.autoFocusField;
fieldId && this.form.focus(fieldId);
}
}

View File

@ -0,0 +1,640 @@
import React from 'react';
import { AccountsState } from 'app/components/accounts';
import { AuthState } from 'app/components/auth';
import { User } from 'app/components/user';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { TransitionMotion, spring } from 'react-motion';
import {
Panel,
PanelBody,
PanelFooter,
PanelHeader,
} from 'app/components/ui/Panel';
import { getLogin } from 'app/components/auth/reducer';
import { Form } from 'app/components/ui/form';
import MeasureHeight from 'app/components/MeasureHeight';
import defaultHelpLinksStyles from 'app/components/auth/helpLinks.scss';
import panelStyles from 'app/components/ui/panel.scss';
import icons from 'app/components/ui/icons.scss';
import authFlow from 'app/services/authFlow';
import { userShape } from 'app/components/user/User';
import * as actions from './actions';
import { RootState } from 'app/reducers';
const opacitySpringConfig = { stiffness: 300, damping: 20 };
const transformSpringConfig = { stiffness: 500, damping: 50, precision: 0.5 };
const changeContextSpringConfig = {
stiffness: 500,
damping: 20,
precision: 0.5,
};
const { helpLinksStyles } = defaultHelpLinksStyles;
type PanelId = string;
/**
* Definition of relation between contexts and panels
*
* Each sub-array is context. Each sub-array item is panel
*
* This definition declares animations between panels:
* - The animation between panels from different contexts will be along Y axe (height toggling)
* - The animation between panels from the same context will be along X axe (sliding)
* - Panel index defines the direction of X transition of both panels
* (e.g. the panel with lower index will slide from left side, and with greater from right side)
*/
const contexts: Array<PanelId[]> = [
['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'],
['register', 'activation', 'resendActivation'],
['acceptRules'],
['chooseAccount', 'permissions'],
];
// eslint-disable-next-line
if (process.env.NODE_ENV !== 'production') {
// test panel uniquenes between contexts
// TODO: it may be moved to tests in future
contexts.reduce((acc, context) => {
context.forEach(panel => {
if (acc[panel]) {
throw new Error(
`Panel ${panel} is already exists in context ${JSON.stringify(
acc[panel],
)}`,
);
}
acc[panel] = context;
});
return acc;
}, {});
}
type ValidationError =
| string
| {
type: string;
payload: { [key: string]: any };
};
type AnimationProps = {
opacitySpring: number;
transformSpring: number;
};
type AnimationContext = {
key: PanelId;
style: AnimationProps;
data: {
Title: React.ReactElement<any>;
Body: React.ReactElement<any>;
Footer: React.ReactElement<any>;
Links: React.ReactElement<any>;
hasBackButton: boolean | ((props: Props) => boolean);
};
};
type OwnProps = {
Title: React.ReactElement<any>;
Body: React.ReactElement<any>;
Footer: React.ReactElement<any>;
Links: React.ReactElement<any>;
children?: React.ReactElement<any>;
};
interface Props extends OwnProps {
// context props
auth: AuthState;
user: User;
accounts: AccountsState;
setErrors: (errors: { [key: string]: ValidationError }) => void;
clearErrors: () => void;
resolve: () => void;
reject: () => void;
}
type State = {
contextHeight: number;
panelId: PanelId | void;
prevPanelId: PanelId | void;
isHeightDirty: boolean;
forceHeight: 1 | 0;
direction: 'X' | 'Y';
};
class PanelTransition extends React.Component<Props, State> {
static childContextTypes = {
auth: PropTypes.shape({
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]),
login: PropTypes.string,
}),
user: userShape,
accounts: PropTypes.shape({
available: PropTypes.array,
}),
requestRedraw: PropTypes.func,
clearErrors: PropTypes.func,
resolve: PropTypes.func,
reject: PropTypes.func,
};
state: State = {
contextHeight: 0,
panelId: this.props.Body && (this.props.Body.type as any).panelId,
isHeightDirty: false,
forceHeight: 0 as const,
direction: 'X' as const,
prevPanelId: undefined,
};
isHeightMeasured: boolean = false;
wasAutoFocused: boolean = false;
body: null | {
autoFocus: () => void;
onFormSubmit: () => void;
} = null;
timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount
getChildContext() {
return {
auth: this.props.auth,
user: this.props.user,
requestRedraw: (): Promise<void> =>
new Promise(resolve =>
this.setState({ isHeightDirty: true }, () => {
this.setState({ isHeightDirty: false });
// wait till transition end
this.timerIds.push(setTimeout(resolve, 200));
}),
),
clearErrors: this.props.clearErrors,
resolve: this.props.resolve,
reject: this.props.reject,
};
}
componentWillReceiveProps(nextProps: Props) {
const nextPanel: PanelId =
nextProps.Body && (nextProps.Body.type as any).panelId;
const prevPanel: PanelId =
this.props.Body && (this.props.Body.type as any).panelId;
if (nextPanel !== prevPanel) {
const direction = this.getDirection(nextPanel, prevPanel);
const forceHeight = direction === 'Y' && nextPanel !== prevPanel ? 1 : 0;
this.props.clearErrors();
this.setState({
direction,
panelId: nextPanel,
prevPanelId: prevPanel,
forceHeight,
});
if (forceHeight) {
this.timerIds.push(
setTimeout(() => {
this.setState({ forceHeight: 0 });
}, 100),
);
}
}
}
componentWillUnmount() {
this.timerIds.forEach(id => clearTimeout(id));
this.timerIds = [];
}
render() {
const { contextHeight, forceHeight } = this.state;
const { 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');
}
const {
panelId,
hasGoBack,
}: {
panelId: PanelId;
hasGoBack: boolean;
} = Body.type as any;
const formHeight = this.state[`formHeight${panelId}`] || 0;
// a hack to disable height animation on first render
const { isHeightMeasured } = this;
this.isHeightMeasured = isHeightMeasured || formHeight > 0;
return (
<TransitionMotion
styles={[
{
key: panelId,
data: { Title, Body, Footer, Links, hasBackButton: hasGoBack },
style: {
transformSpring: spring(0, transformSpringConfig),
opacitySpring: spring(1, opacitySpringConfig),
},
},
{
key: 'common',
style: {
heightSpring: isHeightMeasured
? spring(forceHeight || formHeight, transformSpringConfig)
: formHeight,
switchContextHeightSpring: spring(
forceHeight || contextHeight,
changeContextSpringConfig,
),
},
},
]}
willEnter={this.willEnter}
willLeave={this.willLeave}
>
{items => {
const panels = items.filter(({ key }) => key !== 'common');
const [common] = items.filter(({ key }) => key === 'common');
const contentHeight = {
overflow: 'hidden',
height: forceHeight
? common.style.switchContextHeightSpring
: 'auto',
};
this.tryToAutoFocus(panels.length);
const bodyHeight = {
position: 'relative' as const,
height: `${common.style.heightSpring}px`,
};
return (
<Form
id={panelId}
onSubmit={this.onFormSubmit}
onInvalid={this.onFormInvalid}
isLoading={this.props.auth.isLoading}
>
<Panel>
<PanelHeader>
{panels.map(config => this.getHeader(config))}
</PanelHeader>
<div style={contentHeight}>
<MeasureHeight
state={this.shouldMeasureHeight()}
onMeasure={this.onUpdateContextHeight}
>
<PanelBody>
<div style={bodyHeight}>
{panels.map(config => this.getBody(config))}
</div>
</PanelBody>
<PanelFooter>
{panels.map(config => this.getFooter(config))}
</PanelFooter>
</MeasureHeight>
</div>
</Panel>
<div className={helpLinksStyles}>
{panels.map(config => this.getLinks(config))}
</div>
</Form>
);
}}
</TransitionMotion>
);
}
onFormSubmit = () => {
this.props.clearErrors();
if (this.body) {
this.body.onFormSubmit();
}
};
onFormInvalid = (errors: { [key: string]: ValidationError }) =>
this.props.setErrors(errors);
willEnter = (config: AnimationContext) => this.getTransitionStyles(config);
willLeave = (config: AnimationContext) =>
this.getTransitionStyles(config, { isLeave: true });
/**
* @param {object} config
* @param {string} config.key
* @param {object} [options]
* @param {object} [options.isLeave=false] - true, if this is a leave transition
*
* @returns {object}
*/
getTransitionStyles(
{ key }: AnimationContext,
options: { isLeave?: boolean } = {},
): {
transformSpring: number;
opacitySpring: number;
} {
const { isLeave = false } = options;
const { panelId, prevPanelId } = this.state;
const fromLeft = -1;
const fromRight = 1;
const currentContext = contexts.find(context => context.includes(key));
if (!currentContext) {
throw new Error(`Can not find settings for ${key} panel`);
}
let sign =
prevPanelId &&
panelId &&
currentContext.indexOf(panelId) > currentContext.indexOf(prevPanelId)
? fromRight
: fromLeft;
if (prevPanelId === key) {
sign *= -1;
}
const transform = sign * 100;
return {
transformSpring: isLeave
? spring(transform, transformSpringConfig)
: transform,
opacitySpring: isLeave ? spring(0, opacitySpringConfig) : 1,
};
}
getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' {
const context = contexts.find(item => item.includes(prev));
if (!context) {
throw new Error(`Can not find context for transition ${prev} -> ${next}`);
}
return context.includes(next) ? 'X' : 'Y';
}
onUpdateHeight = (height: number, key: PanelId) => {
const heightKey = `formHeight${key}`;
// @ts-ignore
this.setState({
[heightKey]: height,
});
};
onUpdateContextHeight = (height: number) => {
this.setState({
contextHeight: height,
});
};
onGoBack = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
authFlow.goBack();
};
/**
* Tries to auto focus form fields after transition end
*
* @param {number} length number of panels transitioned
*/
tryToAutoFocus(length: number) {
if (!this.body) {
return;
}
if (length === 1) {
if (!this.wasAutoFocused) {
this.body.autoFocus();
}
this.wasAutoFocused = true;
} else if (this.wasAutoFocused) {
this.wasAutoFocused = false;
}
}
shouldMeasureHeight() {
const errorString = Object.values(this.props.auth.error || {}).reduce(
(acc, item: ValidationError) => {
if (typeof item === 'string') {
return acc + item;
}
return acc + item.type;
},
'',
);
return [
errorString,
this.state.isHeightDirty,
this.props.user.lang,
this.props.accounts.available.length,
].join('');
}
getHeader({ key, style, data }: AnimationContext) {
const { Title } = data;
const { transformSpring } = style;
let { hasBackButton } = data;
if (typeof hasBackButton === 'function') {
hasBackButton = hasBackButton(this.props);
}
const transitionStyle = {
...this.getDefaultTransitionStyles(key, style),
opacity: 1, // reset default
};
const scrollStyle = this.translate(transformSpring, 'Y');
const sideScrollStyle = {
position: 'relative' as const,
zIndex: 2,
...this.translate(-Math.abs(transformSpring)),
};
const backButton = (
<button
style={sideScrollStyle}
className={panelStyles.headerControl}
data-e2e-go-back
type="button"
onClick={this.onGoBack}
>
<span className={icons.arrowLeft} />
</button>
);
return (
<div key={`header/${key}`} style={transitionStyle}>
{hasBackButton ? backButton : null}
<div style={scrollStyle}>{Title}</div>
</div>
);
}
getBody({ key, style, data }: AnimationContext) {
const { Body } = data;
const { transformSpring } = style;
const { direction } = this.state;
let transform: { [key: string]: string } = this.translate(
transformSpring,
direction,
);
let verticalOrigin = 'top';
if (direction === 'Y') {
verticalOrigin = 'bottom';
transform = {};
}
const transitionStyle = {
...this.getDefaultTransitionStyles(key, style),
top: 'auto', // reset default
[verticalOrigin]: 0,
...transform,
};
return (
<MeasureHeight
key={`body/${key}`}
style={transitionStyle}
state={this.shouldMeasureHeight()}
onMeasure={height => this.onUpdateHeight(height, key)}
>
{React.cloneElement(Body, {
ref: body => {
this.body = body;
},
})}
</MeasureHeight>
);
}
getFooter({ key, style, data }: AnimationContext) {
const { Footer } = data;
const transitionStyle = this.getDefaultTransitionStyles(key, style);
return (
<div key={`footer/${key}`} style={transitionStyle}>
{Footer}
</div>
);
}
getLinks({ key, style, data }: AnimationContext) {
const { Links } = data;
const transitionStyle = this.getDefaultTransitionStyles(key, style);
return (
<div key={`links/${key}`} style={transitionStyle}>
{Links}
</div>
);
}
/**
* @param {string} key
* @param {object} style
* @param {number} style.opacitySpring
*
* @returns {object}
*/
getDefaultTransitionStyles(
key: string,
{ opacitySpring }: Readonly<AnimationProps>,
): {
position: 'absolute';
top: number;
left: number;
width: string;
opacity: number;
pointerEvents: 'none' | 'auto';
} {
return {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
opacity: opacitySpring,
pointerEvents: key === this.state.panelId ? 'auto' : 'none',
};
}
translate(value: number, direction: 'X' | 'Y' = 'X', unit: '%' | 'px' = '%') {
return {
WebkitTransform: `translate${direction}(${value}${unit})`,
transform: `translate${direction}(${value}${unit})`,
};
}
}
export default connect(
(state: RootState) => {
const login = getLogin(state);
let user = {
...state.user,
};
if (login) {
user = {
...user,
isGuest: true,
email: '',
username: '',
};
if (/[@.]/.test(login)) {
user.email = login;
} else {
user.username = login;
}
}
return {
user,
accounts: state.accounts, // need this, to re-render height
auth: state.auth,
resolve: authFlow.resolve.bind(authFlow),
reject: authFlow.reject.bind(authFlow),
};
},
{
clearErrors: actions.clearErrors,
setErrors: actions.setErrors,
},
)(PanelTransition);

View File

@ -0,0 +1,17 @@
# How to add new auth panel
To add new panel you need to:
- create panel component at `components/auth/[panelId]`
- add new context in `components/auth/PanelTransition`
- connect component to router in `pages/auth/AuthPage`
- 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`
- whatever else you need
Commit id with example implementation: f4d315c

View File

@ -0,0 +1,46 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
import { User } from 'app/components/user';
import { userShape } from 'app/components/user/User';
interface Props {
isAvailable?: (context: Context) => boolean;
payload?: { [key: string]: any };
label: MessageDescriptor;
}
export type RejectionLinkProps = Props;
interface Context {
reject: (payload: { [key: string]: any } | undefined) => void;
user: User;
}
function RejectionLink(props: Props, context: Context) {
if (props.isAvailable && !props.isAvailable(context)) {
// TODO: if want to properly support multiple links, we should control
// the dividers ' | ' rendered from factory too
return null;
}
return (
<a
href="#"
onClick={event => {
event.preventDefault();
context.reject(props.payload);
}}
>
<Message {...props.label} />
</a>
);
}
RejectionLink.contextTypes = {
reject: PropTypes.func.isRequired,
user: userShape,
};
export default RejectionLink;

View File

@ -0,0 +1,8 @@
{
"title": "User Agreement",
"accept": "Accept",
"declineAndLogout": "Decline and logout",
"description1": "We have updated our {link}.",
"termsOfService": "terms of service",
"description2": "In order to continue using {name} service, you need to accept them."
}

View File

@ -0,0 +1,16 @@
import factory from '../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.declineAndLogout,
},
});

View File

@ -0,0 +1,48 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router-dom';
import icons from 'app/components/ui/icons.scss';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import appInfo from 'app/components/auth/appInfo/AppInfo.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.description1}
values={{
link: (
<Link to="/rules" target="_blank">
<Message {...messages.termsOfService} />
</Link>
),
}}
/>
<br />
<Message
{...messages.description2}
values={{
name: <Message {...appInfo.appName} />,
}}
/>
</p>
</div>
);
}
}

View File

@ -0,0 +1,16 @@
@import '~app/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

@ -0,0 +1,194 @@
import sinon from 'sinon';
import expect from 'app/test/unexpected';
import request from 'app/services/request';
import {
setLoadingState,
oAuthValidate,
oAuthComplete,
setClient,
setOAuthRequest,
setScopes,
setOAuthCode,
requirePermissionsAccept,
login,
setLogin,
} from 'app/components/auth/actions';
const oauthData = {
clientId: '',
redirectUrl: '',
responseType: '',
scope: '',
state: '',
};
describe('components/auth/actions', () => {
const dispatch = sinon.stub().named('store.dispatch');
const getState = sinon.stub().named('store.getState');
function callThunk(fn, ...args) {
const thunk = fn(...args);
return thunk(dispatch, getState);
}
function expectDispatchCalls(calls) {
expect(
dispatch,
'to have calls satisfying',
[[setLoadingState(true)]]
.concat(calls)
.concat([[setLoadingState(false)]]),
);
}
beforeEach(() => {
dispatch.reset();
getState.reset();
getState.returns({});
sinon.stub(request, 'get').named('request.get');
sinon.stub(request, 'post').named('request.post');
});
afterEach(() => {
(request.get as any).restore();
(request.post as any).restore();
});
describe('#oAuthValidate()', () => {
let resp;
beforeEach(() => {
resp = {
client: { id: 123 },
oAuth: { state: 123 },
session: {
scopes: ['scopes'],
},
};
(request.get as any).returns(Promise.resolve(resp));
});
it('should send get request to an api', () =>
callThunk(oAuthValidate, oauthData).then(() => {
expect(request.get, 'to have a call satisfying', [
'/api/oauth2/v1/validate',
{},
]);
}));
it('should dispatch setClient, setOAuthRequest and setScopes', () =>
callThunk(oAuthValidate, oauthData).then(() => {
expectDispatchCalls([
[setClient(resp.client)],
[
setOAuthRequest({
...resp.oAuth,
prompt: 'none',
loginHint: undefined,
}),
],
[setScopes(resp.session.scopes)],
]);
}));
});
describe('#oAuthComplete()', () => {
beforeEach(() => {
getState.returns({
auth: {
oauth: oauthData,
},
});
});
it('should post to api/oauth2/complete', () => {
(request.post as any).returns(
Promise.resolve({
redirectUri: '',
}),
);
return callThunk(oAuthComplete).then(() => {
expect(request.post, 'to have a call satisfying', [
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=',
{},
]);
});
});
it('should dispatch setOAuthCode for static_page redirect', () => {
const resp = {
success: true,
redirectUri: 'static_page?code=123&state=',
};
(request.post as any).returns(Promise.resolve(resp));
return callThunk(oAuthComplete).then(() => {
expectDispatchCalls([
[
setOAuthCode({
success: true,
code: '123',
displayCode: false,
}),
],
]);
});
});
it('should resolve to with success false and redirectUri for access_denied', async () => {
const resp = {
statusCode: 401,
error: 'access_denied',
redirectUri: 'redirectUri',
};
(request.post as any).returns(Promise.reject(resp));
const data = await callThunk(oAuthComplete);
expect(data, 'to equal', {
success: false,
redirectUri: 'redirectUri',
});
});
it('should dispatch requirePermissionsAccept if accept_required', () => {
const resp = {
statusCode: 401,
error: 'accept_required',
};
(request.post as any).returns(Promise.reject(resp));
return callThunk(oAuthComplete).catch(error => {
expect(error.acceptRequired, 'to be true');
expectDispatchCalls([[requirePermissionsAccept()]]);
});
});
});
describe('#login()', () => {
describe('when correct login was entered', () => {
beforeEach(() => {
(request.post as any).returns(
Promise.reject({
errors: {
password: 'error.password_required',
},
}),
);
});
it('should set login', () =>
callThunk(login, { login: 'foo' }).then(() => {
expectDispatchCalls([[setLogin('foo')]]);
}));
});
});
});

View File

@ -0,0 +1,658 @@
import { browserHistory } from 'app/services/history';
import logger from 'app/services/logger';
import localStorage from 'app/services/localStorage';
import loader from 'app/services/loader';
import history from 'app/services/history';
import {
updateUser,
acceptRules as userAcceptRules,
} from 'app/components/user/actions';
import { authenticate, logoutAll } from 'app/components/accounts/actions';
import { getActiveAccount } from 'app/components/accounts/reducer';
import {
login as loginEndpoint,
forgotPassword as forgotPasswordEndpoint,
recoverPassword as recoverPasswordEndpoint,
OAuthResponse,
} from 'app/services/api/authentication';
import oauth, { OauthData, Client, Scope } from 'app/services/api/oauth';
import signup from 'app/services/api/signup';
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from 'app/components/contact/ContactForm';
import { ThunkAction, Dispatch } from 'app/reducers';
import { getCredentials } from './reducer';
type ValidationError =
| string
| {
type: string;
payload: { [key: string]: any };
};
export { updateUser } from 'app/components/user/actions';
export {
authenticate,
logoutAll as logout,
remove as removeAccount,
activate as activateAccount,
} from 'app/components/accounts/actions';
import { Account } from 'app/components/accounts/reducer';
/**
* Reoutes user to the previous page if it is possible
*
* @param {object} options
* @param {string} options.fallbackUrl - an url to route user to if goBack is not possible
*
* @returns {object} - action definition
*/
export function goBack(options: { fallbackUrl?: string }) {
const { fallbackUrl } = options || {};
if (history.canGoBack()) {
browserHistory.goBack();
} else if (fallbackUrl) {
browserHistory.push(fallbackUrl);
}
return {
type: 'noop',
};
}
export function redirect(url: string): () => Promise<void> {
loader.show();
return () =>
new Promise(() => {
// do not resolve promise to make loader visible and
// overcome app rendering
location.href = url;
});
}
const PASSWORD_REQUIRED = 'error.password_required';
const LOGIN_REQUIRED = 'error.login_required';
const ACTIVATION_REQUIRED = 'error.account_not_activated';
const TOTP_REQUIRED = 'error.totp_required';
export function login({
login = '',
password = '',
totp,
rememberMe = false,
}: {
login: string;
password?: string;
totp?: string;
rememberMe?: boolean;
}) {
return wrapInLoader(dispatch =>
loginEndpoint({ login, password, totp, rememberMe })
.then(authHandler(dispatch))
.catch(resp => {
if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) {
return dispatch(setLogin(login));
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation());
} else if (resp.errors.totp === TOTP_REQUIRED) {
return dispatch(
requestTotp({
login,
password,
rememberMe,
}),
);
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
logger.warn('No login on password panel');
return dispatch(logoutAll());
}
}
return Promise.reject(resp);
})
.catch(validationErrorsHandler(dispatch)),
);
}
export function acceptRules() {
return wrapInLoader(dispatch =>
dispatch(userAcceptRules()).catch(validationErrorsHandler(dispatch)),
);
}
export function forgotPassword({
login = '',
captcha = '',
}: {
login: string;
captcha: string;
}) {
return wrapInLoader((dispatch, getState) =>
forgotPasswordEndpoint(login, captcha)
.then(({ data = {} }) =>
dispatch(
updateUser({
maskedEmail: data.emailMask || getState().user.email,
}),
),
)
.catch(validationErrorsHandler(dispatch)),
);
}
export function recoverPassword({
key = '',
newPassword = '',
newRePassword = '',
}: {
key: string;
newPassword: string;
newRePassword: string;
}) {
return wrapInLoader(dispatch =>
recoverPasswordEndpoint(key, newPassword, newRePassword)
.then(authHandler(dispatch))
.catch(validationErrorsHandler(dispatch, '/forgot-password')),
);
}
export function register({
email = '',
username = '',
password = '',
rePassword = '',
captcha = '',
rulesAgreement = false,
}: {
email: string;
username: string;
password: string;
rePassword: string;
captcha: string;
rulesAgreement: boolean;
}) {
return wrapInLoader((dispatch, getState) =>
signup
.register({
email,
username,
password,
rePassword,
rulesAgreement,
lang: getState().user.lang,
captcha,
})
.then(() => {
dispatch(
updateUser({
username,
email,
}),
);
dispatch(needActivation());
browserHistory.push('/activation');
})
.catch(validationErrorsHandler(dispatch)),
);
}
export function activate({
key = '',
}: {
key: string;
}): ThunkAction<Promise<Account>> {
return wrapInLoader(dispatch =>
signup
.activate({ key })
.then(authHandler(dispatch))
.catch(validationErrorsHandler(dispatch, '/resend-activation')),
);
}
export function resendActivation({
email = '',
captcha,
}: {
email: string;
captcha: string;
}) {
return wrapInLoader(dispatch =>
signup
.resendActivation({ email, captcha })
.then(resp => {
dispatch(
updateUser({
email,
}),
);
return resp;
})
.catch(validationErrorsHandler(dispatch)),
);
}
export function contactUs() {
return createPopup({ Popup: ContactForm });
}
export const SET_CREDENTIALS = 'auth:setCredentials';
/**
* Sets login in credentials state
*
* Resets the state, when `null` is passed
*
* @param {string|null} login
*
* @returns {object}
*/
export function setLogin(login: string | null) {
return {
type: SET_CREDENTIALS,
payload: login
? {
login,
}
: null,
};
}
export function relogin(login: string | null): ThunkAction {
return (dispatch, getState) => {
const credentials = getCredentials(getState());
const returnUrl =
credentials.returnUrl || location.pathname + location.search;
dispatch({
type: SET_CREDENTIALS,
payload: {
login,
returnUrl,
isRelogin: true,
},
});
browserHistory.push('/login');
};
}
function requestTotp({
login,
password,
rememberMe,
}: {
login: string;
password: string;
rememberMe: boolean;
}): ThunkAction {
return (dispatch, getState) => {
// merging with current credentials to propogate returnUrl
const credentials = getCredentials(getState());
dispatch({
type: SET_CREDENTIALS,
payload: {
...credentials,
login,
password,
rememberMe,
isTotpRequired: true,
},
});
};
}
export const SET_SWITCHER = 'auth:setAccountSwitcher';
export function setAccountSwitcher(isOn: boolean) {
return {
type: SET_SWITCHER,
payload: isOn,
};
}
export const ERROR = 'auth:error';
export function setErrors(errors: { [key: string]: ValidationError } | null) {
return {
type: ERROR,
payload: errors,
error: true,
};
}
export function clearErrors() {
return setErrors(null);
}
const KNOWN_SCOPES = [
'minecraft_server_session',
'offline_access',
'account_info',
'account_email',
];
/**
* @param {object} oauthData
* @param {string} oauthData.clientId
* @param {string} oauthData.redirectUrl
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
* @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
* Posible values:
* * none - default behaviour
* * consent - forcibly prompt user for rules acceptance
* * select_account - force account choosage, even if user has only one
* @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
* The possible values: account id, email, username
* @param {string} oauthData.state
*
* @returns {Promise}
*/
export function oAuthValidate(oauthData: OauthData) {
// TODO: move to oAuth actions?
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
return wrapInLoader(dispatch =>
oauth
.validate(oauthData)
.then(resp => {
const { scopes } = resp.session;
const invalidScopes = scopes.filter(
scope => !KNOWN_SCOPES.includes(scope),
);
let prompt = (oauthData.prompt || 'none')
.split(',')
.map(item => item.trim());
if (prompt.includes('none')) {
prompt = ['none'];
}
if (invalidScopes.length) {
logger.error('Got invalid scopes after oauth validation', {
invalidScopes,
});
}
dispatch(setClient(resp.client));
dispatch(
setOAuthRequest({
...resp.oAuth,
prompt: oauthData.prompt || 'none',
loginHint: oauthData.loginHint,
}),
);
dispatch(setScopes(scopes));
localStorage.setItem(
'oauthData',
JSON.stringify({
// @see services/authFlow/AuthFlow
timestamp: Date.now(),
payload: oauthData,
}),
);
})
.catch(handleOauthParamsValidation),
);
}
/**
* @param {object} params
* @param {bool} params.accept=false
*
* @returns {Promise}
*/
export function oAuthComplete(params: { accept?: boolean } = {}) {
return wrapInLoader(
async (
dispatch,
getState,
): Promise<{
success: boolean;
redirectUri: string;
}> => {
const oauthData = getState().auth.oauth;
if (!oauthData) {
throw new Error('Can not complete oAuth. Oauth data does not exist');
}
try {
const resp = await oauth.complete(oauthData, params);
localStorage.removeItem('oauthData');
if (resp.redirectUri.startsWith('static_page')) {
const displayCode = resp.redirectUri === 'static_page_with_code';
const [, code] = resp.redirectUri.match(/code=(.+)&/) || [];
[, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || [];
dispatch(
setOAuthCode({
success: resp.success,
code,
displayCode,
}),
);
}
return resp;
} catch (error) {
const resp:
| {
acceptRequired: boolean;
}
| {
unauthorized: boolean;
} = error;
if ('acceptRequired' in resp) {
dispatch(requirePermissionsAccept());
return Promise.reject(resp);
}
return handleOauthParamsValidation(resp);
}
},
);
}
function handleOauthParamsValidation(
resp: {
[key: string]: any;
userMessage?: string;
} = {},
) {
dispatchBsod();
localStorage.removeItem('oauthData');
// eslint-disable-next-line no-alert
resp.userMessage && setTimeout(() => alert(resp.userMessage), 500); // 500 ms to allow re-render
return Promise.reject(resp);
}
export const SET_CLIENT = 'set_client';
export function setClient({ id, name, description }: Client) {
return {
type: SET_CLIENT,
payload: { id, name, description },
};
}
export function resetOAuth(): ThunkAction {
return (dispatch): void => {
localStorage.removeItem('oauthData');
dispatch(setOAuthRequest({}));
};
}
/**
* Resets all temporary state related to auth
*/
export function resetAuth(): ThunkAction {
return (dispatch, getSate): Promise<void> => {
dispatch(setLogin(null));
dispatch(resetOAuth());
// ensure current account is valid
const activeAccount = getActiveAccount(getSate());
if (activeAccount) {
return Promise.resolve(dispatch(authenticate(activeAccount)))
.then(() => {})
.catch(() => {
// its okay. user will be redirected to an appropriate place
});
}
return Promise.resolve();
};
}
export const SET_OAUTH = 'set_oauth';
export function setOAuthRequest(oauth: {
client_id?: string;
redirect_uri?: string;
response_type?: string;
scope?: string;
prompt?: string;
loginHint?: string;
state?: string;
}) {
return {
type: SET_OAUTH,
payload: {
clientId: oauth.client_id,
redirectUrl: oauth.redirect_uri,
responseType: oauth.response_type,
scope: oauth.scope,
prompt: oauth.prompt,
loginHint: oauth.loginHint,
state: oauth.state,
},
};
}
export const SET_OAUTH_RESULT = 'set_oauth_result';
export function setOAuthCode(oauth: {
success: boolean;
code: string;
displayCode: boolean;
}) {
return {
type: SET_OAUTH_RESULT,
payload: {
success: oauth.success,
code: oauth.code,
displayCode: oauth.displayCode,
},
};
}
export const REQUIRE_PERMISSIONS_ACCEPT = 'require_permissions_accept';
export function requirePermissionsAccept() {
return {
type: REQUIRE_PERMISSIONS_ACCEPT,
};
}
export const SET_SCOPES = 'set_scopes';
export function setScopes(scopes: Scope[]) {
if (!(scopes instanceof Array)) {
throw new Error('Scopes must be array');
}
return {
type: SET_SCOPES,
payload: scopes,
};
}
export const SET_LOADING_STATE = 'set_loading_state';
export function setLoadingState(isLoading: boolean) {
return {
type: SET_LOADING_STATE,
payload: isLoading,
};
}
function wrapInLoader<T>(fn: ThunkAction<Promise<T>>): ThunkAction<Promise<T>> {
return (dispatch, getState) => {
dispatch(setLoadingState(true));
const endLoading = () => dispatch(setLoadingState(false));
return fn(dispatch, getState, undefined).then(
resp => {
endLoading();
return resp;
},
resp => {
endLoading();
return Promise.reject(resp);
},
);
};
}
function needActivation() {
return updateUser({
isActive: false,
isGuest: false,
});
}
function authHandler(dispatch: Dispatch) {
return (resp: OAuthResponse): Promise<Account> =>
dispatch(
authenticate({
token: resp.access_token,
refreshToken: resp.refresh_token || null,
}),
).then(resp => {
dispatch(setLogin(null));
return resp;
});
}
function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
return resp => {
if (resp.errors) {
const firstError = Object.keys(resp.errors)[0];
const error = {
type: resp.errors[firstError],
payload: {
isGuest: true,
repeatUrl: '',
},
};
if (resp.data) {
// TODO: this should be formatted on backend
Object.assign(error.payload, resp.data);
}
if (
['error.key_not_exists', 'error.key_expire'].includes(error.type) &&
repeatUrl
) {
// TODO: this should be formatted on backend
error.payload.repeatUrl = repeatUrl;
}
resp.errors[firstError] = error;
dispatch(setErrors(resp.errors));
}
return Promise.reject(resp);
};
}

View File

@ -0,0 +1,8 @@
{
"accountActivationTitle": "Account activation",
"activationMailWasSent": "Please check {email} for the message with further instructions",
"activationMailWasSentNoEmail": "Please check your Email for the message with further instructions",
"confirmEmail": "Confirm Email",
"didNotReceivedEmail": "Did not received Email?",
"enterTheCode": "Enter the code from Email here"
}

View File

@ -0,0 +1,15 @@
import factory from '../factory';
import messages from './Activation.intl.json';
import Body from './ActivationBody';
export default factory({
title: messages.accountActivationTitle,
body: Body,
footer: {
color: 'blue',
label: messages.confirmEmail,
},
links: {
label: messages.didNotReceivedEmail,
},
});

View File

@ -0,0 +1,66 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './activation.scss';
import messages from './Activation.intl.json';
export default class ActivationBody extends BaseAuthBody {
static displayName = 'ActivationBody';
static panelId = 'activation';
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
key: PropTypes.string,
}),
}),
};
autoFocusField =
this.props.match.params && this.props.match.params.key ? null : 'key';
render() {
const { key } = this.props.match.params;
const { email } = this.context.user;
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
<div className={styles.descriptionImage} />
<div className={styles.descriptionText}>
{email ? (
<Message
{...messages.activationMailWasSent}
values={{
email: <b>{email}</b>,
}}
/>
) : (
<Message {...messages.activationMailWasSentNoEmail} />
)}
</div>
</div>
<div className={styles.formRow}>
<Input
{...this.bindField('key')}
color="blue"
center
required
value={key}
readOnly={!!key}
autoComplete="off"
placeholder={messages.enterTheCode}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,19 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.description {
}
.descriptionImage {
composes: envelope from '~app/components/ui/icons.scss';
font-size: 100px;
color: $blue;
}
.descriptionText {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}

View File

@ -0,0 +1,7 @@
{
"appName": "Ely Accounts",
"goToAuth": "Go to auth",
"appDescription": "You are on the Ely.by authorization service, that allows you to safely perform any operations on your account. This single entry point for websites and desktop software, including game launchers.",
"useItYourself": "Visit our {link}, to learn how to use this service in you projects.",
"documentation": "documentation"
}

View File

@ -0,0 +1,57 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Button } from 'app/components/ui/form';
import { FooterMenu } from 'app/components/footerMenu';
import styles from './appInfo.scss';
import messages from './AppInfo.intl.json';
export default class AppInfo extends React.Component<{
name?: string;
description?: string;
onGoToAuth: () => void;
}> {
render() {
const { name, description, onGoToAuth } = this.props;
return (
<div className={styles.appInfo}>
<div className={styles.logoContainer}>
<h2 className={styles.logo}>
{name ? name : <Message {...messages.appName} />}
</h2>
</div>
<div className={styles.descriptionContainer}>
{description ? (
<p className={styles.description}>{description}</p>
) : (
<div>
<p className={styles.description}>
<Message {...messages.appDescription} />
</p>
<p className={styles.description}>
<Message
{...messages.useItYourself}
values={{
link: (
<a href="http://docs.ely.by/oauth.html">
<Message {...messages.documentation} />
</a>
),
}}
/>
</p>
</div>
)}
</div>
<div className={styles.goToAuth}>
<Button onClick={onGoToAuth} label={messages.goToAuth} />
</div>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
);
}
}

View File

@ -0,0 +1,72 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.appInfo {
max-width: 270px;
margin: 0 auto;
padding: 55px 25px;
}
.logoContainer {
position: relative;
padding: 15px 0;
&:after {
content: '';
display: block;
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 40px;
background: $green;
}
}
.logo {
font-family: $font-family-title;
color: #fff;
font-size: 36px;
}
.descriptionContainer {
margin: 20px 0;
}
.description {
$font-color: #ccc;
font-family: $font-family-base;
color: $font-color;
font-size: 13px;
line-height: 1.7;
margin-top: 7px;
a {
color: lighten($font-color, 10%);
border-bottom-color: #666;
&:hover {
color: $font-color;
}
}
}
.goToAuth {
}
@media (min-width: 720px) {
.goToAuth {
display: none;
}
}
.footer {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
text-align: center;
line-height: 1.5;
}

View File

@ -0,0 +1,3 @@
.checkboxInput {
margin-top: 15px;
}

View File

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import React from 'react';
import errorsDict from 'app/services/errorsDict';
import { PanelBodyHeader } from 'app/components/ui/Panel';
let autoHideTimer;
function resetTimer() {
if (autoHideTimer) {
clearTimeout(autoHideTimer);
autoHideTimer = null;
}
}
export default function AuthError({ error, onClose = function() {} }) {
resetTimer();
if (error.payload && error.payload.canRepeatIn) {
error.payload.msLeft = error.payload.canRepeatIn * 1000;
setTimeout(onClose, error.payload.msLeft - Date.now() + 1500); // 1500 to let the user see, that time is elapsed
}
return (
<PanelBodyHeader
type="error"
onClose={() => {
resetTimer();
onClose();
}}
>
{errorsDict.resolve(error)}
</PanelBodyHeader>
);
}
AuthError.displayName = 'AuthError';
AuthError.propTypes = {
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]).isRequired,
onClose: PropTypes.func,
};

View File

@ -0,0 +1,7 @@
{
"chooseAccountTitle": "Choose an account",
"addAccount": "Log into another account",
"logoutAll": "Log out from all accounts",
"pleaseChooseAccount": "Please select an account you're willing to use",
"pleaseChooseAccountForApp": "Please select an account that you want to use to authorize {appName}"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import messages from './ChooseAccount.intl.json';
import Body from './ChooseAccountBody';
export default factory({
title: messages.chooseAccountTitle,
body: Body,
footer: {
label: messages.addAccount,
},
links: [
{
label: messages.logoutAll,
},
],
});

View File

@ -0,0 +1,52 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { AccountSwitcher } from 'app/components/accounts';
import styles from './chooseAccount.scss';
import messages from './ChooseAccount.intl.json';
export default class ChooseAccountBody extends BaseAuthBody {
static displayName = 'ChooseAccountBody';
static panelId = 'chooseAccount';
render() {
const { client } = this.context.auth;
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
{client ? (
<Message
{...messages.pleaseChooseAccountForApp}
values={{
appName: <span className={styles.appName}>{client.name}</span>,
}}
/>
) : (
<div className={styles.description}>
<Message {...messages.pleaseChooseAccount} />
</div>
)}
</div>
<div className={styles.accountSwitcherContainer}>
<AccountSwitcher
allowAdd={false}
allowLogout={false}
highlightActiveAccount={false}
onSwitch={this.onSwitch}
/>
</div>
</div>
);
}
onSwitch = account => {
this.context.resolve(account);
};
}

View File

@ -0,0 +1,18 @@
@import '~app/components/ui/panel.scss';
@import '~app/components/ui/fonts.scss';
.accountSwitcherContainer {
margin-left: -$bodyLeftRightPadding;
margin-right: -$bodyLeftRightPadding;
}
.description {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}
.appName {
color: #fff;
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Button } from 'app/components/ui/form';
import RejectionLink, {
RejectionLinkProps,
} from 'app/components/auth/RejectionLink';
import AuthTitle from 'app/components/auth/AuthTitle';
import { MessageDescriptor } from 'react-intl';
import { Color } from 'app/components/ui';
/**
* @param {object} options
* @param {string|object} options.title - panel title
* @param {React.ReactElement} options.body
* @param {object} options.footer - config for footer Button
* @param {Array|object|null} options.links - link config or an array of link configs
*
* @returns {object} - structure, required for auth panel to work
*/
export default function({
title,
body,
footer,
links,
}: {
title: MessageDescriptor;
body: React.ElementType;
footer: {
color?: Color;
label: string | MessageDescriptor;
autoFocus?: boolean;
};
links?: RejectionLinkProps | RejectionLinkProps[];
}) {
return () => ({
Title: () => <AuthTitle title={title} />,
Body: body,
Footer: () => <Button type="submit" {...footer} />,
Links: () =>
links ? (
<span>
{([] as RejectionLinkProps[])
.concat(links)
.map((link, index) => [
index ? ' | ' : '',
<RejectionLink {...link} key={index} />,
])}
</span>
) : null,
});
}

View File

@ -0,0 +1,7 @@
{
"authForAppSuccessful": "Authorization for {appName} was successfully completed",
"authForAppFailed": "Authorization for {appName} was failed",
"waitAppReaction": "Please, wait till your application response",
"passCodeToApp": "To complete authorization process, please, provide the following code to {appName}",
"copy": "Copy"
}

View File

@ -0,0 +1,110 @@
import React from 'react';
import { connect } from 'react-redux';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import { Button } from 'app/components/ui/form';
import copy from 'app/services/copy';
import { RootState } from 'app/reducers';
import messages from './Finish.intl.json';
import styles from './finish.scss';
interface Props {
appName: string;
code?: string;
state: string;
displayCode?: string;
success?: boolean;
}
class Finish extends React.Component<Props> {
render() {
const { appName, code, state, displayCode, success } = this.props;
const authData = JSON.stringify({
// eslint-disable-next-line @typescript-eslint/camelcase
auth_code: code,
state,
});
history.pushState(null, document.title, `#${authData}`);
return (
<div className={styles.finishPage}>
<Helmet title={authData} />
{success ? (
<div>
<div className={styles.successBackground} />
<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}>{code}</div>
</div>
<Button
color="green"
small
label={messages.copy}
onClick={this.onCopyClick}
/>
</div>
) : (
<div className={styles.description}>
<Message {...messages.waitAppReaction} />
</div>
)}
</div>
) : (
<div>
<div className={styles.failBackground} />
<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>
);
}
onCopyClick = event => {
event.preventDefault();
const { code } = this.props;
if (code) {
copy(code);
}
};
}
export default connect(({ auth }: RootState) => {
if (!auth || !auth.client || !auth.oauth) {
throw new Error('Can not connect Finish component. No auth data in state');
}
return {
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,76 @@
@import '~app/components/ui/colors.scss';
@import '~app/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 '~app/components/ui/icons.scss';
@extend .iconBackground;
}
.failBackground {
composes: close from '~app/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

@ -0,0 +1,7 @@
{
"title": "Forgot password",
"sendMail": "Send mail",
"specifyEmail": "Specify the registration Email address or last used username for your account and we will send an Email with instructions for further password recovery.",
"pleasePressButton": "Please press the button bellow to get an Email with password recovery code.",
"alreadyHaveCode": "Already have a code"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import messages from './ForgotPassword.intl.json';
import Body from './ForgotPasswordBody';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'lightViolet',
autoFocus: true,
label: messages.sendMail,
},
links: {
label: messages.alreadyHaveCode,
},
});

View File

@ -0,0 +1,89 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input, Captcha } from 'app/components/ui/form';
import { getLogin } from 'app/components/auth/reducer';
import { PanelIcon } from 'app/components/ui/Panel';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './forgotPassword.scss';
import messages from './ForgotPassword.intl.json';
export default class ForgotPasswordBody extends BaseAuthBody {
static displayName = 'ForgotPasswordBody';
static panelId = 'forgotPassword';
static hasGoBack = true;
state = {
isLoginEdit: !this.getLogin(),
};
autoFocusField = this.state.isLoginEdit ? 'login' : null;
render() {
const login = this.getLogin();
const isLoginEditShown = this.state.isLoginEdit;
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
{isLoginEditShown ? (
<div>
<p className={styles.descriptionText}>
<Message {...messages.specifyEmail} />
</p>
<Input
{...this.bindField('login')}
icon="envelope"
color="lightViolet"
required
placeholder={messages.accountEmail}
defaultValue={login}
/>
</div>
) : (
<div>
<div className={styles.login}>
{login}
<span className={styles.editLogin} onClick={this.onClickEdit} />
</div>
<p className={styles.descriptionText}>
<Message {...messages.pleasePressButton} />
</p>
</div>
)}
<Captcha {...this.bindField('captcha')} delay={600} />
</div>
);
}
serialize() {
const data = super.serialize();
if (!data.login) {
data.login = this.getLogin();
}
return data;
}
getLogin() {
const login = getLogin(this.context);
const { user } = this.context;
return login || user.username || user.email || '';
}
onClickEdit = () => {
this.setState({
isLoginEdit: true,
});
this.context.requestRedraw().then(() => this.form.focus('login'));
};
}

View File

@ -0,0 +1,31 @@
@import '~app/components/ui/colors.scss';
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}
.login {
composes: email from '~app/components/auth/password/password.scss';
}
.editLogin {
composes: pencil from '~app/components/ui/icons.scss';
position: relative;
bottom: 1px;
padding-left: 3px;
color: #666666;
font-size: 10px;
transition: color 0.3s;
cursor: pointer;
&:hover {
color: #ccc;
}
}

View File

@ -0,0 +1,9 @@
.helpLinks {
margin: 8px 0;
position: relative;
height: 20px;
color: #444;
text-align: center;
font-size: 16px;
}

View File

@ -0,0 +1 @@
export { State as AuthState } from './reducer';

View File

@ -0,0 +1,6 @@
{
"createNewAccount": "Create new account",
"loginTitle": "Sign in",
"emailOrUsername": "Email or username",
"next": "Next"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import Body from './LoginBody';
import messages from './Login.intl.json';
export default factory({
title: messages.loginTitle,
body: Body,
footer: {
color: 'green',
label: messages.next,
},
links: {
isAvailable: context => !context.user.isGuest,
label: messages.createNewAccount,
},
});

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import messages from './Login.intl.json';
export default class LoginBody extends BaseAuthBody {
static displayName = 'LoginBody';
static panelId = 'login';
static hasGoBack = state => {
return !state.user.isGuest;
};
autoFocusField = 'login';
render() {
return (
<div>
{this.renderErrors()}
<Input
{...this.bindField('login')}
icon="envelope"
required
placeholder={messages.emailOrUsername}
/>
</div>
);
}
}

View File

@ -0,0 +1,4 @@
{
"enterTotp": "Enter code",
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
}

View File

@ -0,0 +1,13 @@
import factory from '../factory';
import Body from './MfaBody';
import messages from './Mfa.intl.json';
import passwordMessages from '../password/Password.intl.json';
export default factory({
title: messages.enterTotp,
body: Body,
footer: {
color: 'green',
label: passwordMessages.signInButton,
},
});

View File

@ -0,0 +1,37 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { PanelIcon } from 'app/components/ui/Panel';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './mfa.scss';
import messages from './Mfa.intl.json';
export default class MfaBody extends BaseAuthBody {
static panelId = 'mfa';
static hasGoBack = true;
autoFocusField = 'totp';
render() {
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
<p className={styles.descriptionText}>
<Message {...messages.description} />
</p>
<Input
{...this.bindField('totp')}
icon="key"
required
placeholder={messages.enterTotp}
autoComplete="off"
/>
</div>
);
}
}

View File

@ -0,0 +1,6 @@
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}

View File

@ -0,0 +1,7 @@
{
"passwordTitle": "Enter password",
"signInButton": "Sign in",
"forgotPassword": "Forgot password",
"accountPassword": "Account password",
"rememberMe": "Remember me on this device"
}

View File

@ -0,0 +1,15 @@
import factory from '../factory';
import Body from './PasswordBody';
import messages from './Password.intl.json';
export default factory({
title: messages.passwordTitle,
body: Body,
footer: {
color: 'green',
label: messages.signInButton,
},
links: {
label: messages.forgotPassword,
},
});

View File

@ -0,0 +1,53 @@
import React from 'react';
import icons from 'app/components/ui/icons.scss';
import { Input, Checkbox } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import authStyles from 'app/components/auth/auth.scss';
import styles from './password.scss';
import messages from './Password.intl.json';
export default class PasswordBody extends BaseAuthBody {
static displayName = 'PasswordBody';
static panelId = 'password';
static hasGoBack = true;
autoFocusField = 'password';
render() {
const { user } = this.context;
return (
<div>
{this.renderErrors()}
<div className={styles.miniProfile}>
<div className={styles.avatar}>
{user.avatar ? (
<img src={user.avatar} />
) : (
<span className={icons.user} />
)}
</div>
<div className={styles.email}>{user.email || user.username}</div>
</div>
<Input
{...this.bindField('password')}
icon="key"
type="password"
required
placeholder={messages.accountPassword}
/>
<div className={authStyles.checkboxInput}>
<Checkbox
{...this.bindField('rememberMe')}
defaultChecked
label={messages.rememberMe}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,22 @@
@import '~app/components/ui/fonts.scss';
.avatar {
width: 90px;
height: 90px;
font-size: 90px;
line-height: 1;
margin: 0 auto;
img {
width: 100%;
}
}
.email {
font-family: $font-family-title;
font-size: 18px;
color: #fff;
margin-bottom: 15px;
margin-top: 10px;
}

View File

@ -0,0 +1,12 @@
{
"permissionsTitle": "Application permissions",
"youAuthorizedAs": "You authorized as:",
"theAppNeedsAccess1": "This application needs access",
"theAppNeedsAccess2": "to your data",
"decline": "Decline",
"approve": "Approve",
"scope_minecraft_server_session": "Authorization data for minecraft server",
"scope_offline_access": "Access to your profile data, when you offline",
"scope_account_info": "Access to your profile data (except Email)",
"scope_account_email": "Access to your Email address"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import messages from './Permissions.intl.json';
import Body from './PermissionsBody';
export default factory({
title: messages.permissionsTitle,
body: Body,
footer: {
color: 'orange',
autoFocus: true,
label: messages.approve,
},
links: {
label: messages.decline,
},
});

View File

@ -0,0 +1,65 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import icons from 'app/components/ui/icons.scss';
import { PanelBodyHeader } from 'app/components/ui/Panel';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './permissions.scss';
import messages from './Permissions.intl.json';
export default class PermissionsBody extends BaseAuthBody {
static displayName = 'PermissionsBody';
static panelId = 'permissions';
render() {
const { user } = this.context;
const { scopes } = this.context.auth;
return (
<div>
{this.renderErrors()}
<PanelBodyHeader>
<div className={styles.authInfo}>
<div className={styles.authInfoAvatar}>
{user.avatar ? (
<img src={user.avatar} />
) : (
<span className={icons.user} />
)}
</div>
<div className={styles.authInfoTitle}>
<Message {...messages.youAuthorizedAs} />
</div>
<div className={styles.authInfoEmail}>{user.username}</div>
</div>
</PanelBodyHeader>
<div className={styles.permissionsContainer}>
<div className={styles.permissionsTitle}>
<Message {...messages.theAppNeedsAccess1} />
<br />
<Message {...messages.theAppNeedsAccess2} />
</div>
<ul className={styles.permissionsList}>
{scopes.map(scope => {
const key = `scope_${scope}`;
const message = messages[key];
return (
<li key={key}>
{message ? (
<Message {...message} />
) : (
scope.replace(/^\w|_/g, match =>
match.replace('_', ' ').toUpperCase(),
)
)}
</li>
);
})}
</ul>
</div>
</div>
);
}
}

View File

@ -0,0 +1,77 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.authInfo {
// Отступы сверху и снизу разные т.к. мы ужимаем высоту линии строки с логином на 2 пикселя и из-за этого теряем отступ снизу
padding: 5px 20px 7px;
text-align: left;
}
.authInfoAvatar {
$size: 30px;
float: left;
height: $size;
width: $size;
font-size: $size;
line-height: 1;
margin-right: 10px;
margin-top: 2px;
color: #aaa;
img {
width: 100%;
}
}
.authInfoTitle {
font-size: 14px;
color: #666;
}
.authInfoEmail {
font-family: $font-family-title;
font-size: 20px;
line-height: 16px;
color: #fff;
}
.permissionsContainer {
padding: 15px 12px;
text-align: left;
}
.permissionsTitle {
font-family: $font-family-title;
font-size: 18px;
color: #dd8650;
padding-bottom: 6px;
}
.permissionsList {
list-style: none;
margin-top: 10px;
li {
color: #a9a9a9;
font-size: 14px;
line-height: 1.4;
padding-bottom: 4px;
padding-left: 17px;
position: relative;
&:last-of-type {
padding-bottom: 0;
}
&:before {
content: '';
color: lighter($light_violet);
font-size: 39px; // ~ 9px
line-height: 9px;
position: absolute;
top: 6px;
left: -4px;
}
}
}

View File

@ -0,0 +1,12 @@
{
"title": "Restore password",
"contactSupport": "Contact support",
"messageWasSent": "The recovery code was sent to your account Email.",
"messageWasSentTo": "The recovery code was sent to your Email {email}.",
"enterCodeBelow": "Please enter the code received into the field below:",
"enterNewPasswordBelow": "Enter and repeat new password below:",
"change": "Change password",
"newPassword": "Enter new password",
"newRePassword": "Repeat new password",
"enterTheCode": "Enter confirmation code"
}

View File

@ -0,0 +1,15 @@
import factory from '../factory';
import messages from './RecoverPassword.intl.json';
import Body from './RecoverPasswordBody';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'lightViolet',
label: messages.change,
},
links: {
label: messages.contactSupport,
},
});

View File

@ -0,0 +1,89 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './recoverPassword.scss';
import messages from './RecoverPassword.intl.json';
// TODO: activation code field may be decoupled into common component and reused here and in activation panel
export default class RecoverPasswordBody extends BaseAuthBody {
static displayName = 'RecoverPasswordBody';
static panelId = 'recoverPassword';
static hasGoBack = true;
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
key: PropTypes.string,
}),
}),
};
autoFocusField =
this.props.match.params && this.props.match.params.key
? 'newPassword'
: 'key';
render() {
const { user } = this.context;
const { key } = this.props.match.params;
return (
<div>
{this.renderErrors()}
<p className={styles.descriptionText}>
{user.maskedEmail ? (
<Message
{...messages.messageWasSentTo}
values={{
email: <b>{user.maskedEmail}</b>,
}}
/>
) : (
<Message {...messages.messageWasSent} />
)}{' '}
<Message {...messages.enterCodeBelow} />
</p>
<Input
{...this.bindField('key')}
color="lightViolet"
center
required
value={key}
readOnly={!!key}
autoComplete="off"
placeholder={messages.enterTheCode}
/>
<p className={styles.descriptionText}>
<Message {...messages.enterNewPasswordBelow} />
</p>
<Input
{...this.bindField('newPassword')}
icon="key"
color="lightViolet"
type="password"
required
placeholder={messages.newPassword}
/>
<Input
{...this.bindField('newRePassword')}
icon="key"
color="lightViolet"
type="password"
required
placeholder={messages.newRePassword}
/>
</div>
);
}
}

View File

@ -0,0 +1,8 @@
@import '~app/components/ui/colors.scss';
.descriptionText {
font-size: 15px;
line-height: 1.4;
margin-bottom: 8px;
color: #aaa;
}

View File

@ -0,0 +1,47 @@
import expect from 'app/test/unexpected';
import auth from './reducer';
import {
setLogin,
SET_CREDENTIALS,
setAccountSwitcher,
SET_SWITCHER,
} from './actions';
describe('components/auth/reducer', () => {
describe(SET_CREDENTIALS, () => {
it('should set login', () => {
const expectedLogin = 'foo';
expect(
auth(undefined, setLogin(expectedLogin)).credentials,
'to satisfy',
{
login: expectedLogin,
},
);
});
});
describe(SET_SWITCHER, () => {
it('should be enabled by default', () =>
expect(auth(undefined, {} as any), 'to satisfy', {
isSwitcherEnabled: true,
}));
it('should enable switcher', () => {
const expectedValue = true;
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
isSwitcherEnabled: expectedValue,
});
});
it('should disable switcher', () => {
const expectedValue = false;
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
isSwitcherEnabled: expectedValue,
});
});
});
});

View File

@ -0,0 +1,192 @@
import { combineReducers } from 'redux';
import {
ERROR,
SET_CLIENT,
SET_OAUTH,
SET_OAUTH_RESULT,
SET_SCOPES,
SET_LOADING_STATE,
REQUIRE_PERMISSIONS_ACCEPT,
SET_CREDENTIALS,
SET_SWITCHER,
} from './actions';
type Credentials = {
login?: string;
password?: string;
rememberMe?: boolean;
returnUrl?: string;
isRelogin?: boolean;
isTotpRequired?: boolean;
};
export interface Client {
id: string;
name: string;
description: string;
}
export interface State {
credentials: Credentials;
error:
| string
| {
type: string;
payload: { [key: string]: any };
};
isLoading: boolean;
isSwitcherEnabled: boolean;
client: Client | null;
login: string;
oauth: {
clientId: string;
redirectUrl: string;
responseType: string;
description: string;
scope: string;
prompt: string;
loginHint: string;
state: string;
success?: boolean;
code?: string;
displayCode?: string;
acceptRequired?: boolean;
} | null;
scopes: string[];
}
export default combineReducers({
credentials,
error,
isLoading,
isSwitcherEnabled,
client,
oauth,
scopes,
});
function error(state = null, { type, payload = null, error = false }) {
switch (type) {
case ERROR:
if (!error) {
throw new Error('Expected payload with error');
}
return payload;
default:
return state;
}
}
function credentials(
state = {},
{
type,
payload,
}: {
type: string;
payload: Credentials | null;
},
) {
if (type === SET_CREDENTIALS) {
if (payload && typeof payload === 'object') {
return {
...payload,
};
}
return {};
}
return state;
}
function isSwitcherEnabled(state = true, { type, payload = false }) {
switch (type) {
case SET_SWITCHER:
if (typeof payload !== 'boolean') {
throw new Error('Expected payload of boolean type');
}
return payload;
default:
return state;
}
}
function isLoading(state = false, { type, payload = null }) {
switch (type) {
case SET_LOADING_STATE:
return !!payload;
default:
return state;
}
}
function client(state = null, { type, payload }) {
switch (type) {
case SET_CLIENT:
return {
id: payload.id,
name: payload.name,
description: payload.description,
};
default:
return state;
}
}
function oauth(state: State | null = null, { type, payload }) {
switch (type) {
case SET_OAUTH:
return {
clientId: payload.clientId,
redirectUrl: payload.redirectUrl,
responseType: payload.responseType,
scope: payload.scope,
prompt: payload.prompt,
loginHint: payload.loginHint,
state: payload.state,
};
case SET_OAUTH_RESULT:
return {
...state,
success: payload.success,
code: payload.code,
displayCode: payload.displayCode,
};
case REQUIRE_PERMISSIONS_ACCEPT:
return {
...state,
acceptRequired: true,
};
default:
return state;
}
}
function scopes(state = [], { type, payload = [] }) {
switch (type) {
case SET_SCOPES:
return payload;
default:
return state;
}
}
export function getLogin(state: { [key: string]: any }): string | null {
return state.auth.credentials.login || null;
}
export function getCredentials(state: { [key: string]: any }): Credentials {
return state.auth.credentials;
}

View File

@ -0,0 +1,10 @@
{
"registerTitle": "Sign Up",
"yourNickname": "Your nickname",
"yourEmail": "Your Email",
"accountPassword": "Account password",
"repeatPassword": "Repeat password",
"signUpButton": "Register",
"acceptRules": "I agree with {link}",
"termsOfService": "terms of service"
}

View File

@ -0,0 +1,23 @@
import factory from '../factory';
import activationMessages from '../activation/Activation.intl.json';
import forgotPasswordMessages from '../forgotPassword/ForgotPassword.intl.json';
import messages from './Register.intl.json';
import Body from './RegisterBody';
export default factory({
title: messages.registerTitle,
body: Body,
footer: {
color: 'blue',
label: messages.signUpButton,
},
links: [
{
label: activationMessages.didNotReceivedEmail,
payload: { requestEmail: true },
},
{
label: forgotPasswordMessages.alreadyHaveCode,
},
],
});

View File

@ -0,0 +1,84 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router-dom';
import { Input, Checkbox, Captcha } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import passwordMessages from '../password/Password.intl.json';
import styles from '../auth.scss';
import messages from './Register.intl.json';
// TODO: password and username can be validate for length and sameness
export default class RegisterBody extends BaseAuthBody {
static displayName = 'RegisterBody';
static panelId = 'register';
autoFocusField = 'username';
render() {
return (
<div>
{this.renderErrors()}
<Input
{...this.bindField('username')}
icon="user"
color="blue"
type="text"
required
placeholder={messages.yourNickname}
/>
<Input
{...this.bindField('email')}
icon="envelope"
color="blue"
type="email"
required
placeholder={messages.yourEmail}
/>
<Input
{...this.bindField('password')}
icon="key"
color="blue"
type="password"
required
placeholder={passwordMessages.accountPassword}
/>
<Input
{...this.bindField('rePassword')}
icon="key"
color="blue"
type="password"
required
placeholder={messages.repeatPassword}
/>
<Captcha {...this.bindField('captcha')} delay={600} />
<div className={styles.checkboxInput}>
<Checkbox
{...this.bindField('rulesAgreement')}
color="blue"
required
label={
<Message
{...messages.acceptRules}
values={{
link: (
<Link to="/rules" target="_blank">
<Message {...messages.termsOfService} />
</Link>
),
}}
/>
}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,5 @@
{
"title": "Did not received an Email",
"specifyYourEmail": "Please, enter an Email you've registered with and we will send you new activation code",
"sendNewEmail": "Send new Email"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import forgotPasswordMessages from '../forgotPassword/ForgotPassword.intl.json';
import messages from './ResendActivation.intl.json';
import Body from './ResendActivationBody';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'blue',
label: messages.sendNewEmail,
},
links: {
label: forgotPasswordMessages.alreadyHaveCode,
},
});

View File

@ -0,0 +1,40 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input, Captcha } from 'app/components/ui/form';
import BaseAuthBody from '../BaseAuthBody';
import registerMessages from '../register/Register.intl.json';
import styles from './resendActivation.scss';
import messages from './ResendActivation.intl.json';
export default class ResendActivation extends BaseAuthBody {
static displayName = 'ResendActivation';
static panelId = 'resendActivation';
static hasGoBack = true;
autoFocusField = 'email';
render() {
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
<Message {...messages.specifyYourEmail} />
</div>
<Input
{...this.bindField('email')}
icon="envelope"
color="blue"
type="email"
required
placeholder={registerMessages.yourEmail}
defaultValue={this.context.user.email}
/>
<Captcha {...this.bindField('captcha')} delay={600} />
</div>
);
}
}

View File

@ -0,0 +1,8 @@
@import '~app/components/ui/fonts.scss';
.description {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}

View File

@ -0,0 +1,202 @@
import React from 'react';
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import { shallow, mount } from 'enzyme';
import { IntlProvider } from 'react-intl';
import feedback from 'app/services/api/feedback';
import { User } from 'app/components/user';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
describe('when rendered', () => {
const user = {} as User;
let component;
beforeEach(() => {
component = shallow(<ContactForm user={user} />);
});
[
{
type: 'Input',
name: 'subject',
},
{
type: 'Input',
name: 'email',
},
{
type: 'Dropdown',
name: 'category',
},
{
type: 'TextArea',
name: 'message',
},
].forEach(el => {
it(`should have ${el.name} field`, () => {
expect(component.find(`${el.type}[name="${el.name}"]`), 'to satisfy', {
length: 1,
});
});
});
it('should contain Form', () => {
expect(component.find('Form'), 'to satisfy', { length: 1 });
});
it('should contain submit Button', () => {
expect(component.find('Button[type="submit"]'), 'to satisfy', {
length: 1,
});
});
});
describe('when rendered with user', () => {
const user = {
email: 'foo@bar.com',
} as User;
let component;
beforeEach(() => {
component = shallow(<ContactForm user={user} />);
});
it('should render email field with user email', () => {
expect(
component.find('Input[name="email"]').prop('defaultValue'),
'to equal',
user.email,
);
});
});
describe('when email was successfully sent', () => {
const user = {
email: 'foo@bar.com',
} as User;
let component;
beforeEach(() => {
component = shallow(<ContactForm user={user} />);
component.setState({ isSuccessfullySent: true });
});
it('should not contain Form', () => {
expect(component.find('Form'), 'to satisfy', { length: 0 });
});
});
xdescribe('validation', () => {
const user = {
email: 'foo@bar.com',
} as User;
let component;
let wrapper;
beforeEach(() => {
// TODO: add polyfill for from validation for jsdom
wrapper = mount(
<IntlProvider locale="en" defaultLocale="en">
<ContactForm user={user} ref={el => (component = el)} />
</IntlProvider>,
);
});
it('should require email, subject and message', () => {
// wrapper.find('[type="submit"]').simulate('click');
wrapper.find('form').simulate('submit');
expect(component.form.hasErrors(), 'to be true');
});
});
describe('when user submits form', () => {
const user = {
email: 'foo@bar.com',
} as User;
let component;
let wrapper;
const requestData = {
email: user.email,
subject: 'Test subject',
message: 'Test message',
};
beforeEach(() => {
sinon.stub(feedback, 'send');
// TODO: add polyfill for from validation for jsdom
if (!(Element.prototype as any).checkValidity) {
(Element.prototype as any).checkValidity = () => true;
}
// TODO: try to rewrite with unexpected-react
wrapper = mount(
<IntlProvider locale="en" defaultLocale="en">
<ContactForm user={user} ref={el => (component = el)} />
</IntlProvider>,
);
wrapper.find('input[name="email"]').getDOMNode().value =
requestData.email;
wrapper.find('input[name="subject"]').getDOMNode().value =
requestData.subject;
wrapper.find('textarea[name="message"]').getDOMNode().value =
requestData.message;
});
afterEach(() => {
(feedback.send as any).restore();
});
xit('should call onSubmit', () => {
sinon.stub(component, 'onSubmit');
wrapper.find('form').simulate('submit');
expect(component.onSubmit, 'was called');
});
it('should call send with required data', () => {
(feedback.send as any).returns(Promise.resolve());
component.onSubmit();
expect(feedback.send, 'to have a call satisfying', [requestData]);
});
it('should set isSuccessfullySent', () => {
(feedback.send as any).returns(Promise.resolve());
return component
.onSubmit()
.then(() =>
expect(component.state, 'to satisfy', { isSuccessfullySent: true }),
);
});
it('should handle isLoading during request', () => {
(feedback.send as any).returns(Promise.resolve());
const promise = component.onSubmit();
expect(component.state, 'to satisfy', { isLoading: true });
return promise.then(() =>
expect(component.state, 'to satisfy', { isLoading: false }),
);
});
it('should render success message with user email', () => {
(feedback.send as any).returns(Promise.resolve());
return component
.onSubmit()
.then(() => expect(wrapper.text(), 'to contain', user.email));
});
});
});

View File

@ -0,0 +1,199 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { FormattedMessage as Message } from 'react-intl';
import {
Input,
TextArea,
Button,
Form,
FormModel,
Dropdown,
} from 'app/components/ui/form';
import feedback from 'app/services/api/feedback';
import icons from 'app/components/ui/icons.scss';
import popupStyles from 'app/components/ui/popup/popup.scss';
import { RootState } from 'app/reducers';
import logger from 'app/services/logger';
import { User } from 'app/components/user';
import styles from './contactForm.scss';
import messages from './contactForm.intl.json';
const CONTACT_CATEGORIES = [
// TODO: сюда позже проставить реальные id категорий с backend
<Message key="m1" {...messages.cannotAccessMyAccount} />,
<Message key="m2" {...messages.foundBugOnSite} />,
<Message key="m3" {...messages.improvementsSuggestion} />,
<Message key="m4" {...messages.integrationQuestion} />,
<Message key="m5" {...messages.other} />,
];
export class ContactForm extends React.Component<
{
onClose: () => void;
user: User;
},
{
isLoading: boolean;
isSuccessfullySent: boolean;
lastEmail: string | null;
}
> {
static defaultProps = {
onClose() {},
};
state = {
isLoading: false,
isSuccessfullySent: false,
lastEmail: null,
};
form = new FormModel();
render() {
const { isSuccessfullySent } = this.state || {};
const { onClose } = this.props;
return (
<div
data-e2e="feedbackPopup"
className={
isSuccessfullySent ? styles.successState : styles.contactForm
}
>
<div className={popupStyles.popup}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
<span
className={classNames(icons.close, popupStyles.close)}
onClick={onClose}
/>
</div>
{isSuccessfullySent ? this.renderSuccess() : this.renderForm()}
</div>
</div>
);
}
renderForm() {
const { form } = this;
const { user } = this.props;
const { isLoading } = this.state;
return (
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}>
<div className={popupStyles.body}>
<div className={styles.philosophicalThought}>
<Message {...messages.philosophicalThought} />
</div>
<div className={styles.formDisclaimer}>
<Message {...messages.disclaimer} />
<br />
</div>
<div className={styles.pairInputRow}>
<div className={styles.pairInput}>
<Input
{...form.bindField('subject')}
required
label={messages.subject}
skin="light"
/>
</div>
<div className={styles.pairInput}>
<Input
{...form.bindField('email')}
required
label={messages.email}
type="email"
skin="light"
defaultValue={user.email}
/>
</div>
</div>
<div className={styles.formMargin}>
<Dropdown
{...form.bindField('category')}
label={messages.whichQuestion}
items={CONTACT_CATEGORIES}
block
/>
</div>
<TextArea
{...form.bindField('message')}
required
label={messages.message}
skin="light"
minRows={6}
maxRows={6}
/>
</div>
<div className={styles.footer}>
<Button label={messages.send} block type="submit" />
</div>
</Form>
);
}
renderSuccess() {
const { lastEmail: email } = this.state;
const { onClose } = this.props;
return (
<div>
<div className={styles.successBody}>
<span className={styles.successIcon} />
<div className={styles.successDescription}>
<Message {...messages.youMessageReceived} />
</div>
<div className={styles.sentToEmail}>{email}</div>
</div>
<div className={styles.footer}>
<Button label={messages.close} block onClick={onClose} />
</div>
</div>
);
}
onSubmit = () => {
if (this.state.isLoading) {
return;
}
this.setState({ isLoading: true });
return feedback
.send(this.form.serialize())
.then(() =>
this.setState({
isSuccessfullySent: true,
lastEmail: this.form.value('email'),
}),
)
.catch(resp => {
if (resp.errors) {
this.form.setErrors(resp.errors);
return;
}
logger.warn('Error sending feedback', resp);
})
.finally(() => this.setState({ isLoading: false }));
};
}
export default connect((state: RootState) => ({
user: state.user,
}))(ContactForm);

View File

@ -0,0 +1,27 @@
import React from 'react';
import { connect } from 'react-redux';
import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from './ContactForm';
type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
createContactPopup: () => void;
};
function ContactLink({ createContactPopup, ...props }: Props) {
return (
<a
href="#"
data-e2e-button="feedbackPopup"
onClick={event => {
event.preventDefault();
createContactPopup();
}}
{...props}
/>
);
}
export default connect(null, {
createContactPopup: () => createPopup({ Popup: ContactForm }),
})(ContactLink);

View File

@ -0,0 +1,19 @@
{
"title": "Feedback form",
"subject": "Subject",
"email": "Email",
"message": "Message",
"send": "Send",
"philosophicalThought": "Properly formulated question — half of the answer",
"disclaimer": "Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it",
"whichQuestion": "What are you interested in?",
"cannotAccessMyAccount": "Can not access my account",
"foundBugOnSite": "I found a bug on the site",
"improvementsSuggestion": "I have a suggestion for improving the functional",
"integrationQuestion": "Service integration question",
"other": "Other",
"youMessageReceived": "Your message was received. We will respond to you shortly. The answer will come to your Email:",
"close": "Close"
}

View File

@ -0,0 +1,85 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
@import '~app/components/ui/popup/popup.scss';
/* Form state */
.contactForm {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(500px);
}
.philosophicalThought {
font-family: $font-family-title;
font-size: 19px;
color: $green;
text-align: center;
margin-bottom: 5px;
}
.formDisclaimer {
font-size: 12px;
line-height: 14px;
text-align: center;
max-width: 400px;
margin: 0 auto 10px;
}
.pairInputRow {
display: flex;
margin-bottom: 10px;
}
.pairInput {
width: 50%;
&:first-of-type {
margin-right: $popupPadding;
}
}
.formMargin {
margin-bottom: 20px;
}
/* Success State */
.successState {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(320px);
}
.successBody {
composes: body from '~app/components/ui/popup/popup.scss';
text-align: center;
}
.successDescription {
@extend .formDisclaimer;
margin-bottom: 15px;
}
.successIcon {
composes: checkmark from '~app/components/ui/icons.scss';
font-size: 90px;
color: #aaa;
margin-bottom: 20px;
line-height: 71px;
}
.sentToEmail {
font-family: $font-family-title;
color: #444;
font-size: 18px;
}
/* Common */
.footer {
margin-top: 0;
}

View File

@ -0,0 +1 @@
export { default as ContactLink } from './ContactLink';

View File

@ -0,0 +1,26 @@
{
"accountsForDevelopers": "Ely.by Accounts for developers",
"accountsAllowsYouYoUseOauth2": "Ely.by Accounts service provides users with a quick and easy-to-use way to login to your site, launcher or Minecraft server via OAuth2 authorization protocol. You can find more information about integration with Ely.by Accounts in {ourDocumentation}.",
"ourDocumentation": "our documentation",
"ifYouHaveAnyTroubles": "If you are experiencing difficulties, you can always use {feedback}. We'll surely help you.",
"feedback": "feedback",
"weDontKnowAnythingAboutYou": "We don't know anything about you yet.",
"youMustAuthToBegin": "You have to authorize to start.",
"authorization": "Authorization",
"youDontHaveAnyApplication": "You don't have any app registered yet.",
"shallWeStart": "Shall we start?",
"addNew": "Add new",
"yourApplications": "Your applications:",
"countUsers": "{count, plural, =0 {No users} one {# user} other {# users}}",
"ifYouSuspectingThatSecretHasBeenCompromised": "If you are suspecting that your Client Secret has been compromised, then you may want to reset it value. It'll cause recall of the all \"access\" and \"refresh\" tokens that have been issued. You can also recall all issued tokens without changing Client Secret.",
"revokeAllTokens": "Revoke all tokens",
"resetClientSecret": "Reset Client Secret",
"delete": "Delete",
"editDescription": "{icon} Edit description",
"allRefreshTokensWillBecomeInvalid": "All \"refresh\" tokens will become invalid and after next authorization the user will get permissions prompt.",
"appAndAllTokenWillBeDeleted": "Application and all associated tokens will be deleted.",
"takeCareAccessTokensInvalidation": "Take care because \"access\" tokens won't be invalidated immediately.",
"cancel": "Cancel",
"continue": "Continue",
"performing": "Performing…"
}

View File

@ -0,0 +1,158 @@
import React from 'react';
import classNames from 'classnames';
import { FormattedMessage as Message } from 'react-intl';
import { Helmet } from 'react-helmet';
import { LinkButton } from 'app/components/ui/form';
import { COLOR_GREEN, COLOR_BLUE } from 'app/components/ui';
import { ContactLink } from 'app/components/contact';
import { OauthAppResponse } from 'app/services/api/oauth';
import styles from './applicationsIndex.scss';
import messages from './ApplicationsIndex.intl.json';
import cubeIcon from './icons/cube.svg';
import loadingCubeIcon from './icons/loading-cube.svg';
import toolsIcon from './icons/tools.svg';
import ApplicationsList from './list';
type Props = {
clientId: string | null;
resetClientId: () => void; // notify parent to remove clientId from current location.href
displayForGuest: boolean;
applications: Array<OauthAppResponse>;
isLoading: boolean;
deleteApp: (clientId: string) => Promise<any>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<any>;
};
export default class ApplicationsIndex extends React.Component<Props> {
render() {
return (
<div className={styles.container}>
<div className={styles.welcomeContainer}>
<Message {...messages.accountsForDevelopers}>
{(pageTitle: string) => (
<h2 className={styles.welcomeTitle}>
<Helmet title={pageTitle} />
{pageTitle}
</h2>
)}
</Message>
<div className={styles.welcomeTitleDelimiter} />
<div className={styles.welcomeParagraph}>
<Message
{...messages.accountsAllowsYouYoUseOauth2}
values={{
ourDocumentation: (
<a href="https://docs.ely.by/en/oauth.html" target="_blank">
<Message {...messages.ourDocumentation} />
</a>
),
}}
/>
</div>
<div className={styles.welcomeParagraph}>
<Message
{...messages.ifYouHaveAnyTroubles}
values={{
feedback: (
<ContactLink>
<Message {...messages.feedback} />
</ContactLink>
),
}}
/>
</div>
</div>
{this.getContent()}
</div>
);
}
getContent() {
const {
displayForGuest,
applications,
isLoading,
resetApp,
deleteApp,
clientId,
resetClientId,
} = this.props;
if (applications.length > 0) {
return (
<ApplicationsList
applications={applications}
resetApp={resetApp}
deleteApp={deleteApp}
clientId={clientId}
resetClientId={resetClientId}
/>
);
}
if (displayForGuest) {
return <Guest />;
}
return <Loader noApps={!isLoading} />;
}
}
function Loader({ noApps }: { noApps: boolean }) {
return (
<div className={styles.emptyState} data-e2e={noApps ? 'noApps' : 'loading'}>
<img
src={noApps ? cubeIcon : loadingCubeIcon}
className={styles.emptyStateIcon}
/>
<div
className={classNames(styles.noAppsContainer, {
[styles.noAppsAnimating]: noApps,
})}
>
<div className={styles.emptyStateText}>
<div>
<Message {...messages.youDontHaveAnyApplication} />
</div>
<div>
<Message {...messages.shallWeStart} />
</div>
</div>
<LinkButton
to="/dev/applications/new"
data-e2e="newApp"
label={messages.addNew}
color={COLOR_GREEN}
className={styles.emptyStateActionButton}
/>
</div>
</div>
);
}
function Guest() {
return (
<div className={styles.emptyState}>
<img src={toolsIcon} className={styles.emptyStateIcon} />
<div className={styles.emptyStateText}>
<div>
<Message {...messages.weDontKnowAnythingAboutYou} />
</div>
<div>
<Message {...messages.youMustAuthToBegin} />
</div>
</div>
<LinkButton
to="/login"
label={messages.authorization}
color={COLOR_BLUE}
className={styles.emptyStateActionButton}
/>
</div>
);
}

View File

@ -0,0 +1,87 @@
import { Dispatch } from 'redux';
import { OauthAppResponse } from 'app/services/api/oauth';
import oauth from 'app/services/api/oauth';
import { User } from 'app/components/user';
import { Apps } from './reducer';
type SetAvailableAction = {
type: 'apps:setAvailable';
payload: Array<OauthAppResponse>;
};
type DeleteAppAction = { type: 'apps:deleteApp'; payload: string };
type AddAppAction = { type: 'apps:addApp'; payload: OauthAppResponse };
export type Action = SetAvailableAction | DeleteAppAction | AddAppAction;
export function setAppsList(apps: Array<OauthAppResponse>): SetAvailableAction {
return {
type: 'apps:setAvailable',
payload: apps,
};
}
export function getApp(
state: { apps: Apps },
clientId: string,
): OauthAppResponse | null {
return state.apps.available.find(app => app.clientId === clientId) || null;
}
export function fetchApp(clientId: string) {
return async (dispatch: Dispatch<any>): Promise<void> => {
const app = await oauth.getApp(clientId);
dispatch(addApp(app));
};
}
function addApp(app: OauthAppResponse): AddAppAction {
return {
type: 'apps:addApp',
payload: app,
};
}
export function fetchAvailableApps() {
return async (
dispatch: Dispatch<any>,
getState: () => { user: User },
): Promise<void> => {
const { id } = getState().user;
if (!id) {
dispatch(setAppsList([]));
return;
}
const apps = await oauth.getAppsByUser(id);
dispatch(setAppsList(apps));
};
}
export function deleteApp(clientId: string) {
return async (dispatch: Dispatch<any>): Promise<void> => {
await oauth.delete(clientId);
dispatch(createDeleteAppAction(clientId));
};
}
function createDeleteAppAction(clientId: string): DeleteAppAction {
return {
type: 'apps:deleteApp',
payload: clientId,
};
}
export function resetApp(clientId: string, resetSecret: boolean) {
return async (dispatch: Dispatch<any>): Promise<void> => {
const { data: app } = await oauth.reset(clientId, resetSecret);
if (resetSecret) {
dispatch(addApp(app));
}
};
}

View File

@ -0,0 +1,20 @@
{
"creatingApplication": "Creating an application",
"website": "Web site",
"minecraftServer": "Minecraft server",
"toDisplayRegistrationFormChooseType": "To display registration form for a new application choose necessary type.",
"applicationName": "Application name:",
"appDescriptionWillBeAlsoVisibleOnOauthPage": "Application's description will be displayed at the authorization page too. It isn't a required field. In authorization process the value may be overridden.",
"description": "Description:",
"websiteLinkWillBeUsedAsAdditionalId": "Site's link is optional, but it can be used as an additional identifier of the application.",
"websiteLink": "Website link:",
"redirectUriLimitsAllowableBaseAddress": "Redirection URI (redirectUri) determines a base address, that user will be allowed to be redirected to after authorization. In order to improve security it's better to use the whole path instead of just a domain name. For example: https://example.com/oauth/ely.",
"redirectUri": "Redirect URI:",
"createApplication": "Create application",
"serverName": "Server name:",
"ipAddressIsOptionButPreferable": "IP address is optional, but is very preferable. It might become handy in case of we suddenly decide to play on your server with the entire band (=",
"serverIp": "Server IP:",
"youCanAlsoSpecifyServerSite": "You also can specify either server's site URL or its community in a social network.",
"updatingApplication": "Updating an application",
"updateApplication": "Update application"
}

View File

@ -0,0 +1,142 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Helmet } from 'react-helmet';
import { MessageDescriptor } from 'react-intl';
import { OauthAppResponse } from 'app/services/api/oauth';
import { ApplicationType } from 'app/components/dev/apps';
import { Form, FormModel, Button } from 'app/components/ui/form';
import { BackButton } from 'app/components/profile/ProfileForm';
import { COLOR_GREEN } from 'app/components/ui';
import {
TYPE_APPLICATION,
TYPE_MINECRAFT_SERVER,
} from 'app/components/dev/apps';
import styles from 'app/components/profile/profileForm.scss';
import logger from 'app/services/logger';
import messages from './ApplicationForm.intl.json';
import ApplicationTypeSwitcher from './ApplicationTypeSwitcher';
import WebsiteType from './WebsiteType';
import MinecraftServerType from './MinecraftServerType';
const typeToForm: {
[K in ApplicationType]: {
label: MessageDescriptor;
component: React.ComponentType<any>;
};
} = {
[TYPE_APPLICATION]: {
label: messages.website,
component: WebsiteType,
},
[TYPE_MINECRAFT_SERVER]: {
label: messages.minecraftServer,
component: MinecraftServerType,
},
};
const typeToLabel = Object.keys(typeToForm).reduce(
(result, key: ApplicationType) => {
result[key] = typeToForm[key].label;
return result;
},
{} as {
[K in ApplicationType]: MessageDescriptor;
},
);
export default class ApplicationForm extends React.Component<{
app: OauthAppResponse;
form: FormModel;
displayTypeSwitcher?: boolean;
type: ApplicationType | null;
setType: (type: ApplicationType) => void;
onSubmit: (form: FormModel) => Promise<void>;
}> {
static defaultProps = {
setType: () => {},
};
render() {
const { type, setType, form, displayTypeSwitcher, app } = this.props;
const { component: FormComponent } = (type && typeToForm[type]) || {};
const isUpdate = app.clientId !== '';
return (
<Form form={form} onSubmit={this.onFormSubmit}>
<div className={styles.contentWithBackButton}>
<BackButton to="/dev/applications" />
<div className={styles.form}>
<div className={styles.formBody}>
<Message
{...(isUpdate
? messages.updatingApplication
: messages.creatingApplication)}
>
{(pageTitle: string) => (
<h3 className={styles.title}>
<Helmet title={pageTitle} />
{pageTitle}
</h3>
)}
</Message>
{displayTypeSwitcher && (
<div className={styles.formRow}>
<ApplicationTypeSwitcher
selectedType={type}
setType={setType}
appTypes={typeToLabel}
/>
</div>
)}
{FormComponent ? (
<FormComponent form={form} app={app} />
) : (
<div className={styles.formRow}>
<p className={styles.description}>
<Message
{...messages.toDisplayRegistrationFormChooseType}
/>
</p>
</div>
)}
</div>
</div>
{!!FormComponent && (
<Button
color={COLOR_GREEN}
block
label={
isUpdate
? messages.updateApplication
: messages.createApplication
}
type="submit"
/>
)}
</div>
</Form>
);
}
onFormSubmit = async () => {
const { form } = this.props;
try {
await this.props.onSubmit(form);
} catch (resp) {
if (resp.errors) {
form.setErrors(resp.errors);
return;
}
logger.unexpected(new Error('Error submitting application form'), resp);
}
};
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { ApplicationType } from 'app/components/dev/apps';
import { MessageDescriptor } from 'react-intl';
import { SKIN_LIGHT } from 'app/components/ui';
import { Radio } from 'app/components/ui/form';
import styles from './applicationTypeSwitcher.scss';
export default function ApplicationTypeSwitcher({
setType,
appTypes,
selectedType,
}: {
appTypes: {
[K in ApplicationType]: MessageDescriptor;
};
selectedType: ApplicationType | null;
setType: (type: ApplicationType) => void;
}) {
return (
<div>
{Object.keys(appTypes).map((type: ApplicationType) => (
<div className={styles.radioContainer} key={type}>
<Radio
onChange={() => setType(type)}
skin={SKIN_LIGHT}
label={appTypes[type]}
value={type}
checked={selectedType === type}
/>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { OauthAppResponse } from 'app/services/api/oauth';
import { Input, FormModel } from 'app/components/ui/form';
import { SKIN_LIGHT } from 'app/components/ui';
import styles from 'app/components/profile/profileForm.scss';
import messages from './ApplicationForm.intl.json';
export default function MinecraftServerType({
form,
app,
}: {
form: FormModel;
app: OauthAppResponse;
}) {
return (
<div>
<div className={styles.formRow}>
<Input
{...form.bindField('name')}
label={messages.serverName}
defaultValue={app.name}
required
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.ipAddressIsOptionButPreferable} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('minecraftServerIp')}
label={messages.serverIp}
defaultValue={app.minecraftServerIp}
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.youCanAlsoSpecifyServerSite} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('websiteUrl')}
label={messages.websiteLink}
defaultValue={app.websiteUrl}
skin={SKIN_LIGHT}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input, TextArea, FormModel } from 'app/components/ui/form';
import { OauthAppResponse } from 'app/services/api/oauth';
import { SKIN_LIGHT } from 'app/components/ui';
import styles from 'app/components/profile/profileForm.scss';
import messages from './ApplicationForm.intl.json';
export default function WebsiteType({
form,
app,
}: {
form: FormModel;
app: OauthAppResponse;
}) {
return (
<div>
<div className={styles.formRow}>
<Input
{...form.bindField('name')}
label={messages.applicationName}
defaultValue={app.name}
required
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.appDescriptionWillBeAlsoVisibleOnOauthPage} />
</p>
</div>
<div className={styles.formRow}>
<TextArea
{...form.bindField('description')}
label={messages.description}
defaultValue={app.description}
skin={SKIN_LIGHT}
minRows={3}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.websiteLinkWillBeUsedAsAdditionalId} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('websiteUrl')}
label={messages.websiteLink}
defaultValue={app.websiteUrl}
skin={SKIN_LIGHT}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.redirectUriLimitsAllowableBaseAddress} />
</p>
</div>
<div className={styles.formRow}>
<Input
{...form.bindField('redirectUri')}
label={messages.redirectUri}
defaultValue={app.redirectUri}
required
skin={SKIN_LIGHT}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
.radioContainer {
margin-top: 10px;
}

View File

@ -0,0 +1,262 @@
@import '~app/components/ui/fonts.scss';
@import '~app/components/ui/colors.scss';
.container {
max-width: 500px;
margin: 0 auto;
background: white;
border-bottom: 10px solid #ddd8ce;
@media (max-width: 540px) {
margin: 0 20px;
}
}
.welcomeContainer {
padding: 30px;
background: #f5f5f5;
text-align: center;
border-bottom: 1px solid #eeeeee;
}
.welcomeTitle {
font-size: 30px;
font-family: $font-family-title;
max-width: 245px;
margin: 0 auto 15px;
line-height: 1.2;
}
.welcomeTitleDelimiter {
width: 86px;
height: 3px;
background: $green;
margin: 0 auto 15px;
}
.welcomeParagraph {
color: #666666;
font-size: 14px;
margin-bottom: 15px;
line-height: 1.3;
&:last-of-type {
margin-bottom: 0;
}
}
.emptyState {
padding: 30px 30px 50px;
text-align: center;
}
.emptyStateIcon {
width: 120px;
height: 120px;
margin-bottom: 20px;
}
@mixin emptyStateAnimation($order) {
animation: slide-in-bottom 1s // Total animation time
0.2s + 0.2s * $order // Increase each next element delay
cubic-bezier(0.075, 0.82, 0.165, 1) // easeOutCirc
both;
}
.emptyStateText {
font-family: $font-family-title;
color: #666666;
font-size: 16px;
margin-bottom: 20px;
line-height: 20px;
}
.noAppsContainer {
visibility: hidden;
}
.noAppsAnimating {
visibility: visible;
.emptyStateText {
> div {
&:nth-child(1) {
@include emptyStateAnimation(0);
}
&:nth-child(2) {
@include emptyStateAnimation(1);
}
}
}
.emptyStateActionButton {
@include emptyStateAnimation(2);
}
}
@keyframes slide-in-bottom {
0% {
transform: translateY(50px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.appsListTitleContainer {
display: flex;
align-items: center;
padding: 20px 30px;
border-bottom: 1px solid #eee;
}
.appsListTitle {
font-family: $font-family-title;
font-size: 24px;
flex-grow: 1;
}
.appsListAddNewAppBtn {
}
.appsListContainer {
margin-bottom: 30px;
}
.appItemContainer {
border-bottom: 1px solid #eee;
}
.appItemTile {
padding: 15px 30px;
display: flex;
align-items: center;
cursor: pointer;
transition: background-color 0.25s;
}
.appTileTitle {
flex-grow: 1;
}
.appName {
font-family: $font-family-title;
font-size: 24px;
}
.appStats {
color: #999;
font-size: 14px;
}
.appItemToggle {
}
.appItemToggleIcon {
composes: arrowRight from '~app/components/ui/icons.scss';
position: relative;
left: 0;
font-size: 28px;
color: #ebe8e1;
transition: 0.25s;
.appItemTile:hover & {
color: #777;
}
.appExpanded & {
color: #777;
transform: rotate(360deg) !important; // Prevent it from hover rotating
}
}
$appDetailsContainerRightLeftPadding: 30px;
.appDetailsContainer {
background: #f5f5f5;
border-top: 1px solid #eee;
padding: 5px $appDetailsContainerRightLeftPadding;
position: relative;
transition: transform 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.appDetailsInfoField {
position: relative;
margin-bottom: 20px;
}
.editAppLink {
position: absolute;
top: 4px;
right: 0;
font-size: 12px;
color: #9a9a9a;
border-bottom: 0;
}
.pencilIcon {
composes: pencil from '~app/components/ui/icons.scss';
font-size: 14px;
position: relative;
bottom: 2px;
}
.appDetailsDescription {
font-size: 12px;
color: #9a9a9a;
line-height: 1.4;
margin-bottom: 20px;
}
.appActionsButtons {
}
.appActionButton {
margin: 0 10px 10px 0;
&:last-of-type {
margin-right: 0;
}
}
.appActionContainer {
position: absolute;
top: 100%;
left: 0;
padding: 0 $appDetailsContainerRightLeftPadding;
background: #f5f5f5;
}
.appActionDescription {
composes: appDetailsDescription;
margin-top: 6px;
}
.continueActionButtonWrapper {
display: inline-block;
margin-left: 10px;
}
.continueActionLink {
composes: textLink from '~app/index.scss';
font-family: $font-family-title;
font-size: 14px;
color: #666;
}
.performingAction {
font-family: $font-family-title;
font-size: 14px;
color: #666;
}

View File

@ -0,0 +1,5 @@
<svg viewBox="0 0 120 120" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path fill="#DDD8CE" d="M110,20V10H80V0H40V10H10V20H0v80H10v10H40v10H80V110h30V100h10V20H110ZM20,30V20H50V10H70V20h30V30H70V40H50V30H20Zm90,60H100v10H70v10H60V50H80V40h30V90h0Z">
<animate attributeName="fill" from="#EBE8E2" to="#DDD8CE" dur="1s" />
</path>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@ -0,0 +1,17 @@
<svg viewBox="0 0 120 120" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path fill="url(#gradient)" d="M110,20V10H80V0H40V10H10V20H0v80H10v10H40v10H80V110h30V100h10V20H110ZM20,30V20H50V10H70V20h30V30H70V40H50V30H20Zm90,60H100v10H70v10H60V50H80V40h30V90h0Z" />
<defs>
<linearGradient id="gradient">
<stop offset="0%" stop-color="#EBE8E2">
<animate attributeName="offset" values="-2; 1" dur="1s" repeatCount="indefinite" />
</stop>
<stop offset="50%" stop-color="#DDD8CE">
<animate attributeName="offset" values="-1.5; 1.5" dur="1s" repeatCount="indefinite" />
</stop>
<stop offset="100%" stop-color="#EBE8E2">
<animate attributeName="offset" values="-1; 2" dur="1s" repeatCount="indefinite" />
</stop>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 888 B

View File

@ -0,0 +1,6 @@
<svg viewBox="0 0 120 120" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill="#DDD8CE">
<polygon points="110 20 110 30 90 30 90 10 100 10 100 0 80 0 80 10 70 10 70 40 60 40 60 50 50 50 50 60 40 60 40 70 10 70 10 80 0 80 0 100 10 100 10 90 30 90 30 110 20 110 20 120 40 120 40 110 50 110 50 80 60 80 60 70 70 70 70 60 80 60 80 50 110 50 110 40 120 40 120 20"/>
<path d="M50,30 L50,50 L30,50 L30,40 L20,40 L20,30 L10,30 L10,20 L0,20 L0,0 L20,0 L20,10 L30,10 L30,20 L40,20 L40,30 L50,30 L50,30 Z M110,120 L110,110 L120,110 L120,90 L110,90 L110,80 L100,80 L100,70 L90,70 L90,60 L80,60 L80,70 L70,70 L70,80 L60,80 L60,90 L70,90 L70,100 L80,100 L80,110 L90,110 L90,120 L110,120 L110,120 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 730 B

View File

@ -0,0 +1,3 @@
export type ApplicationType = 'application' | 'minecraft-server';
export const TYPE_APPLICATION: 'application' = 'application';
export const TYPE_MINECRAFT_SERVER: 'minecraft-server' = 'minecraft-server';

View File

@ -0,0 +1,303 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { SKIN_LIGHT, COLOR_BLACK, COLOR_RED } from 'app/components/ui';
import { Input, Button } from 'app/components/ui/form';
import { OauthAppResponse } from 'app/services/api/oauth';
import Collapse from 'app/components/ui/collapse';
import styles from '../applicationsIndex.scss';
import messages from '../ApplicationsIndex.intl.json';
const ACTION_REVOKE_TOKENS = 'revoke-tokens';
const ACTION_RESET_SECRET = 'reset-secret';
const ACTION_DELETE = 'delete';
const actionButtons = [
{
type: ACTION_REVOKE_TOKENS,
label: messages.revokeAllTokens,
},
{
type: ACTION_RESET_SECRET,
label: messages.resetClientSecret,
},
{
type: ACTION_DELETE,
label: messages.delete,
},
];
interface State {
selectedAction: string | null;
isActionPerforming: boolean;
detailsHeight: number;
translateY: number;
}
export default class ApplicationItem extends React.Component<
{
application: OauthAppResponse;
expand: boolean;
onTileClick: (clientId: string) => void;
onResetSubmit: (
clientId: string,
resetClientSecret: boolean,
) => Promise<void>;
onDeleteSubmit: (clientId: string) => Promise<void>;
},
State
> {
state: State = {
selectedAction: null,
isActionPerforming: false,
translateY: 0,
detailsHeight: 0,
};
actionContainer: HTMLDivElement | null;
render() {
const { application: app, expand } = this.props;
const { selectedAction, translateY } = this.state;
return (
<div
className={classNames(styles.appItemContainer, {
[styles.appExpanded]: expand,
})}
data-e2e="appItem"
data-e2e-app-name={app.name}
>
<div className={styles.appItemTile} onClick={this.onTileToggle}>
<div className={styles.appTileTitle}>
<div className={styles.appName}>{app.name}</div>
<div className={styles.appStats}>
Client ID: {app.clientId}
{typeof app.countUsers !== 'undefined' && (
<span>
{' | '}
<Message
{...messages.countUsers}
values={{
count: app.countUsers,
}}
/>
</span>
)}
</div>
</div>
<div className={styles.appItemToggle}>
<div className={styles.appItemToggleIcon} />
</div>
</div>
<Collapse isOpened={expand} onRest={this.onCollapseRest}>
<div
className={styles.appDetailsContainer}
style={{ transform: `translateY(-${translateY}px)` }}
>
<div className={styles.appDetailsInfoField}>
<Link
to={`/dev/applications/${app.clientId}`}
className={styles.editAppLink}
>
<Message
{...messages.editDescription}
values={{
icon: <div className={styles.pencilIcon} />,
}}
/>
</Link>
<Input
label="Client ID:"
skin={SKIN_LIGHT}
disabled
value={app.clientId}
copy
/>
</div>
<div className={styles.appDetailsInfoField}>
<Input
label="Client Secret:"
skin={SKIN_LIGHT}
disabled
value={app.clientSecret}
copy
/>
</div>
<div className={styles.appDetailsDescription}>
<Message
{...messages.ifYouSuspectingThatSecretHasBeenCompromised}
/>
</div>
<div className={styles.appActionsButtons}>
{actionButtons.map(({ type, label }) => (
<Button
key={type}
label={label}
color={COLOR_BLACK}
className={styles.appActionButton}
disabled={!!selectedAction && selectedAction !== type}
onClick={this.onActionButtonClick(type)}
small
/>
))}
</div>
<div
className={styles.appActionContainer}
ref={el => {
this.actionContainer = el;
}}
>
{this.getActionContent()}
</div>
</div>
</Collapse>
</div>
);
}
getActionContent() {
const { selectedAction, isActionPerforming } = this.state;
switch (selectedAction) {
case ACTION_REVOKE_TOKENS:
case ACTION_RESET_SECRET:
return (
<div>
<div className={styles.appActionDescription}>
<Message {...messages.allRefreshTokensWillBecomeInvalid} />{' '}
<Message {...messages.takeCareAccessTokensInvalidation} />
</div>
<div className={styles.appActionsButtons}>
<Button
label={messages.cancel}
color={COLOR_BLACK}
className={styles.appActionButton}
onClick={this.onActionButtonClick(null)}
small
/>
<div className={styles.continueActionButtonWrapper}>
{isActionPerforming ? (
<div className={styles.performingAction}>
<Message {...messages.performing} />
</div>
) : (
<div
className={styles.continueActionLink}
onClick={this.onResetSubmit(
selectedAction === ACTION_RESET_SECRET,
)}
>
<Message {...messages.continue} />
</div>
)}
</div>
</div>
</div>
);
case ACTION_DELETE:
return (
<div>
<div className={styles.appActionDescription}>
<Message {...messages.appAndAllTokenWillBeDeleted} />{' '}
<Message {...messages.takeCareAccessTokensInvalidation} />
</div>
<div className={styles.appActionsButtons}>
<Button
label={messages.cancel}
color={COLOR_BLACK}
className={styles.appActionButton}
onClick={this.onActionButtonClick(null)}
small
/>
<div className={styles.continueActionButtonWrapper}>
{isActionPerforming ? (
<div className={styles.performingAction}>
<Message {...messages.performing} />
</div>
) : (
<Button
label={messages.delete}
color={COLOR_RED}
className={styles.appActionButton}
onClick={this.onSubmitDelete}
small
/>
)}
</div>
</div>
</div>
);
default:
return null;
}
}
setActiveAction = (type: string | null) => {
const { actionContainer } = this;
if (!actionContainer) {
return;
}
this.setState(
{
selectedAction: type,
},
() => {
const translateY = actionContainer.offsetHeight;
this.setState({ translateY });
},
);
};
onTileToggle = () => {
const { onTileClick, application } = this.props;
onTileClick(application.clientId);
};
onCollapseRest = () => {
if (!this.props.expand && this.state.selectedAction) {
this.setActiveAction(null);
}
};
onActionButtonClick = (type: string | null) => () => {
this.setActiveAction(type === this.state.selectedAction ? null : type);
};
onResetSubmit = (resetClientSecret: boolean) => async () => {
const { onResetSubmit, application } = this.props;
this.setState({
isActionPerforming: true,
});
await onResetSubmit(application.clientId, resetClientSecret);
this.setState({
isActionPerforming: false,
});
this.setActiveAction(null);
};
onSubmitDelete = () => {
const { onDeleteSubmit, application } = this.props;
this.setState({
isActionPerforming: true,
});
onDeleteSubmit(application.clientId);
};
}

View File

@ -0,0 +1,112 @@
import React from 'react';
import { restoreScroll } from 'app/components/ui/scroll/scroll';
import { FormattedMessage as Message } from 'react-intl';
import { LinkButton } from 'app/components/ui/form';
import { COLOR_GREEN } from 'app/components/ui';
import { OauthAppResponse } from 'app/services/api/oauth';
import messages from '../ApplicationsIndex.intl.json';
import styles from '../applicationsIndex.scss';
import ApplicationItem from './ApplicationItem';
type Props = {
applications: OauthAppResponse[];
deleteApp: (clientId: string) => Promise<any>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<any>;
resetClientId: () => void;
clientId: string | null;
};
type State = {
expandedApp: string | null;
};
export default class ApplicationsList extends React.Component<Props, State> {
state = {
expandedApp: null,
};
appsRefs: { [key: string]: HTMLDivElement | null } = {};
componentDidMount() {
this.checkForActiveApp();
}
componentDidUpdate() {
this.checkForActiveApp();
}
render() {
const { applications, resetApp, deleteApp } = this.props;
const { expandedApp } = this.state;
return (
<div>
<div className={styles.appsListTitleContainer}>
<div className={styles.appsListTitle}>
<Message {...messages.yourApplications} />
</div>
<LinkButton
to="/dev/applications/new"
data-e2e="newApp"
label={messages.addNew}
color={COLOR_GREEN}
className={styles.appsListAddNewAppBtn}
/>
</div>
<div className={styles.appsListContainer}>
{applications.map(app => (
<div
key={app.clientId}
ref={elem => {
this.appsRefs[app.clientId] = elem;
}}
>
<ApplicationItem
application={app}
expand={app.clientId === expandedApp}
onTileClick={this.onTileClick}
onResetSubmit={resetApp}
onDeleteSubmit={deleteApp}
/>
</div>
))}
</div>
</div>
);
}
checkForActiveApp() {
const { applications, clientId } = this.props;
const { expandedApp } = this.state;
if (
clientId &&
expandedApp !== clientId &&
applications.some(app => app.clientId === clientId)
) {
requestAnimationFrame(() =>
this.onTileClick(clientId, { noReset: true }),
);
}
}
onTileClick = (
clientId: string,
{ noReset = false }: { noReset?: boolean } = {},
) => {
const { clientId: initialClientId, resetClientId } = this.props;
const expandedApp = this.state.expandedApp === clientId ? null : clientId;
if (initialClientId && noReset !== true) {
resetClientId();
}
this.setState({ expandedApp }, () => {
if (expandedApp !== null) {
// TODO: @SleepWalker: мб у тебя есть идея, как это сделать более правильно и менее дёргано?
setTimeout(() => restoreScroll(this.appsRefs[clientId]), 150);
}
});
};
}

View File

@ -0,0 +1 @@
export { default } from './ApplicationsList';

View File

@ -0,0 +1,50 @@
import { OauthAppResponse } from 'app/services/api/oauth';
import { Action } from './actions';
export interface Apps {
available: OauthAppResponse[];
}
const defaults: Apps = {
available: [],
};
export default function apps(state: Apps = defaults, action: Action): Apps {
switch (action.type) {
case 'apps:setAvailable':
return {
...state,
available: action.payload,
};
case 'apps:addApp': {
const { payload } = action;
const available = [...state.available];
let index = available.findIndex(app => app.clientId === payload.clientId);
if (index === -1) {
index = available.length;
}
available[index] = action.payload;
return {
...state,
available,
};
}
case 'apps:deleteApp':
return {
...state,
available: state.available.filter(
app => app.clientId !== action.payload,
),
};
default:
}
return state;
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import LanguageSwitcher from 'app/components/languageSwitcher';
import { create as createPopup } from 'app/components/ui/popup/actions';
import { ContactLink } from 'app/components/contact';
import styles from './footerMenu.scss';
import messages from './footerMenu.intl.json';
type Props = {
createLanguageSwitcherPopup: () => void;
};
class FooterMenu extends React.Component<Props> {
render() {
return (
<div className={styles.footerMenu}>
<Link to="/rules">
<Message {...messages.rules} />
</Link>
{' | '}
<ContactLink>
<Message {...messages.contactUs} />
</ContactLink>
{' | '}
<Link to="/dev">
<Message {...messages.forDevelopers} />
</Link>
<div className={styles.langTriggerContainer}>
<a
href="#"
className={styles.langTrigger}
onClick={this.onLanguageSwitcher}
>
<span className={styles.langTriggerIcon} />
<Message {...messages.siteLanguage} />
</a>
</div>
</div>
);
}
onLanguageSwitcher = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
this.props.createLanguageSwitcherPopup();
};
}
// mark this component, as not pure, because it is stateless,
// but should be re-rendered, if current lang was changed
export default connect(
null,
{
createLanguageSwitcherPopup: () => createPopup({ Popup: LanguageSwitcher }),
},
null,
{ pure: false },
)(FooterMenu);

View File

@ -0,0 +1,6 @@
{
"rules": "Rules",
"contactUs": "Contact Us",
"siteLanguage": "Site language",
"forDevelopers": "For developers"
}

View File

@ -0,0 +1,23 @@
.footerMenu {
font-size: 12px;
line-height: 1.5;
color: #666;
a {
color: #666;
border-bottom-color: #666;
}
}
.langTriggerContainer {
text-align: center;
}
.langTriggerIcon {
composes: globe from '~app/components/ui/icons.scss';
position: relative;
bottom: 1px;
font-size: 11px;
margin-right: 3px;
}

Some files were not shown because too many files have changed in this diff Show More