diff --git a/src/App.js b/src/App.js index f62c227..d6c6430 100644 --- a/src/App.js +++ b/src/App.js @@ -6,8 +6,10 @@ import "@patternfly/patternfly/patternfly.css"; import "@patternfly/patternfly/patternfly-addons.css"; import store from "./store"; import { getConfig } from "./config/configActions"; +import { checkAuthentification } from "./auth/authActions"; import * as Containers from "./containers"; -import Header from "./layout/navigation/Header"; +import Header from "./layout/Header"; +import PrivateRoute from "./auth/PrivateRoute"; import Page from "./layout/Page"; class App extends Component { @@ -16,7 +18,11 @@ class App extends Component { }; componentDidMount() { - store.dispatch(getConfig()).then(() => this.setState({ isLoading: false })); + store + .dispatch(getConfig()) + .then(() => store.dispatch(checkAuthentification())) + .catch(error => console.error(error)) + .then(() => this.setState({ isLoading: false })); } render() { @@ -28,15 +34,16 @@ class App extends Component { }> - - + diff --git a/src/auth/PrivateRoute.js b/src/auth/PrivateRoute.js new file mode 100644 index 0000000..24ef17a --- /dev/null +++ b/src/auth/PrivateRoute.js @@ -0,0 +1,34 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { Redirect, Route } from "react-router-dom"; + +class PrivateRoute extends Component { + render() { + const { isAuthenticated, component: Component, ...props } = this.props; + return ( + + isAuthenticated ? ( + + ) : ( + + ) + } + /> + ); + } +} + +function mapStateToProps(state) { + return { + isAuthenticated: state.auth.isAuthenticated + }; +} + +export default connect(mapStateToProps)(PrivateRoute); diff --git a/src/auth/authActions.js b/src/auth/authActions.js new file mode 100644 index 0000000..4c2e5d3 --- /dev/null +++ b/src/auth/authActions.js @@ -0,0 +1,39 @@ +import http from "../http"; +import * as types from "./authActionsTypes"; +import { setCredentials, removeCredentials } from "./localStorage"; + +export function logout() { + removeCredentials(); + return { + type: types.LOGOUT + }; +} + +export function checkAuthentification() { + return (dispatch, getState) => { + const state = getState(); + return http({ + method: "get", + url: `${state.config.apiURL}/api/v1/` + }) + .then(response => { + dispatch({ + type: types.LOGIN + }); + return response; + }) + .catch(error => { + if (error.response && error.response.status === 401) { + dispatch(logout()); + } + throw error; + }); + }; +} + +export function login(username, password) { + return dispatch => { + setCredentials({ username, password }); + return dispatch(checkAuthentification()); + }; +} diff --git a/src/auth/authActions.test.js b/src/auth/authActions.test.js new file mode 100644 index 0000000..14a7619 --- /dev/null +++ b/src/auth/authActions.test.js @@ -0,0 +1,38 @@ +import axios from "axios"; +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import axiosMockAdapter from "axios-mock-adapter"; + +import { checkAuthentification } from "./authActions"; +import * as types from "./authActionsTypes"; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +const axiosMock = new axiosMockAdapter(axios); + +it("checkAuthentification", () => { + axiosMock.onGet("https://api.example.org/api/v1/").reply(200, {}); + const expectedActions = [ + { + type: types.LOGIN + } + ]; + const store = mockStore({ config: { apiURL: "https://api.example.org" } }); + return store.dispatch(checkAuthentification()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); +}); + +it("checkAuthentification unauthorized", () => { + axiosMock.onGet("https://api.example.org/api/v1/").reply(401, {}); + const expectedActions = [ + { + type: types.LOGOUT + } + ]; + const store = mockStore({ config: { apiURL: "https://api.example.org" } }); + return store.dispatch(checkAuthentification()).catch(() => { + expect(store.getActions()).toEqual(expectedActions); + }); +}); diff --git a/src/auth/authActionsTypes.js b/src/auth/authActionsTypes.js new file mode 100644 index 0000000..f413b89 --- /dev/null +++ b/src/auth/authActionsTypes.js @@ -0,0 +1,2 @@ +export const LOGIN = "LOGIN"; +export const LOGOUT = "LOGOUT"; diff --git a/src/auth/authReducer.js b/src/auth/authReducer.js new file mode 100644 index 0000000..299421d --- /dev/null +++ b/src/auth/authReducer.js @@ -0,0 +1,22 @@ +import * as types from "./authActionsTypes"; + +const initialState = { + isAuthenticated: true +}; + +export default function(state = initialState, action) { + switch (action.type) { + case types.LOGIN: + return { + ...state, + isAuthenticated: true + } + case types.LOGOUT: + return { + ...state, + isAuthenticated: false + } + default: + return state; + } +} diff --git a/src/auth/authReducer.test.js b/src/auth/authReducer.test.js new file mode 100644 index 0000000..0734a5a --- /dev/null +++ b/src/auth/authReducer.test.js @@ -0,0 +1,24 @@ +import reducer from "./authReducer"; +import * as types from "./authActionsTypes"; + +it("returns the initial state", () => { + expect(reducer(undefined, {})).toEqual({ isAuthenticated: true }); +}); + +it("LOGIN", () => { + const newState = reducer(undefined, { + type: types.LOGIN + }); + expect(newState).toEqual({ + isAuthenticated: true + }); +}); + +it("LOGOUT", () => { + const newState = reducer(undefined, { + type: types.LOGOUT + }); + expect(newState).toEqual({ + isAuthenticated: false + }); +}); diff --git a/src/auth/localStorage.js b/src/auth/localStorage.js new file mode 100644 index 0000000..20831b4 --- /dev/null +++ b/src/auth/localStorage.js @@ -0,0 +1,15 @@ +const TOKEN = "ARA"; + +export function getCredentials() { + const credentials = localStorage.getItem(TOKEN); + if (!credentials) return null; + return JSON.parse(credentials); +} + +export function setCredentials(credentials) { + localStorage.setItem(TOKEN, JSON.stringify(credentials)); +} + +export function removeCredentials() { + localStorage.removeItem(TOKEN); +} diff --git a/src/auth/localStorage.test.js b/src/auth/localStorage.test.js new file mode 100644 index 0000000..aa62762 --- /dev/null +++ b/src/auth/localStorage.test.js @@ -0,0 +1,20 @@ +import { + setCredentials, + getCredentials, + removeCredentials, +} from "./localStorage"; + +it("localStorage getCredentials", () => { + expect(getCredentials()).toBe(null); +}); + +it("localStorage setCredentials getCredentials removeCredentials", () => { + const credentials = { + username: "foo", + password: "bar" + }; + setCredentials(credentials); + expect(getCredentials()).toEqual(credentials); + removeCredentials(); + expect(getCredentials()).toBe(null); +}); diff --git a/src/config/configActions.js b/src/config/configActions.js index b4707e9..6683d9b 100644 --- a/src/config/configActions.js +++ b/src/config/configActions.js @@ -1,4 +1,4 @@ -import axios from "axios"; +import http from "../http"; import * as types from "./configActionsTypes"; export function setConfig(config) { @@ -10,7 +10,7 @@ export function setConfig(config) { export function getConfig() { return dispatch => { - return axios.get(`${process.env.PUBLIC_URL}/config.json`).then(response => { + return http.get(`${process.env.PUBLIC_URL}/config.json`).then(response => { const config = response.data; dispatch(setConfig(config)); return response; diff --git a/src/containers.js b/src/containers.js index 230e86b..9e5288e 100644 --- a/src/containers.js +++ b/src/containers.js @@ -1,4 +1,5 @@ export { default as Container404 } from "./layout/Container404"; export { default as LoadingContainer } from "./layout/LoadingContainer"; +export { default as LoginContainer } from "./login/LoginContainer"; export { default as PlaybooksContainer } from "./playbooks/PlaybooksContainer"; export { default as PlaybookContainer } from "./playbooks/PlaybookContainer"; diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..5531a77 --- /dev/null +++ b/src/http.js @@ -0,0 +1,12 @@ +import axios from "axios"; +import { getCredentials } from "./auth/localStorage"; + +axios.interceptors.request.use(config => { + const credentials = getCredentials(); + if (credentials) { + config.auth = credentials + } + return config; +}); + +export default axios; diff --git a/src/images/logo.svg b/src/images/logo.svg new file mode 100644 index 0000000..7d11757 --- /dev/null +++ b/src/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/layout/navigation/Header.js b/src/layout/Header.js similarity index 76% rename from src/layout/navigation/Header.js rename to src/layout/Header.js index 6789bfd..059f292 100644 --- a/src/layout/navigation/Header.js +++ b/src/layout/Header.js @@ -1,4 +1,5 @@ import React, { Component } from "react"; +import { connect } from "react-redux"; import styled from "styled-components"; import { withRouter } from "react-router"; import { @@ -9,7 +10,7 @@ import { NavVariants, PageHeader } from "@patternfly/react-core"; -import logo from "./logo.svg"; +import logo from "../images/logo.svg"; const Logo = styled(Brand)` height: 45px; @@ -17,7 +18,8 @@ const Logo = styled(Brand)` class Header extends Component { render() { - const { location, history } = this.props; + const { location, history, isAuthenticated } = this.props; + if (!isAuthenticated) return null; const TopNav = (