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

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

View File

@@ -0,0 +1,247 @@
@import '~app/components/ui/fonts.scss';
// Original: http://codepen.io/vanderlanth/pen/rxpNMY
.page {
margin: 80px auto 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.loading {
width: 200px;
height: 100px;
margin-bottom: 50px;
position: relative;
display: flex;
justify-content: center;
align-items: flex-end;
animation: loadStab 1s ease-out infinite;
}
.cube {
width: 50px;
height: 50px;
background: white;
animation: cubeRotate 1s ease-out infinite;
}
.road {
position: absolute;
width: 100%;
height: 1px;
background: white;
left: 0;
bottom: 0;
animation: roadStab 1s ease-out infinite;
}
@keyframes cubeRotate {
0% {
transform: rotate(0deg) translate3D(0, 0, 0);
}
65% {
transform: rotate(45deg) translate3D(0, -13px, 0);
}
90% {
transform: rotate(70deg) translate3D(0, -8px, 0);
}
100% {
transform: rotate(90deg) translate3D(0, 0, 0);
}
}
@keyframes roadStab {
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, 2px, 0);
}
90% {
transform: translate3D(0, 4px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
}
@keyframes loadStab {
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, -2px, 0);
}
95% {
transform: translate3D(0, -2px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
}
//------------------ MOUNTAINS ---------------------
.rocks {
width: 100%;
height: 100%;
position: absolute;
bottom: -50px;
left: 0;
overflow: hidden;
animation: roadStab 1s ease-out infinite;
}
@mixin rock($rockName, $bottom, $delay) {
.#{$rockName} {
position: absolute;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
border-bottom: 4px solid white;
bottom: $bottom;
right: -2%;
animation: rockTravelling 10s $delay ease-out infinite;
}
}
@include rock('rockOne', 23px, 0s);
@include rock('rockTwo', 40px, 2s);
@include rock('rockThree', 30px, 6s);
@include rock('rockFour', 10px, 4s);
@include rock('rockFive', 18px, 8s);
@keyframes rockTravelling {
0% {
right: -2%;
}
10% {
right: 8%;
}
20% {
right: 18%;
}
30% {
right: 29%;
}
40% {
right: 40%;
}
50% {
right: 51%;
}
60% {
right: 62%;
}
70% {
right: 72%;
}
80% {
right: 82%;
}
90% {
right: 92%;
}
100% {
right: 102%;
}
}
//------------------ CLOUDS ---------------------
.clouds {
width: 200%;
height: 200%;
animation: roadStab 1s ease-out infinite, cloudStab 1s ease-out infinite;
position: absolute;
bottom: -50px;
left: -50%;
overflow: hidden;
}
.cloud {
position: absolute;
will-change: animation;
background-image: url('./cloud.svg');
background-size: cover;
}
.cloudOne {
composes: cloud;
top: 5px;
width: 100px;
height: 32px;
animation: cloudTravelling 16s linear infinite;
}
.cloudTwo {
composes: cloud;
top: 65px;
right: -30%;
width: 50px;
height: 16px;
animation: cloudTravelling 21s 5s linear infinite;
}
.cloudThree {
composes: cloud;
top: 40px;
right: -30%;
width: 70px;
height: 22px;
animation: cloudTravelling 26s 11s linear infinite;
}
@keyframes cloudTravelling {
0% {
right: -30%;
}
100% {
right: 110%;
}
}
@keyframes cloudStab {
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, 2px, 0);
}
85% {
transform: translate3D(0, 2px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
}
%text {
font-family: $font-family-title;
text-align: center;
padding: 0 10px;
line-height: 1.2;
}
.text {
@extend %text;
font-size: 24px;
margin-top: 25px;
color: #666;
}
.subText {
@extend %text;
font-size: 16px;
margin-top: 5px;
color: #9a9a9a;
}

View File

@@ -0,0 +1,6 @@
{
"title": "Page not found",
"nothingHere": "This is not a place that you are looking for",
"returnToTheHomePage": "Try to go back to the {link}",
"homePage": "main page"
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { FooterMenu } from 'app/components/footerMenu';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import styles from './404.scss';
import messages from './PageNotFound.intl.json';
import profileStyles from '../profile/profile.scss';
export default function PageNotFound() {
return (
<div className={styles.page}>
<Message {...messages.title}>
{pageTitle => <Helmet title={pageTitle} />}
</Message>
<div className={styles.loading}>
<div className={styles.cube} />
<div className={styles.road} />
<div className={styles.rocks}>
<span className={styles.rockOne} />
<span className={styles.rockTwo} />
<span className={styles.rockThree} />
<span className={styles.rockFour} />
<span className={styles.rockFive} />
</div>
<div className={styles.clouds}>
<span className={styles.cloudOne} />
<span className={styles.cloudTwo} />
<span className={styles.cloudThree} />
</div>
</div>
<p className={styles.text}>
<Message {...messages.nothingHere} />
</p>
<p className={styles.subText}>
<Message
{...messages.returnToTheHomePage}
values={{
link: (
<Link to="/">
<Message {...messages.homePage} />
</Link>
),
}}
/>
</p>
<div className={profileStyles.footer}>
<FooterMenu />
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="100px" height="32px" viewBox="0 0 100 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M26.0667904,10.4823748 L60.6679035,10.4823748 L57.3747681,10.4823748 C54.4805195,10.4823748 52.1335807,12.8293135 52.1335807,15.7235622 C52.1335807,18.6178108 54.4805195,20.9647495 57.3747681,20.9647495 L60.6679035,20.9647495 L26.0667904,20.9647495 L32.6994434,20.9647495 C35.593692,20.9647495 37.9406308,18.6178108 37.9406308,15.7235622 C37.9406308,12.8293135 35.593692,10.4823748 32.6994434,10.4823748 L26.0667904,10.4823748 L26.0667904,10.4823748 Z M0,26.2059369 C0,23.3116883 2.34693878,20.9647495 5.24118738,20.9647495 L69.7124304,20.9647495 C72.606679,20.9647495 74.9536178,23.3116883 74.9536178,26.2059369 C74.9536178,29.1001855 72.606679,31.4471243 69.7124304,31.4471243 L5.24118738,31.4471243 C2.34693878,31.4471243 0,29.1001855 0,26.2059369 L0,26.2059369 Z M16.7903525,5.24118738 C16.7903525,2.34693878 19.1372913,0 22.0315399,0 L94.7588126,0 C97.6530612,0 100,2.34693878 100,5.24118738 C100,8.13543599 97.6530612,10.4823748 94.7588126,10.4823748 L22.0315399,10.4823748 C19.1372913,10.4823748 16.7903525,8.13543599 16.7903525,5.24118738 L16.7903525,5.24118738 Z" id="Shape" stroke="none" fill="#FFFFFF" fill-rule="evenodd"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import AppInfo from 'app/components/auth/appInfo/AppInfo';
import PanelTransition from 'app/components/auth/PanelTransition';
import Register from 'app/components/auth/register/Register';
import Login from 'app/components/auth/login/Login';
import Permissions from 'app/components/auth/permissions/Permissions';
import ChooseAccount from 'app/components/auth/chooseAccount/ChooseAccount';
import Activation from 'app/components/auth/activation/Activation';
import ResendActivation from 'app/components/auth/resendActivation/ResendActivation';
import Password from 'app/components/auth/password/Password';
import AcceptRules from 'app/components/auth/acceptRules/AcceptRules';
import ForgotPassword from 'app/components/auth/forgotPassword/ForgotPassword';
import RecoverPassword from 'app/components/auth/recoverPassword/RecoverPassword';
import Mfa from 'app/components/auth/mfa/Mfa';
import Finish from 'app/components/auth/finish/Finish';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { RootState } from 'app/reducers';
import { Client } from 'app/components/auth/reducer';
import styles from './auth.scss';
// TODO: after migrating to new react router (posibly) this view started remounting
// after route change e.g. /login -> /password which results in state dropping
// we should find why this view is remounting or move isSidebarHidden into store
// so that it persist disregarding remounts
let isSidebarHiddenCache = false;
interface Props {
client: Client | null;
}
class AuthPage extends React.Component<
Props,
{
isSidebarHidden: boolean;
}
> {
state = {
isSidebarHidden: isSidebarHiddenCache,
};
render() {
const { isSidebarHidden } = this.state;
const { client } = this.props;
return (
<div>
<div
className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}
>
<AppInfo {...client} onGoToAuth={this.onGoToAuth} />
</div>
<div className={styles.content} data-e2e-content>
<Switch>
<Route path="/login" render={renderPanelTransition(Login)} />
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
<Route path="/password" render={renderPanelTransition(Password)} />
<Route path="/register" render={renderPanelTransition(Register)} />
<Route
path="/activation/:key?"
render={renderPanelTransition(Activation)}
/>
<Route
path="/resend-activation"
render={renderPanelTransition(ResendActivation)}
/>
<Route
path="/oauth/permissions"
render={renderPanelTransition(Permissions)}
/>
<Route
path="/choose-account"
render={renderPanelTransition(ChooseAccount)}
/>
<Route
path="/oauth/choose-account"
render={renderPanelTransition(ChooseAccount)}
/>
<Route path="/oauth/finish" component={Finish} />
<Route
path="/accept-rules"
render={renderPanelTransition(AcceptRules)}
/>
<Route
path="/forgot-password"
render={renderPanelTransition(ForgotPassword)}
/>
<Route
path="/recover-password/:key?"
render={renderPanelTransition(RecoverPassword)}
/>
<Redirect to="/404" />
</Switch>
</div>
</div>
);
}
onGoToAuth = () => {
isSidebarHiddenCache = true;
this.setState({
isSidebarHidden: true,
});
};
}
function renderPanelTransition(factory) {
const { Title, Body, Footer, Links } = factory();
return props => (
<PanelTransition
key="panel-transition"
Title={<Title />}
Body={<Body {...props} />}
Footer={<Footer />}
Links={<Links />}
/>
);
}
export default withRouter(
connect((state: RootState) => ({
client: state.auth.client,
}))(AuthPage),
);

View File

@@ -0,0 +1,7 @@
{
"title": "Authorization successful",
"applicationAuth": "Application authorization",
"authorizationSuccessful": "Authorization has been successfully completed.",
"authorizationForAppSuccessful": "Authorization for {appName} has been successfully completed.",
"youCanCloseThisPage": "You can close this window and return to your application."
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import loader from 'app/services/loader';
import { Query } from 'app/services/request';
import rootMessages from '../root/RootPage.intl.json';
import styles from './success-oauth.scss';
import messages from './SuccessOauthPage.intl.json';
export default class SuccessOauthPage extends React.Component<{
location: {
query: Query<'appName'>;
};
}> {
componentDidMount() {
this.onPageUpdate();
setTimeout(() => {
try {
// try to close window if possible
// @ts-ignore
window.open('', '_self').close();
} catch (err) {
// don't care
}
}, 8000);
}
componentDidUpdate() {
this.onPageUpdate();
}
onPageUpdate() {
loader.hide();
}
render() {
const appName = this.props.location.query.get('appName');
return (
<div className={styles.page}>
<Message {...messages.title}>
{pageTitle => <Helmet title={pageTitle} />}
</Message>
<div className={styles.wrapper}>
<Link to="/" className={styles.logo}>
<Message {...rootMessages.siteName} />
</Link>
<div className={styles.title}>
<Message {...messages.applicationAuth} />
</div>
<div className={styles.checkmark} />
<div className={styles.description}>
{appName ? (
<Message
{...messages.authorizationForAppSuccessful}
values={{
appName: <b>{appName}</b>,
}}
/>
) : (
<Message {...messages.authorizationSuccessful} />
)}
&nbsp;
<Message {...messages.youCanCloseThisPage} />
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,49 @@
@import '~app/components/ui/colors.scss';
$sidebar-width: 320px;
.sidebar {
position: absolute;
bottom: 0;
right: 0;
left: 0;
top: 50px;
z-index: 10;
background: $black;
}
.hiddenSidebar {
composes: sidebar;
display: none;
}
.content {
text-align: center;
max-width: 340px;
margin: 0 auto;
}
@media (min-width: 350px) {
.content {
padding: 55px 0;
}
}
@media (min-width: 720px) {
.content {
padding: 55px 50px;
margin-left: $sidebar-width;
}
.sidebar {
right: auto;
width: $sidebar-width;
}
.hiddenSidebar {
display: block;
}
}

View File

@@ -0,0 +1,70 @@
@import '~app/components/ui/fonts.scss';
@import '~app/components/ui/colors.scss';
.page {
border-top: 50px solid #ddd8ce;
padding: 85px 10px;
}
.wrapper {
position: relative;
margin: 0 auto;
padding: 55px 25px;
max-width: 330px;
box-sizing: border-box;
background: #fff;
border: 3px solid #ddd8ce;
text-align: center;
}
.logo {
$borderWidth: 3px;
position: absolute;
top: -28px;
left: 50%;
transform: translate(-50%, 0);
padding: 0 20px;
font-family: $font-family-title;
font-size: 33px;
line-height: 50px - $borderWidth * 2;
color: #fff;
background: $green;
border: 3px solid darker($green);
&:hover {
color: #fff;
background: $green;
border: 3px solid darker($green);
}
}
.title {
font-family: $font-family-title;
font-size: 20px;
margin-bottom: 20px;
}
.checkmark {
composes: checkmark from '~app/components/ui/icons.scss';
color: lighter($green);
font-size: 66px;
margin-bottom: 28px;
}
.description {
font-size: 13px;
color: #9a9a9a;
line-height: 1.4;
b {
color: #666;
}
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { connect } from 'react-redux';
import {
fetchAvailableApps,
resetApp,
deleteApp,
} from 'app/components/dev/apps/actions';
import ApplicationsIndex from 'app/components/dev/apps/ApplicationsIndex';
import { User } from 'app/components/user';
import { OauthAppResponse } from 'app/services/api/oauth';
import { RootState } from 'app/reducers';
interface Props extends RouteComponentProps {
user: User;
apps: OauthAppResponse[];
fetchAvailableApps: () => Promise<void>;
deleteApp: (clientId: string) => Promise<void>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<void>;
}
type State = {
isLoading: boolean;
forceUpdate: boolean;
};
class ApplicationsListPage extends React.Component<Props, State> {
state = {
isLoading: false,
forceUpdate: false,
};
componentDidMount() {
!this.props.user.isGuest && this.loadApplicationsList();
}
componentDidUpdate({ user }: Props) {
if (this.props.user !== user) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ forceUpdate: true });
this.loadApplicationsList();
}
}
render() {
const { user, apps, resetApp, deleteApp, location } = this.props;
const { isLoading, forceUpdate } = this.state;
const clientId = location.hash.substr(1) || null;
return (
<ApplicationsIndex
displayForGuest={user.isGuest}
applications={forceUpdate ? [] : apps}
isLoading={isLoading}
deleteApp={deleteApp}
resetApp={resetApp}
clientId={clientId}
resetClientId={this.resetClientId}
/>
);
}
loadApplicationsList = async () => {
this.setState({ isLoading: true });
await this.props.fetchAvailableApps();
this.setState({
isLoading: false,
forceUpdate: false,
});
};
resetClientId = () => {
const { history, location } = this.props;
if (location.hash) {
history.push({ ...location, hash: '' });
}
};
}
export default connect(
(state: RootState) => ({
user: state.user,
apps: state.apps.available,
}),
{
fetchAvailableApps,
resetApp,
deleteApp,
},
)(ApplicationsListPage);

View File

@@ -0,0 +1,69 @@
import React, { Component } from 'react';
import { FormModel } from 'app/components/ui/form';
import ApplicationForm from 'app/components/dev/apps/applicationForm/ApplicationForm';
import oauth from 'app/services/api/oauth';
import { browserHistory } from 'app/services/history';
import { OauthAppResponse } from 'app/services/api/oauth';
import { ApplicationType } from 'app/components/dev/apps';
const app: OauthAppResponse = {
clientId: '',
clientSecret: '',
countUsers: 0,
createdAt: 0,
type: 'application',
name: '',
description: '',
websiteUrl: '',
redirectUri: '',
minecraftServerIp: '',
};
interface State {
type: ApplicationType | null;
}
export default class CreateNewApplicationPage extends Component<{}, State> {
state: State = {
type: null,
};
form: FormModel = new FormModel();
render() {
return (
<ApplicationForm
form={this.form}
displayTypeSwitcher
onSubmit={this.onSubmit}
type={this.state.type}
setType={this.setType}
app={app}
/>
);
}
onSubmit = async () => {
const { form } = this;
const { type } = this.state;
if (!type) {
throw new Error('Form was submitted without specified type');
}
form.beginLoading();
const result = await oauth.create(type, form.serialize());
form.endLoading();
this.goToMainPage(result.data.clientId);
};
setType = (type: ApplicationType) => {
this.setState({
type,
});
};
goToMainPage = (hash?: string) =>
browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { FooterMenu } from 'app/components/footerMenu';
import PrivateRoute from 'app/containers/PrivateRoute';
import styles from './dev.scss';
import ApplicationsListPage from './ApplicationsListPage';
import CreateNewApplicationPage from './CreateNewApplicationPage';
import UpdateApplicationPage from './UpdateApplicationPage';
export default function DevPage() {
return (
<div className={styles.container}>
<div data-e2e-content>
<Switch>
<Route
path="/dev/applications"
exact
component={ApplicationsListPage}
/>
<PrivateRoute
path="/dev/applications/new"
exact
component={CreateNewApplicationPage}
/>
<PrivateRoute
path="/dev/applications/:clientId"
component={UpdateApplicationPage}
/>
<Redirect to="/dev/applications" />
</Switch>
</div>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { connect } from 'react-redux';
import logger from 'app/services/logger';
import { RouteComponentProps } from 'react-router';
import { FormModel } from 'app/components/ui/form';
import { browserHistory } from 'app/services/history';
import oauth from 'app/services/api/oauth';
import loader from 'app/services/loader';
import PageNotFound from 'app/pages/404/PageNotFound';
import {
getApp,
fetchApp as fetchAppAction,
} from 'app/components/dev/apps/actions';
import ApplicationForm from 'app/components/dev/apps/applicationForm/ApplicationForm';
import { OauthAppResponse } from 'app/services/api/oauth';
import { RootState } from 'app/reducers';
type OwnProps = RouteComponentProps<{
clientId: string;
}>;
interface Props extends OwnProps {
app: OauthAppResponse | null;
fetchApp: (app: string) => Promise<void>;
}
class UpdateApplicationPage extends React.Component<
Props,
{
isNotFound: boolean;
}
> {
form: FormModel = new FormModel();
state = {
isNotFound: false,
};
componentDidMount() {
this.props.app === null && this.fetchApp();
}
render() {
const { app } = this.props;
if (this.state.isNotFound) {
return <PageNotFound />;
}
if (!app) {
// we are loading
return null;
}
return (
<ApplicationForm
form={this.form}
onSubmit={this.onSubmit}
app={app}
type={app.type}
/>
);
}
async fetchApp() {
const { fetchApp, match } = this.props;
try {
loader.show();
await fetchApp(match.params.clientId);
} catch (resp) {
const { status } = resp.originalResponse;
if (status === 403) {
this.goToMainPage();
return;
}
if (status === 404) {
this.setState({
isNotFound: true,
});
return;
}
logger.unexpected('Error fetching app', resp);
} finally {
loader.hide();
}
}
onSubmit = async () => {
const { form } = this;
const { app } = this.props;
if (!app || !app.clientId) {
throw new Error('Form has an invalid state');
}
form.beginLoading();
const result = await oauth.update(app.clientId, form.serialize());
form.endLoading();
this.goToMainPage(result.data.clientId);
};
goToMainPage = (hash?: string) =>
browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
}
export default connect(
(state: RootState, props: OwnProps) => ({
app: getApp(state, props.match.params.clientId),
}),
{
fetchApp: fetchAppAction,
},
)(UpdateApplicationPage);

View File

@@ -0,0 +1,18 @@
.container {
padding: 55px 0 65px;
}
.footer {
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
text-align: center;
}
@media (max-width: 720px) {
.container {
padding-top: 20px;
}
}

View File

@@ -0,0 +1,120 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { RouteComponentProps } from 'react-router-dom';
import FormModel from 'app/components/ui/form/FormModel';
import ChangeEmail from 'app/components/profile/changeEmail/ChangeEmail';
import {
requestEmailChange,
setNewEmail,
confirmNewEmail,
} from 'app/services/api/accounts';
import { RootState } from 'app/reducers';
interface RouteParams {
step: 'step1' | 'step2' | 'step3';
code: string;
}
interface Props extends RouteComponentProps<RouteParams> {
lang: string;
email: string;
}
class ChangeEmailPage extends React.Component<Props> {
static contextTypes = {
userId: PropTypes.number.isRequired,
onSubmit: PropTypes.func.isRequired,
goToProfile: PropTypes.func.isRequired,
};
componentWillMount() {
const { step } = this.props.match.params;
if (step && !/^step[123]$/.test(step)) {
// wrong param value
this.props.history.push('/404');
}
}
render() {
const { step = 'step1', code } = this.props.match.params;
return (
<ChangeEmail
onSubmit={this.onSubmit}
email={this.props.email}
lang={this.props.lang}
step={Number(step.slice(-1)) - 1}
onChangeStep={this.onChangeStep}
code={code}
/>
);
}
onChangeStep = (step: number) => {
this.props.history.push(`/profile/change-email/step${++step}`);
};
onSubmit = (step: number, form: FormModel) => {
return this.context
.onSubmit({
form,
sendData: () => {
const { userId } = this.context;
const data = form.serialize();
switch (step) {
case 0:
return requestEmailChange(userId, data.password).catch(
handleErrors(),
);
case 1:
return setNewEmail(userId, data.email, data.key).catch(
handleErrors('/profile/change-email'),
);
case 2:
return confirmNewEmail(userId, data.key).catch(
handleErrors('/profile/change-email'),
);
default:
throw new Error(`Unsupported step ${step}`);
}
},
})
.then(() => {
step > 1 && this.context.goToProfile();
});
};
}
function handleErrors(repeatUrl: string | void) {
return resp => {
if (resp.errors) {
if (resp.errors.key) {
resp.errors.key = {
type: resp.errors.key,
payload: {},
};
if (
['error.key_not_exists', 'error.key_expire'].includes(
resp.errors.key.type,
) &&
repeatUrl
) {
Object.assign(resp.errors.key.payload, {
repeatUrl,
});
}
}
}
return Promise.reject(resp);
};
}
export default connect((state: RootState) => ({
email: state.user.email,
lang: state.user.lang,
}))(ChangeEmailPage);

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { changePassword } from 'app/services/api/accounts';
import { FormModel } from 'app/components/ui/form';
import ChangePassword from 'app/components/profile/changePassword/ChangePassword';
import { User } from 'app/components/user';
import { updateUser } from 'app/components/user/actions';
type OwnProps = {};
type Props = OwnProps & {
updateUser: (fields: Partial<User>) => void;
};
class ChangePasswordPage extends React.Component<Props> {
static contextTypes = {
userId: PropTypes.number.isRequired,
onSubmit: PropTypes.func.isRequired,
goToProfile: PropTypes.func.isRequired,
};
form = new FormModel();
render() {
return <ChangePassword onSubmit={this.onSubmit} form={this.form} />;
}
onSubmit = () => {
const { form } = this;
return this.context
.onSubmit({
form,
sendData: () => changePassword(this.context.userId, form.serialize()),
})
.then(() => {
this.props.updateUser({
passwordChangedAt: Date.now() / 1000,
});
this.context.goToProfile();
});
};
}
export default connect(null, {
updateUser,
})(ChangePasswordPage);

View File

@@ -0,0 +1,84 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { updateUser } from 'app/components/user/actions';
import { RootState } from 'app/reducers';
import { changeUsername } from 'app/services/api/accounts';
import { FormModel } from 'app/components/ui/form';
import ChangeUsername from 'app/components/profile/changeUsername/ChangeUsername';
type OwnProps = {};
type Props = {
username: string;
updateUsername: (username: string) => void;
};
class ChangeUsernamePage extends React.Component<Props> {
static contextTypes = {
userId: PropTypes.number.isRequired,
onSubmit: PropTypes.func.isRequired,
goToProfile: PropTypes.func.isRequired,
};
form = new FormModel();
actualUsername: string;
componentWillMount() {
this.actualUsername = this.props.username;
}
componentWillUnmount() {
this.props.updateUsername(this.actualUsername);
}
render() {
return (
<ChangeUsername
form={this.form}
onSubmit={this.onSubmit}
onChange={this.onUsernameChange}
username={this.props.username}
/>
);
}
onUsernameChange = (username: string) => {
this.props.updateUsername(username);
};
onSubmit = () => {
const { form } = this;
if (this.actualUsername === this.props.username) {
this.context.goToProfile();
return Promise.resolve();
}
return this.context
.onSubmit({
form,
sendData: () => {
const { username, password } = form.serialize();
return changeUsername(this.context.userId, username, password);
},
})
.then(() => {
this.actualUsername = form.value('username');
this.context.goToProfile();
});
};
}
export default connect(
(state: RootState) => ({
username: state.user.username,
}),
{
updateUsername: username => updateUser({ username }),
},
)(ChangeUsernamePage);

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import MultiFactorAuth, {
MfaStep,
} from 'app/components/profile/multiFactorAuth';
import { FormModel } from 'app/components/ui/form';
import { User } from 'app/components/user';
import { RootState } from 'app/reducers';
interface Props
extends RouteComponentProps<{
step?: '1' | '2' | '3';
}> {
user: User;
}
class MultiFactorAuthPage extends React.Component<Props> {
static contextTypes = {
onSubmit: PropTypes.func.isRequired,
goToProfile: PropTypes.func.isRequired,
};
componentWillMount() {
const { step } = this.props.match.params;
const { user } = this.props;
if (step) {
if (!/^[1-3]$/.test(step)) {
// wrong param value
this.props.history.push('/404');
return;
}
if (user.isOtpEnabled) {
this.props.history.push('/mfa');
}
}
}
render() {
const { user } = this.props;
return (
<MultiFactorAuth
isMfaEnabled={user.isOtpEnabled}
onSubmit={this.onSubmit}
step={this.getStep()}
onChangeStep={this.onChangeStep}
onComplete={this.onComplete}
/>
);
}
getStep(): MfaStep {
const step = Number(this.props.match.params.step) - 1;
if (step !== 0 && step !== 1 && step !== 2) {
return 1;
}
return step;
}
onChangeStep = (step: MfaStep) => {
this.props.history.push(`/profile/mfa/step${step + 1}`);
};
onSubmit = (form: FormModel, sendData: () => Promise<void>) => {
return this.context.onSubmit({
form,
sendData,
});
};
onComplete = () => {
this.context.goToProfile();
};
}
export default connect(({ user }: RootState) => ({ user }))(
MultiFactorAuthPage,
);

View File

@@ -0,0 +1,185 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Route, Switch, Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import { fetchUserData } from 'app/components/user/actions';
import { create as createPopup } from 'app/components/ui/popup/actions';
import PasswordRequestForm from 'app/components/profile/passwordRequestForm/PasswordRequestForm';
import logger from 'app/services/logger';
import { browserHistory } from 'app/services/history';
import { FooterMenu } from 'app/components/footerMenu';
import Profile from 'app/components/profile/Profile';
import ChangePasswordPage from 'app/pages/profile/ChangePasswordPage';
import ChangeUsernamePage from 'app/pages/profile/ChangeUsernamePage';
import ChangeEmailPage from 'app/pages/profile/ChangeEmailPage';
import MultiFactorAuthPage from 'app/pages/profile/MultiFactorAuthPage';
import { FormModel } from 'app/components/ui/form';
import { RootState } from 'app/reducers';
import styles from './profile.scss';
interface Props {
userId: number;
onSubmit: (options: {
form: FormModel;
sendData: () => Promise<any>;
}) => void;
fetchUserData: () => Promise<any>;
}
class ProfilePage extends React.Component<Props> {
static childContextTypes = {
userId: PropTypes.number,
onSubmit: PropTypes.func,
goToProfile: PropTypes.func,
};
getChildContext() {
return {
userId: this.props.userId,
onSubmit: this.props.onSubmit,
goToProfile: () => this.props.fetchUserData().then(this.goToProfile),
};
}
render() {
return (
<div className={styles.container}>
<Switch>
<Route
path="/profile/mfa/step:step([1-3])"
component={MultiFactorAuthPage}
/>
<Route path="/profile/mfa" exact component={MultiFactorAuthPage} />
<Route
path="/profile/change-password"
exact
component={ChangePasswordPage}
/>
<Route
path="/profile/change-username"
exact
component={ChangeUsernamePage}
/>
<Route
path="/profile/change-email/:step?/:code?"
component={ChangeEmailPage}
/>
<Route path="/profile" exact component={Profile} />
<Route path="/" exact component={Profile} />
<Redirect to="/404" />
</Switch>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
);
}
goToProfile = () => browserHistory.push('/');
}
export default connect(
(state: RootState) => ({
userId: state.user.id,
}),
{
fetchUserData,
onSubmit: ({
form,
sendData,
}: {
form: FormModel;
sendData: () => Promise<any>;
}) => dispatch => {
form.beginLoading();
return sendData()
.catch(resp => {
const requirePassword = resp.errors && !!resp.errors.password;
// prevalidate user input, because requestPassword popup will block the
// entire form from input, so it must be valid
if (resp.errors) {
delete resp.errors.password;
if (resp.errors.email && resp.data && resp.data.canRepeatIn) {
resp.errors.email = {
type: resp.errors.email,
payload: {
msLeft: resp.data.canRepeatIn * 1000,
},
};
}
if (Object.keys(resp.errors).length) {
form.setErrors(resp.errors);
return Promise.reject(resp);
}
if (requirePassword) {
return requestPassword(form);
}
}
return Promise.reject(resp);
})
.catch(resp => {
if (!resp || !resp.errors) {
logger.warn('Unexpected profile editing error', {
resp,
});
} else {
return Promise.reject(resp);
}
})
.finally(() => form.endLoading());
function requestPassword(form) {
return new Promise((resolve, reject) => {
dispatch(
createPopup({
Popup(props: { onClose: () => Promise<any> }) {
const onSubmit = () => {
form.beginLoading();
sendData()
.then(resolve)
.then(props.onClose)
.catch(resp => {
if (resp.errors) {
form.setErrors(resp.errors);
const parentFormHasErrors =
Object.keys(resp.errors).filter(
name => name !== 'password',
).length > 0;
if (parentFormHasErrors) {
// something wrong with parent form, hidding popup and show that form
props.onClose();
reject(resp);
logger.warn(
'Profile: can not submit pasword popup due to errors in source form',
{ resp },
);
}
} else {
return Promise.reject(resp);
}
})
.finally(() => form.endLoading());
};
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
},
disableOverlayClose: true,
}),
);
});
}
},
},
)(ProfilePage);

View File

@@ -0,0 +1,18 @@
.container {
padding: 55px 10px 65px; // 65px for footer
}
.footer {
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
text-align: center;
}
@media (max-width: 720px) {
.container {
padding-top: 20px;
}
}

View File

@@ -0,0 +1,3 @@
{
"siteName": "Ely.by"
}

View File

@@ -0,0 +1,114 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { resetAuth } from 'app/components/auth/actions';
import { withRouter } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import { Route, Link, Switch } from 'react-router-dom';
import Helmet from 'react-helmet';
import classNames from 'classnames';
import AuthPage from 'app/pages/auth/AuthPage';
import ProfilePage from 'app/pages/profile/ProfilePage';
import RulesPage from 'app/pages/rules/RulesPage';
import DevPage from 'app/pages/dev/DevPage';
import PageNotFound from 'app/pages/404/PageNotFound';
import { ScrollIntoView } from 'app/components/ui/scroll';
import PrivateRoute from 'app/containers/PrivateRoute';
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import Userbar from 'app/components/userbar/Userbar';
import PopupStack from 'app/components/ui/popup/PopupStack';
import loader from 'app/services/loader';
import { getActiveAccount } from 'app/components/accounts/reducer';
import { User } from 'app/components/user';
import { Account } from 'app/components/accounts/reducer';
import { RootState } from 'app/reducers';
import styles from './root.scss';
import messages from './RootPage.intl.json';
class RootPage extends Component<{
account: Account | null;
user: User;
isPopupActive: boolean;
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
location: {
pathname: string;
};
}> {
componentDidMount() {
this.onPageUpdate();
}
componentDidUpdate() {
this.onPageUpdate();
}
onPageUpdate() {
loader.hide();
}
render() {
const { props } = this;
const { user, account, isPopupActive, onLogoClick } = this.props;
const isRegisterPage = props.location.pathname === '/register';
if (document && document.body) {
document.body.style.overflow = isPopupActive ? 'hidden' : '';
}
return (
<div className={styles.root}>
<Helmet>
<html lang={user.lang} />
</Helmet>
<ScrollIntoView top />
<div
id="view-port"
className={classNames(styles.viewPort, {
[styles.isPopupActive]: isPopupActive,
})}
>
<div className={styles.header} data-e2e-toolbar>
<div className={styles.headerContent}>
<Link to="/" className={styles.logo} onClick={onLogoClick}>
<Message {...messages.siteName} />
</Link>
<div className={styles.userbar}>
<Userbar
account={account}
guestAction={isRegisterPage ? 'login' : 'register'}
/>
</div>
</div>
</div>
<div className={styles.body}>
<Switch>
<PrivateRoute path="/profile" component={ProfilePage} />
<Route path="/404" component={PageNotFound} />
<Route path="/rules" component={RulesPage} />
<Route path="/dev" component={DevPage} />
<AuthFlowRoute exact path="/" component={ProfilePage} />
<AuthFlowRoute path="/" component={AuthPage} />
<Route component={PageNotFound} />
</Switch>
</div>
</div>
<PopupStack />
</div>
);
}
}
export default withRouter(
connect(
(state: RootState) => ({
user: state.user,
account: getActiveAccount(state),
isPopupActive: state.popup.popups.length > 0,
}),
{
onLogoClick: resetAuth,
},
)(RootPage),
);

View File

@@ -0,0 +1,66 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
$userBarHeight: 50px;
.root {
height: 100%;
}
.viewPort {
height: 100%;
}
.isPopupActive {
filter: blur(5px);
transition: filter 0.4s 0.1s ease;
}
.wrapper {
max-width: 756px;
margin: 0 auto;
}
.header {
position: fixed;
top: 0;
z-index: 100;
height: $userBarHeight;
width: 100%;
background: $green;
}
.headerContent {
composes: wrapper;
position: relative;
}
.logo {
line-height: 50px;
padding: 0 20px;
display: inline-block;
background: darker($green);
border-bottom: none;
font-family: $font-family-title;
font-size: 33px;
color: #fff !important;
}
.body {
// TODO: должны ли мы здесь описать базовый шрифт, его размер и базовую линию?
composes: wrapper;
position: relative;
min-height: 100%;
box-sizing: border-box;
padding-top: $userBarHeight; // place for header
}
.userbar {
position: absolute;
right: 0;
left: 115px;
top: 0;
}

View File

@@ -0,0 +1,24 @@
{
"title": "Site rules",
"mainProvisions": "Main provisions",
"mainProvision1": "{name} service was created for the organization of safety access to Ely.by's users accounts, his partners and any side project that wish to use one of the our's services.",
"mainProvision2": "We (here and in the next points) — Ely.by project developers team that make creating qualitative services for Minecraft community.",
"mainProvision3": "Ely.by is side project, that has nothing to do with Mojang and Microsoft companies. We don't provide support to Minecraft premium accounts, and we have nothing to do with servers that use or don't use our services.",
"mainProvision4": "The registration of the users account at server is free. Account creation Ely.by is only possible at that page {link}.",
"emailAndNickname": "Email and nickname",
"emailAndNickname1": "Account registration with usage of temporary mail services is prohibited. We speak about services that gives random Email in any quantity.",
"emailAndNickname2": "We try to counteract it, but if you succesed in registration of account with usage of temporary mail services, there wont be any technical support for it and later, during of update of ours filters, account will be blocked with your nickname.",
"emailAndNickname3": "There are no any moral restrictions for users nickname that will be used in game.",
"emailAndNickname4": "Nicknames, belonging to famous persons, can be released at their favor for requirement and proves of that persons.",
"emailAndNickname5": "Minecraft premium account owner has right to require a control restore of his nickname an if it happened you have to change your nickname in 3 days or it will be done automatically.",
"emailAndNickname6": "If there is no any activity at your account during last 3 month, your nickname can be occupied by any user.",
"emailAndNickname7": "We aren't responsible for losing your game progress at servers if it was result of nickname changing, including changes on our demand.",
"elyAccountsAsService": "{name} as service",
"elyAccountsAsServiceDesc1": "{name} has free providing to any project, that interested in it usage for Minecraft.",
"elyAccountsAsServiceDesc2": "Despite we do our utmost to provide fast and stable work of service, we are not saved from DDOS-attack, hosters links work interruptions, electricity disorders or any cases, that impossible to be predicted. For avoiding possible incomprehension, we obliged to discuss next agreements, that will work in case of situations mentioned before:",
"elyAccountsAsService1": "We don't have any guarantee about fault free work time of this service.",
"elyAccountsAsService2": "We are not responsible for delays and lost income as the result of ours service inoperability."
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import sinon from 'sinon';
import expect from 'app/test/unexpected';
import { shallow } from 'enzyme';
import RulesPage from './RulesPage';
describe('RulesPage', () => {
describe('#onRuleClick()', () => {
const id = 'rule-1-2';
const pathname = '/foo';
const search = '?bar';
let page;
let replace;
beforeEach(() => {
replace = sinon.stub().named('history.replace');
page = shallow(
<RulesPage
location={{ pathname, search } as any}
history={{ replace }}
/>,
);
});
it('should update location on rule click', () => {
const expectedUrl = `/foo?bar#${id}`;
page.find(`#${id}`).simulate('click', {
target: document.createElement('li'),
currentTarget: {
id,
},
});
expect(replace, 'to have a call satisfying', [expectedUrl]);
});
it('should not update location if link was clicked', () => {
page.find(`#${id}`).simulate('click', {
target: document.createElement('a'),
currentTarget: {
id,
},
});
expect(replace, 'was not called');
});
it('should not update location if defaultPrevented', () => {
page.find(`#${id}`).simulate('click', {
defaultPrevented: true,
target: {
tagName: 'li',
},
currentTarget: {
id,
},
});
expect(replace, 'was not called');
});
it('should not update location if no id', () => {
page.find(`#${id}`).simulate('click', {
target: {
tagName: 'li',
},
currentTarget: {},
});
expect(replace, 'was not called');
});
});
});

View File

@@ -0,0 +1,175 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import { FooterMenu } from 'app/components/footerMenu';
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
import styles from './rules.scss';
import messages from './RulesPage.intl.json';
const projectName = <Message {...appInfo.appName} />;
import classNames from 'classnames';
const rules = [
{
title: <Message {...messages.mainProvisions} />,
items: [
<Message
key="0"
{...messages.mainProvision1}
values={{
name: <b>{projectName}</b>,
}}
/>,
<Message key="1" {...messages.mainProvision2} />,
<Message key="2" {...messages.mainProvision3} />,
<Message
key="3"
{...messages.mainProvision4}
values={{
link: <Link to="/register">https://account.ely.by/register</Link>,
}}
/>,
],
},
{
title: <Message {...messages.emailAndNickname} />,
items: [
<Message key="0" {...messages.emailAndNickname1} />,
<Message key="1" {...messages.emailAndNickname2} />,
<Message key="2" {...messages.emailAndNickname3} />,
<Message key="3" {...messages.emailAndNickname4} />,
<Message key="4" {...messages.emailAndNickname5} />,
<Message key="5" {...messages.emailAndNickname6} />,
<Message key="6" {...messages.emailAndNickname7} />,
],
},
{
title: (
<Message
{...messages.elyAccountsAsService}
values={{
name: projectName,
}}
/>
),
description: (
<div>
<p>
<Message
{...messages.elyAccountsAsServiceDesc1}
values={{
name: <b>{projectName}</b>,
}}
/>
</p>
<p>
<Message {...messages.elyAccountsAsServiceDesc2} />
</p>
</div>
),
items: [
<Message key="0" {...messages.elyAccountsAsService1} />,
<Message key="1" {...messages.elyAccountsAsService2} />,
],
},
];
export default class RulesPage extends Component<{
location: {
pathname: string;
search: string;
hash: string;
};
history: {
replace: Function;
};
}> {
render() {
let { hash } = this.props.location;
if (hash) {
hash = hash.substring(1);
}
return (
<div>
<Message {...messages.title}>
{pageTitle => <Helmet title={pageTitle} />}
</Message>
<div className={styles.rules}>
{rules.map((block, sectionIndex) => (
<div className={styles.rulesSection} key={sectionIndex}>
<h2
className={classNames(styles.rulesSectionTitle, {
[styles.target]:
RulesPage.getTitleHash(sectionIndex) === hash,
})}
id={RulesPage.getTitleHash(sectionIndex)}
>
{block.title}
</h2>
<div className={styles.rulesBody}>
{block.description ? (
<div className={styles.blockDescription}>
{block.description}
</div>
) : (
''
)}
<ol className={styles.rulesList}>
{block.items.map((item, ruleIndex) => (
<li
className={classNames(styles.rulesItem, {
[styles.target]:
RulesPage.getRuleHash(sectionIndex, ruleIndex) ===
hash,
})}
key={ruleIndex}
id={RulesPage.getRuleHash(sectionIndex, ruleIndex)}
onClick={this.onRuleClick.bind(this)}
>
{item}
</li>
))}
</ol>
</div>
</div>
))}
</div>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
);
}
onRuleClick(event: React.SyntheticEvent<HTMLElement>) {
if (
event.defaultPrevented ||
!event.currentTarget.id ||
event.target instanceof HTMLAnchorElement
) {
// some-one have already processed this event or it is a link
return;
}
const { id } = event.currentTarget;
const newPath = `${this.props.location.pathname}${this.props.location.search}#${id}`;
this.props.history.replace(newPath);
}
static getTitleHash(sectionIndex: number) {
return `rule-${sectionIndex + 1}`;
}
static getRuleHash(sectionIndex: number, ruleIndex: number) {
return `${RulesPage.getTitleHash(sectionIndex)}-${ruleIndex + 1}`;
}
}

View File

@@ -0,0 +1,98 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.rules {
max-width: 500px;
margin: 30px auto 0;
padding: 0 20px;
}
.rulesSection {
margin-bottom: 30px;
}
.rulesSectionTitle {
line-height: 50px;
font-family: $font-family-title;
font-size: 20px;
color: #fff;
padding: 0;
margin: 0;
text-align: center;
background: $blue;
}
.rulesBody {
position: relative;
// z-index, чтобы положить :before ниже текста, но выше фона блока
z-index: 0;
padding: 20px;
background: #fff;
font-size: 14px;
}
%rulesTextFormat {
line-height: 1.4;
margin-bottom: 10px;
}
.blockDescription {
@extend %rulesTextFormat;
p {
@extend %rulesTextFormat;
}
}
.rulesList {
padding: 0;
margin: 0;
padding-left: 20px;
}
.rulesItem {
@extend %rulesTextFormat;
list-style: decimal;
position: relative;
cursor: pointer;
&:last-of-type {
margin-bottom: 0;
}
&.target {
&:before {
cursor: default;
$border: 8px solid #ddd8ce;
content: '';
position: absolute;
top: -10px;
left: -40px;
width: calc(100% + 60px);
height: calc(100% + 20px);
background: $white;
border-left: $border;
border-right: $border;
box-sizing: border-box;
z-index: -1;
}
}
a {
color: #444;
border-bottom-color: #aaa;
&:hover {
border-bottom-color: #444;
}
}
}
.footer {
text-align: center;
margin-bottom: 20px;
}