Skip to content
Snippets Groups Projects
Commit 9fb8fc5f authored by Jack Reed's avatar Jack Reed Committed by Chris Beer
Browse files

Add AuthenticationLogout component that logs out of service and opens a new window

parent 593b40ba
Branches
Tags
No related merge requests found
Showing
with 268 additions and 5 deletions
import React from 'react';
import { shallow } from 'enzyme';
import Fab from '@material-ui/core/Fab';
import { AuthenticationLogout } from '../../../src/components/AuthenticationLogout';
/**
* Helper function to create a shallow wrapper around AuthenticationLogout
*/
function createWrapper(props) {
return shallow(
<AuthenticationLogout
authServiceId="http://example.com/auth"
label="Log out now!"
logoutServiceId="http://example.com/logout"
status="ok"
t={key => key}
windowId="w"
{...props}
/>,
);
}
describe('AuthenticationLogout', () => {
it('when status is not ok, render fragment', () => {
const wrapper = createWrapper({ status: 'fail' });
expect(wrapper.matchesElement(<></>)).toBe(true);
});
it('renders Fab with logout label', () => {
const wrapper = createWrapper();
expect(wrapper.find(Fab).length).toBe(1);
expect(wrapper.find(Fab).text()).toBe('Log out now!');
});
it('click opens a new window to logout and resets state', () => {
const mockWindow = {};
const open = jest.fn(() => mockWindow);
const reset = jest.fn(() => {});
const wrapper = createWrapper({ depWindow: { open }, resetAuthenticationState: reset });
expect(wrapper.find(Fab).props().onClick());
expect(open).toHaveBeenCalledWith('http://example.com/logout');
expect(reset).toHaveBeenCalledWith({ authServiceId: 'http://example.com/auth' });
});
});
...@@ -5,9 +5,10 @@ import Collapse from '@material-ui/core/Collapse'; ...@@ -5,9 +5,10 @@ import Collapse from '@material-ui/core/Collapse';
import DialogActions from '@material-ui/core/DialogActions'; import DialogActions from '@material-ui/core/DialogActions';
import SanitizedHtml from '../../../src/containers/SanitizedHtml'; import SanitizedHtml from '../../../src/containers/SanitizedHtml';
import { WindowAuthenticationControl } from '../../../src/components/WindowAuthenticationControl'; import { WindowAuthenticationControl } from '../../../src/components/WindowAuthenticationControl';
import AuthenticationLogout from '../../../src/containers/AuthenticationLogout';
/** /**
* Helper function to create a shallow wrapper around ErrorDialog * Helper function to create a shallow wrapper around WindowAuthenticationControl
*/ */
function createWrapper(props) { function createWrapper(props) {
return shallow( return shallow(
...@@ -27,9 +28,9 @@ function createWrapper(props) { ...@@ -27,9 +28,9 @@ function createWrapper(props) {
describe('WindowAuthenticationControl', () => { describe('WindowAuthenticationControl', () => {
let wrapper; let wrapper;
it('renders nothing if it is not degraded', () => { it('renders AuthenticationLogout if it is not degraded', () => {
wrapper = createWrapper({ degraded: false }); wrapper = createWrapper({ degraded: false });
expect(wrapper.matchesElement(<></>)).toBe(true); expect(wrapper.find(AuthenticationLogout).length).toBe(1);
}); });
describe('with a non-interactive login', () => { describe('with a non-interactive login', () => {
......
...@@ -135,4 +135,20 @@ describe('access tokens response reducer', () => { ...@@ -135,4 +135,20 @@ describe('access tokens response reducer', () => {
}, },
}); });
}); });
describe('should handle RESET_AUTHENTICATION_STATE', () => {
it('does nothing if tokenServiceId is not present', () => {
expect(accessTokensReducer({}, {
tokenServiceId: 'foo',
type: ActionTypes.RESET_AUTHENTICATION_STATE,
})).toEqual({});
});
it('removes tokenServiceId', () => {
expect(accessTokensReducer({
foo: 'otherStuff',
}, {
tokenServiceId: 'foo',
type: ActionTypes.RESET_AUTHENTICATION_STATE,
})).toEqual({});
});
});
}); });
...@@ -103,4 +103,20 @@ describe('auth response reducer', () => { ...@@ -103,4 +103,20 @@ describe('auth response reducer', () => {
}, },
}); });
}); });
describe('should handle RESET_AUTHENTICATION_STATE', () => {
it('does nothing if id is not present', () => {
expect(authReducer({}, {
id: 'foo',
type: ActionTypes.RESET_AUTHENTICATION_STATE,
})).toEqual({});
});
it('removes id', () => {
expect(authReducer({
foo: 'otherStuff',
}, {
id: 'foo',
type: ActionTypes.RESET_AUTHENTICATION_STATE,
})).toEqual({});
});
});
}); });
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
selectNextAuthService, selectNextAuthService,
selectInfoResponse, selectInfoResponse,
getVisibleCanvasNonTiledResources, getVisibleCanvasNonTiledResources,
selectLogoutAuthService,
} from '../../../src/state/selectors/canvases'; } from '../../../src/state/selectors/canvases';
describe('getVisibleCanvases', () => { describe('getVisibleCanvases', () => {
...@@ -314,6 +315,50 @@ describe('selectCanvasAuthService', () => { ...@@ -314,6 +315,50 @@ describe('selectCanvasAuthService', () => {
}); });
}); });
describe('selectLogoutAuthService', () => {
it('returns a logout auth service if one exists', () => {
const logout = {
'@id': 'http://foo/logout',
profile: 'http://iiif.io/api/auth/1/logout',
};
const resource = {
service: [
{
'@id': 'login',
profile: 'http://iiif.io/api/auth/1/login',
service: [
logout,
],
},
],
};
const state = {
auth: {
login: {
ok: true,
},
},
infoResponses: {
'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': {
json: resource,
},
},
manifests: {
a: {
json: manifestFixture001,
},
},
};
expect(
selectLogoutAuthService(
state,
{ canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' },
).id,
)
.toBe(logout['@id']);
});
});
describe('selectInfoResponse', () => { describe('selectInfoResponse', () => {
it('returns in the info response for the first canvas resource', () => { it('returns in the info response for the first canvas resource', () => {
const resource = { some: 'resource' }; const resource = { some: 'resource' };
......
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Fab from '@material-ui/core/Fab';
/**
*
*/
export class AuthenticationLogout extends Component {
/** */
constructor(props) {
super(props);
this.handleLogout = this.handleLogout.bind(this);
}
/** */
handleLogout() {
const {
authServiceId, depWindow, logoutServiceId, resetAuthenticationState,
} = this.props;
(depWindow || window).open(logoutServiceId);
resetAuthenticationState({ authServiceId });
}
/** */
render() {
const {
label, status, t,
} = this.props;
if (status !== 'ok') return <></>;
return (
<Fab color="primary" variant="extended" onClick={this.handleLogout}>
{label || t('logout')}
</Fab>
);
}
}
AuthenticationLogout.propTypes = {
authServiceId: PropTypes.string,
depWindow: PropTypes.object, // eslint-disable-line react/forbid-prop-types
label: PropTypes.string,
logoutServiceId: PropTypes.string,
resetAuthenticationState: PropTypes.func.isRequired,
status: PropTypes.string,
t: PropTypes.func,
};
AuthenticationLogout.defaultProps = {
authServiceId: undefined,
depWindow: undefined,
label: undefined,
logoutServiceId: undefined,
status: undefined,
t: () => {},
};
...@@ -7,6 +7,7 @@ import DialogActions from '@material-ui/core/DialogActions'; ...@@ -7,6 +7,7 @@ import DialogActions from '@material-ui/core/DialogActions';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import LockIcon from '@material-ui/icons/LockSharp'; import LockIcon from '@material-ui/icons/LockSharp';
import SanitizedHtml from '../containers/SanitizedHtml'; import SanitizedHtml from '../containers/SanitizedHtml';
import AuthenticationLogout from '../containers/AuthenticationLogout';
/** /**
*/ */
...@@ -67,10 +68,11 @@ export class WindowAuthenticationControl extends Component { ...@@ -67,10 +68,11 @@ export class WindowAuthenticationControl extends Component {
profile, profile,
status, status,
t, t,
windowId,
} = this.props; } = this.props;
const failed = status === 'failed'; const failed = status === 'failed';
if ((!degraded || !profile) && status !== 'fetching') return <></>; if ((!degraded || !profile) && status !== 'fetching') return <AuthenticationLogout windowId={windowId} />;
if (!this.isInteractive() && !failed) return <></>; if (!this.isInteractive() && !failed) return <></>;
const { showFailureMessage, open } = this.state; const { showFailureMessage, open } = this.state;
......
import { compose } from 'redux';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core';
import { withPlugins } from '../extend/withPlugins';
import {
getCurrentCanvas,
selectAuthStatus,
selectCanvasAuthService,
selectLogoutAuthService,
} from '../state/selectors';
import * as actions from '../state/actions';
import { AuthenticationLogout } from '../components/AuthenticationLogout';
/**
* mapStateToProps - to hook up connect
* @memberof App
* @private
*/
const mapStateToProps = (state, { windowId }) => {
const canvasId = (getCurrentCanvas(state, { windowId }) || {}).id;
const service = selectCanvasAuthService(state, { canvasId, windowId });
const logoutService = selectLogoutAuthService(state, { canvasId, windowId });
return {
authServiceId: service && service.id,
label: logoutService && logoutService.getLabel()[0].value,
logoutServiceId: logoutService && logoutService.id,
status: service && selectAuthStatus(state, service),
};
};
const mapDispatchToProps = {
resetAuthenticationState: actions.resetAuthenticationState,
};
const styles = {};
const enhance = compose(
connect(mapStateToProps, mapDispatchToProps),
withStyles(styles),
withTranslation(),
withPlugins('AuthenticationLogout'),
);
export default enhance(AuthenticationLogout);
...@@ -69,6 +69,7 @@ ...@@ -69,6 +69,7 @@
"links": "Links", "links": "Links",
"listAllOpenWindows": "Jump to window", "listAllOpenWindows": "Jump to window",
"login": "Log in", "login": "Log in",
"logout": "Log out",
"manifestError": "The resource cannot be added:", "manifestError": "The resource cannot be added:",
"maximizeWindow": "Maximize window", "maximizeWindow": "Maximize window",
"menu": "Menu", "menu": "Menu",
......
...@@ -57,6 +57,7 @@ const ActionTypes = { ...@@ -57,6 +57,7 @@ const ActionTypes = {
REQUEST_ACCESS_TOKEN: 'mirador/REQUEST_ACCESS_TOKEN', REQUEST_ACCESS_TOKEN: 'mirador/REQUEST_ACCESS_TOKEN',
RECEIVE_ACCESS_TOKEN: 'mirador/RECEIVE_ACCESS_TOKEN', RECEIVE_ACCESS_TOKEN: 'mirador/RECEIVE_ACCESS_TOKEN',
RECEIVE_ACCESS_TOKEN_FAILURE: 'mirador/RECEIVE_ACCESS_TOKEN_FAILURE', RECEIVE_ACCESS_TOKEN_FAILURE: 'mirador/RECEIVE_ACCESS_TOKEN_FAILURE',
RESET_AUTHENTICATION_STATE: 'mirador/RESET_AUTHENTICATION_STATE',
REQUEST_SEARCH: 'mirador/REQUEST_SEARCH', REQUEST_SEARCH: 'mirador/REQUEST_SEARCH',
RECEIVE_SEARCH: 'mirador/RECEIVE_SEARCH', RECEIVE_SEARCH: 'mirador/RECEIVE_SEARCH',
......
...@@ -127,3 +127,20 @@ export function resolveAccessTokenRequest({ messageId, ...json }) { ...@@ -127,3 +127,20 @@ export function resolveAccessTokenRequest({ messageId, ...json }) {
} }
}); });
} }
/**
* Resets authentication state for a token service
*/
export function resetAuthenticationState({ authServiceId }) {
return ((dispatch, getState) => {
const { accessTokens } = getState();
const currentService = Object.values(accessTokens)
.find(service => service.authId === authServiceId);
dispatch({
id: authServiceId,
tokenServiceId: currentService && currentService.id,
type: ActionTypes.RESET_AUTHENTICATION_STATE,
});
});
}
import normalizeUrl from 'normalize-url'; import normalizeUrl from 'normalize-url';
import { removeIn } from 'immutable';
import { Utils } from 'manifesto.js/dist-esmodule/Utils'; import { Utils } from 'manifesto.js/dist-esmodule/Utils';
import ActionTypes from '../actions/action-types'; import ActionTypes from '../actions/action-types';
...@@ -59,6 +59,8 @@ export function accessTokensReducer(state = {}, action) { ...@@ -59,6 +59,8 @@ export function accessTokensReducer(state = {}, action) {
isFetching: false, isFetching: false,
}, },
}; };
case ActionTypes.RESET_AUTHENTICATION_STATE:
return removeIn(state, [action.tokenServiceId]);
default: default:
return state; return state;
} }
......
import normalizeUrl from 'normalize-url'; import normalizeUrl from 'normalize-url';
import { removeIn } from 'immutable';
import ActionTypes from '../actions/action-types'; import ActionTypes from '../actions/action-types';
import { selectNextAuthService } from '../selectors/canvases'; import { selectNextAuthService } from '../selectors/canvases';
...@@ -51,6 +53,8 @@ export const authReducer = (state = {}, action) => { ...@@ -51,6 +53,8 @@ export const authReducer = (state = {}, action) => {
ok: action.ok, ok: action.ok,
}, },
}; };
case ActionTypes.RESET_AUTHENTICATION_STATE:
return removeIn(state, [action.id]);
default: return state; default: return state;
} }
}; };
...@@ -266,6 +266,19 @@ export const selectCanvasAuthService = createSelector( ...@@ -266,6 +266,19 @@ export const selectCanvasAuthService = createSelector(
}, },
); );
export const selectLogoutAuthService = createSelector(
[
selectInfoResponse,
state => state,
],
(infoResponse, state) => {
if (!infoResponse) return undefined;
const authService = selectActiveAuthService(state, infoResponse.json);
if (!authService) return undefined;
return authService.getService('http://iiif.io/api/auth/1/logout');
},
);
/** */ /** */
export function selectAuthStatus({ auth }, service) { export function selectAuthStatus({ auth }, service) {
if (!service) return null; if (!service) return null;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment