From cf08d68c9206c9e55b9485865c878c6a9d54d079 Mon Sep 17 00:00:00 2001
From: Jesse Chan
Date: Sun, 11 Oct 2020 00:07:11 +0800
Subject: [PATCH] auth, Users: initial preparation for multi client support
BREAKING CHANGE
---
client/src/javascript/actions/AuthActions.ts | 10 +-
.../src/javascript/actions/ClientActions.ts | 13 +-
.../javascript/components/auth/AuthForm.tsx | 37 ++-
.../general/ClientConnectionInterruption.tsx | 43 ++-
.../RTorrentConnectionTypeSelection.tsx | 114 -------
.../ClientConnectionSettingsForm.tsx | 71 +++++
.../RTorrentConnectionSettingsForm.tsx | 165 ++++++++++
.../modals/settings-modal/AuthTab.tsx | 43 ++-
.../src/javascript/constants/ServerActions.ts | 4 +-
.../src/javascript/i18n/strings.compiled.json | 122 +++++---
client/src/javascript/i18n/strings.json | 27 +-
client/src/javascript/stores/AuthStore.ts | 37 ++-
config.cli.js | 21 +-
config.d.ts | 9 +-
config.docker.js | 23 --
config.template.js | 14 +-
package-lock.json | 6 +
package.json | 3 +-
server/app.ts | 2 +-
server/config/passport.ts | 2 +-
server/middleware/requireAdmin.ts | 4 +-
server/models/HistoryEra.ts | 2 +-
server/models/Users.ts | 80 ++---
server/models/settings.ts | 2 +-
server/routes/api/auth.ts | 285 ++++++++++++------
server/routes/api/client.ts | 41 ++-
server/services/BaseService.ts | 2 +-
server/services/clientGatewayService.ts | 17 +-
server/services/clientRequestManager.ts | 18 +-
server/services/index.ts | 2 +-
shared/schema/Auth.ts | 19 ++
shared/schema/ClientConnectionSettings.ts | 63 ++++
shared/schema/api/auth.ts | 37 +++
shared/types/Auth.ts | 17 --
shared/types/api/auth.ts | 27 --
35 files changed, 878 insertions(+), 504 deletions(-)
delete mode 100644 client/src/javascript/components/general/RTorrentConnectionTypeSelection.tsx
create mode 100644 client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
create mode 100644 client/src/javascript/components/general/connection-settings/RTorrentConnectionSettingsForm.tsx
delete mode 100644 config.docker.js
create mode 100644 shared/schema/Auth.ts
create mode 100644 shared/schema/ClientConnectionSettings.ts
create mode 100644 shared/schema/api/auth.ts
delete mode 100644 shared/types/Auth.ts
delete mode 100644 shared/types/api/auth.ts
diff --git a/client/src/javascript/actions/AuthActions.ts b/client/src/javascript/actions/AuthActions.ts
index d323dbb4..bd8491c1 100644
--- a/client/src/javascript/actions/AuthActions.ts
+++ b/client/src/javascript/actions/AuthActions.ts
@@ -2,11 +2,11 @@ import axios from 'axios';
import type {
AuthAuthenticationOptions,
- AuthRegisterOptions,
+ AuthRegistrationOptions,
AuthUpdateUserOptions,
AuthVerificationResponse,
-} from '@shared/types/api/auth';
-import type {Credentials} from '@shared/types/Auth';
+} from '@shared/schema/api/auth';
+import type {Credentials} from '@shared/schema/Auth';
import AppDispatcher from '../dispatcher/AppDispatcher';
import ClientActions from './ClientActions';
@@ -57,7 +57,7 @@ const AuthActions = {
]);
}),
- createUser: (options: AuthRegisterOptions) =>
+ createUser: (options: AuthRegistrationOptions) =>
axios
.post(`${baseURI}api/auth/register?cookie=false`, options)
.then((json) => json.data)
@@ -122,7 +122,7 @@ const AuthActions = {
},
),
- register: (options: AuthRegisterOptions) =>
+ register: (options: AuthRegistrationOptions) =>
axios
.post(`${baseURI}api/auth/register`, options)
.then((json) => json.data)
diff --git a/client/src/javascript/actions/ClientActions.ts b/client/src/javascript/actions/ClientActions.ts
index f113bf11..3b569c86 100644
--- a/client/src/javascript/actions/ClientActions.ts
+++ b/client/src/javascript/actions/ClientActions.ts
@@ -1,6 +1,6 @@
import axios from 'axios';
-import type {ConnectionSettingsForm} from '@shared/types/Auth';
+import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings';
import type {TransferDirection} from '@shared/types/TransferData';
import AppDispatcher from '../dispatcher/AppDispatcher';
@@ -78,15 +78,8 @@ const ClientActions = {
},
),
- testClientConnectionSettings: (connectionSettings: ConnectionSettingsForm) => {
- const requestPayload = {
- host: connectionSettings.rtorrentHost,
- port: connectionSettings.rtorrentPort,
- socketPath: connectionSettings.rtorrentSocketPath,
- };
-
- return axios.post(`${baseURI}api/client/connection-test`, requestPayload).then((json) => json.data);
- },
+ testClientConnectionSettings: (connectionSettings: ClientConnectionSettings) =>
+ axios.post(`${baseURI}api/client/connection-test`, connectionSettings).then((json) => json.data),
testConnection: () =>
axios
diff --git a/client/src/javascript/components/auth/AuthForm.tsx b/client/src/javascript/components/auth/AuthForm.tsx
index c97546b9..bddfe80a 100644
--- a/client/src/javascript/components/auth/AuthForm.tsx
+++ b/client/src/javascript/components/auth/AuthForm.tsx
@@ -1,17 +1,21 @@
import {injectIntl, WrappedComponentProps} from 'react-intl';
import React from 'react';
-import type {ConnectionSettingsForm, Credentials} from '@shared/types/Auth';
+import {AccessLevel} from '@shared/schema/Auth';
+
+import type {Credentials} from '@shared/schema/Auth';
import {Button, Form, FormError, FormRow, Panel, PanelContent, PanelHeader, PanelFooter, Textbox} from '../../ui';
import AuthActions from '../../actions/AuthActions';
import AuthStore from '../../stores/AuthStore';
+import ClientConnectionSettingsForm from '../general/connection-settings/ClientConnectionSettingsForm';
import connectStores from '../../util/connectStores';
import history from '../../util/history';
-import RTorrentConnectionTypeSelection from '../general/RTorrentConnectionTypeSelection';
+
+import type {ClientConnectionSettingsFormType} from '../general/connection-settings/ClientConnectionSettingsForm';
type LoginFormData = Pick;
-type RegisterFormData = Pick & ConnectionSettingsForm;
+type RegisterFormData = Pick;
interface AuthFormProps extends WrappedComponentProps {
mode: 'login' | 'register';
@@ -24,6 +28,7 @@ interface AuthFormStates extends Record {
class AuthForm extends React.Component {
formRef?: Form | null;
+ settingsFormRef: React.RefObject = React.createRef();
constructor(props: AuthFormProps) {
super(props);
@@ -92,20 +97,28 @@ class AuthForm extends React.Component {
} else {
const config = submission.formData as Partial;
- if (config.username == null || config.username === '' || config.password == null || config.password === '') {
- this.setState({isSubmitting: false}, () => {
- // do nothing.
- });
+ if (
+ config.username == null ||
+ config.username === '' ||
+ config.password == null ||
+ config.password === '' ||
+ this.settingsFormRef.current == null
+ ) {
+ this.setState({isSubmitting: false});
+ return;
+ }
+
+ const connectionSettings = this.settingsFormRef.current.getConnectionSettings();
+ if (connectionSettings == null) {
+ this.setState({isSubmitting: false});
return;
}
AuthActions.register({
username: config.username,
password: config.password,
- host: config.rtorrentHost || null,
- port: config.rtorrentPort || null,
- socketPath: config.rtorrentSocketPath || null,
- isAdmin: true,
+ client: connectionSettings,
+ level: AccessLevel.ADMINISTRATOR,
}).then(() => {
this.setState({isSubmitting: false}, () => history.replace('overview'));
});
@@ -149,7 +162,7 @@ class AuthForm extends React.Component {
{isLoginMode ? null : (
-
+
)}
diff --git a/client/src/javascript/components/general/ClientConnectionInterruption.tsx b/client/src/javascript/components/general/ClientConnectionInterruption.tsx
index 95e74b6c..f5d7db5a 100644
--- a/client/src/javascript/components/general/ClientConnectionInterruption.tsx
+++ b/client/src/javascript/components/general/ClientConnectionInterruption.tsx
@@ -1,16 +1,16 @@
import {FormattedMessage} from 'react-intl';
import React from 'react';
-import {ConnectionSettingsForm} from '@shared/types/Auth';
-
import {Button, Form, FormError, FormRow, FormRowItem, Panel, PanelContent, PanelHeader, PanelFooter} from '../../ui';
import AuthActions from '../../actions/AuthActions';
import AuthStore from '../../stores/AuthStore';
import Checkmark from '../icons/Checkmark';
import ClientActions from '../../actions/ClientActions';
+import ClientConnectionSettingsForm from './connection-settings/ClientConnectionSettingsForm';
import connectStores from '../../util/connectStores';
import FloodActions from '../../actions/FloodActions';
-import RTorrentConnectionTypeSelection from './RTorrentConnectionTypeSelection';
+
+import type {ClientConnectionSettingsFormType} from './connection-settings/ClientConnectionSettingsForm';
interface ClientConnectionInterruptionProps {
isAdmin?: boolean;
@@ -28,6 +28,7 @@ class ClientConnectionInterruption extends React.Component<
ClientConnectionInterruptionStates
> {
formRef?: Form | null;
+ settingsFormRef: React.RefObject = React.createRef();
constructor(props: ClientConnectionInterruptionProps) {
super(props);
@@ -47,26 +48,19 @@ class ClientConnectionInterruption extends React.Component<
}
};
- handleFormSubmit = ({formData}: {formData: ConnectionSettingsForm}) => {
+ handleFormSubmit = () => {
const currentUsername = AuthStore.getCurrentUsername();
- if (currentUsername == null) {
+ if (currentUsername == null || this.settingsFormRef.current == null) {
return;
}
- if (
- (formData.connectionType === 'socket' && formData.rtorrentSocketPath == null) ||
- (formData.connectionType === 'tcp' && (formData.rtorrentHost == null || formData.rtorrentPort == null))
- ) {
+ const connectionSettings = this.settingsFormRef.current.getConnectionSettings();
+ if (connectionSettings == null) {
return;
}
- AuthActions.updateUser(
- currentUsername,
- formData.connectionType === 'socket'
- ? {socketPath: formData.rtorrentSocketPath as string}
- : {host: formData.rtorrentHost as string, port: Number(formData.rtorrentPort)},
- )
+ AuthActions.updateUser(currentUsername, {client: connectionSettings})
.then(() => {
FloodActions.restartActivityStream();
})
@@ -76,15 +70,19 @@ class ClientConnectionInterruption extends React.Component<
};
handleTestButtonClick = () => {
- if (this.state.isTestingConnection || this.formRef == null) return;
- const formData = this.formRef.getFormData() as ConnectionSettingsForm;
+ if (this.state.isTestingConnection || this.formRef == null || this.settingsFormRef.current == null) return;
+
+ const connectionSettings = this.settingsFormRef.current.getConnectionSettings();
+ if (connectionSettings == null) {
+ return;
+ }
this.setState(
{
isTestingConnection: true,
},
() => {
- ClientActions.testClientConnectionSettings(formData)
+ ClientActions.testClientConnectionSettings(connectionSettings)
.then(() => {
this.setState({
hasTestedConnection: true,
@@ -169,7 +167,7 @@ class ClientConnectionInterruption extends React.Component<
{this.renderFormError()}
-
+
@@ -193,11 +191,10 @@ const ConnectedClientConnectionInterruption = connectStores(ClientConnectionInte
{
store: AuthStore,
event: ['AUTH_LOGIN_SUCCESS', 'AUTH_REGISTER_SUCCESS', 'AUTH_VERIFY_SUCCESS', 'AUTH_VERIFY_ERROR'],
- getValue: ({store}) => {
- const storeAuth = store as typeof AuthStore;
+ getValue: () => {
return {
- isAdmin: storeAuth.isAdmin(),
- isInitialUser: storeAuth.getIsInitialUser(),
+ isAdmin: AuthStore.isAdmin(),
+ isInitialUser: AuthStore.getIsInitialUser(),
};
},
},
diff --git a/client/src/javascript/components/general/RTorrentConnectionTypeSelection.tsx b/client/src/javascript/components/general/RTorrentConnectionTypeSelection.tsx
deleted file mode 100644
index 688bef3d..00000000
--- a/client/src/javascript/components/general/RTorrentConnectionTypeSelection.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
-import React, {Component} from 'react';
-import {FormGroup, FormRow, Radio, Textbox} from '../../ui';
-
-interface RTorrentConnectionTypeSelectionProps extends WrappedComponentProps {
- onChange?: (connectionType: RTorrentConnectionTypeSelectionStates['connectionType']) => void;
-}
-
-interface RTorrentConnectionTypeSelectionStates {
- connectionType: 'tcp' | 'socket';
-}
-
-class RTorrentConnectionTypeSelection extends Component<
- RTorrentConnectionTypeSelectionProps,
- RTorrentConnectionTypeSelectionStates
-> {
- constructor(props: RTorrentConnectionTypeSelectionProps) {
- super(props);
- this.state = {
- connectionType: 'tcp',
- };
- this.handleTypeChange = this.handleTypeChange.bind(this);
- }
-
- handleTypeChange(event: React.MouseEvent | KeyboardEvent) {
- const inputElement = event.target as HTMLInputElement;
-
- if (inputElement == null) {
- return;
- }
-
- const connectionType = inputElement.value as RTorrentConnectionTypeSelectionStates['connectionType'];
-
- if (this.state.connectionType !== connectionType) {
- this.setState({connectionType});
- }
-
- if (typeof this.props.onChange === 'function') {
- this.props.onChange(connectionType);
- }
- }
-
- renderConnectionOptions() {
- if (this.state.connectionType === 'tcp') {
- return (
-
- }
- placeholder={this.props.intl.formatMessage({
- id: 'auth.rtorrentHost',
- })}
- />
- }
- placeholder={this.props.intl.formatMessage({
- id: 'auth.rtorrentPort',
- })}
- />
-
- );
- }
-
- return (
-
- }
- placeholder={this.props.intl.formatMessage({
- id: 'auth.rtorrentSocketPath',
- })}
- />
-
- );
- }
-
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {this.renderConnectionOptions()}
-
-
- );
- }
-}
-
-export default injectIntl(RTorrentConnectionTypeSelection);
diff --git a/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx b/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
new file mode 100644
index 00000000..0d24103d
--- /dev/null
+++ b/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
+
+import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+
+import RTorrentConnectionSettingsForm from './RTorrentConnectionSettingsForm';
+import {FormRow, Select, SelectItem} from '../../../ui';
+
+const getClientSelectItems = (): React.ReactNodeArray => {
+ return [
+
+
+ ,
+ ];
+};
+
+interface ClientConnectionSettingsFormStates {
+ client: ClientConnectionSettings['client'];
+}
+
+class ClientConnectionSettingsForm extends React.Component {
+ settingsRef: React.RefObject = React.createRef();
+
+ constructor(props: WrappedComponentProps) {
+ super(props);
+
+ // Only rTorrent is supported at this moment.
+ this.state = {
+ client: 'rTorrent',
+ };
+ }
+
+ getConnectionSettings(): ClientConnectionSettings | null {
+ if (this.settingsRef.current == null) {
+ return null;
+ }
+
+ return this.settingsRef.current.getConnectionSettings();
+ }
+
+ render() {
+ let settingsForm: React.ReactNode = null;
+ switch (this.state.client) {
+ case 'rTorrent':
+ settingsForm = ;
+ break;
+ default:
+ break;
+ }
+
+ return (
+
+
+
+
+ {settingsForm}
+
+ );
+ }
+}
+
+export type ClientConnectionSettingsFormType = ClientConnectionSettingsForm;
+
+export default injectIntl(ClientConnectionSettingsForm, {forwardRef: true});
diff --git a/client/src/javascript/components/general/connection-settings/RTorrentConnectionSettingsForm.tsx b/client/src/javascript/components/general/connection-settings/RTorrentConnectionSettingsForm.tsx
new file mode 100644
index 00000000..f808059c
--- /dev/null
+++ b/client/src/javascript/components/general/connection-settings/RTorrentConnectionSettingsForm.tsx
@@ -0,0 +1,165 @@
+import {FormattedMessage, IntlShape} from 'react-intl';
+import React, {Component} from 'react';
+
+import type {
+ RTorrentConnectionSettings,
+ RTorrentSocketConnectionSettings,
+ RTorrentTCPConnectionSettings,
+} from '@shared/schema/ClientConnectionSettings';
+
+import {FormGroup, FormRow, Radio, Textbox} from '../../../ui';
+
+export interface RTorrentConnectionSettingsProps {
+ intl: IntlShape;
+}
+
+export interface RTorrentConnectionSettingsFormData {
+ type?: string;
+ socket?: string;
+ host?: string;
+ port?: string;
+}
+
+class RTorrentConnectionSettingsForm extends Component<
+ RTorrentConnectionSettingsProps,
+ RTorrentConnectionSettingsFormData
+> {
+ constructor(props: RTorrentConnectionSettingsProps) {
+ super(props);
+ this.state = {
+ type: 'tcp',
+ };
+ this.getConnectionSettings = this.getConnectionSettings.bind(this);
+ this.handleFormChange = this.handleFormChange.bind(this);
+ }
+
+ getConnectionSettings(): RTorrentConnectionSettings | null {
+ switch (this.state.type) {
+ case 'socket': {
+ if (this.state.socket == null) {
+ return null;
+ }
+ const settings: RTorrentSocketConnectionSettings = {
+ client: 'rTorrent',
+ type: 'socket',
+ version: 1,
+ socket: this.state.socket,
+ };
+ return settings;
+ }
+ case 'tcp': {
+ const portAsNumber = Number(this.state.port);
+ if (this.state.host == null || portAsNumber == null) {
+ return null;
+ }
+ const settings: RTorrentTCPConnectionSettings = {
+ client: 'rTorrent',
+ type: 'tcp',
+ version: 1,
+ host: this.state.host,
+ port: portAsNumber,
+ };
+ return settings;
+ }
+ default:
+ return null;
+ }
+ }
+
+ handleFormChange(
+ event: React.MouseEvent | KeyboardEvent | React.ChangeEvent,
+ field: keyof RTorrentConnectionSettingsFormData,
+ ) {
+ const inputElement = event.target as HTMLInputElement;
+
+ if (inputElement == null) {
+ return;
+ }
+
+ const {value} = inputElement;
+
+ if (this.state[field] !== value) {
+ this.setState((prev) => {
+ return {
+ ...prev,
+ [field]: value,
+ };
+ });
+ }
+ }
+
+ renderConnectionOptions() {
+ if (this.state.type === 'tcp') {
+ return (
+
+ this.handleFormChange(e, 'host')}
+ id="host"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.rtorrent.host.input.placeholder',
+ })}
+ />
+ this.handleFormChange(e, 'port')}
+ id="port"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.rtorrent.port.input.placeholder',
+ })}
+ />
+
+ );
+ }
+
+ return (
+
+ this.handleFormChange(e, 'socket')}
+ id="socket"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.rtorrent.socket.input.placeholder',
+ })}
+ />
+
+ );
+ }
+
+ render() {
+ return (
+
+
+
+
+
+ this.handleFormChange(e, 'type')}
+ groupID="type"
+ id="tcp"
+ grow={false}
+ checked={this.state.type === 'tcp'}>
+
+
+ this.handleFormChange(e, 'type')}
+ groupID="type"
+ id="socket"
+ grow={false}
+ checked={this.state.type === 'socket'}>
+
+
+
+
+
+ {this.renderConnectionOptions()}
+
+
+ );
+ }
+}
+
+export default RTorrentConnectionSettingsForm;
diff --git a/client/src/javascript/components/modals/settings-modal/AuthTab.tsx b/client/src/javascript/components/modals/settings-modal/AuthTab.tsx
index be64ed72..0ebd6701 100644
--- a/client/src/javascript/components/modals/settings-modal/AuthTab.tsx
+++ b/client/src/javascript/components/modals/settings-modal/AuthTab.tsx
@@ -3,18 +3,24 @@ import {CSSTransition, TransitionGroup} from 'react-transition-group';
import {FormattedMessage, injectIntl} from 'react-intl';
import React from 'react';
-import type {Credentials, ConnectionSettingsForm} from '@shared/types/Auth';
+import {AccessLevel, Credentials} from '@shared/schema/Auth';
import {Button, Checkbox, Form, FormError, FormRowItem, FormRow, LoadingRing, Textbox} from '../../../ui';
import AuthActions from '../../../actions/AuthActions';
import AuthStore from '../../../stores/AuthStore';
+import ClientConnectionSettingsForm from '../../general/connection-settings/ClientConnectionSettingsForm';
import Close from '../../icons/Close';
import connectStores from '../../../util/connectStores';
import ModalFormSectionHeader from '../ModalFormSectionHeader';
-import RTorrentConnectionTypeSelection from '../../general/RTorrentConnectionTypeSelection';
import SettingsTab from './SettingsTab';
-type AuthTabFormData = Pick & ConnectionSettingsForm;
+import type {ClientConnectionSettingsFormType} from '../../general/connection-settings/ClientConnectionSettingsForm';
+
+interface AuthTabFormData {
+ username: string;
+ password: string;
+ isAdmin: boolean;
+}
class AuthTab extends SettingsTab {
state = {
@@ -27,6 +33,8 @@ class AuthTab extends SettingsTab {
formRef?: Form | null = null;
+ settingsFormRef: React.RefObject = React.createRef();
+
componentDidMount() {
if (!this.props.isAdmin) return;
@@ -86,7 +94,7 @@ class AuthTab extends SettingsTab {
};
handleFormSubmit = () => {
- if (this.formData == null) {
+ if (this.formData == null || this.settingsFormRef.current == null) {
return;
}
@@ -105,13 +113,22 @@ class AuthTab extends SettingsTab {
} else {
this.setState({isAddingUser: true});
+ const connectionSettings = this.settingsFormRef.current.getConnectionSettings();
+ if (connectionSettings == null) {
+ this.setState({
+ addUserError: this.props.intl.formatMessage({
+ id: 'connection.settings.error.empty',
+ }),
+ isAddingUser: false,
+ });
+ return;
+ }
+
AuthActions.createUser({
username: this.formData.username,
password: this.formData.password,
- host: this.formData.rtorrentHost || null,
- port: this.formData.rtorrentPort || null,
- socketPath: this.formData.rtorrentSocketPath || null,
- isAdmin: this.formData.isAdmin === true,
+ client: connectionSettings,
+ level: this.formData.isAdmin === true ? AccessLevel.ADMINISTRATOR : AccessLevel.USER,
})
.then(AuthActions.fetchUsers, (error) => {
this.setState({addUserError: error.response.data.message, isAddingUser: false});
@@ -209,7 +226,8 @@ class AuthTab extends SettingsTab {
-
+
+