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