mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-12-24 05:59:51 +05:30
Implemented UI for Accounts applications management.
Introduced copy service and injected it usage into auth finish page. Introduced Collapse component. Introduced Radio component. Generalized Checkbox component to share Radio component styles. Improved Textarea component: it now has auto height functionality. Improved profile/BackButton component: now you can pass custom url. BSOD is no longer displayed on 404 response.
This commit is contained in:
parent
cc50dab0e4
commit
cf3a33937a
@ -147,7 +147,11 @@
|
||||
"semi-spacing": "error",
|
||||
"keyword-spacing": "warn",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": ["error", "never"],
|
||||
"space-before-function-paren": ["error", {
|
||||
"anonymous": "never",
|
||||
"named": "never",
|
||||
"asyncArrow": "always"
|
||||
}],
|
||||
"space-in-parens": "warn",
|
||||
"space-infix-ops": "warn",
|
||||
"space-unary-ops": "error",
|
||||
|
@ -27,6 +27,7 @@
|
||||
"dependencies": {
|
||||
"babel-polyfill": "^6.3.14",
|
||||
"classnames": "^2.1.3",
|
||||
"copy-to-clipboard": "~3.0.8",
|
||||
"debounce": "^1.0.0",
|
||||
"flag-icon-css": "^2.8.0",
|
||||
"intl": "^1.2.2",
|
||||
@ -43,6 +44,7 @@
|
||||
"react-motion": "^0.5.0",
|
||||
"react-redux": "^5.0.6",
|
||||
"react-router-dom": "^4.1.1",
|
||||
"react-textarea-autosize": "^6.0.0",
|
||||
"react-transition-group": "^1.1.3",
|
||||
"redux": "^3.0.4",
|
||||
"redux-localstorage": "^0.4.1",
|
||||
|
@ -370,7 +370,7 @@ export function oAuthValidate(oauthData: {
|
||||
export function oAuthComplete(params: {accept?: bool} = {}) {
|
||||
return wrapInLoader((dispatch, getState) =>
|
||||
oauth.complete(getState().auth.oauth, params)
|
||||
.then((resp) => {
|
||||
.then((resp: Object) => {
|
||||
localStorage.removeItem('oauthData');
|
||||
|
||||
if (resp.redirectUri.startsWith('static_page')) {
|
||||
|
3
src/components/auth/auth.scss
Normal file
3
src/components/auth/auth.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.checkboxInput {
|
||||
margin-top: 15px;
|
||||
}
|
@ -6,6 +6,7 @@ import { FormattedMessage as Message } from 'react-intl';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Button } from 'components/ui/form';
|
||||
import copy from 'services/copy';
|
||||
|
||||
import messages from './Finish.intl.json';
|
||||
import styles from './finish.scss';
|
||||
@ -21,13 +22,8 @@ class Finish extends Component {
|
||||
success: PropTypes.bool
|
||||
};
|
||||
|
||||
state = {
|
||||
isCopySupported: document.queryCommandSupported && document.queryCommandSupported('copy')
|
||||
};
|
||||
|
||||
render() {
|
||||
const {appName, code, state, displayCode, success} = this.props;
|
||||
const {isCopySupported} = this.state;
|
||||
const authData = JSON.stringify({
|
||||
auth_code: code, // eslint-disable-line
|
||||
state
|
||||
@ -53,18 +49,16 @@ class Finish extends Component {
|
||||
<Message {...messages.passCodeToApp} values={{appName}} />
|
||||
</div>
|
||||
<div className={styles.codeContainer}>
|
||||
<div className={styles.code} ref={this.setCode}>{code}</div>
|
||||
<div className={styles.code}>
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
{isCopySupported ? (
|
||||
<Button
|
||||
color="green"
|
||||
small
|
||||
label={messages.copy}
|
||||
onClick={this.onCopyClick}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<Button
|
||||
color="green"
|
||||
small
|
||||
label={messages.copy}
|
||||
onClick={this.onCopyClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.description}>
|
||||
@ -91,28 +85,7 @@ class Finish extends Component {
|
||||
|
||||
onCopyClick = (event) => {
|
||||
event.preventDefault();
|
||||
// http://stackoverflow.com/a/987376/5184751
|
||||
|
||||
try {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this.code);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
selection.removeAllRanges();
|
||||
|
||||
// TODO: было бы ещё неплохо сделать какую-то анимацию, вроде "Скопировано",
|
||||
// ибо сейчас после клика как-то неубедительно, скопировалось оно или нет
|
||||
console.log('Copying text command was %s', successful ? 'successful' : 'unsuccessful');
|
||||
} catch (err) {
|
||||
// not critical
|
||||
}
|
||||
};
|
||||
|
||||
setCode = (el) => {
|
||||
this.code = el;
|
||||
copy(this.props.code);
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import icons from 'components/ui/icons.scss';
|
||||
import { Input, Checkbox } from 'components/ui/form';
|
||||
import BaseAuthBody from 'components/auth/BaseAuthBody';
|
||||
|
||||
import authStyles from 'components/auth/auth.scss';
|
||||
import styles from './password.scss';
|
||||
import messages from './Password.intl.json';
|
||||
|
||||
@ -40,10 +41,12 @@ export default class PasswordBody extends BaseAuthBody {
|
||||
placeholder={messages.accountPassword}
|
||||
/>
|
||||
|
||||
<Checkbox {...this.bindField('rememberMe')}
|
||||
defaultChecked
|
||||
label={messages.rememberMe}
|
||||
/>
|
||||
<div className={authStyles.checkboxInput}>
|
||||
<Checkbox {...this.bindField('rememberMe')}
|
||||
defaultChecked
|
||||
label={messages.rememberMe}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { Input, Checkbox, Captcha } from 'components/ui/form';
|
||||
import BaseAuthBody from 'components/auth/BaseAuthBody';
|
||||
import passwordMessages from 'components/auth/password/Password.intl.json';
|
||||
|
||||
import styles from 'components/auth/auth.scss';
|
||||
import messages from './Register.intl.json';
|
||||
|
||||
// TODO: password and username can be validate for length and sameness
|
||||
@ -56,19 +57,21 @@ export default class RegisterBody extends BaseAuthBody {
|
||||
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
|
||||
<Checkbox {...this.bindField('rulesAgreement')}
|
||||
color="blue"
|
||||
required
|
||||
label={
|
||||
<Message {...messages.acceptRules} values={{
|
||||
link: (
|
||||
<Link to="/rules" target="_blank">
|
||||
<Message {...messages.termsOfService} />
|
||||
</Link>
|
||||
)
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -118,6 +118,8 @@ export class ContactForm extends Component {
|
||||
required
|
||||
label={messages.message}
|
||||
skin="light"
|
||||
minRows={6}
|
||||
maxRows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
246
src/components/dev/apps/ApplicationItem.js
Normal file
246
src/components/dev/apps/ApplicationItem.js
Normal file
@ -0,0 +1,246 @@
|
||||
// @flow
|
||||
import React, { Component } 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 'components/ui';
|
||||
import { Input, Button } from 'components/ui/form';
|
||||
import Collapse from 'components/ui/collapse';
|
||||
|
||||
import styles from './applicationsIndex.scss';
|
||||
import messages from './ApplicationsIndex.intl.json';
|
||||
|
||||
import type { Node } from 'react';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
const ACTION_REVOKE_TOKENS = 'revoke-tokens';
|
||||
const ACTION_RESET_SECRET = 'reset-secret';
|
||||
const ACTION_DELETE = 'delete';
|
||||
|
||||
export default class ApplicationItem extends Component<{
|
||||
application: OauthAppResponse,
|
||||
expand: bool,
|
||||
onTileClick: (string) => void,
|
||||
onResetSubmit: (string, bool) => Promise<*>,
|
||||
onDeleteSubmit: (string) => Promise<*>,
|
||||
}, {
|
||||
selectedAction: ?string,
|
||||
isActionPerforming: bool,
|
||||
detailsHeight: number,
|
||||
}> {
|
||||
state = {
|
||||
selectedAction: null,
|
||||
isActionPerforming: false,
|
||||
detailsHeight: 0,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { application: app, expand } = this.props;
|
||||
const { selectedAction, isActionPerforming } = this.state;
|
||||
|
||||
let actionContent: Node;
|
||||
// eslint-disable-next-line
|
||||
switch (selectedAction) {
|
||||
case ACTION_REVOKE_TOKENS:
|
||||
case ACTION_RESET_SECRET:
|
||||
actionContent = (
|
||||
<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>
|
||||
);
|
||||
break;
|
||||
case ACTION_DELETE:
|
||||
actionContent = (
|
||||
<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>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO: @SleepWalker: нужно сделать так, чтобы форматирование числа пользователей шло через пробел
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.appItemContainer, {
|
||||
[styles.appExpanded]: expand,
|
||||
})}>
|
||||
<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}>
|
||||
<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}>
|
||||
<Button
|
||||
label={messages.revokeAllTokens}
|
||||
color={COLOR_BLACK}
|
||||
className={styles.appActionButton}
|
||||
disabled={selectedAction && selectedAction !== ACTION_REVOKE_TOKENS}
|
||||
onClick={this.onActionButtonClick(ACTION_REVOKE_TOKENS)}
|
||||
small
|
||||
/>
|
||||
<Button
|
||||
label={messages.resetClientSecret}
|
||||
color={COLOR_BLACK}
|
||||
className={styles.appActionButton}
|
||||
disabled={selectedAction && selectedAction !== ACTION_RESET_SECRET}
|
||||
onClick={this.onActionButtonClick(ACTION_RESET_SECRET)}
|
||||
small
|
||||
/>
|
||||
<Button
|
||||
label={messages.delete}
|
||||
color={COLOR_BLACK}
|
||||
className={styles.appActionButton}
|
||||
disabled={selectedAction && selectedAction !== ACTION_DELETE}
|
||||
onClick={this.onActionButtonClick(ACTION_DELETE)}
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
{actionContent}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onTileToggle = () => {
|
||||
const { onTileClick, application } = this.props;
|
||||
onTileClick(application.clientId);
|
||||
};
|
||||
|
||||
onCollapseRest = () => {
|
||||
if (!this.props.expand && this.state.selectedAction) {
|
||||
this.setState({
|
||||
selectedAction: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onActionButtonClick = (type: ?string) => () => {
|
||||
this.setState({
|
||||
selectedAction: type === this.state.selectedAction ? null : type,
|
||||
});
|
||||
};
|
||||
|
||||
onResetSubmit = (resetClientSecret: bool) => async () => {
|
||||
const { onResetSubmit, application } = this.props;
|
||||
this.setState({
|
||||
isActionPerforming: true,
|
||||
});
|
||||
await onResetSubmit(application.clientId, resetClientSecret);
|
||||
this.setState({
|
||||
isActionPerforming: false,
|
||||
selectedAction: null,
|
||||
});
|
||||
};
|
||||
|
||||
onSubmitDelete = () => {
|
||||
const { onDeleteSubmit, application } = this.props;
|
||||
this.setState({
|
||||
isActionPerforming: true,
|
||||
});
|
||||
onDeleteSubmit(application.clientId);
|
||||
};
|
||||
}
|
26
src/components/dev/apps/ApplicationsIndex.intl.json
Normal file
26
src/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…"
|
||||
}
|
198
src/components/dev/apps/ApplicationsIndex.js
Normal file
198
src/components/dev/apps/ApplicationsIndex.js
Normal file
@ -0,0 +1,198 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
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 ApplicationItem from './ApplicationItem';
|
||||
|
||||
import { LinkButton } from 'components/ui/form';
|
||||
import { COLOR_GREEN, COLOR_BLUE } from 'components/ui';
|
||||
import { restoreScroll } from 'components/ui/scroll/scroll';
|
||||
|
||||
import type { Node } from 'react';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
type Props = {
|
||||
location: {
|
||||
hash: string,
|
||||
},
|
||||
displayForGuest: bool,
|
||||
applications: Array<OauthAppResponse>,
|
||||
isLoading: bool,
|
||||
createContactPopup: () => void,
|
||||
deleteApp: (string) => Promise<any>,
|
||||
resetApp: (string, bool) => Promise<any>,
|
||||
};
|
||||
|
||||
type State = {
|
||||
expandedApp: ?string,
|
||||
};
|
||||
|
||||
class ApplicationsIndex extends Component<Props, State> {
|
||||
state = {
|
||||
expandedApp: null,
|
||||
};
|
||||
|
||||
appsRefs = {};
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const { applications, isLoading, location } = this.props;
|
||||
if (isLoading !== prevProps.isLoading && applications.length) {
|
||||
const hash = location.hash.substr(1);
|
||||
if (hash !== '' && applications.some((app) => app.clientId === hash)) {
|
||||
requestAnimationFrame(() => this.onTileClick(hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { displayForGuest, applications, isLoading, resetApp, deleteApp } = this.props;
|
||||
const { expandedApp } = this.state;
|
||||
|
||||
let content: Node;
|
||||
if (displayForGuest) {
|
||||
content = (
|
||||
<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>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
content = (
|
||||
<div className={styles.emptyState}>
|
||||
<img src={loadingCubeIcon} className={styles.loadingStateIcon} />
|
||||
</div>
|
||||
);
|
||||
} else if (applications.length > 0) {
|
||||
content = (
|
||||
<div>
|
||||
<div className={styles.appsListTitleContainer}>
|
||||
<div className={styles.appsListTitle}>
|
||||
<Message {...messages.yourApplications} />
|
||||
</div>
|
||||
<LinkButton
|
||||
to="/dev/applications/new"
|
||||
label={messages.addNew}
|
||||
color={COLOR_GREEN}
|
||||
className={styles.appsListAddNewAppBtn}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.appsListContainer}>
|
||||
{applications.map((app: OauthAppResponse) => (
|
||||
<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>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className={styles.emptyState}>
|
||||
<img src={cubeIcon} className={styles.emptyStateIcon} />
|
||||
<div className={styles.emptyStateText}>
|
||||
<div>
|
||||
<Message {...messages.youDontHaveAnyApplication}/>
|
||||
</div>
|
||||
<div>
|
||||
<Message {...messages.shallWeStart}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkButton
|
||||
to="/dev/applications/new"
|
||||
label={messages.addNew}
|
||||
color={COLOR_GREEN}
|
||||
className={styles.emptyStateActionButton}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="http://docs.ely.by/oauth.html" target="_blank">
|
||||
<Message {...messages.ourDocumentation} />
|
||||
</a>
|
||||
),
|
||||
}} />
|
||||
</div>
|
||||
<div className={styles.welcomeParagraph}>
|
||||
<Message {...messages.ifYouHaveAnyTroubles} values={{
|
||||
feedback: (
|
||||
<a href="#" onClick={this.onContact}>
|
||||
<Message {...messages.feedback} />
|
||||
</a>
|
||||
),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onTileClick = (clientId: string) => {
|
||||
const expandedApp = this.state.expandedApp === clientId ? null : clientId;
|
||||
this.setState({expandedApp}, () => {
|
||||
if (expandedApp !== null) {
|
||||
// TODO: @SleepWalker: мб у тебя есть идея, как это сделать более правильно и менее дёргано?
|
||||
setTimeout(() => restoreScroll(this.appsRefs[clientId]), 150);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onContact = (event) => {
|
||||
event.preventDefault();
|
||||
this.props.createContactPopup();
|
||||
};
|
||||
}
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router';
|
||||
import { create as createPopup } from 'components/ui/popup/actions';
|
||||
import ContactForm from 'components/contact/ContactForm';
|
||||
|
||||
export default withRouter(connect(null, {
|
||||
createContactPopup: () => createPopup(ContactForm),
|
||||
})(ApplicationsIndex));
|
68
src/components/dev/apps/actions.js
Normal file
68
src/components/dev/apps/actions.js
Normal file
@ -0,0 +1,68 @@
|
||||
// @flow
|
||||
|
||||
import oauth from 'services/api/oauth';
|
||||
|
||||
import type { Dispatch } from 'redux';
|
||||
import type { Apps } from './reducer';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
import type { User } from 'components/user';
|
||||
|
||||
export const SET_AVAILABLE = 'SET_AVAILABLE';
|
||||
export function setAppsList(apps: Array<OauthAppResponse>) {
|
||||
return {
|
||||
type: SET_AVAILABLE,
|
||||
payload: apps,
|
||||
};
|
||||
}
|
||||
|
||||
export function getApp(state: {apps: Apps}, clientId: string): ?OauthAppResponse {
|
||||
return state.apps.available.find((app) => app.clientId === clientId) || null;
|
||||
}
|
||||
|
||||
export function fetchApp(clientId: string) {
|
||||
return async (dispatch: Dispatch<any>, getState: () => {apps: Apps}) => {
|
||||
const fetchedApp = await oauth.getApp(clientId);
|
||||
const { available } = getState().apps;
|
||||
let newApps: Array<OauthAppResponse>;
|
||||
if (available.some((app) => app.clientId === fetchedApp.clientId)) {
|
||||
newApps = available.map((app) => app.clientId === fetchedApp.clientId ? fetchedApp : app);
|
||||
} else {
|
||||
newApps = [...available, fetchedApp];
|
||||
}
|
||||
|
||||
return dispatch(setAppsList(newApps));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchAvailableApps() {
|
||||
return async (dispatch: Dispatch<any>, getState: () => {user: User}) => {
|
||||
const { id } = getState().user;
|
||||
if (!id) {
|
||||
throw new Error('store.user.id is required for this action');
|
||||
}
|
||||
|
||||
const apps = await oauth.getAppsByUser(id);
|
||||
|
||||
return dispatch(setAppsList(apps));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteApp(clientId: string) {
|
||||
return async (dispatch: Dispatch<any>, getState: () => {apps: Apps}) => {
|
||||
await oauth.delete(clientId);
|
||||
const apps = getState().apps.available.filter((app) => app.clientId !== clientId);
|
||||
|
||||
return dispatch(setAppsList(apps));
|
||||
};
|
||||
}
|
||||
|
||||
export function resetApp(clientId: string, resetSecret: bool) {
|
||||
return async (dispatch: Dispatch<any>, getState: () => {apps: Apps}) => {
|
||||
const result = await oauth.reset(clientId, resetSecret);
|
||||
if (resetSecret) {
|
||||
const apps = getState().apps.available.map((app) => app.clientId === clientId ? result.data : app);
|
||||
|
||||
return dispatch(setAppsList(apps));
|
||||
}
|
||||
};
|
||||
}
|
@ -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"
|
||||
}
|
129
src/components/dev/apps/applicationForm/ApplicationForm.js
Normal file
129
src/components/dev/apps/applicationForm/ApplicationForm.js
Normal file
@ -0,0 +1,129 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { Form, FormModel, Button } from 'components/ui/form';
|
||||
import { BackButton } from 'components/profile/ProfileForm';
|
||||
import { COLOR_GREEN } from 'components/ui';
|
||||
|
||||
import { TYPE_APPLICATION, TYPE_MINECRAFT_SERVER } from 'components/dev/apps';
|
||||
import styles from 'components/profile/profileForm.scss';
|
||||
import messages from './ApplicationForm.intl.json';
|
||||
|
||||
import ApplicationTypeSwitcher from './ApplicationTypeSwitcher';
|
||||
import WebsiteType from './WebsiteType';
|
||||
import MinecraftServerType from './MinecraftServerType';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
const typeToForm: {
|
||||
[key: string]: {
|
||||
label: MessageDescriptor,
|
||||
component: ComponentType<any>,
|
||||
},
|
||||
} = {
|
||||
[TYPE_APPLICATION]: {
|
||||
label: messages.website,
|
||||
component: WebsiteType,
|
||||
},
|
||||
[TYPE_MINECRAFT_SERVER]: {
|
||||
label: messages.minecraftServer,
|
||||
component: MinecraftServerType,
|
||||
},
|
||||
};
|
||||
|
||||
const typeToLabel: {
|
||||
[key: string]: MessageDescriptor,
|
||||
} = Object.keys(typeToForm).reduce((result, key: string) => {
|
||||
result[key] = typeToForm[key].label;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
export default class ApplicationForm extends Component<{
|
||||
app: OauthAppResponse,
|
||||
form: FormModel,
|
||||
displayTypeSwitcher?: bool,
|
||||
type: ?string,
|
||||
setType: (string) => void,
|
||||
onSubmit: (FormModel) => Promise<*>,
|
||||
}> {
|
||||
static displayName = 'ApplicationForm';
|
||||
|
||||
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 && (
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.toDisplayRegistrationFormChooseType} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{FormComponent && <FormComponent form={form} app={app} />}
|
||||
</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;
|
||||
}
|
||||
|
||||
throw resp;
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { SKIN_LIGHT } from 'components/ui';
|
||||
import { Radio } from 'components/ui/form';
|
||||
|
||||
import styles from './applicationTypeSwitcher.scss';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
export default class ApplicationTypeSwitcher extends Component<{
|
||||
appTypes: {
|
||||
[key: string]: MessageDescriptor,
|
||||
},
|
||||
selectedType: ?string,
|
||||
setType: (type: string) => void,
|
||||
}> {
|
||||
render() {
|
||||
const { appTypes, selectedType } = this.props;
|
||||
|
||||
return (
|
||||
<div onChange={this.onChange}>
|
||||
{Object.keys(appTypes).map((type: string) => (
|
||||
<div className={styles.radioContainer} key={type}>
|
||||
<Radio
|
||||
skin={SKIN_LIGHT}
|
||||
label={appTypes[type]}
|
||||
value={type}
|
||||
checked={selectedType === type}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onChange = (event: {target: {value: string}}) => {
|
||||
this.props.setType(event.target.value);
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import styles from 'components/profile/profileForm.scss';
|
||||
import messages from './ApplicationForm.intl.json';
|
||||
|
||||
import { Input, FormModel } from 'components/ui/form';
|
||||
import { SKIN_LIGHT } from 'components/ui';
|
||||
|
||||
import type {OauthAppResponse} from 'services/api/oauth';
|
||||
|
||||
export default class MinecraftServerType extends Component<{
|
||||
form: FormModel,
|
||||
app: OauthAppResponse,
|
||||
}> {
|
||||
render() {
|
||||
const { form, app } = this.props;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
74
src/components/dev/apps/applicationForm/WebsiteType.js
Normal file
74
src/components/dev/apps/applicationForm/WebsiteType.js
Normal file
@ -0,0 +1,74 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import styles from 'components/profile/profileForm.scss';
|
||||
import messages from './ApplicationForm.intl.json';
|
||||
|
||||
import { Input, TextArea, FormModel } from 'components/ui/form';
|
||||
import { SKIN_LIGHT } from 'components/ui';
|
||||
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
export default class WebsiteType extends Component<{
|
||||
form: FormModel,
|
||||
app: OauthAppResponse,
|
||||
}> {
|
||||
render() {
|
||||
const { form, app } = this.props;
|
||||
|
||||
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;
|
||||
}
|
252
src/components/dev/apps/applicationsIndex.scss
Normal file
252
src/components/dev/apps/applicationsIndex.scss
Normal file
@ -0,0 +1,252 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
@import '~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
|
||||
.2s + .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;
|
||||
|
||||
> 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;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingStateIcon {
|
||||
composes: emptyStateIcon;
|
||||
|
||||
margin-bottom: 130px; // TODO: this is needed to render empty state without height jumping. Maybe it can be done more dynamically?
|
||||
}
|
||||
|
||||
.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 .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 'components/ui/icons.scss';
|
||||
|
||||
position: relative;
|
||||
left: 0;
|
||||
|
||||
font-size: 28px;
|
||||
color: #ebe8e1;
|
||||
|
||||
transition: .25s;
|
||||
|
||||
.appItemTile:hover & {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.appExpanded & {
|
||||
color: #777;
|
||||
transform: rotate(360deg)!important; // Prevent it from hover rotating
|
||||
}
|
||||
}
|
||||
|
||||
.appDetailsContainer {
|
||||
background: #F5F5F5;
|
||||
border-top: 1px solid #eee;
|
||||
padding: 5px 30px;
|
||||
}
|
||||
|
||||
.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 '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;
|
||||
}
|
||||
}
|
||||
|
||||
.appActionDescription {
|
||||
composes: appDetailsDescription;
|
||||
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.continueActionButtonWrapper {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.continueActionLink {
|
||||
composes: textLink from 'index.scss';
|
||||
|
||||
font-family: $font-family-title;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.performingAction {
|
||||
font-family: $font-family-title;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
5
src/components/dev/apps/icons/cube.svg
Normal file
5
src/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
src/components/dev/apps/icons/loading-cube.svg
Normal file
17
src/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
src/components/dev/apps/icons/tools.svg
Normal file
6
src/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 |
4
src/components/dev/apps/index.js
Normal file
4
src/components/dev/apps/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
// @flow
|
||||
|
||||
export const TYPE_APPLICATION = 'application';
|
||||
export const TYPE_MINECRAFT_SERVER = 'minecraft-server';
|
29
src/components/dev/apps/reducer.js
Normal file
29
src/components/dev/apps/reducer.js
Normal file
@ -0,0 +1,29 @@
|
||||
// @flow
|
||||
import { SET_AVAILABLE } from './actions';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
export type Apps = {
|
||||
+available: Array<OauthAppResponse>,
|
||||
};
|
||||
|
||||
const defaults: Apps = {
|
||||
available: [],
|
||||
};
|
||||
|
||||
export default function apps(
|
||||
state: Apps = defaults,
|
||||
{type, payload}: {type: string, payload: ?Object}
|
||||
) {
|
||||
switch (type) {
|
||||
case SET_AVAILABLE:
|
||||
return {
|
||||
...state,
|
||||
available: payload,
|
||||
};
|
||||
|
||||
default:
|
||||
return state || {
|
||||
...defaults
|
||||
};
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import PropTypes from 'prop-types';
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -10,14 +10,12 @@ import { create as createPopup } from 'components/ui/popup/actions';
|
||||
import styles from './footerMenu.scss';
|
||||
import messages from './footerMenu.intl.json';
|
||||
|
||||
class FooterMenu extends Component {
|
||||
class FooterMenu extends Component<{
|
||||
createContactPopup: () => void,
|
||||
createLanguageSwitcherPopup: () => void,
|
||||
}> {
|
||||
static displayName = 'FooterMenu';
|
||||
|
||||
static propTypes = {
|
||||
createContactPopup: PropTypes.func.isRequired,
|
||||
createLanguageSwitcherPopup: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.footerMenu}>
|
||||
@ -28,6 +26,10 @@ class FooterMenu extends Component {
|
||||
<a href="#" onClick={this.onContact}>
|
||||
<Message {...messages.contactUs} />
|
||||
</a>
|
||||
{' | '}
|
||||
<Link to="/dev">
|
||||
<Message {...messages.forDevelopers} />
|
||||
</Link>
|
||||
|
||||
<div className={styles.langTriggerContainer}>
|
||||
<a href="#" className={styles.langTrigger} onClick={this.onLanguageSwitcher}>
|
||||
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"rules": "Rules",
|
||||
"contactUs": "Contact Us",
|
||||
"siteLanguage": "Site language"
|
||||
"siteLanguage": "Site language",
|
||||
"forDevelopers": "For developers"
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
@ -9,12 +10,20 @@ import styles from 'components/profile/profileForm.scss';
|
||||
|
||||
import messages from './ProfileForm.intl.json';
|
||||
|
||||
export class BackButton extends FormComponent {
|
||||
export class BackButton extends FormComponent<{
|
||||
to: string,
|
||||
}> {
|
||||
static displayName = 'BackButton';
|
||||
|
||||
static defaultProps = {
|
||||
to: '/',
|
||||
};
|
||||
|
||||
render() {
|
||||
const { to } = this.props;
|
||||
|
||||
return (
|
||||
<Link className={styles.backButton} to="/" title={this.formatMessage(messages.back)}>
|
||||
<Link className={styles.backButton} to={to} title={this.formatMessage(messages.back)}>
|
||||
<span className={styles.backIcon} />
|
||||
<span className={styles.backText}>
|
||||
<Message {...messages.back} />
|
||||
|
@ -13,7 +13,7 @@ export default function BsodMiddleware(dispatchBsod: Function, logger: Logger) {
|
||||
(resp instanceof InternalServerError
|
||||
&& resp.error.code !== ABORT_ERR
|
||||
) || (resp.originalResponse
|
||||
&& /404|5\d\d/.test((resp.originalResponse.status: string))
|
||||
&& /5\d\d/.test((resp.originalResponse.status: string))
|
||||
)
|
||||
)) {
|
||||
dispatchBsod();
|
||||
|
@ -47,7 +47,7 @@
|
||||
composes: button;
|
||||
|
||||
height: 30px;
|
||||
padding: 0 7px;
|
||||
padding: 0 15px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
}
|
||||
@ -75,6 +75,7 @@
|
||||
@include button-theme('darkBlue', $dark_blue);
|
||||
@include button-theme('lightViolet', $light_violet);
|
||||
@include button-theme('violet', $violet);
|
||||
@include button-theme('red', $red);
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
@ -90,3 +91,8 @@
|
||||
outline: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.black.disabled {
|
||||
background: #95A5A6;
|
||||
cursor: default;
|
||||
}
|
||||
|
85
src/components/ui/collapse/Collapse.js
Normal file
85
src/components/ui/collapse/Collapse.js
Normal file
@ -0,0 +1,85 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
|
||||
import MeasureHeight from 'components/MeasureHeight';
|
||||
|
||||
import styles from './collapse.scss';
|
||||
|
||||
import type { Node } from 'react';
|
||||
|
||||
type Props = {
|
||||
isOpened?: bool,
|
||||
children: Node,
|
||||
onRest: () => void,
|
||||
};
|
||||
|
||||
export default class Collapse extends Component<Props, {
|
||||
height: number,
|
||||
wasInitialized: bool,
|
||||
}> {
|
||||
state = {
|
||||
height: 0,
|
||||
wasInitialized: false,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onRest: () => {},
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (this.props.isOpened !== nextProps.isOpened && !this.state.wasInitialized) {
|
||||
this.setState({
|
||||
wasInitialized: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// TODO: @SleepWalker сейчас при первой отрисовке можно увидеть дёргание родительского блока. Надо пофиксить.
|
||||
const { isOpened, children, onRest } = this.props;
|
||||
const { height, wasInitialized } = this.state;
|
||||
|
||||
return (
|
||||
<div className={styles.overflow}>
|
||||
<MeasureHeight
|
||||
state={this.shouldMeasureHeight()}
|
||||
onMeasure={this.onUpdateHeight}
|
||||
>
|
||||
<Motion
|
||||
style={{
|
||||
top: wasInitialized ? spring(isOpened ? 0 : -height) : -height,
|
||||
}}
|
||||
onRest={onRest}
|
||||
>
|
||||
{({top}) => (
|
||||
<div
|
||||
className={styles.content}
|
||||
style={{
|
||||
marginTop: top,
|
||||
visibility: wasInitialized ? 'inherit' : 'hidden',
|
||||
// height: wasInitialized ? 'auto' : 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</MeasureHeight>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onUpdateHeight = (height: number) => {
|
||||
this.setState({
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
shouldMeasureHeight = () => {
|
||||
return [
|
||||
this.props.isOpened,
|
||||
this.state.wasInitialized,
|
||||
].join('');
|
||||
};
|
||||
}
|
9
src/components/ui/collapse/collapse.scss
Normal file
9
src/components/ui/collapse/collapse.scss
Normal file
@ -0,0 +1,9 @@
|
||||
@import '~components/ui/colors.scss';
|
||||
|
||||
.overflow {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
}
|
2
src/components/ui/collapse/index.js
Normal file
2
src/components/ui/collapse/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export { default } from './Collapse';
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@ -9,19 +10,21 @@ import { COLOR_GREEN } from 'components/ui';
|
||||
import FormComponent from './FormComponent';
|
||||
|
||||
import type { Color } from 'components/ui';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
export default class Button extends FormComponent {
|
||||
props: {
|
||||
label: string | {id: string},
|
||||
block: bool,
|
||||
small: bool,
|
||||
loading: bool,
|
||||
className: string,
|
||||
color: Color
|
||||
};
|
||||
|
||||
export default class Button extends FormComponent<{
|
||||
label: string | MessageDescriptor,
|
||||
block?: bool,
|
||||
small?: bool,
|
||||
loading?: bool,
|
||||
className?: string,
|
||||
color?: Color,
|
||||
disabled?: bool,
|
||||
component?: string | ComponentType<any>,
|
||||
} | HTMLButtonElement> {
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN
|
||||
color: COLOR_GREEN,
|
||||
component: 'button',
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -29,23 +32,27 @@ export default class Button extends FormComponent {
|
||||
color,
|
||||
block,
|
||||
small,
|
||||
disabled,
|
||||
className,
|
||||
loading,
|
||||
label,
|
||||
component: ComponentProp,
|
||||
...restProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
<ComponentProp
|
||||
className={classNames(buttons[color], {
|
||||
[buttons.loading]: loading,
|
||||
[buttons.block]: block,
|
||||
[buttons.smallButton]: small
|
||||
[buttons.smallButton]: small,
|
||||
[buttons.disabled]: disabled,
|
||||
}, className)}
|
||||
disabled={disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{this.formatMessage(label)}
|
||||
</button>
|
||||
</ComponentProp>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,27 @@
|
||||
import PropTypes from 'prop-types';
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { colors, skins, SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
import { SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
import { omit } from 'functions';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class Checkbox extends FormInputComponent {
|
||||
static displayName = 'Checkbox';
|
||||
import type { Color, Skin } from 'components/ui';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
static propTypes = {
|
||||
color: PropTypes.oneOf(colors),
|
||||
skin: PropTypes.oneOf(skins),
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
PropTypes.string
|
||||
]).isRequired
|
||||
};
|
||||
export default class Checkbox extends FormInputComponent<{
|
||||
color: Color,
|
||||
skin: Skin,
|
||||
label: string | MessageDescriptor,
|
||||
}> {
|
||||
static displayName = 'Checkbox';
|
||||
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK
|
||||
skin: SKIN_DARK,
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -34,13 +30,13 @@ export default class Checkbox extends FormInputComponent {
|
||||
|
||||
label = this.formatMessage(label);
|
||||
|
||||
const props = omit(this.props, Object.keys(Checkbox.propTypes));
|
||||
const props = omit(this.props, ['color', 'skin', 'label']);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles[`${color}CheckboxRow`], styles[`${skin}CheckboxRow`])}>
|
||||
<label className={styles.checkboxContainer}>
|
||||
<div className={classNames(styles[`${color}MarkableRow`], styles[`${skin}MarkableRow`])}>
|
||||
<label className={styles.markableContainer}>
|
||||
<input ref={this.setEl}
|
||||
className={styles.checkboxInput}
|
||||
className={styles.markableInput}
|
||||
type="checkbox"
|
||||
{...props}
|
||||
/>
|
||||
|
@ -1,57 +1,56 @@
|
||||
import PropTypes from 'prop-types';
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { uniqueId, omit } from 'functions';
|
||||
import copy from 'services/copy';
|
||||
import icons from 'components/ui/icons.scss';
|
||||
import { colors, skins, SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
import { SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class Input extends FormInputComponent {
|
||||
static displayName = 'Input';
|
||||
import type { Skin, Color } from 'components/ui';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
static propTypes = {
|
||||
placeholder: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
PropTypes.string
|
||||
]),
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
PropTypes.string
|
||||
]),
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string.isRequired,
|
||||
payload: PropTypes.object.isRequired
|
||||
})
|
||||
]),
|
||||
icon: PropTypes.string,
|
||||
skin: PropTypes.oneOf(skins),
|
||||
color: PropTypes.oneOf(colors),
|
||||
center: PropTypes.bool
|
||||
};
|
||||
let copiedStateTimeout;
|
||||
|
||||
export default class Input extends FormInputComponent<{
|
||||
skin: Skin,
|
||||
color: Color,
|
||||
center: bool,
|
||||
disabled: bool,
|
||||
label?: string | MessageDescriptor,
|
||||
placeholder?: string | MessageDescriptor,
|
||||
error?: string | {type: string, payload: string},
|
||||
icon?: string,
|
||||
copy?: bool,
|
||||
}, {
|
||||
wasCopied: bool,
|
||||
}> {
|
||||
static displayName = 'Input';
|
||||
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK
|
||||
skin: SKIN_DARK,
|
||||
center: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
wasCopied: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { color, skin, center } = this.props;
|
||||
let { icon, label } = this.props;
|
||||
let { icon, label, copy } = this.props;
|
||||
const { wasCopied } = this.state;
|
||||
|
||||
const props = omit({
|
||||
type: 'text',
|
||||
...this.props
|
||||
}, Object.keys(Input.propTypes).filter((prop) => prop !== 'placeholder'));
|
||||
...this.props,
|
||||
}, ['label', 'error', 'skin', 'color', 'center', 'icon', 'copy']);
|
||||
|
||||
if (label) {
|
||||
if (!props.id) {
|
||||
@ -77,6 +76,19 @@ export default class Input extends FormInputComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
copy = (
|
||||
<div
|
||||
className={classNames(styles.copyIcon, {
|
||||
[icons.clipboard]: !wasCopied,
|
||||
[icons.checkmark]: wasCopied,
|
||||
[styles.copyCheckmark]: wasCopied,
|
||||
})}
|
||||
onClick={this.onCopy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{label}
|
||||
@ -92,6 +104,7 @@ export default class Input extends FormInputComponent {
|
||||
{...props}
|
||||
/>
|
||||
{icon}
|
||||
{copy}
|
||||
</div>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
@ -106,4 +119,15 @@ export default class Input extends FormInputComponent {
|
||||
this.el.focus();
|
||||
setTimeout(this.el.focus.bind(this.el), 10);
|
||||
}
|
||||
|
||||
onCopy = async () => {
|
||||
try {
|
||||
clearTimeout(copiedStateTimeout);
|
||||
await copy(this.getValue());
|
||||
this.setState({wasCopied: true});
|
||||
copiedStateTimeout = setTimeout(() => this.setState({wasCopied: false}), 2000);
|
||||
} catch (err) {
|
||||
// it's okay
|
||||
}
|
||||
};
|
||||
}
|
||||
|
14
src/components/ui/form/LinkButton.js
Normal file
14
src/components/ui/form/LinkButton.js
Normal file
@ -0,0 +1,14 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type {ElementProps} from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Button from './Button';
|
||||
|
||||
export default function LinkButton(props: ElementProps<typeof Button> | ElementProps<typeof Link>) {
|
||||
const {to, ...restProps} = props;
|
||||
|
||||
return (
|
||||
<Button component={Link} to={to} {...restProps} />
|
||||
);
|
||||
}
|
58
src/components/ui/form/Radio.js
Normal file
58
src/components/ui/form/Radio.js
Normal file
@ -0,0 +1,58 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
import { omit } from 'functions';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
import type { Color, Skin } from 'components/ui';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
export default class Radio extends FormInputComponent<{
|
||||
color: Color,
|
||||
skin: Skin,
|
||||
label: string | MessageDescriptor,
|
||||
}> {
|
||||
static displayName = 'Radio';
|
||||
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { color, skin } = this.props;
|
||||
let { label } = this.props;
|
||||
|
||||
label = this.formatMessage(label);
|
||||
|
||||
const props = omit(this.props, ['color', 'skin', 'label']);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles[`${color}MarkableRow`], styles[`${skin}MarkableRow`])}>
|
||||
<label className={styles.markableContainer}>
|
||||
<input ref={this.setEl}
|
||||
className={styles.markableInput}
|
||||
type="radio"
|
||||
{...props}
|
||||
/>
|
||||
<div className={styles.radio} />
|
||||
{label}
|
||||
</label>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.el.checked ? 1 : 0;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.el.focus();
|
||||
}
|
||||
}
|
@ -1,38 +1,38 @@
|
||||
import PropTypes from 'prop-types';
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { uniqueId, omit } from 'functions';
|
||||
import { colors, skins, SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
import { SKIN_DARK, COLOR_GREEN } from 'components/ui';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class TextArea extends FormInputComponent {
|
||||
static displayName = 'TextArea';
|
||||
import type { Skin, Color } from 'components/ui';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
static propTypes = {
|
||||
placeholder: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
PropTypes.string
|
||||
]),
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
PropTypes.string
|
||||
]),
|
||||
error: PropTypes.string,
|
||||
skin: PropTypes.oneOf(skins),
|
||||
color: PropTypes.oneOf(colors)
|
||||
};
|
||||
type TextareaAutosizeProps = {
|
||||
onHeightChange?: (number, TextareaAutosizeProps) => void,
|
||||
useCacheForDOMMeasurements?: bool,
|
||||
minRows?: number,
|
||||
maxRows?: number,
|
||||
inputRef?: (HTMLTextAreaElement) => void,
|
||||
} | HTMLTextAreaElement;
|
||||
|
||||
export default class TextArea extends FormInputComponent<{
|
||||
placeholder?: string | MessageDescriptor,
|
||||
label?: string | MessageDescriptor,
|
||||
error?: string,
|
||||
skin: Skin,
|
||||
color: Color,
|
||||
} | TextareaAutosizeProps> {
|
||||
static displayName = 'TextArea';
|
||||
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK
|
||||
skin: SKIN_DARK,
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -41,8 +41,8 @@ export default class TextArea extends FormInputComponent {
|
||||
|
||||
const props = omit({
|
||||
type: 'text',
|
||||
...this.props
|
||||
}, Object.keys(TextArea.propTypes).filter((prop) => prop !== 'placeholder'));
|
||||
...this.props,
|
||||
}, ['label', 'error', 'skin', 'color']);
|
||||
|
||||
if (label) {
|
||||
if (!props.id) {
|
||||
@ -64,7 +64,7 @@ export default class TextArea extends FormInputComponent {
|
||||
<div className={styles.formRow}>
|
||||
{label}
|
||||
<div className={styles.textAreaContainer}>
|
||||
<textarea ref={this.setEl}
|
||||
<TextareaAutosize inputRef={this.setEl}
|
||||
className={classNames(
|
||||
styles.textArea,
|
||||
styles[`${skin}TextField`],
|
||||
|
@ -94,6 +94,23 @@
|
||||
@include form-transition();
|
||||
}
|
||||
|
||||
.copyIcon {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 10px;
|
||||
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 20px;
|
||||
|
||||
transition: .25s;
|
||||
}
|
||||
|
||||
.copyCheckmark {
|
||||
color: $green!important;
|
||||
}
|
||||
|
||||
.darkTextField {
|
||||
background: $black;
|
||||
|
||||
@ -106,11 +123,32 @@
|
||||
~ .textFieldIcon {
|
||||
border-color: lighter($black);
|
||||
}
|
||||
|
||||
~ .copyIcon {
|
||||
color: #999;
|
||||
background: $black;
|
||||
|
||||
&:hover {
|
||||
background: lighter($black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightTextField {
|
||||
background: #fff;
|
||||
|
||||
&:disabled {
|
||||
background: #DCD8CD;
|
||||
|
||||
~ .copyIcon {
|
||||
background: #dcd8ce;
|
||||
|
||||
&:hover {
|
||||
background: #EBE8E2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
color: #aaa;
|
||||
@ -120,6 +158,15 @@
|
||||
~ .textFieldIcon {
|
||||
border-color: #dcd8cd;
|
||||
}
|
||||
|
||||
~ .copyIcon {
|
||||
color: #AAA;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textFieldLabel {
|
||||
@ -151,9 +198,10 @@
|
||||
}
|
||||
|
||||
.textArea {
|
||||
height: 150px;
|
||||
height: auto; // unset .textField height
|
||||
min-height: 50px;
|
||||
padding: 5px 10px;
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@ -169,23 +217,23 @@
|
||||
@include input-theme('violet', $violet);
|
||||
|
||||
/**
|
||||
* Checkbox
|
||||
* Markable is our common name for checkboxes and radio buttons
|
||||
*/
|
||||
@mixin checkbox-theme($themeName, $color) {
|
||||
.#{$themeName}CheckboxRow {
|
||||
composes: checkboxRow;
|
||||
@mixin markable-theme($themeName, $color) {
|
||||
.#{$themeName}MarkableRow {
|
||||
composes: markableRow;
|
||||
|
||||
.checkboxContainer {
|
||||
.markableContainer {
|
||||
&:hover {
|
||||
.checkbox {
|
||||
.mark {
|
||||
border-color: $color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkboxInput {
|
||||
.markableInput {
|
||||
&:checked {
|
||||
+ .checkbox {
|
||||
+ .mark {
|
||||
background: $color;
|
||||
border-color: $color;
|
||||
}
|
||||
@ -194,12 +242,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.checkboxRow {
|
||||
.markableRow {
|
||||
height: 22px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
.markableContainer {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding-left: 27px;
|
||||
@ -211,7 +258,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkboxPosition {
|
||||
.markPosition {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
@ -222,12 +269,12 @@
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.checkboxInput {
|
||||
composes: checkboxPosition;
|
||||
.markableInput {
|
||||
composes: markPosition;
|
||||
opacity: 0;
|
||||
|
||||
&:checked {
|
||||
+ .checkbox {
|
||||
+ .mark {
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
@ -235,8 +282,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
composes: checkboxPosition;
|
||||
.mark {
|
||||
composes: markPosition;
|
||||
composes: checkmark from 'components/ui/icons.scss';
|
||||
|
||||
border: 2px #dcd8cd solid;
|
||||
@ -254,21 +301,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lightCheckboxRow {
|
||||
.checkboxContainer {
|
||||
.checkbox {
|
||||
composes: mark;
|
||||
}
|
||||
|
||||
.radio {
|
||||
composes: mark;
|
||||
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.lightMarkableRow {
|
||||
.markableContainer {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.darkCheckboxRow {
|
||||
.checkboxContainer {
|
||||
.darkMarkableRow {
|
||||
.markableContainer {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@include checkbox-theme('green', $green);
|
||||
@include checkbox-theme('blue', $blue);
|
||||
@include checkbox-theme('red', $red);
|
||||
@include markable-theme('green', $green);
|
||||
@include markable-theme('blue', $blue);
|
||||
@include markable-theme('red', $red);
|
||||
|
||||
.isFormLoading {
|
||||
// TODO: надо бы разнести from и input на отдельные модули,
|
||||
|
@ -1,7 +1,9 @@
|
||||
import Input from './Input';
|
||||
import TextArea from './TextArea';
|
||||
import Checkbox from './Checkbox';
|
||||
import Radio from './Radio';
|
||||
import Button from './Button';
|
||||
import LinkButton from './LinkButton';
|
||||
import Form from './Form';
|
||||
import FormModel from './FormModel';
|
||||
import Dropdown from './Dropdown';
|
||||
@ -12,7 +14,9 @@ export {
|
||||
Input,
|
||||
TextArea,
|
||||
Button,
|
||||
LinkButton,
|
||||
Checkbox,
|
||||
Radio,
|
||||
Form,
|
||||
FormModel,
|
||||
Dropdown,
|
||||
|
3
src/icons/webfont/clipboard.svg
Executable file
3
src/icons/webfont/clipboard.svg
Executable file
@ -0,0 +1,3 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path d="M17.119 2.134h-2.222v2.133h2.222v-2.133zM19.341 7.467h-6.665c-0.623 0-1.12-0.48-1.12-1.067v-2.134c0-0.587 0.498-1.067 1.12-1.067h0.053c-0.036-0.177-0.053-0.355-0.053-0.533 0-1.476 1.475-2.667 3.324-2.667s3.341 1.191 3.341 2.667c0 0.178-0.036 0.356-0.072 0.533h0.072c0.606 0 1.102 0.48 1.102 1.067v2.133c0 0.587-0.497 1.067-1.102 1.067zM27.11 32h-22.219c-1.226 0-2.222-0.96-2.222-2.134v-23.466c0-1.173 0.996-2.133 2.222-2.133h5.564v2.667c0 0.889 0.409 1.6 0.925 1.6h9.243c0.517 0 0.925-0.71 0.925-1.6v-2.667h5.564c1.226 0 2.222 0.96 2.222 2.134v23.466c-0 1.174-0.997 2.134-2.223 2.134z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 706 B |
@ -25,10 +25,11 @@ b {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
a {
|
||||
a, .textLink {
|
||||
color: #444;
|
||||
border-bottom: 1px dotted #444;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: .25s;
|
||||
|
||||
&:hover {
|
||||
|
61
src/pages/dev/ApplicationsListPage.js
Normal file
61
src/pages/dev/ApplicationsListPage.js
Normal file
@ -0,0 +1,61 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import ApplicationsIndex from 'components/dev/apps/ApplicationsIndex';
|
||||
|
||||
import type { User } from 'components/user';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
class ApplicationsListPage extends Component<{
|
||||
user: User,
|
||||
apps: Array<OauthAppResponse>,
|
||||
fetchAvailableApps: () => Promise<*>,
|
||||
deleteApp: (string) => Promise<*>,
|
||||
resetApp: (string, bool) => Promise<*>,
|
||||
}, {
|
||||
isLoading: bool,
|
||||
}> {
|
||||
static displayName = 'ApplicationsListPage';
|
||||
|
||||
state = {
|
||||
appsList: [],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
!this.props.user.isGuest && this.loadApplicationsList();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, apps, resetApp, deleteApp } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<ApplicationsIndex
|
||||
displayForGuest={user.isGuest}
|
||||
applications={apps}
|
||||
isLoading={isLoading}
|
||||
deleteApp={deleteApp}
|
||||
resetApp={resetApp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
loadApplicationsList = async () => {
|
||||
this.setState({isLoading: true});
|
||||
await this.props.fetchAvailableApps();
|
||||
this.setState({isLoading: false});
|
||||
};
|
||||
}
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchAvailableApps, resetApp, deleteApp } from 'components/dev/apps/actions';
|
||||
|
||||
export default connect((state) => ({
|
||||
user: state.user,
|
||||
apps: state.apps.available,
|
||||
}), {
|
||||
fetchAvailableApps,
|
||||
resetApp,
|
||||
deleteApp,
|
||||
})(ApplicationsListPage);
|
71
src/pages/dev/CreateNewApplicationPage.js
Normal file
71
src/pages/dev/CreateNewApplicationPage.js
Normal file
@ -0,0 +1,71 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { FormModel } from 'components/ui/form';
|
||||
|
||||
import ApplicationForm from 'components/dev/apps/applicationForm/ApplicationForm';
|
||||
|
||||
import oauth from 'services/api/oauth';
|
||||
import { browserHistory } from 'services/history';
|
||||
|
||||
import type {OauthAppResponse} from 'services/api/oauth';
|
||||
|
||||
const app: OauthAppResponse = {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
countUsers: 0,
|
||||
createdAt: 0,
|
||||
type: '',
|
||||
name: '',
|
||||
description: '',
|
||||
websiteUrl: '',
|
||||
redirectUri: '',
|
||||
minecraftServerIp: '',
|
||||
};
|
||||
|
||||
export default class CreateNewApplicationPage extends Component<{}, {
|
||||
type: ?string,
|
||||
}> {
|
||||
static displayName = 'CreateNewApplicationPage';
|
||||
|
||||
state = {
|
||||
type: null,
|
||||
};
|
||||
|
||||
form = 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: string) => {
|
||||
this.setState({
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
goToMainPage = (hash?: string) => browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
|
||||
}
|
30
src/pages/dev/DevPage.js
Normal file
30
src/pages/dev/DevPage.js
Normal file
@ -0,0 +1,30 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import styles from './dev.scss';
|
||||
|
||||
import ApplicationsListPage from './ApplicationsListPage';
|
||||
import CreateNewApplicationPage from './CreateNewApplicationPage';
|
||||
import UpdateApplicationPage from './UpdateApplicationPage';
|
||||
|
||||
import { FooterMenu } from 'components/footerMenu';
|
||||
|
||||
export default class DevPage extends Component<{}> {
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Switch>
|
||||
<Route path="/dev/applications" exact component={ApplicationsListPage} />
|
||||
<Route path="/dev/applications/new" exact component={CreateNewApplicationPage} />
|
||||
<Route path="/dev/applications/:clientId" component={UpdateApplicationPage} />
|
||||
<Redirect to="/dev/applications" />
|
||||
</Switch>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<FooterMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
113
src/pages/dev/UpdateApplicationPage.js
Normal file
113
src/pages/dev/UpdateApplicationPage.js
Normal file
@ -0,0 +1,113 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { FormModel } from 'components/ui/form';
|
||||
|
||||
import ApplicationForm from 'components/dev/apps/applicationForm/ApplicationForm';
|
||||
|
||||
import { browserHistory } from 'services/history';
|
||||
import oauth from 'services/api/oauth';
|
||||
import loader from 'services/loader';
|
||||
|
||||
import PageNotFound from 'pages/404/PageNotFound';
|
||||
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
|
||||
type MatchType = {
|
||||
match: {
|
||||
params: {
|
||||
clientId: string,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class UpdateApplicationPage extends Component<{
|
||||
app: ?OauthAppResponse,
|
||||
fetchApp: (string) => Promise<*>,
|
||||
} & MatchType, {
|
||||
isNotFound: bool,
|
||||
}> {
|
||||
static displayName = 'UpdateApplicationPage';
|
||||
|
||||
form = new FormModel();
|
||||
|
||||
state = {
|
||||
isNotFound: false,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
this.props.app === null && this.fetchApp();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { app } = this.props;
|
||||
if (this.state.isNotFound) {
|
||||
return (
|
||||
<PageNotFound />
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (<div/>);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
throw 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}` : ''}`);
|
||||
}
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { getApp, fetchApp } from 'components/dev/apps/actions';
|
||||
|
||||
export default connect((state, props: MatchType) => ({
|
||||
app: getApp(state, props.match.params.clientId),
|
||||
}), {
|
||||
fetchApp,
|
||||
})(UpdateApplicationPage);
|
18
src/pages/dev/dev.scss
Normal file
18
src/pages/dev/dev.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ import classNames from 'classnames';
|
||||
import AuthPage from 'pages/auth/AuthPage';
|
||||
import ProfilePage from 'pages/profile/ProfilePage';
|
||||
import RulesPage from 'pages/rules/RulesPage';
|
||||
import DevPage from 'pages/dev/DevPage';
|
||||
import PageNotFound from 'pages/404/PageNotFound';
|
||||
import { ScrollIntoView } from 'components/ui/scroll';
|
||||
import PrivateRoute from 'containers/PrivateRoute';
|
||||
@ -83,6 +84,7 @@ class RootPage extends Component<{
|
||||
<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} />
|
||||
|
@ -6,6 +6,7 @@ import accounts from 'components/accounts/reducer';
|
||||
import i18n from 'components/i18n/reducer';
|
||||
import popup from 'components/ui/popup/reducer';
|
||||
import bsod from 'components/ui/bsod/reducer';
|
||||
import apps from 'components/dev/apps/reducer';
|
||||
|
||||
export default combineReducers({
|
||||
bsod,
|
||||
@ -13,5 +14,6 @@ export default combineReducers({
|
||||
user,
|
||||
accounts,
|
||||
i18n,
|
||||
popup
|
||||
popup,
|
||||
apps,
|
||||
});
|
||||
|
@ -1,15 +1,39 @@
|
||||
// @flow
|
||||
/* eslint camelcase: off */
|
||||
import request from 'services/request';
|
||||
|
||||
export type OauthAppResponse = {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
type: string,
|
||||
name: string,
|
||||
websiteUrl: string,
|
||||
createdAt: number,
|
||||
// fields for 'application' type
|
||||
countUsers?: number,
|
||||
description?: string,
|
||||
redirectUri?: string,
|
||||
// fields for 'minecraft-server' type
|
||||
minecraftServerIp?: string,
|
||||
};
|
||||
|
||||
type FormPayloads = {
|
||||
name?: string,
|
||||
description?: string,
|
||||
websiteUrl?: string,
|
||||
redirectUri?: string,
|
||||
minecraftServerIp?: string,
|
||||
};
|
||||
|
||||
export default {
|
||||
validate(oauthData) {
|
||||
validate(oauthData: Object) {
|
||||
return request.get(
|
||||
'/api/oauth2/v1/validate',
|
||||
getOAuthRequest(oauthData)
|
||||
).catch(handleOauthParamsValidation);
|
||||
},
|
||||
|
||||
complete(oauthData, params = {}) {
|
||||
complete(oauthData: Object, params: Object = {}) {
|
||||
const query = request.buildQuery(getOAuthRequest(oauthData));
|
||||
|
||||
return request.post(
|
||||
@ -25,20 +49,44 @@ export default {
|
||||
}
|
||||
|
||||
if (resp.status === 401 && resp.name === 'Unauthorized') {
|
||||
const error = new Error('Unauthorized');
|
||||
const error: Object = new Error('Unauthorized');
|
||||
error.unauthorized = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resp.statusCode === 401 && resp.error === 'accept_required') {
|
||||
const error = new Error('Permissions accept required');
|
||||
const error: Object = new Error('Permissions accept required');
|
||||
error.acceptRequired = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return handleOauthParamsValidation(resp);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
create(type: string, formParams: FormPayloads): Promise<{success: bool, data: OauthAppResponse}> {
|
||||
return request.post(`/api/v1/oauth2/${type}`, formParams);
|
||||
},
|
||||
|
||||
update(clientId: string, formParams: FormPayloads): Promise<{success: bool, data: OauthAppResponse}> {
|
||||
return request.put(`/api/v1/oauth2/${clientId}`, formParams);
|
||||
},
|
||||
|
||||
getApp(clientId: string): Promise<OauthAppResponse> {
|
||||
return request.get(`/api/v1/oauth2/${clientId}`);
|
||||
},
|
||||
|
||||
getAppsByUser(userId: number): Promise<Array<OauthAppResponse>> {
|
||||
return request.get(`/api/v1/accounts/${userId}/oauth2/clients`);
|
||||
},
|
||||
|
||||
reset(clientId: string, regenerateSecret: bool = false): Promise<{success: bool, data: OauthAppResponse}> {
|
||||
return request.post(`/api/v1/oauth2/${clientId}/reset${regenerateSecret ? '?regenerateSecret' : ''}`);
|
||||
},
|
||||
|
||||
delete(clientId: string): Promise<{success: bool}> {
|
||||
return request.delete(`/api/v1/oauth2/${clientId}`);
|
||||
},
|
||||
};
|
||||
/**
|
||||
* @param {object} oauthData
|
||||
|
18
src/services/copy.js
Normal file
18
src/services/copy.js
Normal file
@ -0,0 +1,18 @@
|
||||
// @flow
|
||||
import copyToClipboard from 'copy-to-clipboard';
|
||||
|
||||
/**
|
||||
* Simple wrapper to copy-to-clipboard library, that adds support
|
||||
* for the new navigator.clipboard API.
|
||||
*
|
||||
* @param {string} content
|
||||
* @return {Promise<*>}
|
||||
*/
|
||||
export default async function copy(content: string): Promise<void> {
|
||||
if (navigator.clipboard) {
|
||||
// $FlowFixMe there is no typing for navigator.clipboard
|
||||
return navigator.clipboard.writeText(content);
|
||||
}
|
||||
|
||||
return copyToClipboard(content);
|
||||
}
|
@ -32,5 +32,7 @@
|
||||
"accountNotActivated": "The account is not activated",
|
||||
"accountBanned": "Account is blocked",
|
||||
"emailNotFound": "Specified E‑mail is not found",
|
||||
"accountAlreadyActivated": "This account is already activated"
|
||||
"accountAlreadyActivated": "This account is already activated",
|
||||
"redirectUriRequired": "Redirect URI is required",
|
||||
"redirectUriInvalid": "Redirect URI is invalid"
|
||||
}
|
||||
|
@ -86,6 +86,9 @@ const errorsMap = {
|
||||
'error.captcha_required': () => <Message {...messages.captchaRequired} />,
|
||||
'error.captcha_invalid': () => errorsMap['error.captcha_required'](),
|
||||
|
||||
'error.redirectUri_required': () => <Message {...messages.redirectUriRequired} />,
|
||||
'error.redirectUri_invalid': () => <Message {...messages.redirectUriInvalid} />,
|
||||
|
||||
suggestResetPassword: () => (
|
||||
<span>
|
||||
<br/>
|
||||
|
@ -1,9 +1,16 @@
|
||||
// On page initialization loader is already visible, so initial value is 1
|
||||
let stack = 1;
|
||||
|
||||
export default {
|
||||
show() {
|
||||
document.getElementById('loader').classList.add('is-active');
|
||||
if (++stack !== 1) {
|
||||
document.getElementById('loader').classList.add('is-active');
|
||||
}
|
||||
},
|
||||
|
||||
hide() {
|
||||
document.getElementById('loader').classList.remove('is-active');
|
||||
if (--stack === 0) {
|
||||
document.getElementById('loader').classList.remove('is-active');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -14,6 +14,15 @@ type Middleware = {
|
||||
catch?: () => Promise<*>
|
||||
};
|
||||
|
||||
const buildOptions = (method: string, data?: Object, options: Object) => ({
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
body: buildQuery(data),
|
||||
...options,
|
||||
});
|
||||
|
||||
export default {
|
||||
/**
|
||||
* @param {string} url
|
||||
@ -23,14 +32,7 @@ export default {
|
||||
* @return {Promise}
|
||||
*/
|
||||
post<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> {
|
||||
return doFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||
},
|
||||
body: buildQuery(data),
|
||||
...options
|
||||
});
|
||||
return doFetch(url, buildOptions('POST', data, options));
|
||||
},
|
||||
|
||||
/**
|
||||
@ -54,14 +56,18 @@ export default {
|
||||
* @return {Promise}
|
||||
*/
|
||||
delete<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> {
|
||||
return doFetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||
},
|
||||
body: buildQuery(data),
|
||||
...options
|
||||
});
|
||||
return doFetch(url, buildOptions('DELETE', data, options));
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} [data] - request data
|
||||
* @param {object} [options] - additional options for fetch or middlewares
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
put<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> {
|
||||
return doFetch(url, buildOptions('PUT', data, options));
|
||||
},
|
||||
|
||||
/**
|
||||
@ -87,7 +93,7 @@ export default {
|
||||
*/
|
||||
addMiddleware(middleware: Middleware) {
|
||||
middlewareLayer.add(middleware);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const checkStatus = (resp: Response) => resp.status >= 200 && resp.status < 300
|
||||
|
16
yarn.lock
16
yarn.lock
@ -1785,6 +1785,12 @@ cookie@0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
|
||||
|
||||
copy-to-clipboard@^3.0.8:
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.0.8.tgz#f4e82f4a8830dce4666b7eb8ded0c9bcc313aba9"
|
||||
dependencies:
|
||||
toggle-selection "^1.0.3"
|
||||
|
||||
core-js@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-0.6.1.tgz#1b4970873e8101bf8c435af095faa9024f4b9b58"
|
||||
@ -5552,6 +5558,12 @@ react-test-renderer@^15.5.4:
|
||||
fbjs "^0.8.9"
|
||||
object-assign "^4.1.0"
|
||||
|
||||
react-textarea-autosize@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-6.0.0.tgz#31e44dc7f40aef4fb140dcc8b8c4e1b9cd15c8e3"
|
||||
dependencies:
|
||||
prop-types "^15.6.0"
|
||||
|
||||
react-transform-catch-errors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-transform-catch-errors/-/react-transform-catch-errors-1.0.2.tgz#1b4d4a76e97271896fc16fe3086c793ec88a9eeb"
|
||||
@ -6658,6 +6670,10 @@ to-fast-properties@^1.0.0, to-fast-properties@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
|
||||
|
||||
toggle-selection@^1.0.3:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
|
||||
|
||||
toposort@^1.0.0:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.6.tgz#c31748e55d210effc00fdcdc7d6e68d7d7bb9cec"
|
||||
|
Loading…
Reference in New Issue
Block a user