Support authentication
Test at startup if /api/v1/ returns 401 status code. If yes redirect to login page, else continue. On login save credentials (username, password) in localStorage. On every request we set credentials in header if present. Fixes: https://github.com/ansible-community/ara-web/issues/1 Change-Id: I0f0b18b5590dec4ebfce32aa6519bb46fc8533f5
This commit is contained in:
parent
41c073b8fd
commit
9e2feefb2e
15
src/App.js
15
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 {
|
||||
<Page header={<Header />}>
|
||||
<Switch>
|
||||
<Redirect from="/" exact to="/playbooks" />
|
||||
<Route
|
||||
<PrivateRoute
|
||||
path="/playbooks"
|
||||
exact
|
||||
component={Containers.PlaybooksContainer}
|
||||
/>
|
||||
<Route
|
||||
<PrivateRoute
|
||||
path="/playbooks/:id"
|
||||
component={Containers.PlaybookContainer}
|
||||
/>
|
||||
<Route path="/login" component={Containers.LoginContainer} />
|
||||
<Route component={Containers.Container404} />
|
||||
</Switch>
|
||||
</Page>
|
||||
|
34
src/auth/PrivateRoute.js
Normal file
34
src/auth/PrivateRoute.js
Normal file
@ -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 (
|
||||
<Route
|
||||
{...props}
|
||||
render={props =>
|
||||
isAuthenticated ? (
|
||||
<Component {...props} />
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: "/login",
|
||||
state: { from: props.location }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
isAuthenticated: state.auth.isAuthenticated
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PrivateRoute);
|
39
src/auth/authActions.js
Normal file
39
src/auth/authActions.js
Normal file
@ -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());
|
||||
};
|
||||
}
|
38
src/auth/authActions.test.js
Normal file
38
src/auth/authActions.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
2
src/auth/authActionsTypes.js
Normal file
2
src/auth/authActionsTypes.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const LOGIN = "LOGIN";
|
||||
export const LOGOUT = "LOGOUT";
|
22
src/auth/authReducer.js
Normal file
22
src/auth/authReducer.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
24
src/auth/authReducer.test.js
Normal file
24
src/auth/authReducer.test.js
Normal file
@ -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
|
||||
});
|
||||
});
|
15
src/auth/localStorage.js
Normal file
15
src/auth/localStorage.js
Normal file
@ -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);
|
||||
}
|
20
src/auth/localStorage.test.js
Normal file
20
src/auth/localStorage.test.js
Normal file
@ -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);
|
||||
});
|
@ -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;
|
||||
|
@ -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";
|
||||
|
12
src/http.js
Normal file
12
src/http.js
Normal file
@ -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;
|
1
src/images/logo.svg
Normal file
1
src/images/logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 6.0 KiB |
@ -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 = (
|
||||
<Nav onSelect={this.onNavSelect} aria-label="Nav">
|
||||
<NavList variant={NavVariants.horizontal}>
|
||||
@ -49,4 +51,10 @@ class Header extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Header);
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
isAuthenticated: state.auth.isAuthenticated
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(withRouter(Header));
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 6.9 KiB |
116
src/login/LoginContainer.js
Normal file
116
src/login/LoginContainer.js
Normal file
@ -0,0 +1,116 @@
|
||||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
LoginFooterItem,
|
||||
LoginForm,
|
||||
LoginPage,
|
||||
ListItem
|
||||
} from "@patternfly/react-core";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import logo from "../images/logo.svg";
|
||||
import { login } from "../auth/authActions";
|
||||
|
||||
export class LoginContainer extends Component {
|
||||
state = {
|
||||
showHelperText: false,
|
||||
helperText: "",
|
||||
username: "",
|
||||
isValidUsername: true,
|
||||
password: "",
|
||||
isValidPassword: true,
|
||||
redirectToReferrer: false
|
||||
};
|
||||
|
||||
handleUsernameChange = username => {
|
||||
this.setState({ username });
|
||||
};
|
||||
|
||||
handlePasswordChange = password => {
|
||||
this.setState({ password });
|
||||
};
|
||||
|
||||
onLoginButtonClick = event => {
|
||||
event.preventDefault();
|
||||
const { username, password } = this.state;
|
||||
const { login } = this.props;
|
||||
login(username, password)
|
||||
.then(() => {
|
||||
this.setState({ redirectToReferrer: true });
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
showHelperText: true,
|
||||
isValidUsername: false,
|
||||
isValidPassword: false,
|
||||
helperText: "Invalid username or password"
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
username,
|
||||
isValidUsername,
|
||||
password,
|
||||
isValidPassword,
|
||||
showHelperText,
|
||||
helperText,
|
||||
redirectToReferrer
|
||||
} = this.state;
|
||||
const { location, isAuthenticated } = this.props;
|
||||
const { from } = location.state || { from: { pathname: "/" } };
|
||||
|
||||
if (redirectToReferrer || isAuthenticated) return <Redirect to={from} />;
|
||||
|
||||
const loginForm = (
|
||||
<LoginForm
|
||||
showHelperText={showHelperText}
|
||||
helperText={helperText}
|
||||
usernameLabel="Username"
|
||||
usernameValue={username}
|
||||
isValidUsername={isValidUsername}
|
||||
onChangeUsername={this.handleUsernameChange}
|
||||
passwordLabel="Password"
|
||||
passwordValue={password}
|
||||
isValidPassword={isValidPassword}
|
||||
onChangePassword={this.handlePasswordChange}
|
||||
onLoginButtonClick={this.onLoginButtonClick}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<LoginPage
|
||||
footerListVariants="inline"
|
||||
brandImgSrc={logo}
|
||||
brandImgAlt="Ara"
|
||||
footerListItems={
|
||||
<ListItem>
|
||||
<LoginFooterItem href="https://ara.readthedocs.io/en/feature-1.0/">
|
||||
Documentation
|
||||
</LoginFooterItem>
|
||||
</ListItem>
|
||||
}
|
||||
textContent="The ARA API server you are connecting to, requires authentication. Please specify your credentials to proceed."
|
||||
loginTitle="Log in to your account"
|
||||
>
|
||||
{loginForm}
|
||||
</LoginPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
isAuthenticated: state.auth.isAuthenticated
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
login: (username, password) => dispatch(login(username, password))
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LoginContainer);
|
@ -1,10 +1,10 @@
|
||||
import axios from "axios";
|
||||
import http from "../http";
|
||||
import * as types from "./playbooksActionsTypes";
|
||||
|
||||
export function getPlaybooks() {
|
||||
return (dispatch, getState) => {
|
||||
const { apiURL } = getState().config;
|
||||
return axios.get(`${apiURL}/api/v1/playbooks`).then(response => {
|
||||
return http.get(`${apiURL}/api/v1/playbooks`).then(response => {
|
||||
dispatch({
|
||||
type: types.FETCH_PLAYBOOKS,
|
||||
playbooks: response.data.results
|
||||
@ -17,6 +17,6 @@ export function getPlaybooks() {
|
||||
export function getPlaybook(playbook) {
|
||||
return (dispatch, getState) => {
|
||||
const { apiURL } = getState().config;
|
||||
return axios.get(`${apiURL}/api/v1/playbooks/${playbook.id}`);
|
||||
return http.get(`${apiURL}/api/v1/playbooks/${playbook.id}`);
|
||||
};
|
||||
}
|
||||
|
19
src/setupTests.js
Normal file
19
src/setupTests.js
Normal file
@ -0,0 +1,19 @@
|
||||
const localStorageMock = (function() {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: function(key) {
|
||||
return store[key] || null;
|
||||
},
|
||||
setItem: function(key, value) {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
removeItem: function(key) {
|
||||
delete store[key];
|
||||
},
|
||||
clear: function() {
|
||||
store = {};
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
global.localStorage = localStorageMock;
|
@ -2,11 +2,13 @@ import { createStore, applyMiddleware, combineReducers } from "redux";
|
||||
import thunk from "redux-thunk";
|
||||
import configReducer from "./config/configReducer";
|
||||
import playbooksReducer from "./playbooks/playbooksReducer";
|
||||
import authReducer from "./auth/authReducer";
|
||||
|
||||
const store = createStore(
|
||||
combineReducers({
|
||||
config: configReducer,
|
||||
playbooks: playbooksReducer
|
||||
playbooks: playbooksReducer,
|
||||
auth: authReducer
|
||||
}),
|
||||
applyMiddleware(thunk)
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user