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 6, 2025
1 parent db4002f commit 7671353
Show file tree
Hide file tree
Showing 10 changed files with 274 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
150 changes: 150 additions & 0 deletions client/src/components/SudoModePasswordField/SudoModePasswordField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import Button from 'components/Button/Button';
import i18n from 'i18n';
import Config from 'lib/Config';
import backend from 'lib/Backend';
import qs from 'qs';
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() {
const passwordFieldRef = createRef();
const [responseMessage, setResponseMessage] = useState('');
const [showVerify, setShowVerify] = useState(false);

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

function reloadPage() {
// Add a ?reload=1 query parameter to the URL to force the browser to reload the page
// window.location.reload() does not work as expected
const query = qs.parse(window.location.search, { ignoreQueryPrefix: true });
const reload = query.reload ? parseInt(query.reload, 10) + 1 : 1;
const hrefNoQuery = window.location.href.split('?')[0];
window.location.href = hrefNoQuery + qs.stringify({ ...query, reload }, { addQueryPrefix: true });
}

/**
* 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 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) {
reloadPage();
} 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>;
}

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,47 @@
/* global window */
import jQuery from 'jquery';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { loadComponent } from 'lib/Injector';

jQuery.entwine('ss', ($) => {
$('.js-injector-boot .SudoModePasswordField').entwine({
Component: null,
ReactRoot: null,

onmatch() {
// onmatch will match both the field holder and the field
// we only want to run this on the field holder
if (this.is('input')) {
return;
}
this._super();
const cmsContent = this.closest('.cms-content').attr('id');
const context = (cmsContent)
? { context: cmsContent }
: {};
const SudoModePasswordField = loadComponent('SudoModePasswordField', context);
this.setComponent(SudoModePasswordField);
this.refresh();
},

onunmatch() {
this._super();
const root = this.getReactRoot();
if (root) {
root.unmount();
this.setReactRoot(null);
}
},

refresh() {
const SudoModePasswordField = this.getComponent();
let root = this.getReactRoot();
if (!root) {
root = createRoot(this[0]);
}
root.render(<SudoModePasswordField/>);
this.setReactRoot(root);
},
});
});
1 change: 1 addition & 0 deletions client/src/styles/bundle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
@import "../components/Search/SearchForm";
@import "../components/Search/SearchToggle";
@import "../components/Search/SearchBox";
@import "../components/SudoModePasswordField/SudoModePasswordField";
@import "../components/Tabs/Tabs";
@import "../components/Tag/Tag";
@import "../components/Tag/TagList";
Expand Down
7 changes: 7 additions & 0 deletions code/LeftAndMain.php
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,13 @@ public function getEditForm($id = null, $fields = null)
$readonlyFields = $form->Fields()->makeReadonly();
$form->setFields($readonlyFields);
}

// Check if the the record is a DataObject and if that DataObject requires sudo mode
// If so then require sudo mode for the edit form
if (is_a($record, DataObject::class) && $record->getRequireSudoMode()) {
$form->requireSudoMode();
}

return $form;
}

Expand Down
7 changes: 7 additions & 0 deletions code/ModelAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ public function getEditForm($id = null, $fields = null)
$form->setFormAction($editFormAction);
$form->setAttribute('data-pjax-fragment', 'CurrentForm');

// Check if the the record requires sudo mode, If so then require sudo mode for the edit form
$list = $this->getList();
$record = Injector::inst()->create($list->dataClass());
if ($record->getRequireSudoMode()) {
$form->requireSudoMode();
}

$this->extend('updateEditForm', $form);

return $form;
Expand Down

0 comments on commit 7671353

Please sign in to comment.