mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace
This commit is contained in:
34
packages/app/App.tsx
Normal file
34
packages/app/App.tsx
Normal 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);
|
70
packages/app/components/MeasureHeight.tsx
Normal file
70
packages/app/components/MeasureHeight.tsx
Normal 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);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"addAccount": "Add account",
|
||||
"goToEly": "Go to Ely.by profile",
|
||||
"logout": "Log out"
|
||||
}
|
198
packages/app/components/accounts/AccountSwitcher.tsx
Normal file
198
packages/app/components/accounts/AccountSwitcher.tsx
Normal 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);
|
225
packages/app/components/accounts/accountSwitcher.scss
Normal file
225
packages/app/components/accounts/accountSwitcher.scss
Normal 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;
|
||||
}
|
||||
}
|
497
packages/app/components/accounts/actions.test.ts
Normal file
497
packages/app/components/accounts/actions.test.ts
Normal 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 },
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
354
packages/app/components/accounts/actions.ts
Normal file
354
packages/app/components/accounts/actions.ts
Normal 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();
|
||||
};
|
||||
}
|
78
packages/app/components/accounts/actions/pure-actions.ts
Normal file
78
packages/app/components/accounts/actions/pure-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
2
packages/app/components/accounts/index.ts
Normal file
2
packages/app/components/accounts/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { State as AccountsState, Account } from './reducer';
|
||||
export { default as AccountSwitcher } from './AccountSwitcher';
|
162
packages/app/components/accounts/reducer.test.ts
Normal file
162
packages/app/components/accounts/reducer.test.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
136
packages/app/components/accounts/reducer.ts
Normal file
136
packages/app/components/accounts/reducer.ts
Normal 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;
|
||||
}
|
16
packages/app/components/auth/AuthTitle.tsx
Normal file
16
packages/app/components/auth/AuthTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
67
packages/app/components/auth/BaseAuthBody.tsx
Normal file
67
packages/app/components/auth/BaseAuthBody.tsx
Normal 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);
|
||||
}
|
||||
}
|
640
packages/app/components/auth/PanelTransition.tsx
Normal file
640
packages/app/components/auth/PanelTransition.tsx
Normal 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);
|
17
packages/app/components/auth/README.md
Normal file
17
packages/app/components/auth/README.md
Normal 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
|
46
packages/app/components/auth/RejectionLink.tsx
Normal file
46
packages/app/components/auth/RejectionLink.tsx
Normal 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;
|
@ -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."
|
||||
}
|
16
packages/app/components/auth/acceptRules/AcceptRules.ts
Normal file
16
packages/app/components/auth/acceptRules/AcceptRules.ts
Normal 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,
|
||||
},
|
||||
});
|
48
packages/app/components/auth/acceptRules/AcceptRulesBody.js
Normal file
48
packages/app/components/auth/acceptRules/AcceptRulesBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
16
packages/app/components/auth/acceptRules/acceptRules.scss
Normal file
16
packages/app/components/auth/acceptRules/acceptRules.scss
Normal 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;
|
||||
}
|
194
packages/app/components/auth/actions.test.ts
Normal file
194
packages/app/components/auth/actions.test.ts
Normal 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')]]);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
658
packages/app/components/auth/actions.ts
Normal file
658
packages/app/components/auth/actions.ts
Normal 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);
|
||||
};
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"accountActivationTitle": "Account activation",
|
||||
"activationMailWasSent": "Please check {email} for the message with further instructions",
|
||||
"activationMailWasSentNoEmail": "Please check your E‑mail for the message with further instructions",
|
||||
"confirmEmail": "Confirm E‑mail",
|
||||
"didNotReceivedEmail": "Did not received E‑mail?",
|
||||
"enterTheCode": "Enter the code from E‑mail here"
|
||||
}
|
15
packages/app/components/auth/activation/Activation.ts
Normal file
15
packages/app/components/auth/activation/Activation.ts
Normal 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,
|
||||
},
|
||||
});
|
66
packages/app/components/auth/activation/ActivationBody.js
Normal file
66
packages/app/components/auth/activation/ActivationBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
19
packages/app/components/auth/activation/activation.scss
Normal file
19
packages/app/components/auth/activation/activation.scss
Normal 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;
|
||||
}
|
7
packages/app/components/auth/appInfo/AppInfo.intl.json
Normal file
7
packages/app/components/auth/appInfo/AppInfo.intl.json
Normal 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"
|
||||
}
|
57
packages/app/components/auth/appInfo/AppInfo.tsx
Normal file
57
packages/app/components/auth/appInfo/AppInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
72
packages/app/components/auth/appInfo/appInfo.scss
Normal file
72
packages/app/components/auth/appInfo/appInfo.scss
Normal 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;
|
||||
}
|
3
packages/app/components/auth/auth.scss
Normal file
3
packages/app/components/auth/auth.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.checkboxInput {
|
||||
margin-top: 15px;
|
||||
}
|
45
packages/app/components/auth/authError/AuthError.js
Normal file
45
packages/app/components/auth/authError/AuthError.js
Normal 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,
|
||||
};
|
@ -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}"
|
||||
}
|
16
packages/app/components/auth/chooseAccount/ChooseAccount.ts
Normal file
16
packages/app/components/auth/chooseAccount/ChooseAccount.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
@ -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);
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
50
packages/app/components/auth/factory.tsx
Normal file
50
packages/app/components/auth/factory.tsx
Normal 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,
|
||||
});
|
||||
}
|
7
packages/app/components/auth/finish/Finish.intl.json
Normal file
7
packages/app/components/auth/finish/Finish.intl.json
Normal 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"
|
||||
}
|
110
packages/app/components/auth/finish/Finish.tsx
Normal file
110
packages/app/components/auth/finish/Finish.tsx
Normal 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);
|
76
packages/app/components/auth/finish/finish.scss
Normal file
76
packages/app/components/auth/finish/finish.scss
Normal 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;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Forgot password",
|
||||
"sendMail": "Send mail",
|
||||
"specifyEmail": "Specify the registration E‑mail address or last used username for your account and we will send an E‑mail with instructions for further password recovery.",
|
||||
"pleasePressButton": "Please press the button bellow to get an E‑mail with password recovery code.",
|
||||
"alreadyHaveCode": "Already have a code"
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
@ -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'));
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
9
packages/app/components/auth/helpLinks.scss
Normal file
9
packages/app/components/auth/helpLinks.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.helpLinks {
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
|
||||
color: #444;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
1
packages/app/components/auth/index.ts
Normal file
1
packages/app/components/auth/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { State as AuthState } from './reducer';
|
6
packages/app/components/auth/login/Login.intl.json
Normal file
6
packages/app/components/auth/login/Login.intl.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"createNewAccount": "Create new account",
|
||||
"loginTitle": "Sign in",
|
||||
"emailOrUsername": "E‑mail or username",
|
||||
"next": "Next"
|
||||
}
|
16
packages/app/components/auth/login/Login.ts
Normal file
16
packages/app/components/auth/login/Login.ts
Normal 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,
|
||||
},
|
||||
});
|
30
packages/app/components/auth/login/LoginBody.js
Normal file
30
packages/app/components/auth/login/LoginBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
4
packages/app/components/auth/mfa/Mfa.intl.json
Normal file
4
packages/app/components/auth/mfa/Mfa.intl.json
Normal 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"
|
||||
}
|
13
packages/app/components/auth/mfa/Mfa.tsx
Normal file
13
packages/app/components/auth/mfa/Mfa.tsx
Normal 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,
|
||||
},
|
||||
});
|
37
packages/app/components/auth/mfa/MfaBody.tsx
Normal file
37
packages/app/components/auth/mfa/MfaBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
6
packages/app/components/auth/mfa/mfa.scss
Normal file
6
packages/app/components/auth/mfa/mfa.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
7
packages/app/components/auth/password/Password.intl.json
Normal file
7
packages/app/components/auth/password/Password.intl.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"passwordTitle": "Enter password",
|
||||
"signInButton": "Sign in",
|
||||
"forgotPassword": "Forgot password",
|
||||
"accountPassword": "Account password",
|
||||
"rememberMe": "Remember me on this device"
|
||||
}
|
15
packages/app/components/auth/password/Password.ts
Normal file
15
packages/app/components/auth/password/Password.ts
Normal 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,
|
||||
},
|
||||
});
|
53
packages/app/components/auth/password/PasswordBody.js
Normal file
53
packages/app/components/auth/password/PasswordBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
22
packages/app/components/auth/password/password.scss
Normal file
22
packages/app/components/auth/password/password.scss
Normal 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;
|
||||
}
|
@ -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 E‑mail)",
|
||||
"scope_account_email": "Access to your E‑mail address"
|
||||
}
|
16
packages/app/components/auth/permissions/Permissions.ts
Normal file
16
packages/app/components/auth/permissions/Permissions.ts
Normal 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,
|
||||
},
|
||||
});
|
65
packages/app/components/auth/permissions/PermissionsBody.js
Normal file
65
packages/app/components/auth/permissions/PermissionsBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
77
packages/app/components/auth/permissions/permissions.scss
Normal file
77
packages/app/components/auth/permissions/permissions.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "Restore password",
|
||||
"contactSupport": "Contact support",
|
||||
"messageWasSent": "The recovery code was sent to your account E‑mail.",
|
||||
"messageWasSentTo": "The recovery code was sent to your E‑mail {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"
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
47
packages/app/components/auth/reducer.test.ts
Normal file
47
packages/app/components/auth/reducer.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
192
packages/app/components/auth/reducer.ts
Normal file
192
packages/app/components/auth/reducer.ts
Normal 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;
|
||||
}
|
10
packages/app/components/auth/register/Register.intl.json
Normal file
10
packages/app/components/auth/register/Register.intl.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"registerTitle": "Sign Up",
|
||||
"yourNickname": "Your nickname",
|
||||
"yourEmail": "Your E‑mail",
|
||||
"accountPassword": "Account password",
|
||||
"repeatPassword": "Repeat password",
|
||||
"signUpButton": "Register",
|
||||
"acceptRules": "I agree with {link}",
|
||||
"termsOfService": "terms of service"
|
||||
}
|
23
packages/app/components/auth/register/Register.ts
Normal file
23
packages/app/components/auth/register/Register.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
84
packages/app/components/auth/register/RegisterBody.js
Normal file
84
packages/app/components/auth/register/RegisterBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Did not received an E‑mail",
|
||||
"specifyYourEmail": "Please, enter an E‑mail you've registered with and we will send you new activation code",
|
||||
"sendNewEmail": "Send new E‑mail"
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
202
packages/app/components/contact/ContactForm.test.tsx
Normal file
202
packages/app/components/contact/ContactForm.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
199
packages/app/components/contact/ContactForm.tsx
Normal file
199
packages/app/components/contact/ContactForm.tsx
Normal 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);
|
27
packages/app/components/contact/ContactLink.tsx
Normal file
27
packages/app/components/contact/ContactLink.tsx
Normal 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);
|
19
packages/app/components/contact/contactForm.intl.json
Normal file
19
packages/app/components/contact/contactForm.intl.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "Feedback form",
|
||||
"subject": "Subject",
|
||||
"email": "E‑mail",
|
||||
"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 E‑mail:",
|
||||
"close": "Close"
|
||||
}
|
85
packages/app/components/contact/contactForm.scss
Normal file
85
packages/app/components/contact/contactForm.scss
Normal 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;
|
||||
}
|
1
packages/app/components/contact/index.ts
Normal file
1
packages/app/components/contact/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as ContactLink } from './ContactLink';
|
26
packages/app/components/dev/apps/ApplicationsIndex.intl.json
Normal file
26
packages/app/components/dev/apps/ApplicationsIndex.intl.json
Normal 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…"
|
||||
}
|
158
packages/app/components/dev/apps/ApplicationsIndex.tsx
Normal file
158
packages/app/components/dev/apps/ApplicationsIndex.tsx
Normal 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>
|
||||
);
|
||||
}
|
87
packages/app/components/dev/apps/actions.ts
Normal file
87
packages/app/components/dev/apps/actions.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
@ -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"
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
.radioContainer {
|
||||
margin-top: 10px;
|
||||
}
|
262
packages/app/components/dev/apps/applicationsIndex.scss
Normal file
262
packages/app/components/dev/apps/applicationsIndex.scss
Normal 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;
|
||||
}
|
5
packages/app/components/dev/apps/icons/cube.svg
Normal file
5
packages/app/components/dev/apps/icons/cube.svg
Normal 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 |
17
packages/app/components/dev/apps/icons/loading-cube.svg
Normal file
17
packages/app/components/dev/apps/icons/loading-cube.svg
Normal 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 |
6
packages/app/components/dev/apps/icons/tools.svg
Normal file
6
packages/app/components/dev/apps/icons/tools.svg
Normal 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 |
3
packages/app/components/dev/apps/index.ts
Normal file
3
packages/app/components/dev/apps/index.ts
Normal 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';
|
303
packages/app/components/dev/apps/list/ApplicationItem.tsx
Normal file
303
packages/app/components/dev/apps/list/ApplicationItem.tsx
Normal 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);
|
||||
};
|
||||
}
|
112
packages/app/components/dev/apps/list/ApplicationsList.tsx
Normal file
112
packages/app/components/dev/apps/list/ApplicationsList.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
1
packages/app/components/dev/apps/list/index.ts
Normal file
1
packages/app/components/dev/apps/list/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ApplicationsList';
|
50
packages/app/components/dev/apps/reducer.ts
Normal file
50
packages/app/components/dev/apps/reducer.ts
Normal 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;
|
||||
}
|
62
packages/app/components/footerMenu/FooterMenu.tsx
Normal file
62
packages/app/components/footerMenu/FooterMenu.tsx
Normal 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);
|
6
packages/app/components/footerMenu/footerMenu.intl.json
Normal file
6
packages/app/components/footerMenu/footerMenu.intl.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"rules": "Rules",
|
||||
"contactUs": "Contact Us",
|
||||
"siteLanguage": "Site language",
|
||||
"forDevelopers": "For developers"
|
||||
}
|
23
packages/app/components/footerMenu/footerMenu.scss
Normal file
23
packages/app/components/footerMenu/footerMenu.scss
Normal 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
Reference in New Issue
Block a user