diff --git a/interface/package-lock.json b/interface/package-lock.json index d0cff9c..f6e0094 100644 --- a/interface/package-lock.json +++ b/interface/package-lock.json @@ -6624,8 +6624,7 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", @@ -6634,8 +6633,7 @@ }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -6738,8 +6736,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -6749,7 +6746,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6769,13 +6765,11 @@ }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.2.4", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -6792,7 +6786,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -6865,8 +6858,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -6876,7 +6868,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -6982,7 +6973,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -9357,6 +9347,11 @@ "array-includes": "^3.0.3" } }, + "jwt-decode": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz", + "integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", diff --git a/interface/package.json b/interface/package.json index 93dc77c..7806e30 100644 --- a/interface/package.json +++ b/interface/package.json @@ -6,6 +6,7 @@ "@material-ui/core": "^3.9.3", "@material-ui/icons": "^3.0.2", "compression-webpack-plugin": "^2.0.0", + "jwt-decode": "^2.2.0", "moment": "^2.24.0", "prop-types": "^15.7.2", "react": "^16.8.6", diff --git a/interface/src/AppRouting.js b/interface/src/AppRouting.js index fe5bfe6..5270cbc 100644 --- a/interface/src/AppRouting.js +++ b/interface/src/AppRouting.js @@ -1,25 +1,39 @@ import React, { Component } from 'react'; -import { Route, Redirect, Switch } from 'react-router'; +import { Redirect, Route, Switch } from 'react-router'; + +// authentication +import * as Authentication from './authentication/Authentication'; +import AuthenticationWrapper from './authentication/AuthenticationWrapper'; +import AuthenticatedRoute from './authentication/AuthenticatedRoute'; // containers import WiFiConfiguration from './containers/WiFiConfiguration'; import NTPConfiguration from './containers/NTPConfiguration'; import OTAConfiguration from './containers/OTAConfiguration'; import APConfiguration from './containers/APConfiguration'; +import LoginPage from './containers/LoginPage'; class AppRouting extends Component { - render() { - return ( - - - - - - - - ) - } + + componentWillMount() { + Authentication.clearLoginRedirect(); + } + + render() { + return ( + + + + + + + + + + + ) + } } export default AppRouting; diff --git a/interface/src/authentication/AuthenticatedRoute.js b/interface/src/authentication/AuthenticatedRoute.js new file mode 100644 index 0000000..231eead --- /dev/null +++ b/interface/src/authentication/AuthenticatedRoute.js @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { + Redirect, Route +} from "react-router-dom"; + +import { withAuthenticationContext } from './Context.js'; +import * as Authentication from './Authentication'; +import { withNotifier } from '../components/SnackbarNotification'; + +export class AuthenticatedRoute extends React.Component { + + render() { + const { raiseNotification, authenticationContext, component: Component, ...rest } = this.props; + const { location } = this.props; + const renderComponent = (props) => { + if (authenticationContext.jwt) { + return ( + + ); + } + Authentication.storeLoginRedirect(location); + raiseNotification("Please log in to continue."); + return ( + + ); + } + return ( + + ); + } + +} + +export default withNotifier(withAuthenticationContext(AuthenticatedRoute)); diff --git a/interface/src/authentication/Authentication.js b/interface/src/authentication/Authentication.js new file mode 100644 index 0000000..48fefe8 --- /dev/null +++ b/interface/src/authentication/Authentication.js @@ -0,0 +1,56 @@ +import history from '../history'; + +export const ACCESS_TOKEN = 'access_token'; +export const LOGIN_PATHNAME = 'loginPathname'; +export const LOGIN_SEARCH = 'loginSearch'; + +export function storeLoginRedirect(location) { + if (location) { + localStorage.setItem(LOGIN_PATHNAME, location.pathname); + localStorage.setItem(LOGIN_SEARCH, location.search); + } +} + +export function clearLoginRedirect() { + localStorage.removeItem(LOGIN_PATHNAME); + localStorage.removeItem(LOGIN_SEARCH); +} + +export function fetchLoginRedirect() { + const loginPathname = localStorage.getItem(LOGIN_PATHNAME); + const loginSearch = localStorage.getItem(LOGIN_SEARCH); + clearLoginRedirect(); + return { + pathname: loginPathname || "/", + search: (loginPathname && loginSearch) || undefined + }; +} + +/** + * Wraps the normal fetch routene with one with provides the access token if present. + */ +export function secureFetch(url, params) { + if (localStorage.getItem(ACCESS_TOKEN)) { + params = params || {}; + params.headers = params.headers || new Headers(); + params.headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN) + } + return fetch(url, params); +} + +/** + * Wraps the normal fetch routene which redirects on 401 response. + */ +export function redirectingSecureFetch(url, params) { + return new Promise(function (resolve, reject) { + secureFetch(url, params).then(response => { + if (response.status === 401) { + history.go("/"); + } else { + resolve(response); + } + }).catch(error => { + reject(error); + }); + }); +} diff --git a/interface/src/authentication/AuthenticationWrapper.js b/interface/src/authentication/AuthenticationWrapper.js new file mode 100644 index 0000000..c9cb02a --- /dev/null +++ b/interface/src/authentication/AuthenticationWrapper.js @@ -0,0 +1,91 @@ +import * as React from 'react'; +import history from '../history' +import { withNotifier } from '../components/SnackbarNotification'; + +import { ACCESS_TOKEN } from './Authentication'; +import { AuthenticationContext } from './Context'; +import jwtDecode from 'jwt-decode'; + +class AuthenticationWrapper extends React.Component { + + constructor(props) { + super(props); + this.refresh = this.refresh.bind(this); + this.signIn = this.signIn.bind(this); + this.signOut = this.signOut.bind(this); + this.state = { + context: { + refresh: this.refresh, + signIn: this.signIn, + signOut: this.signOut + }, + initialized: false + }; + } + + componentDidMount() { + this.refresh(); + } + + render() { + return ( + + {this.state.initialized ? this.renderContent() : this.renderContentLoading()} + + ); + } + + renderContent() { + return ( + + {this.props.children} + + ); + } + + renderContentLoading() { + return ( +
THIS IS WHERE THE LOADING MESSAGE GOES
+ ); + } + + refresh() { + var accessToken = localStorage.getItem(ACCESS_TOKEN); + if (accessToken) { + try { + this.setState({ initialized: true, context: { ...this.state.context, jwt: jwtDecode(accessToken) } }); + } catch (err) { + localStorage.removeItem(ACCESS_TOKEN); + this.props.raiseNotification("Please log in again."); + history.push('/'); + } + } else { + this.setState({ initialized: true }); + } + } + + signIn(accessToken) { + try { + this.setState({ context: { ...this.state.context, jwt: jwtDecode(accessToken) } }); + localStorage.setItem(ACCESS_TOKEN, accessToken); + } catch (err) { + this.props.raiseNotification("JWT did not parse."); + history.push('/'); + } + } + + signOut() { + localStorage.removeItem(ACCESS_TOKEN); + this.setState({ + context: { + ...this.state.context, + me: undefined + } + }); + this.props.raiseNotification("You have signed out."); + history.push('/'); + } + +} + +export default withNotifier(AuthenticationWrapper) diff --git a/interface/src/authentication/Context.js b/interface/src/authentication/Context.js new file mode 100644 index 0000000..571e0ce --- /dev/null +++ b/interface/src/authentication/Context.js @@ -0,0 +1,15 @@ +import * as React from "react"; + +export const AuthenticationContext = React.createContext( + {} +); + +export function withAuthenticationContext(Component) { + return function AuthenticationContextComponent(props) { + return ( + + {authenticationContext => } + + ); + }; +} diff --git a/interface/src/containers/LoginPage.js b/interface/src/containers/LoginPage.js new file mode 100644 index 0000000..d26dbce --- /dev/null +++ b/interface/src/containers/LoginPage.js @@ -0,0 +1,104 @@ +import React, { Component } from 'react'; + +import { withStyles } from '@material-ui/core/styles'; +import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import Fab from '@material-ui/core/Fab'; +import { APP_NAME } from '../constants/App'; +import ForwardIcon from '@material-ui/icons/Forward'; + +const styles = theme => ({ + loginPage: { + padding: theme.spacing.unit * 2, + height: "100vh", + display: "flex" + }, + loginPanel: { + margin: "auto", + padding: theme.spacing.unit * 2, + paddingTop: "200px", + backgroundImage: 'url("/app/icon.png")', + backgroundRepeat: "no-repeat", + backgroundPosition: "50% "+ theme.spacing.unit * 2 +"px", + backgroundSize: "auto 150px", + textAlign: "center" + }, + extendedIcon: { + marginRight: theme.spacing.unit, + }, + loadingSettings: { + margin: theme.spacing.unit, + }, + loadingSettingsDetails: { + margin: theme.spacing.unit * 4, + textAlign: "center" + }, + textField: { + width: "100%" + }, + button: { + marginRight: theme.spacing.unit * 2, + marginTop: theme.spacing.unit * 2, + } +}); + +class LoginPage extends Component { + + constructor(props) { + super(props); + this.state = { + username: '', + password: '' + }; + } + + handleValueChange = name => event => { + this.setState({ [name]: event.target.value }); + }; + + onSubmit = event => { + // TODO + }; + + render() { + const { username, password } = this.state; + const { classes } = this.props; + return ( +
+ + {APP_NAME} + + + + + + Login + + + +
+ ); + } + +} + +export default withStyles(styles)(LoginPage);