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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 357 B

View File

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

After

Width:  |  Height:  |  Size: 888 B

View File

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

After

Width:  |  Height:  |  Size: 730 B

View File

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

View File

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

View File

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

View File

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

View File

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