[MM-50352] Improve URL validation and add/edit server experience (#2720)

* [MM-50352] Improve URL validation and add/edit server experience

* Fix build

* Fix translations

* First pass of fixes

* Some changes to avoid 2 clicks, tests

* PR feedback

* Update translations

* PR feedback

* Fix translations

* PR feedback

* E2E test fixes
This commit is contained in:
Devin Binnie
2023-05-24 09:04:38 -04:00
committed by GitHub
parent a87e770c73
commit 1239add076
25 changed files with 712 additions and 275 deletions

View File

@@ -1,14 +1,13 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useEffect} from 'react';
import React, {useState, useCallback, useEffect, useRef} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import {UniqueServer} from 'types/config';
import {isValidURL, parseURL} from 'common/utils/url';
import {MODAL_TRANSITION_TIMEOUT} from 'common/utils/constants';
import {MODAL_TRANSITION_TIMEOUT, URLValidationStatus} from 'common/utils/constants';
import womanLaptop from 'renderer/assets/svg/womanLaptop.svg';
@@ -22,7 +21,6 @@ import 'renderer/css/components/ConfigureServer.scss';
import 'renderer/css/components/LoadingScreen.css';
type ConfigureServerProps = {
currentServers: UniqueServer[];
server?: UniqueServer;
mobileView?: boolean;
darkMode?: boolean;
@@ -36,7 +34,6 @@ type ConfigureServerProps = {
};
function ConfigureServer({
currentServers,
server,
mobileView,
darkMode,
@@ -56,35 +53,62 @@ function ConfigureServer({
id,
} = server || {};
const mounted = useRef(false);
const [transition, setTransition] = useState<'inFromRight' | 'outToLeft'>();
const [name, setName] = useState(prevName || '');
const [url, setUrl] = useState(prevURL || '');
const [nameError, setNameError] = useState('');
const [urlError, setURLError] = useState('');
const [urlError, setURLError] = useState<{type: STATUS; value: string}>();
const [showContent, setShowContent] = useState(false);
const [waiting, setWaiting] = useState(false);
const canSave = name && url && !nameError && !urlError;
const [validating, setValidating] = useState(false);
const validationTimestamp = useRef<number>();
const validationTimeout = useRef<NodeJS.Timeout>();
const editing = useRef(false);
const canSave = name && url && !nameError && !validating && urlError && urlError.type !== STATUS.ERROR;
useEffect(() => {
setTransition('inFromRight');
setShowContent(true);
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const checkProtocolInURL = (checkURL: string): Promise<string> => {
if (isValidURL(checkURL)) {
return Promise.resolve(checkURL);
}
return window.desktop.modals.pingDomain(checkURL).
then((result: string) => {
const newURL = `${result}://${checkURL}`;
setUrl(newURL);
return newURL;
}).
catch(() => {
console.error(`Could not ping url: ${checkURL}`);
return checkURL;
});
const fetchValidationResult = (urlToValidate: string) => {
setValidating(true);
setURLError({
type: STATUS.INFO,
value: formatMessage({id: 'renderer.components.configureServer.url.validating', defaultMessage: 'Validating...'}),
});
const requestTime = Date.now();
validationTimestamp.current = requestTime;
validateURL(urlToValidate).then(({validatedURL, serverName, message}) => {
if (editing.current) {
setValidating(false);
setURLError(undefined);
return;
}
if (!validationTimestamp.current || requestTime < validationTimestamp.current) {
return;
}
if (validatedURL) {
setUrl(validatedURL);
}
if (serverName) {
setName((prev) => {
return prev.length ? prev : serverName;
});
}
if (message) {
setTransition(undefined);
setURLError(message);
}
setValidating(false);
});
};
const validateName = () => {
@@ -97,46 +121,76 @@ function ConfigureServer({
});
}
if (currentServers.find(({name: existingName}) => existingName === newName)) {
return formatMessage({
id: 'renderer.components.newServerModal.error.serverNameExists',
defaultMessage: 'A server with the same name already exists.',
});
}
return '';
};
const validateURL = async (fullURL: string) => {
if (!fullURL) {
return formatMessage({
id: 'renderer.components.newServerModal.error.urlRequired',
defaultMessage: 'URL is required.',
});
const validateURL = async (url: string) => {
let message;
const validationResult = await window.desktop.validateServerURL(url);
if (validationResult.validatedURL) {
setUrl(validationResult.validatedURL);
}
if (!parseURL(fullURL)) {
return formatMessage({
id: 'renderer.components.newServerModal.error.urlIncorrectFormatting',
defaultMessage: 'URL is not formatted correctly.',
});
if (validationResult?.status === URLValidationStatus.Missing) {
message = {
type: STATUS.ERROR,
value: formatMessage({
id: 'renderer.components.newServerModal.error.urlRequired',
defaultMessage: 'URL is required.',
}),
};
}
if (!isValidURL(fullURL)) {
return formatMessage({
id: 'renderer.components.newServerModal.error.urlNeedsHttp',
defaultMessage: 'URL should start with http:// or https://.',
});
if (validationResult?.status === URLValidationStatus.Invalid) {
message = {
type: STATUS.ERROR,
value: formatMessage({
id: 'renderer.components.newServerModal.error.urlIncorrectFormatting',
defaultMessage: 'URL is not formatted correctly.',
}),
};
}
if (currentServers.find(({url: existingURL}) => parseURL(existingURL)?.toString === parseURL(fullURL)?.toString())) {
return formatMessage({
id: 'renderer.components.newServerModal.error.serverUrlExists',
defaultMessage: 'A server with the same URL already exists.',
});
if (validationResult?.status === URLValidationStatus.Insecure) {
message = {
type: STATUS.WARNING,
value: formatMessage({id: 'renderer.components.configureServer.url.insecure', defaultMessage: 'Your server URL is potentially insecure. For best results, use a URL with the HTTPS protocol.'}),
};
}
return '';
if (validationResult?.status === URLValidationStatus.NotMattermost) {
message = {
type: STATUS.WARNING,
value: formatMessage({id: 'renderer.components.configureServer.url.notMattermost', defaultMessage: 'The server URL provided does not appear to point to a valid Mattermost server. Please verify the URL and check your connection.'}),
};
}
if (validationResult?.status === URLValidationStatus.URLNotMatched) {
message = {
type: STATUS.WARNING,
value: formatMessage({id: 'renderer.components.configureServer.url.urlNotMatched', defaultMessage: 'The server URL provided does not match the configured Site URL on your Mattermost server. Server version: {serverVersion}'}, {serverVersion: validationResult.serverVersion}),
};
}
if (validationResult?.status === URLValidationStatus.URLUpdated) {
message = {
type: STATUS.INFO,
value: formatMessage({id: 'renderer.components.configureServer.url.urlUpdated', defaultMessage: 'The server URL provided has been updated to match the configured Site URL on your Mattermost server. Server version: {serverVersion}'}, {serverVersion: validationResult.serverVersion}),
};
}
if (validationResult?.status === URLValidationStatus.OK) {
message = {
type: STATUS.SUCCESS,
value: formatMessage({id: 'renderer.components.configureServer.url.ok', defaultMessage: 'Server URL is valid. Server version: {serverVersion}'}, {serverVersion: validationResult.serverVersion}),
};
}
return {
validatedURL: validationResult.validatedURL,
serverName: validationResult.serverName,
message,
};
};
const handleNameOnChange = ({target: {value}}: React.ChangeEvent<HTMLInputElement>) => {
@@ -151,8 +205,18 @@ function ConfigureServer({
setUrl(value);
if (urlError) {
setURLError('');
setURLError(undefined);
}
editing.current = true;
clearTimeout(validationTimeout.current as unknown as number);
validationTimeout.current = setTimeout(() => {
if (!mounted.current) {
return;
}
editing.current = false;
fetchValidationResult(value);
}, 1000);
};
const handleOnSaveButtonClick = (e: React.MouseEvent) => {
@@ -183,21 +247,11 @@ function ConfigureServer({
return;
}
const fullURL = await checkProtocolInURL(url.trim());
const urlError = await validateURL(fullURL);
if (urlError) {
setTransition(undefined);
setURLError(urlError);
setWaiting(false);
return;
}
setTransition('outToLeft');
setTimeout(() => {
onConnect({
url: fullURL,
url,
name,
id,
});
@@ -269,7 +323,7 @@ function ConfigureServer({
/>
</div>
</div>
<div className={classNames('ConfigureServer__card', transition, {'with-error': nameError || urlError})}>
<div className={classNames('ConfigureServer__card', transition, {'with-error': nameError || urlError?.type === STATUS.ERROR})}>
<div
className='ConfigureServer__card-content'
onKeyDown={handleOnCardEnterKeyDown}
@@ -286,10 +340,7 @@ function ConfigureServer({
inputSize={SIZE.LARGE}
value={url}
onChange={handleURLOnChange}
customMessage={urlError ? ({
type: STATUS.ERROR,
value: urlError,
}) : ({
customMessage={urlError ?? ({
type: STATUS.INFO,
value: formatMessage({id: 'renderer.components.configureServer.url.info', defaultMessage: 'The URL of your Mattermost server'}),
})}
@@ -321,7 +372,10 @@ function ConfigureServer({
extraClasses='ConfigureServer__card-form-button'
saving={waiting}
onClick={handleOnSaveButtonClick}
defaultMessage={formatMessage({id: 'renderer.components.configureServer.connect.default', defaultMessage: 'Connect'})}
defaultMessage={urlError?.type === STATUS.WARNING ?
formatMessage({id: 'renderer.components.configureServer.connect.override', defaultMessage: 'Connect anyway'}) :
formatMessage({id: 'renderer.components.configureServer.connect.default', defaultMessage: 'Connect'})
}
savingMessage={formatMessage({id: 'renderer.components.configureServer.connect.saving', defaultMessage: 'Connecting…'})}
disabled={!canSave}
darkMode={darkMode}

View File

@@ -22,7 +22,7 @@ export enum SIZE {
}
export type CustomMessageInputType = {
type: 'info' | 'error' | 'warning' | 'success';
type: STATUS;
value: string;
} | null;

View File

@@ -3,18 +3,20 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Modal, Button, FormGroup, FormControl, FormLabel, FormText} from 'react-bootstrap';
import {Modal, Button, FormGroup, FormControl, FormLabel, FormText, Spinner} from 'react-bootstrap';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
import {UniqueServer} from 'types/config';
import {URLValidationResult} from 'types/server';
import {isValidURL} from 'common/utils/url';
import {URLValidationStatus} from 'common/utils/constants';
import 'renderer/css/components/NewServerModal.scss';
type Props = {
onClose?: () => void;
onSave?: (server: UniqueServer) => void;
server?: UniqueServer;
currentServers?: UniqueServer[];
editMode?: boolean;
show?: boolean;
restoreFocus?: boolean;
@@ -29,11 +31,15 @@ type State = {
serverId?: string;
serverOrder: number;
saveStarted: boolean;
validationStarted: boolean;
validationResult?: URLValidationResult;
}
class NewServerModal extends React.PureComponent<Props, State> {
wasShown?: boolean;
serverUrlInputRef?: HTMLInputElement;
validationTimeout?: NodeJS.Timeout;
mounted: boolean;
static defaultProps = {
restoreFocus: true,
@@ -43,48 +49,37 @@ class NewServerModal extends React.PureComponent<Props, State> {
super(props);
this.wasShown = false;
this.mounted = false;
this.state = {
serverName: '',
serverUrl: '',
serverOrder: props.currentOrder || 0,
saveStarted: false,
validationStarted: false,
};
}
initializeOnShow() {
componentDidMount(): void {
this.mounted = true;
}
componentWillUnmount(): void {
this.mounted = false;
}
initializeOnShow = () => {
this.setState({
serverName: this.props.server ? this.props.server.name : '',
serverUrl: this.props.server ? this.props.server.url : '',
serverId: this.props.server?.id,
saveStarted: false,
validationStarted: false,
validationResult: undefined,
});
}
getServerNameValidationError() {
if (!this.state.saveStarted) {
return null;
if (this.props.editMode && this.props.server) {
this.validateServerURL(this.props.server.url);
}
if (this.props.currentServers) {
const currentServers = [...this.props.currentServers];
if (currentServers.find((server) => server.id !== this.state.serverId && server.name === this.state.serverName)) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.serverNameExists'
defaultMessage='A server with the same name already exists.'
/>
);
}
}
return this.state.serverName.length > 0 ? null : (
<FormattedMessage
id='renderer.components.newServerModal.error.nameRequired'
defaultMessage='Name is required.'
/>
);
}
getServerNameValidationState() {
return this.getServerNameValidationError() === null ? null : 'error';
}
handleServerNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -93,108 +88,205 @@ class NewServerModal extends React.PureComponent<Props, State> {
});
}
getServerUrlValidationError() {
if (!this.state.saveStarted) {
return null;
}
if (this.props.currentServers) {
const currentServers = [...this.props.currentServers];
if (currentServers.find((server) => server.id !== this.state.serverId && server.url === this.state.serverUrl)) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.serverUrlExists'
defaultMessage='A server with the same URL already exists.'
/>
);
}
}
if (this.state.serverUrl.length === 0) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.urlRequired'
defaultMessage='URL is required.'
/>
);
}
if (!(/^https?:\/\/.*/).test(this.state.serverUrl.trim())) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.urlNeedsHttp'
defaultMessage='URL should start with http:// or https://.'
/>
);
}
if (!isValidURL(this.state.serverUrl.trim())) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.urlIncorrectFormatting'
defaultMessage='URL is not formatted correctly.'
/>
);
}
return null;
}
getServerUrlValidationState() {
return this.getServerUrlValidationError() === null ? null : 'error';
}
handleServerUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const serverUrl = e.target.value;
this.setState({serverUrl});
this.setState({serverUrl, validationResult: undefined});
this.validateServerURL(serverUrl);
}
addProtocolToUrl = (serverUrl: string): Promise<void> => {
if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) {
return Promise.resolve(undefined);
validateServerURL = (serverUrl: string) => {
clearTimeout(this.validationTimeout as unknown as number);
this.validationTimeout = setTimeout(() => {
if (!this.mounted) {
return;
}
const currentTimeout = this.validationTimeout;
this.setState({validationStarted: true});
window.desktop.validateServerURL(serverUrl, this.props.server?.id).then((validationResult) => {
if (!this.mounted) {
return;
}
if (currentTimeout !== this.validationTimeout) {
return;
}
this.setState({validationResult, validationStarted: false, serverUrl: validationResult.validatedURL ?? serverUrl, serverName: this.state.serverName ? this.state.serverName : validationResult.serverName ?? ''});
});
}, 1000);
}
isServerURLErrored = () => {
return this.state.validationResult?.status === URLValidationStatus.Invalid ||
this.state.validationResult?.status === URLValidationStatus.Missing;
}
getServerURLMessage = () => {
if (this.state.validationStarted) {
return (
<div>
<Spinner
className='NewServerModal-validationSpinner'
animation='border'
size='sm'
/>
<FormattedMessage
id='renderer.components.newServerModal.validating'
defaultMessage='Validating...'
/>
</div>
);
}
return window.desktop.modals.pingDomain(serverUrl).
then((result: string) => {
this.setState({serverUrl: `${result}://${this.state.serverUrl}`});
}).
catch(() => {
console.error(`Could not ping url: ${serverUrl}`);
});
if (!this.state.validationResult) {
return null;
}
switch (this.state.validationResult?.status) {
case URLValidationStatus.Missing:
return (
<div
id='urlValidation'
className='error'
>
<i className='icon-close-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.error.urlRequired'
defaultMessage='URL is required.'
/>
</div>
);
case URLValidationStatus.Invalid:
return (
<div
id='urlValidation'
className='error'
>
<i className='icon-close-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.error.urlIncorrectFormatting'
defaultMessage='URL is not formatted correctly.'
/>
</div>
);
case URLValidationStatus.URLExists:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.error.serverUrlExists'
defaultMessage='A server named {serverName} with the same Site URL already exists.'
values={{serverName: this.state.validationResult.existingServerName}}
/>
</div>
);
case URLValidationStatus.Insecure:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.insecure'
defaultMessage='Your server URL is potentially insecure. For best results, use a URL with the HTTPS protocol.'
/>
</div>
);
case URLValidationStatus.NotMattermost:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.notMattermost'
defaultMessage='The server URL provided does not appear to point to a valid Mattermost server. Please verify the URL and check your connection.'
/>
</div>
);
case URLValidationStatus.URLNotMatched:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.urlNotMatched'
defaultMessage='The server URL does not match the configured Site URL on your Mattermost server. Server version: {serverVersion}'
values={{serverVersion: this.state.validationResult.serverVersion}}
/>
</div>
);
case URLValidationStatus.URLUpdated:
return (
<div
id='urlValidation'
className='info'
>
<i className='icon-information-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.urlUpdated'
defaultMessage='The server URL provided has been updated to match the configured Site URL on your Mattermost server. Server version: {serverVersion}'
values={{serverVersion: this.state.validationResult.serverVersion}}
/>
</div>
);
}
return (
<div
id='urlValidation'
className='success'
>
<i className='icon-check-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.success.ok'
defaultMessage='Server URL is valid. Server version: {serverVersion}'
values={{serverVersion: this.state.validationResult.serverVersion}}
/>
</div>
);
}
getError() {
const nameError = this.getServerNameValidationError();
const urlError = this.getServerUrlValidationError();
if (nameError && urlError) {
getServerNameMessage = () => {
if (!this.state.serverName.length) {
return (
<>
{nameError}
<br/>
{urlError}
</>
<div
id='nameValidation'
className='error'
>
<i className='icon-close-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.error.nameRequired'
defaultMessage='Name is required.'
/>
</div>
);
} else if (nameError) {
return nameError;
} else if (urlError) {
return urlError;
}
return null;
}
validateForm() {
return this.getServerNameValidationState() === null &&
this.getServerUrlValidationState() === null;
}
save = () => {
if (!this.state.validationResult) {
return;
}
if (this.isServerURLErrored()) {
return;
}
save = async () => {
await this.addProtocolToUrl(this.state.serverUrl);
this.setState({
saveStarted: true,
}, () => {
if (this.validateForm()) {
this.props.onSave?.({
url: this.state.serverUrl,
name: this.state.serverName,
id: this.state.serverId,
});
}
this.props.onSave?.({
url: this.state.serverUrl,
name: this.state.serverName,
id: this.state.serverId,
});
});
}
@@ -291,7 +383,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
this.props.setInputRef(ref);
}
}}
isInvalid={Boolean(this.getServerUrlValidationState())}
isInvalid={this.isServerURLErrored()}
autoFocus={true}
/>
<FormControl.Feedback/>
@@ -318,7 +410,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
}}
isInvalid={Boolean(this.getServerNameValidationState())}
isInvalid={!this.state.serverName.length}
/>
<FormControl.Feedback/>
<FormText className='NewServerModal-noBottomSpace'>
@@ -329,15 +421,15 @@ class NewServerModal extends React.PureComponent<Props, State> {
</FormText>
</FormGroup>
</form>
<div
className='NewServerModal-validation'
>
{this.getServerNameMessage()}
{this.getServerURLMessage()}
</div>
</Modal.Body>
<Modal.Footer>
<div
className='pull-left modal-error'
>
{this.getError()}
</div>
{this.props.onClose &&
<Button
id='cancelNewServerModal'
@@ -354,7 +446,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
<Button
id='saveNewServerModal'
onClick={this.save}
disabled={!this.validateForm()}
disabled={!this.state.serverName.length || !this.state.validationResult || this.isServerURLErrored()}
variant='primary'
>
{this.getSaveButtonLabel()}