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);