diff --git a/__tests__/src/components/AuthenticationLogout.test.js b/__tests__/src/components/AuthenticationLogout.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e120d6e9188e17a7cbcacfb18993dad313de7de4 --- /dev/null +++ b/__tests__/src/components/AuthenticationLogout.test.js @@ -0,0 +1,42 @@ +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' }); + }); +}); diff --git a/__tests__/src/components/WindowAuthenticationControl.test.js b/__tests__/src/components/WindowAuthenticationControl.test.js index c7f0fb66e8f96b0ef8e197fcb1aa7f8b22d1c247..ffa31741da69f6c345cc8ee1de67ac0ba56e12d3 100644 --- a/__tests__/src/components/WindowAuthenticationControl.test.js +++ b/__tests__/src/components/WindowAuthenticationControl.test.js @@ -5,9 +5,10 @@ import Collapse from '@material-ui/core/Collapse'; import DialogActions from '@material-ui/core/DialogActions'; import SanitizedHtml from '../../../src/containers/SanitizedHtml'; 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) { return shallow( @@ -27,9 +28,9 @@ function createWrapper(props) { describe('WindowAuthenticationControl', () => { let wrapper; - it('renders nothing if it is not degraded', () => { + it('renders AuthenticationLogout if it is not degraded', () => { wrapper = createWrapper({ degraded: false }); - expect(wrapper.matchesElement(<></>)).toBe(true); + expect(wrapper.find(AuthenticationLogout).length).toBe(1); }); describe('with a non-interactive login', () => { diff --git a/__tests__/src/reducers/accessTokens.test.js b/__tests__/src/reducers/accessTokens.test.js index 1acf76a2081abe040c5b634de9abb8ecfaad5aba..acf6f8891759ff28f31d6237f8cca0e010497803 100644 --- a/__tests__/src/reducers/accessTokens.test.js +++ b/__tests__/src/reducers/accessTokens.test.js @@ -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({}); + }); + }); }); diff --git a/__tests__/src/reducers/auth.test.js b/__tests__/src/reducers/auth.test.js index 151db1096fcc933050ef1506e2c9108034a2ba87..af971069e85fb6ffbc3e3fd65f5c3415f396c5b3 100644 --- a/__tests__/src/reducers/auth.test.js +++ b/__tests__/src/reducers/auth.test.js @@ -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({}); + }); + }); }); diff --git a/__tests__/src/selectors/canvases.test.js b/__tests__/src/selectors/canvases.test.js index 3ed5a6a27adb00c1d6968442f12e8070e348997b..d135fa576096e7fe5c7772d47d6793c5809d3836 100644 --- a/__tests__/src/selectors/canvases.test.js +++ b/__tests__/src/selectors/canvases.test.js @@ -13,6 +13,7 @@ import { selectNextAuthService, selectInfoResponse, getVisibleCanvasNonTiledResources, + selectLogoutAuthService, } from '../../../src/state/selectors/canvases'; describe('getVisibleCanvases', () => { @@ -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', () => { it('returns in the info response for the first canvas resource', () => { const resource = { some: 'resource' }; diff --git a/src/components/AuthenticationLogout.js b/src/components/AuthenticationLogout.js new file mode 100644 index 0000000000000000000000000000000000000000..82c37de15f562c0053fa00a740d31ada12b1bfbb --- /dev/null +++ b/src/components/AuthenticationLogout.js @@ -0,0 +1,57 @@ +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: () => {}, +}; diff --git a/src/components/WindowAuthenticationControl.js b/src/components/WindowAuthenticationControl.js index 95f9d6cf14bfbff635954f272f6a8f0af81cfed9..7ea9e787db7a2b8abbb6f584e6f489c8f3d10b9f 100644 --- a/src/components/WindowAuthenticationControl.js +++ b/src/components/WindowAuthenticationControl.js @@ -7,6 +7,7 @@ import DialogActions from '@material-ui/core/DialogActions'; import Typography from '@material-ui/core/Typography'; import LockIcon from '@material-ui/icons/LockSharp'; import SanitizedHtml from '../containers/SanitizedHtml'; +import AuthenticationLogout from '../containers/AuthenticationLogout'; /** */ @@ -67,10 +68,11 @@ export class WindowAuthenticationControl extends Component { profile, status, t, + windowId, } = this.props; const failed = status === 'failed'; - if ((!degraded || !profile) && status !== 'fetching') return <></>; + if ((!degraded || !profile) && status !== 'fetching') return <AuthenticationLogout windowId={windowId} />; if (!this.isInteractive() && !failed) return <></>; const { showFailureMessage, open } = this.state; diff --git a/src/containers/AuthenticationLogout.js b/src/containers/AuthenticationLogout.js new file mode 100644 index 0000000000000000000000000000000000000000..1178992d1a2e45e993bbded92f091b62211cdb14 --- /dev/null +++ b/src/containers/AuthenticationLogout.js @@ -0,0 +1,46 @@ +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); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 56a2cbc22da2eb2b92af38024ab05714c35ae5cb..e121a0bc256a292e8e0cbf192b0f9f5a42a8d2ef 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -69,6 +69,7 @@ "links": "Links", "listAllOpenWindows": "Jump to window", "login": "Log in", + "logout": "Log out", "manifestError": "The resource cannot be added:", "maximizeWindow": "Maximize window", "menu": "Menu", diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index de5fe096ca5fa062984c726d5134dce461f13d7a..114ff0b47b557061f375467a4407e4464613ad70 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -57,6 +57,7 @@ const ActionTypes = { REQUEST_ACCESS_TOKEN: 'mirador/REQUEST_ACCESS_TOKEN', RECEIVE_ACCESS_TOKEN: 'mirador/RECEIVE_ACCESS_TOKEN', RECEIVE_ACCESS_TOKEN_FAILURE: 'mirador/RECEIVE_ACCESS_TOKEN_FAILURE', + RESET_AUTHENTICATION_STATE: 'mirador/RESET_AUTHENTICATION_STATE', REQUEST_SEARCH: 'mirador/REQUEST_SEARCH', RECEIVE_SEARCH: 'mirador/RECEIVE_SEARCH', diff --git a/src/state/actions/auth.js b/src/state/actions/auth.js index 7421e173d1603f3fa717fb5fcfa50eb778701a89..c509cad09387492a9a1ae750480f847150398163 100644 --- a/src/state/actions/auth.js +++ b/src/state/actions/auth.js @@ -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, + }); + }); +} diff --git a/src/state/reducers/accessTokens.js b/src/state/reducers/accessTokens.js index 76cbeba1c395627592c24042f79abb1d91e7ec73..fd7b25babc22a433e3254ab159d564001faba42e 100644 --- a/src/state/reducers/accessTokens.js +++ b/src/state/reducers/accessTokens.js @@ -1,5 +1,5 @@ import normalizeUrl from 'normalize-url'; - +import { removeIn } from 'immutable'; import { Utils } from 'manifesto.js/dist-esmodule/Utils'; import ActionTypes from '../actions/action-types'; @@ -59,6 +59,8 @@ export function accessTokensReducer(state = {}, action) { isFetching: false, }, }; + case ActionTypes.RESET_AUTHENTICATION_STATE: + return removeIn(state, [action.tokenServiceId]); default: return state; } diff --git a/src/state/reducers/auth.js b/src/state/reducers/auth.js index 99594e001aaa12982990684bcba1039ef355cb79..7fbc678e5e5b265483a4c1c29916ca1f07a4da69 100644 --- a/src/state/reducers/auth.js +++ b/src/state/reducers/auth.js @@ -1,4 +1,6 @@ import normalizeUrl from 'normalize-url'; +import { removeIn } from 'immutable'; + import ActionTypes from '../actions/action-types'; import { selectNextAuthService } from '../selectors/canvases'; @@ -51,6 +53,8 @@ export const authReducer = (state = {}, action) => { ok: action.ok, }, }; + case ActionTypes.RESET_AUTHENTICATION_STATE: + return removeIn(state, [action.id]); default: return state; } }; diff --git a/src/state/selectors/canvases.js b/src/state/selectors/canvases.js index 347e1646bd3fc3bc3cd570360156985b881aaa6c..5513e2eab30fe383f97046eef95282c0f4896ede 100644 --- a/src/state/selectors/canvases.js +++ b/src/state/selectors/canvases.js @@ -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) { if (!service) return null;