Skip to content

Commit

Permalink
NEW SudoModePasswordField component
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Feb 10, 2025
1 parent db4002f commit e68e687
Show file tree
Hide file tree
Showing 12 changed files with 410 additions and 3 deletions.
4 changes: 2 additions & 2 deletions 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.

2 changes: 2 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import PopoverOptionSet from 'components/PopoverOptionSet/PopoverOptionSet';
import ToastsContainer from 'containers/ToastsContainer/ToastsContainer';
import ListboxField from 'components/ListboxField/ListboxField';
import SearchableDropdownField from 'components/SearchableDropdownField/SearchableDropdownField';
import SudoModePasswordField from 'components/SudoModePasswordField/SudoModePasswordField';

export default () => {
Injector.component.registerMany({
Expand Down Expand Up @@ -106,5 +107,6 @@ export default () => {
ToastsContainer,
ListboxField,
SearchableDropdownField,
SudoModePasswordField,
});
};
2 changes: 2 additions & 0 deletions client/src/bundles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import 'expose-loader?exposes=withDragDropContext!lib/withDragDropContext';
import 'expose-loader?exposes=withRouter!lib/withRouter';
import 'expose-loader?exposes=ssUrlLib!lib/urls';
import 'expose-loader?exposes=SearchableDropdownField!components/SearchableDropdownField/SearchableDropdownField';
import 'expose-loader?exposes=SudoModePasswordField!components/SudoModePasswordField/SudoModePasswordField';

// Legacy CMS
import '../legacy/jquery.changetracker';
Expand Down Expand Up @@ -113,6 +114,7 @@ import '../legacy/ConfirmedPasswordField';
import '../legacy/SelectionGroup';
import '../legacy/DateField';
import '../legacy/ToggleCompositeField';
import '../legacy/SudoModePasswordField/SudoModePasswordFieldEntwine';
import '../legacy/TreeDropdownField/TreeDropdownFieldEntwine';
import '../legacy/UsedOnTable/UsedOnTableEntwine';
import '../legacy/DatetimeField';
Expand Down
156 changes: 156 additions & 0 deletions client/src/components/SudoModePasswordField/SudoModePasswordField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Button from 'components/Button/Button';
import i18n from 'i18n';
import Config from 'lib/Config';
import backend from 'lib/Backend';
import PropTypes from 'prop-types';
import React, { createRef, useState } from 'react';
import { InputGroup, InputGroupAddon, Input, FormGroup, Label, FormFeedback } from 'reactstrap';

/**
* A password field that allows the user to enter their password to activate sudo mode.
* This will make an XHR request to the server to activate sudo mode.
* The page will be reloaded if the request is successful.
*/
function SudoModePasswordField(props) {
const {
onSuccess,
} = props;
const passwordFieldRef = createRef();
const [responseMessage, setResponseMessage] = useState('');
const [showVerify, setShowVerify] = useState(false);

const clientConfig = Config.getSection('SilverStripe\\Admin\\SudoModeController');

/**
* Handle clicking the button to confirm the sudo mode notice
* and trigger the verify form to be rendered.
*/
function handleConfirmClick() {
setShowVerify(true);
}

/**
* Handle clicking the button to verify the sudo mode password
*/
async function handleVerifyClick() {
const url = clientConfig.endpoints.activate;
if (url === null) {
// Allow a null url to be set to prevent an XHR request for testing purposes
setResponseMessage('Invalid password message');
return;
}
const fetcher = backend.createEndpointFetcher({
url: clientConfig.endpoints.activate,
method: 'post',
payloadFormat: 'urlencoded',
responseFormat: 'json',
});
const data = {
Password: passwordFieldRef.current.value,
};
const headers = {
'X-SecurityID': Config.get('SecurityID'),
};
const responseJson = await fetcher(data, headers);
if (responseJson.result) {
onSuccess();
} else {
setResponseMessage(responseJson.message);
}
}

/**
* Treat pressing enter on the password field the same as clicking the
* verify button.
*/
function handleVerifyKeyDown(evt) {
if (evt.key === 'Enter') {
// Prevent the form from submitting
evt.stopPropagation();
evt.preventDefault();
// Trigger the button click
handleVerifyClick();
}
}

/**
* Renders a confirmation notice to the user that they will need to verify themselves
* to enter sudo mode.
*/
function renderConfirm() {
const helpLink = clientConfig.helpLink;
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.'
) }
{ helpLink && (
<a href={helpLink} className="sudo-mode-password-field__notice-help" target="_blank" rel="noopener noreferrer">
{ i18n._t('Admin.WHATS_THIS', 'What is this?') }
</a>
) }
</p>
{ !showVerify && (
<Button
className="sudo-mode-password-field__notice-button font-icon-lock"
color="info"
onClick={() => handleConfirmClick()}
>
{ i18n._t('Admin.VERIFY_TO_CONTINUE', 'Verify to continue') }
</Button>
) }
</div>;
}

/**
* Renders the password verification form to enter sudo mode
*/
function renderVerify() {
const inputProps = {
type: 'password',
name: 'SudoModePassword',
id: 'SudoModePassword',
className: 'no-change-track',
onKeyDown: (evt) => handleVerifyKeyDown(evt),
innerRef: passwordFieldRef,
};
const validationProps = responseMessage ? { valid: false, invalid: true } : {};
return <div className="sudo-mode-password-field__verify">
<FormGroup className="sudo-mode-password-field__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-password-field__verify-button"
color="info"
onClick={() => handleVerifyClick()}
>
{ i18n._t('Admin.VERIFY', 'Verify') }
</Button>
</InputGroupAddon>
<FormFeedback>{ responseMessage }</FormFeedback>
</InputGroup>
</FormGroup>
</div>;
}

// Render the component
return <div className="sudo-mode-password-field">
<div className="sudo-mode-password-field-inner alert alert-info panel panel--padded">
{ renderConfirm() }
{ showVerify && renderVerify() }
</div>
</div>;
}

SudoModePasswordField.propTypes = {
onSuccess: PropTypes.func.isRequired,
};

export { SudoModePasswordField as Component };

export default SudoModePasswordField;
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// entwine component before the react component has loaded
// styles are set to prevent a FOUT
.SudoModePasswordField {
min-height: 108px;

@include media-breakpoint-up(lg) {
min-height: 140px;
}

.form__field-holder input {
display: none;
}
}

// React component
.sudo-mode-password-field {
@include media-breakpoint-up(lg) {
width: 100%;
max-width: 700px;
margin-left: $form-check-input-gutter;
}

&__inner {
margin-bottom: 0;
padding-bottom: 1rem;
}

&__notice {
margin-bottom: 0;
}

&__notice-button {
margin-right: 1rem;
}

&__notice-help {
margin-left: 3px;
}

&__verify {
margin-top: 1rem;
}

&__verify-form-group.form-group {
margin: 0;
}

// Reactstrap requires form feedback to be places in the same input group as the field
// that is marked as invalid, which causes Bootstrap to remove these properties from the
// attached button. This restores the properties to what they were.
.input-group-append:not(:last-child) .sudo-mode__verify-button {
border-top-right-radius: 0.23rem;
border-bottom-right-radius: 0.23rem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { Component as SudoModePasswordField } from '../SudoModePasswordField';

window.ss.config = {
SecurityID: '12345',
sections: [
{
name: 'SilverStripe\\Admin\\SudoModeController',
endpoints: {
// Setting the endpoint to null will prevent an XHR request from being made
activate: null,
}
},
],
};

export default {
title: 'Admin/SudoModePasswordField',
component: SudoModePasswordField,
decorators: [],
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'The SudoModePasswordField component. Enter "password" to simulate a successful request.'
},
canvas: {
sourceState: 'shown',
},
}
},
};

export const _SudoModePasswordField = (props) => <SudoModePasswordField
{...props}
onSuccess={() => {}}
/>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* global jest, test, expect, window */

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Component as SudoModePasswordField } from '../SudoModePasswordField';

window.ss.config = {
sections: [
{
name: 'SilverStripe\\Admin\\SudoModeController',
endpoints: {
activate: 'some/path',
}
},
]
};

let doResolve;

jest.mock('lib/Backend', () => ({
createEndpointFetcher: () => () => (
new Promise((resolve) => {
doResolve = resolve;
})
)
}));

function makeProps(obj = {}) {
return {
onSuccess: () => {},
...obj,
};
}

test('SudoModePasswordField should call onSuccess on success', async () => {
const onSuccess = jest.fn();
render(
<SudoModePasswordField {...makeProps({
onSuccess
})}
/>
);
const confirmButton = await screen.findByText('Verify to continue');
fireEvent.click(confirmButton);
const passwordField = await screen.findByLabelText('Enter your password');
passwordField.value = 'password';
const verifyButton = await screen.findByText('Verify');
fireEvent.click(verifyButton);
await doResolve({
result: true,
message: ''
});
expect(onSuccess).toBeCalled();
});

test('SudoModePasswordField should show a message on failure', async () => {
const onSuccess = jest.fn();
render(
<SudoModePasswordField {...makeProps({
onSuccess
})}
/>
);
const confirmButton = await screen.findByText('Verify to continue');
fireEvent.click(confirmButton);
const passwordField = await screen.findByLabelText('Enter your password');
passwordField.value = 'password';
const verifyButton = await screen.findByText('Verify');
fireEvent.click(verifyButton);
doResolve({
result: false,
message: 'A big failure'
});
const message = await screen.findByText('A big failure');
expect(message).not.toBeNull();
expect(onSuccess).not.toBeCalled();
});
Loading

0 comments on commit e68e687

Please sign in to comment.