Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH Refactor sudo mode components to reduce code reuse #1905

Merged
merged 1 commit into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function SudoModePasswordField(props) {
const {
onSuccess,
autocomplete,
verifyMessage,
} = props;
const passwordFieldRef = createRef();
const [responseMessage, setResponseMessage] = useState('');
Expand Down Expand Up @@ -80,12 +81,16 @@ function SudoModePasswordField(props) {
*/
function renderConfirm() {
const helpLink = clientConfig.helpLink;
let verifyMessageValue = verifyMessage;
if (!verifyMessageValue) {
verifyMessageValue = i18n._t(
'Admin.SUDO_MODE_PASSWORD_FIELD_VERIFY',
'This section is protected and is in read-only mode. Before editing please verify that it\'s you first.'
);
}
return <div className="sudo-mode__notice sudo-mode-password-field__notice--required">
<p className="sudo-mode-password-field__notice-message">
{ i18n._t(
'Admin.SUDO_MODE_PASSWORD_FIELD_VERIFY',
'This section is protected and is in read-only mode. Before editing please verify that it\'s you first.'
) }
{ verifyMessageValue }
{ helpLink && (
<a href={helpLink} className="sudo-mode-password-field__notice-help" target="_blank" rel="noopener noreferrer">
{ i18n._t('Admin.WHATS_THIS', 'What is this?') }
Expand Down Expand Up @@ -150,6 +155,7 @@ function SudoModePasswordField(props) {
}

SudoModePasswordField.propTypes = {
verifyMessage: PropTypes.string,
onSuccess: PropTypes.func.isRequired,
autocomplete: PropTypes.string.isRequired,
};
Expand Down
190 changes: 7 additions & 183 deletions client/src/containers/SudoMode/SudoMode.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Button, InputGroup, InputGroupAddon, Input, FormGroup, Label, FormFeedback } from 'reactstrap';
import { loadComponent } from 'lib/Injector';
import fetch from 'isomorphic-fetch';
import Config from 'lib/Config';
import i18n from 'i18n';
import SudoModePasswordField from '../../components/SudoModePasswordField/SudoModePasswordField';

// See SudoModeController::getClientConfig()
const configSectionKey = 'SilverStripe\\Admin\\SudoModeController';
Expand All @@ -29,78 +28,7 @@ const withSudoMode = (WrappedComponent) => {

this.state = {
active: Config.getSection(configSectionKey).sudoModeActive || false,
showVerification: false,
loading: false,
errorMessage: null,
};

this.handleConfirmNotice = this.handleConfirmNotice.bind(this);
this.handleVerify = this.handleVerify.bind(this);
this.handleVerifyInputKeyPress = this.handleVerifyInputKeyPress.bind(this);

// React 15 compatible ref callback
this.passwordInput = null;
this.setPasswordInput = element => {
this.passwordInput = element;
};
}

/**
* Action called when clicking the button to confirm the sudo mode notice
* and trigger the verification form to be rendered.
*/
handleConfirmNotice() {
this.setState({
showVerification: true,
}, () => this.passwordInput && this.passwordInput.focus());
}

/**
* Action called when the user has entered their password and requested
* verification of sudo mode state.
*/
handleVerify() {
this.setState({
loading: true,
});

const payload = new FormData();
payload.append('SecurityID', Config.get('SecurityID'));
payload.append('Password', this.passwordInput.value);

// Validate the request
fetch(Config.getSection(configSectionKey).endpoints.activate, {
method: 'POST',
body: payload,
}).then(response => response.json().then(result => {
// Happy path, send the user to the wrapped component
if (result.result) {
return this.setState({
loading: false,
active: true,
});
}

// Validation error, show them the message
return this.setState({
loading: false,
errorMessage: result.message,
}, () => this.passwordInput.focus());
}));
}

/**
* Treat pressing enter on the password field the same as clicking the
* verify button.
*
* @param {object} event
*/
handleVerifyInputKeyPress(event) {
if (event.charCode === 13) {
event.stopPropagation();
event.preventDefault();
this.handleVerify();
}
}

/**
Expand All @@ -112,117 +40,13 @@ const withSudoMode = (WrappedComponent) => {
return this.state.active === true;
}

/**
* Renders a notice to the user that they will need to verify themself
* to enter sudo mode and continue to use this functionality.
*
* @returns {HTMLElement}
*/
renderSudoModeNotice() {
const { i18n } = window;
const { showVerification } = this.state;

const helpLink = Config.getSection(configSectionKey).helpLink || null;

return (
<div className="sudo-mode__notice sudo-mode__notice--required">
<p className="sudo-mode__notice-message">
{ i18n._t('Admin.VERIFY_ITS_YOU', 'Verify it\'s you first.') }
{ helpLink && (
<a href={helpLink} className="sudo-mode__notice-help" target="_blank" rel="noopener noreferrer">
{ i18n._t('Admin.WHATS_THIS', 'What is this?') }
</a>
) }
</p>
{ !showVerification && (
<Button
className="sudo-mode__notice-button font-icon-lock"
color="info"
onClick={this.handleConfirmNotice}
>
{ i18n._t('Admin.VERIFY_TO_CONTINUE', 'Verify to continue') }
</Button>
) }
</div>
);
}

/**
* Renders the password verification form to enter sudo mode
*
* @returns {HTMLElement}
*/
renderSudoModeVerification() {
const { i18n } = window;
const { errorMessage } = this.state;

const inputProps = {
type: 'password',
name: 'sudoModePassword',
id: 'sudoModePassword',
className: 'no-change-track',
onKeyPress: this.handleVerifyInputKeyPress,
innerRef: this.setPasswordInput,
};
const validationProps = errorMessage ? { valid: false, invalid: true } : {};

return (
<div className="sudo-mode__verify">
<FormGroup className="sudo-mode__verify-form-group">
<Label for="sudoModePassword">
{ i18n._t('Admin.ENTER_PASSWORD', 'Enter your password') }
</Label>

<InputGroup>
<Input {...inputProps} {...validationProps} />
<InputGroupAddon addonType="append">
<Button
className="sudo-mode__verify-button"
color="info"
onClick={this.handleVerify}
>
{ i18n._t('Admin.VERIFY', 'Verify') }
</Button>
</InputGroupAddon>
<FormFeedback>{ errorMessage }</FormFeedback>
</InputGroup>
</FormGroup>
</div>
);
}

/**
* Renders the "sudo mode" notice or verification screen
*
* @returns {HTMLElement}
*/
renderSudoMode() {
const { showVerification, loading } = this.state;

const LoadingComponent = this.props.LoadingComponent || loadComponent(
'CircularLoading',
'SudoMode'
);

if (loading) {
return (
<div className="sudo-mode alert alert-info">
<LoadingComponent block />
</div>
);
}

return (
<div className="sudo-mode alert alert-info">
{ this.renderSudoModeNotice() }
{ showVerification && this.renderSudoModeVerification() }
</div>
);
}

render() {
if (!this.isSudoModeActive()) {
return this.renderSudoMode();
return <SudoModePasswordField
verifyMessage={i18n._t('Admin.VERIFY_ITS_YOU', 'Verify it\'s you first.')}
onSuccess={() => this.setState({ active: true })}
autocomplete="off"
/>; // this.renderSudoMode();
}
return <WrappedComponent {...this.props} />;
}
Expand Down
39 changes: 0 additions & 39 deletions client/src/containers/SudoMode/SudoMode.scss

This file was deleted.

62 changes: 5 additions & 57 deletions client/src/containers/SudoMode/tests/SudoMode-test.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
/* global jest, test, describe, it, expect */
import React from 'react';
import fetch from 'isomorphic-fetch';
import { fireEvent, render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import withSudoMode from '../SudoMode';

jest.mock('isomorphic-fetch');

const sectionConfigKey = 'SilverStripe\\Admin\\SudoModeController';
const TestComponent = () => <div className="test-component" />;
const LoadingComponent = () => <div className="loading-component" data-testid="loading-component" />;
const ComponentWithSudoMode = withSudoMode(TestComponent);

function resetWindowConfig(options) {
Expand Down Expand Up @@ -40,72 +36,24 @@ test('SudoMode renders the wrapped component when sudo mode is active', () => {
resetWindowConfig({ sudoModeActive: true });
const { container } = render(<ComponentWithSudoMode />);
expect(container.querySelector('.test-component')).not.toBeNull();
expect(container.querySelector('.sudo-mode')).toBeNull();
expect(container.querySelector('.sudo-mode-password-field')).toBeNull();
});

test('SudoMode renders a sudo mode verification screen when sudo mode is inactive', () => {
resetWindowConfig({ sudoModeActive: false });
const { container } = render(<ComponentWithSudoMode />);
expect(container.querySelector('.test-component')).toBeNull();
expect(container.querySelector('.sudo-mode')).not.toBeNull();
expect(container.querySelector('.sudo-mode-password-field')).not.toBeNull();
});

test('SudoMode renders a notice', () => {
resetWindowConfig({ sudoModeActive: false });
const { container } = render(<ComponentWithSudoMode />);
expect(container.querySelector('.sudo-mode__notice')).not.toBeNull();
});

test('SudoMode renders a loading component after entering password and clicking verify', async () => {
fetch.mockClear();
fetch.mockImplementation(() => Promise.resolve({
status: 200,
json: () => Promise.resolve({
result: true,
}),
}));
resetWindowConfig({ sudoModeActive: false });
const { container } = render(
<ComponentWithSudoMode {...{
LoadingComponent
}}
/>
);
fireEvent.click(container.querySelector('.sudo-mode__notice-button'));
fireEvent.change(container.querySelector('#sudoModePassword'), {
target: { value: 'password' }
});
fireEvent.click(container.querySelector('.sudo-mode__verify-button'));
expect(await screen.findByTestId('loading-component')).not.toBeNull();
expect(container.querySelector('.sudo-mode-password-field__notice-message').textContent).toBe('Verify it\'s you first.');
});

test('SudoMode renders a help link when one is provided', () => {
resetWindowConfig({ sudoModeActive: false, helpLink: 'http://google.com' });
const { container } = render(<ComponentWithSudoMode />);
expect(container.querySelector('.sudo-mode__notice-help').href).toBe('http://google.com/');
});

test('Sudo mode shows errors on failure', async () => {
resetWindowConfig({ sudoModeActive: false });
fetch.mockClear();
fetch.mockImplementation(() => Promise.resolve({
status: 200,
json: () => Promise.resolve({
result: false,
message: 'It broke because its a test.',
}),
}));
const { container } = render(
<ComponentWithSudoMode {...{
LoadingComponent
}}
/>
);
fireEvent.click(container.querySelector('.sudo-mode__notice-button'));
fireEvent.change(container.querySelector('#sudoModePassword'), {
target: { value: 'password' }
});
fireEvent.click(container.querySelector('.sudo-mode__verify-button'));
await screen.findByTestId('loading-component');
expect(container.querySelector('.invalid-feedback').innerHTML).toBe('It broke because its a test.');
expect(container.querySelector('.sudo-mode-password-field__notice-help').href).toBe('http://google.com/');
});
Loading