Skip to content

Commit

Permalink
Merge pull request #25 from silverstripe/ui-rendering
Browse files Browse the repository at this point in the history
UI rendering
  • Loading branch information
emteknetnz authored Mar 23, 2021
2 parents eba1041 + d5f59bd commit ad407bb
Show file tree
Hide file tree
Showing 28 changed files with 1,366 additions and 677 deletions.
4 changes: 4 additions & 0 deletions .ss-storybook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
src: 'client/src',
fileMatcher: /(\/bundle\.scss|[A-Za-z]-story\.jsx?)$/,
};
10 changes: 1 addition & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@ Allow users to manage and revoke access to multiple login sessions across device
[![Version](http://img.shields.io/packagist/v/silverstripe/session-manager.svg?style=flat)](https://packagist.org/packages/silverstripe/session-manager)
[![License](http://img.shields.io/packagist/l/silverstripe/session-manager.svg?style=flat)](LICENSE)

**This module is in development, and is not ready for use in production**

![CMS view](images/cms.png)

## How it works
## Developer Details

The module introduces a new database record type: `LoginSession`.
On first login, it creates a new record of this type, recording the IP and User-Agent,
Expand Down Expand Up @@ -92,9 +90,3 @@ Alternatively, you can create a system cron entry to run the `LoginSessionGarbag
```
`*/5 * * * * /path/to/webroot/vendor/bin/sake dev/tasks/LoginSessionGarbageCollectionTask
```

## To-do

- Privacy warning (storing IP/User-Agent - GDPR)
- More manual testing
- Unit test coverage
2 changes: 2 additions & 0 deletions _config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ SilverStripe\Security\RememberLoginHash:
SilverStripe\Admin\LeftAndMain:
extra_requirements_javascript:
- 'silverstripe/session-manager: client/dist/js/bundle.js'
extra_requirements_css:
- 'silverstripe/session-manager: client/dist/styles/bundle.css'
---
Name: session-manager-log-in-handler
After: '#coreauthentication'
Expand Down
13 changes: 0 additions & 13 deletions client/dist/js/GridFieldRevokeLoginSessionAction.js

This file was deleted.

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

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion client/dist/js/bundle.js.map

This file was deleted.

1 change: 1 addition & 0 deletions client/dist/styles/bundle.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion client/dist/styles/bundle.css.map

This file was deleted.

12 changes: 12 additions & 0 deletions client/lang/src/en/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"SessionManager.DELETE_CONFIRMATION": "Are you sure you want to delete this login session?",
"SessionManager.CONFIRMATION_TITLE": "Are you sure?",
"SessionManager.DELETE_CONFIRMATION_BUTTON": "Remove login session",
"SessionManager.CURRENT": "Current",
"SessionManager.AUTHENTICATED": "authenticated {createdElapsed}...",
"SessionManager.LAST_ACTIVE": "last active {lastAccessedElapsed}...",
"SessionManager.LOG_OUT": "Log out",
"SessionManager.LOGGING_OUT": "Logging out...",
"SessionManager.LOG_OUT_CONFIRMED": "Successfully logged out of device.",
"SessionManager.ACTIVITY_TOOLTIP_TEXT": "Signed in {signedIn}, Last active {lastActive}",
}
4 changes: 3 additions & 1 deletion client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import SessionManagerField from 'components/SessionManagerField';
import LoginSession from 'components/LoginSession/LoginSession';
import SessionManagerField from 'components/SessionManagerField/SessionManagerField';
import Injector from 'lib/Injector';

export default () => {
Injector.component.registerMany({
LoginSession,
SessionManagerField,
});
};
3 changes: 2 additions & 1 deletion client/src/bundles/bundle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
@import "variables";

// Import all of your SCSS stylesheets using relative paths from "components"
@import '../components/SessionManagerField.scss';
@import '../components/LoginSession/LoginSession.scss';
@import '../components/SessionManagerField/SessionManagerField.scss';

// Import any legacy SCSS stylesheets
// e.g. @import '../styles/MyComponent-ModelAdmin-legacy.scss
149 changes: 149 additions & 0 deletions client/src/components/LoginSession/LoginSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/* global window */
import React, { useState } from 'react';
import confirm from '@silverstripe/reactstrap-confirm';
import Config from 'lib/Config'; // eslint-disable-line
import backend from 'lib/Backend';
import moment from 'moment';
import i18n from 'i18n';
import PropTypes from 'prop-types';
import jQuery from 'jquery';
import 'regenerator-runtime/runtime';

function LoginSession(props) {
const [loading, setLoading] = useState({ complete: false, failed: false, submitting: false });

async function logOut() {
setLoading({ ...loading, submitting: true });

const endpoint = backend.createEndpointFetcher({
url: `${props.LogOutEndpoint}/:id`,
method: 'delete',
payloadSchema: {
id: { urlReplacement: ':id', remove: true },
SecurityID: { querystring: true }
}
});

endpoint({
id: props.ID,
SecurityID: Config.get('SecurityID')
})
.then(response => {
setLoading({
complete: true,
failed: !!response.error && !response.success,
submitting: false
});

if (response.success) {
setTimeout(() => {
setLoading({
complete: true,
failed: !!response.error && !response.success,
fadeOutComplete: true,
submitting: false
});

jQuery.noticeAdd({
text: i18n._t(
'SessionManager.LOG_OUT_CONFIRMED',
'Successfully logged out of device.'
),
stay: false,
type: 'success'
});
}, 2000);
}
})
.catch((error) => {
setLoading({ complete: true, failed: true, submitting: false });

error.response.json().then(response => {
jQuery.noticeAdd({ text: response.errors, stay: false, type: 'error' });
});
});
}

async function attemptLogOut() {
// Confirm with the user
const confirmMessage = i18n._t(
'SessionManager.DELETE_CONFIRMATION',
'Are you sure you want to delete this login session?'
);
const confirmTitle = i18n._t('SessionManager.CONFIRMATION_TITLE', 'Are you sure?');
const buttonLabel = i18n._t('SessionManager.DELETE_CONFIRMATION_BUTTON', 'Remove login session');

if (!await confirm(confirmMessage, { title: confirmTitle, confirmLabel: buttonLabel })) {
return;
}

logOut();
}

if (loading.fadeOutComplete) {
return null;
}

const created = moment(props.Created);
const createdElapsed = moment.utc(props.Created).fromNow();
const lastAccessed = moment(props.LastAccessed);
const lastAccessedElapsed = moment.utc(props.LastAccessed).fromNow();
const currentStr = i18n._t('SessionManager.CURRENT', 'Current');
const lastActiveStr = props.IsCurrent ?
i18n.inject(
i18n._t('SessionManager.AUTHENTICATED', 'authenticated {createdElapsed}...'),
{ createdElapsed }
)
: i18n.inject(
i18n._t('SessionManager.LAST_ACTIVE', 'last active {lastAccessedElapsed}...'),
{ lastAccessedElapsed }
);
const logOutStr = (loading.submitting || (loading.complete && !loading.failed)) ?
i18n._t('SessionManager.LOGGING_OUT', 'Logging out...')
: i18n._t('SessionManager.LOG_OUT', 'Log out');

const activityTooltip = i18n.inject(
i18n._t('Admin.ACTIVITY_TOOLTIP_TEXT', 'Signed in {signedIn}, Last active {lastActive}'),
{
signedIn: created.format('L LT'),
lastActive: lastAccessed.format('L LT')
}
);

return (
<div className={`login-session ${(loading.complete && !loading.failed) ? 'hidden' : ''}`}>
<p>{props.UserAgent}</p>
<p className="text-muted">
{props.IPAddress}
<span data-toggle="tooltip" data-placement="top" title={activityTooltip}>
, {lastActiveStr}
</span>
</p>
{props.IsCurrent &&
<p>
<strong className={'text-success'}>
{currentStr}
</strong>
</p>
}
{!props.IsCurrent && <a
role={'button'}
tabIndex={'0'}
className={'login-session__logout'}
onClick={loading.submitting ? null : attemptLogOut}
>{logOutStr}</a>}
</div>
);
}

LoginSession.propTypes = {
ID: PropTypes.number.isRequired,
IPAddress: PropTypes.string.isRequired,
IsCurrent: PropTypes.bool,
UserAgent: PropTypes.string,
Created: PropTypes.string.isRequired,
LastAccessed: PropTypes.string.isRequired,
LogOutEndpoint: PropTypes.string.isRequired,
};

export default LoginSession;
22 changes: 22 additions & 0 deletions client/src/components/LoginSession/LoginSession.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.login-session {
&.hidden {
visibility: hidden;
opacity: 0;
transition: visibility 0s 2s, opacity 2s linear;
}

p {
margin: 0;
}
}

.login-session__logout:not([href]) {
cursor: pointer;
color: $link-color;
font-weight: bold;
outline: none;

&:hover, &:active {
text-decoration: underline;
}
}
22 changes: 22 additions & 0 deletions client/src/components/LoginSession/tests/LoginSession-story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import LoginSession from 'components/LoginSession/LoginSession';
import { withKnobs, boolean } from '@storybook/addon-knobs/react';

const props = {
ID: 1,
IPAddress: '127.0.0.1',
UserAgent: 'Chrome on Mac OS X 10.15.7',
Created: '2021-01-20 00:33:41',
LastAccessed: '2021-03-11 03:47:22',
LogOutEndpoint: 'admin/loginsession/remove',
};

storiesOf('SessionManager/LoginSession', module)
.addDecorator(withKnobs)
.add('Login session', () => (
<LoginSession
{...props}
IsCurrent={boolean('IsCurrent', false)}
/>
));
Loading

0 comments on commit ad407bb

Please sign in to comment.