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:
ErickSkrauch 2018-03-25 22:16:45 +03:00
parent cc50dab0e4
commit cf3a33937a
56 changed files with 2054 additions and 213 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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')) {

View File

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

View File

@ -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);
};
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -118,6 +118,8 @@ export class ContactForm extends Component {
required
label={messages.message}
skin="light"
minRows={6}
maxRows={6}
/>
</div>

View 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);
};
}

View File

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

View File

@ -0,0 +1,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));

View 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));
}
};
}

View File

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

View File

@ -0,0 +1,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;
}
};
}

View File

@ -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);
}
}

View File

@ -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>
);
}
}

View 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>
);
}
}

View File

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

View 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;
}

View File

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

After

Width:  |  Height:  |  Size: 357 B

View File

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

After

Width:  |  Height:  |  Size: 888 B

View File

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

After

Width:  |  Height:  |  Size: 730 B

View File

@ -0,0 +1,4 @@
// @flow
export const TYPE_APPLICATION = 'application';
export const TYPE_MINECRAFT_SERVER = 'minecraft-server';

View 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
};
}
}

View File

@ -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}>

View File

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

View File

@ -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} />

View File

@ -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();

View File

@ -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;
}

View 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('');
};
}

View File

@ -0,0 +1,9 @@
@import '~components/ui/colors.scss';
.overflow {
overflow: hidden;
}
.content {
}

View File

@ -0,0 +1,2 @@
// @flow
export { default } from './Collapse';

View File

@ -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>
);
}
}

View File

@ -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}
/>

View File

@ -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
}
};
}

View 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} />
);
}

View 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();
}
}

View File

@ -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`],

View File

@ -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 на отдельные модули,

View File

@ -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,

View 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

View File

@ -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 {

View 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);

View 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
View 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>
);
}
}

View 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
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

@ -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} />

View File

@ -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,
});

View File

@ -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
View 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);
}

View File

@ -32,5 +32,7 @@
"accountNotActivated": "The account is not activated",
"accountBanned": "Account is blocked",
"emailNotFound": "Specified Email 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"
}

View File

@ -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/>

View File

@ -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');
}
}
};

View File

@ -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

View File

@ -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"