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>
|
||||
|
|
|
@ -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);
|
|
@ -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());
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,2 @@
|
|||
export const LOGIN = "LOGIN";
|
||||
export const LOGOUT = "LOGOUT";
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
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 |
|
@ -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}`);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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…
Reference in New Issue