improve authentication

This commit is contained in:
Sebastian Sdorra
2018-07-11 21:01:29 +02:00
parent 1b6df5ee08
commit b604d613a3
16 changed files with 359 additions and 110 deletions

View File

@@ -0,0 +1,23 @@
//@flow
import React from "react";
import Notification from "./Notification";
type Props = {
error: Error
};
class ErrorNotification extends React.Component<Props> {
render() {
const { error } = this.props;
if (error) {
return (
<Notification type="danger">
<strong>Error:</strong> {error.message}
</Notification>
);
}
return "";
}
}
export default ErrorNotification;

View File

@@ -0,0 +1,20 @@
//@flow
import React from "react";
type Props = {
me: any
};
class Footer extends React.Component<Props> {
render() {
return (
<footer class="footer">
<div class="container is-centered">
<p class="has-text-centered">{this.props.me.username}</p>
</div>
</footer>
);
}
}
export default Footer;

View File

@@ -1,5 +1,5 @@
//@flow //@flow
import React from "react"; import * as React from "react";
import Logo from "./Logo"; import Logo from "./Logo";
type Props = { type Props = {

View File

@@ -6,6 +6,7 @@ type Props = {
label?: string, label?: string,
placeholder?: string, placeholder?: string,
type?: string, type?: string,
autofocus?: boolean,
onChange: string => void onChange: string => void
}; };
@@ -15,6 +16,14 @@ class InputField extends React.Component<Props> {
placeholder: "" placeholder: ""
}; };
field: ?HTMLInputElement;
componentDidMount() {
if (this.props.autofocus && this.field) {
this.field.focus();
}
}
handleInput = (event: SyntheticInputEvent<HTMLInputElement>) => { handleInput = (event: SyntheticInputEvent<HTMLInputElement>) => {
this.props.onChange(event.target.value); this.props.onChange(event.target.value);
}; };
@@ -35,6 +44,9 @@ class InputField extends React.Component<Props> {
{this.renderLabel()} {this.renderLabel()}
<div className="control"> <div className="control">
<input <input
ref={input => {
this.field = input;
}}
className="input" className="input"
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}

View File

@@ -0,0 +1,43 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import Image from "../images/loading.svg";
const styles = {
wrapper: {
position: "relative"
},
loading: {
width: "128px",
height: "128px",
position: "absolute",
top: "50%",
left: "50%",
margin: "64px 0 0 -64px"
},
image: {
width: "128px",
height: "128px"
}
};
type Props = {
classes: any
};
class Loading extends React.Component<Props> {
render() {
const { classes } = this.props;
return (
<div className={classes.wrapper}>
<div className={classes.loading}>
<img className={classes.image} src={Image} alt="Loading ..." />
</div>
</div>
);
}
}
export default injectSheet(styles)(Loading);

View File

@@ -2,7 +2,9 @@
import React from "react"; import React from "react";
import Image from "../images/logo.png"; import Image from "../images/logo.png";
class Logo extends React.PureComponent { type Props = {};
class Logo extends React.Component<Props> {
render() { render() {
return <img src={Image} alt="SCM-Manager logo" />; return <img src={Image} alt="SCM-Manager logo" />;
} }

View File

@@ -0,0 +1,37 @@
//@flow
import * as React from "react";
import classNames from "classnames";
type NotificationType = "primary" | "info" | "success" | "warning" | "danger";
type Props = {
type: NotificationType,
onClose?: () => void,
children?: React.Node
};
class Notification extends React.Component<Props> {
static defaultProps = {
type: "info"
};
renderCloseButton() {
const { onClose } = this.props;
if (onClose) {
return <button className="delete" onClick={onClose} />;
}
return "";
}
render() {
const { type, children, onClose } = this.props;
return (
<div className={classNames("notification", "is-" + type)}>
{this.renderCloseButton()}
{children}
</div>
);
}
}
export default Notification;

View File

@@ -2,6 +2,8 @@
import React from "react"; import React from "react";
import PrimaryNavigationLink from "./PrimaryNavigationLink"; import PrimaryNavigationLink from "./PrimaryNavigationLink";
type Props = {};
class PrimaryNavigation extends React.Component<Props> { class PrimaryNavigation extends React.Component<Props> {
render() { render() {
return ( return (

View File

@@ -1,5 +1,5 @@
//@flow //@flow
import React from "react"; import * as React from "react";
import { Route, Link } from "react-router-dom"; import { Route, Link } from "react-router-dom";
type Props = { type Props = {

View File

@@ -5,30 +5,36 @@ import classNames from "classnames";
type Props = { type Props = {
value: string, value: string,
disabled: boolean,
isLoading: boolean,
large?: boolean, large?: boolean,
fullWidth?: boolean fullWidth?: boolean
}; };
class SubmitButton extends React.Component<Props> { class SubmitButton extends React.Component<Props> {
render() { render() {
const { value, large, fullWidth } = this.props; const { value, large, fullWidth, isLoading, disabled } = this.props;
const largeClass = large ? "is-large" : ""; const largeClass = large ? "is-large" : "";
const fullWidthClass = fullWidth ? "is-fullwidth" : ""; const fullWidthClass = fullWidth ? "is-fullwidth" : "";
const loadingClass = isLoading ? "is-loading" : "";
return ( return (
<div className="field"> <div className="field">
<div className="control"> <div className="control">
<input <button
type="submit" type="submit"
disabled={disabled}
className={classNames( className={classNames(
"button", "button",
"is-link", "is-link",
largeClass, largeClass,
fullWidthClass fullWidthClass,
loadingClass
)} )}
value={value} >
/> {value}
</button>
</div> </div>
</div> </div>
); );

View File

@@ -1,45 +1,50 @@
import React, { Component } from "react"; import React, { Component } from "react";
import Main from "./Main"; import Main from "./Main";
import Login from "./Login"; import Login from "./Login";
import { getIsAuthenticated } from "../modules/login";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import { ThunkDispatch } from "redux-thunk"; import { ThunkDispatch } from "redux-thunk";
import { fetchMe } from "../modules/me";
import "./App.css"; import "./App.css";
import Header from "../components/Header"; import Header from "../components/Header";
import PrimaryNavigation from "../components/PrimaryNavigation"; import PrimaryNavigation from "../components/PrimaryNavigation";
import Loading from "../components/Loading";
import Notification from "../components/Notification";
import Footer from "../components/Footer";
type Props = { type Props = {
login: boolean, login: boolean,
username: string, me: any,
getAuthState: () => void, fetchMe: () => void,
loading: boolean loading: boolean
}; };
class App extends Component<Props> { class App extends Component<Props> {
componentDidMount() { componentDidMount() {
this.props.getAuthState(); this.props.fetchMe();
} }
render() { render() {
const { login, loading } = this.props; const { me, loading } = this.props;
let content; let content = [];
let navigation; let navigation;
if (loading) { if (loading) {
content = <div>Loading...</div>; content.push(<Loading />);
} else if (!login) { } else if (!me) {
content = <Login />; content.push(<Login />);
} else { } else {
content = <Main />; content.push(<Main />, <Footer me={me} />);
navigation = <PrimaryNavigation />; navigation = <PrimaryNavigation />;
} }
return ( return (
<div className="App"> <div className="App">
<Header>{navigation}</Header> <Header>{navigation}</Header>
{content} {content.map(c => {
return c;
})}
</div> </div>
); );
} }
@@ -47,12 +52,12 @@ class App extends Component<Props> {
const mapDispatchToProps = (dispatch: ThunkDispatch) => { const mapDispatchToProps = (dispatch: ThunkDispatch) => {
return { return {
getAuthState: () => dispatch(getIsAuthenticated()) fetchMe: () => dispatch(fetchMe())
}; };
}; };
const mapStateToProps = state => { const mapStateToProps = state => {
return state.login || {}; return state.me || {};
}; };
export default withRouter( export default withRouter(

View File

@@ -9,11 +9,9 @@ import SubmitButton from "../components/SubmitButton";
import classNames from "classnames"; import classNames from "classnames";
import Avatar from "../images/blib.jpg"; import Avatar from "../images/blib.jpg";
import ErrorNotification from "../components/ErrorNotification";
const styles = { const styles = {
spacing: {
paddingTop: "5rem"
},
avatar: { avatar: {
marginTop: "-70px", marginTop: "-70px",
paddingBottom: "20px" paddingBottom: "20px"
@@ -32,6 +30,8 @@ const styles = {
}; };
type Props = { type Props = {
loading: boolean,
error: Error,
classes: any, classes: any,
login: (username: string, password: string) => void login: (username: string, password: string) => void
}; };
@@ -55,16 +55,26 @@ class Login extends React.Component<Props, State> {
this.setState({ password: value }); this.setState({ password: value });
}; };
handleSubmit(event: Event) { handleSubmit = (event: Event) => {
event.preventDefault(); event.preventDefault();
if (this.isValid()) {
this.props.login(this.state.username, this.state.password); this.props.login(this.state.username, this.state.password);
} }
};
isValid() {
return this.state.username && this.state.password;
}
isInValid() {
return !this.isValid();
}
render() { render() {
const { classes } = this.props; const { classes, loading, error } = this.props;
return ( return (
<section className="hero is-fullheight"> <section className="hero has-background-light">
<div className={classes.spacing}> <div className="hero-body">
<div className="container has-text-centered"> <div className="container has-text-centered">
<div className="column is-4 is-offset-4"> <div className="column is-4 is-offset-4">
<h3 className="title">Login</h3> <h3 className="title">Login</h3>
@@ -77,9 +87,11 @@ class Login extends React.Component<Props, State> {
alt="SCM-Manager" alt="SCM-Manager"
/> />
</figure> </figure>
<form onSubmit={this.handleSubmit.bind(this)}> <ErrorNotification error={error} />
<form onSubmit={this.handleSubmit}>
<InputField <InputField
placeholder="Your Username" placeholder="Your Username"
autofocus={true}
onChange={this.handleUsernameChange} onChange={this.handleUsernameChange}
/> />
<InputField <InputField
@@ -87,7 +99,12 @@ class Login extends React.Component<Props, State> {
type="password" type="password"
onChange={this.handlePasswordChange} onChange={this.handlePasswordChange}
/> />
<SubmitButton value="Login" fullWidth={true} /> <SubmitButton
value="Login"
disabled={this.isInValid()}
fullWidth={true}
isLoading={loading}
/>
</form> </form>
</div> </div>
</div> </div>
@@ -99,7 +116,7 @@ class Login extends React.Component<Props, State> {
} }
const mapStateToProps = state => { const mapStateToProps = state => {
return {}; return state.login || {};
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {

View File

@@ -7,6 +7,7 @@ import { routerReducer, routerMiddleware } from "react-router-redux";
import repositories from "./repositories/modules/repositories"; import repositories from "./repositories/modules/repositories";
import users from "./users/modules/users"; import users from "./users/modules/users";
import login from "./modules/login"; import login from "./modules/login";
import me from "./modules/me";
import type { BrowserHistory } from "history/createBrowserHistory"; import type { BrowserHistory } from "history/createBrowserHistory";
@@ -18,7 +19,8 @@ function createReduxStore(history: BrowserHistory) {
router: routerReducer, router: routerReducer,
repositories, repositories,
users, users,
login login,
me
}); });
return createStore( return createStore(

View File

@@ -0,0 +1,36 @@
<svg version="1.1" id="L7" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve">
<path fill="#33B2E8" d="M31.6,3.5C5.9,13.6-6.6,42.7,3.5,68.4c10.1,25.7,39.2,38.3,64.9,28.1l-3.1-7.9c-21.3,8.4-45.4-2-53.8-23.3
c-8.4-21.3,2-45.4,23.3-53.8L31.6,3.5z">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="2s"
from="0 50 50"
to="360 50 50"
repeatCount="indefinite" />
</path>
<path fill="#33B2E8" d="M42.3,39.6c5.7-4.3,13.9-3.1,18.1,2.7c4.3,5.7,3.1,13.9-2.7,18.1l4.1,5.5c8.8-6.5,10.6-19,4.1-27.7
c-6.5-8.8-19-10.6-27.7-4.1L42.3,39.6z">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="1s"
from="0 50 50"
to="-360 50 50"
repeatCount="indefinite" />
</path>
<path fill="#33B2E8" d="M82,35.7C74.1,18,53.4,10.1,35.7,18S10.1,46.6,18,64.3l7.6-3.4c-6-13.5,0-29.3,13.5-35.3s29.3,0,35.3,13.5
L82,35.7z">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="2s"
from="0 50 50"
to="360 50 50"
repeatCount="indefinite" />
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,97 +1,58 @@
//@flow //@flow
import { apiClient, NOT_AUTHENTICATED_ERROR } from "../apiclient"; import { apiClient, NOT_AUTHENTICATED_ERROR } from "../apiclient";
import { fetchMe } from "./me";
const LOGIN_URL = "/auth/access_token"; const LOGIN_URL = "/auth/access_token";
const AUTHENTICATION_INFO_URL = "/me";
export const LOGIN = "scm/auth/login";
export const LOGIN_REQUEST = "scm/auth/login_request"; export const LOGIN_REQUEST = "scm/auth/login_request";
export const LOGIN_SUCCESSFUL = "scm/auth/login_successful"; export const LOGIN_SUCCESSFUL = "scm/auth/login_successful";
export const LOGIN_FAILED = "scm/auth/login_failed"; export const LOGIN_FAILED = "scm/auth/login_failed";
export const GET_IS_AUTHENTICATED_REQUEST = "scm/auth/is_authenticated_request";
export const GET_IS_AUTHENTICATED = "scm/auth/get_is_authenticated";
export const IS_AUTHENTICATED = "scm/auth/is_authenticated";
export const IS_NOT_AUTHENTICATED = "scm/auth/is_not_authenticated";
export function getIsAuthenticatedRequest() { export function login(username: string, password: string) {
return { const login_data = {
type: GET_IS_AUTHENTICATED_REQUEST cookie: true,
grant_type: "password",
username,
password
}; };
}
export function getIsAuthenticated() {
return function(dispatch: any => void) { return function(dispatch: any => void) {
dispatch(getIsAuthenticatedRequest()); dispatch(loginRequest());
return apiClient return apiClient
.get(AUTHENTICATION_INFO_URL) .post(LOGIN_URL, login_data)
.then(response => { .then(response => {
return response.json(); // not the best way or?
dispatch(fetchMe());
dispatch(loginSuccessful());
}) })
.then(data => { .catch(err => {
if (data) { dispatch(loginFailed(err));
dispatch(isAuthenticated(data.username));
}
})
.catch((error: Error) => {
if (error === NOT_AUTHENTICATED_ERROR) {
dispatch(isNotAuthenticated());
} else {
// TODO: Handle errors other than not_authenticated
}
}); });
}; };
} }
export function isAuthenticated(username: string) {
return {
type: IS_AUTHENTICATED,
username
};
}
export function isNotAuthenticated() {
return {
type: IS_NOT_AUTHENTICATED
};
}
export function loginRequest() { export function loginRequest() {
return { return {
type: LOGIN_REQUEST type: LOGIN_REQUEST
}; };
} }
export function login(username: string, password: string) {
var login_data = {
cookie: true,
grant_type: "password",
username,
password
};
return function(dispatch: any => void) {
dispatch(loginRequest());
return apiClient.post(LOGIN_URL, login_data).then(response => {
if (response.ok) {
dispatch(getIsAuthenticated());
dispatch(loginSuccessful());
}
});
};
}
export function loginSuccessful() { export function loginSuccessful() {
return { return {
type: LOGIN_SUCCESSFUL type: LOGIN_SUCCESSFUL
}; };
} }
export default function reducer( export function loginFailed(error: Error) {
state: any = { loading: true }, return {
action: any = {} type: LOGIN_FAILED,
) { payload: error
};
}
export default function reducer(state: any = {}, action: any = {}) {
switch (action.type) { switch (action.type) {
case LOGIN: case LOGIN_REQUEST:
return { return {
...state, ...state,
loading: true, loading: true,
@@ -112,21 +73,6 @@ export default function reducer(
login: false, login: false,
error: action.payload error: action.payload
}; };
case IS_AUTHENTICATED:
return {
...state,
login: true,
loading: false,
username: action.username
};
case IS_NOT_AUTHENTICATED:
return {
...state,
login: false,
loading: false,
username: null,
error: null
};
default: default:
return state; return state;

98
scm-ui/src/modules/me.js Normal file
View File

@@ -0,0 +1,98 @@
//@flow
import { apiClient, NOT_AUTHENTICATED_ERROR } from "../apiclient";
const AUTHENTICATION_INFO_URL = "/me";
export const ME_AUTHENTICATED_REQUEST = "scm/auth/me_request";
export const ME_AUTHENTICATED_SUCCESS = "scm/auth/me_success";
export const ME_AUTHENTICATED_FAILURE = "scm/auth/me_failure";
export const ME_UNAUTHENTICATED = "scm/auth/me_unauthenticated";
export function meRequest() {
return {
type: ME_AUTHENTICATED_REQUEST
};
}
export function meSuccess(user: any) {
return {
type: ME_AUTHENTICATED_SUCCESS,
payload: user
};
}
export function meFailure(error: Error) {
return {
type: ME_AUTHENTICATED_FAILURE,
payload: error
};
}
export function meUnauthenticated() {
return {
type: ME_UNAUTHENTICATED
};
}
export function fetchMe() {
return function(dispatch: any => void) {
dispatch(meRequest());
return apiClient
.get(AUTHENTICATION_INFO_URL)
.then(response => {
return response.json();
})
.then(data => {
if (data) {
dispatch(meSuccess(data));
}
})
.catch((error: Error) => {
if (error === NOT_AUTHENTICATED_ERROR) {
dispatch(meUnauthenticated());
} else {
dispatch(meFailure(error));
}
});
};
}
export default function reducer(
state: any = { loading: true },
action: any = {}
) {
switch (action.type) {
case ME_AUTHENTICATED_REQUEST:
return {
...state,
loading: true,
me: null,
error: null
};
case ME_AUTHENTICATED_SUCCESS:
return {
...state,
loading: false,
me: action.payload,
error: null
};
case ME_AUTHENTICATED_FAILURE:
return {
...state,
loading: false,
me: null,
error: action.payload
};
case ME_UNAUTHENTICATED:
return {
...state,
loading: false,
me: null,
error: null
};
default:
return state;
}
}