Merge pull request #29 from rjwats/ft_user_security

Ft user security
This commit is contained in:
rjwats 2019-06-03 22:22:24 +01:00 committed by GitHub
commit 5a94ad9350
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 3078 additions and 1008 deletions

243
README.md
View File

@ -1,29 +1,24 @@
# ESP8266 React
A simple, extensible framework for getting up and running with the ESP8266/ESP32 microchip and a react front end.
A simple, secure and extensible framework for IoT projects built on ESP8266/ESP32 platforms with responsive React front-end.
Designed to work with the PlatformIO IDE with limited setup.
Designed to work with the PlatformIO IDE with [limited setup](#getting-started). Please read below for setup, build and upload instructions.
This project supports ESP8266 and ESP32 devices, see build instruction below for more details.
![Screenshots](/media/screenshots.png?raw=true "Screenshots")
## Why I made this project
## Features
I found I was repeating a lot of work when starting new IoT projects with the ESP8266 chip.
Provides many of the features required for IoT projects:
Most of my IoT projects have required:
* Configurable WiFi - Network scanner and WiFi configuration screen
* Configurable Access Point - Can be continuous or automatically enabled when WiFi connection fails
* Network Time - Synchronization with NTP
* Remote Firmware Updates - Enable secured OTA updates
* Security - Protected RESTful endpoints and a secured user interface
* Configurable WiFi
* Configurable access point
* Synchronization with NTP
* The ability to perform OTA updates
The back end is provided by a set of RESTful endpoints and the React based front end is responsive and scales well to various screen sizes.
I also wanted to adopt a decent client side framework so the back end could be simplified to a set of REST endpoints.
All of the above features are included in this framework, which I plan to use as a basis for my IoT projects.
The interface is responsive and should work well on mobile devices. It also has the prerequisite manifest/icon file, so it can be added to the home screen if desired.
![Screenshots](/screenshots/screenshots.png?raw=true "Screenshots")
The front end has the prerequisite manifest file and icon, so it can be added to the home screen of a mobile device if required.
## Getting Started
@ -32,22 +27,61 @@ The interface is responsive and should work well on mobile devices. It also has
You will need the following before you can get started.
* [PlatformIO](https://platformio.org/) - IDE for development
* [NPM](https://www.npmjs.com/) - For building the interface
* Bash shell, or Git Bash if you are under windows
* [Node.js](https://nodejs.org) - For building the interface with npm
* Bash shell, or [Git Bash](https://gitforwindows.org/) if you are under windows
### Installing in PlatformIO
### Building and uploading the firmware
Pull the project and add it to PlatformIO as a project folder (File > Add Project Folder).
Pull the project and open it in PlatformIO. PlatformIO should download the ESP8266 platform and the project library dependencies automatically.
PlatformIO should download the ESP8266 platform and the project library dependencies automatically.
The project structure is as follows:
Once the platform and libraries are downloaded the back end should be compiling.
Resource | Description
---- | -----------
[data/](data) | The file system image directory
[interface/](interface) | React based front end
[src/](src) | C++ back end for the ESP8266 device
[platformio.ini](platformio.ini) | PlatformIO project configuration file
### Building the firmware
Once the platform and libraries are downloaded the back end should successfully build within PlatformIO.
The firmware may be built by pressing the "Build" button:
![build](/media/build.png?raw=true "build")
Alternatively type the run command:
```bash
platformio run
```
#### Uploading the firmware
The project is configured to upload over a serial connection by default. You can change this to use OTA updates by uncommenting the relevant lines in ['platformio.ini'](platformio.ini).
The firmware may be uploaded to the device by pressing the "Upload" button:
![uploadfw](/media/uploadfw.png?raw=true "uploadfw")
Alternatively run the 'upload' target:
```bash
platformio run -t upload
```
### Building the interface
The interface has been configured with create-react-app and react-app-rewired so the build can customized for the target device. The large artefacts are gzipped and source maps and service worker are excluded from the production build.
The interface has been configured with create-react-app and react-app-rewired so the build can customized for the target device. The large artefacts are gzipped and source maps and service worker are excluded from the production build. This reduces the production build to around ~200k, which easily fits on the device.
You will find the interface code in the ./interface directory. Change to this directory with your bash shell (or Git Bash) and use the standard commands you would with any react app built with create-react-app:
Change to the ['interface'](interface) directory with your bash shell (or Git Bash) and use the standard commands you would with any react app built with create-react-app:
#### Change to interface directory
```bash
cd interface
```
#### Download and install the node modules
@ -61,29 +95,86 @@ npm install
npm run build
```
**NB: The build command will also delete the previously built interface (the ./data/www directory) and replace it with the freshly built one, ready for upload to the device.**
> **Note**: The build command will also delete the previously built interface, in the ['data/www'](data/www) directory, replacing it with the freshly built one ready to upload to the device.
#### Running the interface locally
#### Uploading the file system image
The compiled user interface may be uploaded to the device by pressing the "Upload File System image" button:
![uploadfs](/media/uploadfs.png?raw=true "uploadfs")
Alternatively run the 'uploadfs' target:
```bash
platformio run -t uploadfs
```
### Running the interface locally
You can run a local development server to allow you preview changes to the front end without the need to upload a file system image to the device after each change. Change to the interface directory and run the following command:
```bash
npm start
```
**NB: To run the interface locally you will need to modify the endpoint root path and enable CORS.**
> **Note**: To run the interface locally you will need to modify the endpoint root path and enable CORS.
The endpoint root path can be found in .env.development, defined as the environment variable 'REACT_APP_ENDPOINT_ROOT'. This needs to be the root URL of the device running the back end, for example:
#### Changing the endpoint root
```
The endpoint root path can be found in ['interface/.env.development'](interface/.env.development), defined as the environment variable 'REACT_APP_ENDPOINT_ROOT'. This needs to be the root URL of the device running the back end, for example:
```js
REACT_APP_ENDPOINT_ROOT=http://192.168.0.6/rest/
```
CORS can be enabled on the back end by uncommenting the -D ENABLE_CORS build flag in platformio.ini and re-deploying.
#### Enabling CORS
You can enable CORS on the back end by uncommenting the -D ENABLE_CORS build flag in ['platformio.ini'](platformio.ini) then re-building and uploading the firmware to the device. The default settings assume you will be accessing the development server on the default port on [http://localhost:3000](http://localhost:3000) this can also be changed if required:
```
-D ENABLE_CORS
-D CORS_ORIGIN=\"http://localhost:3000\"
```
## Device Configuration
As well as containing the interface, the SPIFFS image (in the ['data'](data) folder) contains a JSON settings file for each of the configurable features. The config files can be found in the ['data/config'](data/config) directory:
File | Description
---- | -----------
[apSettings.json](data/config/apSettings.json) | Access point settings
[ntpSettings.json](data/config/ntpSettings.json) | NTP synchronization settings
[otaSettings.json](data/config/otaSettings.json) | OTA update configuration
[securitySettings.json](data/config/securitySettings.json) | Security settings and user credentials
[wifiSettings.json](data/config/wifiSettings.json) | WiFi connection settings
### Access point settings
The default settings configure the device to bring up an access point on start up which can be used to configure the device:
* SSID: ESP8266-React
* Password: esp-react
### Security settings and user credentials
The security settings and user credentials provide the following users by default:
Username | Password
-------- | --------
admin | admin
guest | guest
It is recommended that you change the JWT secret and user credentials from their defaults protect your device. You can do this in the user interface, or by modifying [securitySettings.json](data/config/securitySettings.json) before uploading the file system image.
## Building for different devices
This project supports ESP8266 and ESP32 platforms however your target device will need at least a 1MB flash chip to support OTA programming.
This project supports ESP8266 and ESP32 platforms. To support OTA programming, enough free space to upload the new sketch and file system image will be required. It is recommended that a board with at least 2mb of flash is used.
By default this project is configured to build for the esp12e device. This is an esp8266 device with 4MB of flash. The following config in platformio.ini configures the build:
By default, the target device is "esp12e". This is a common ESP8266 variant with 4mb of flash:
![ESP12E](/media/esp12e.jpg?raw=true "ESP12E")
The settings file ['platformio.ini'](platformio.ini) configures the platform and board:
```
[env:esp12e]
@ -91,7 +182,11 @@ platform = espressif8266
board = esp12e
```
If you want to build for an ESP32 device, all you need to do is re-configure playformio.ini with your devices settings:
If you want to build for an ESP32 device, all you need to do is re-configure ['platformio.ini'](platformio.ini) with your devices settings.
![ESP32](/media/esp32.jpg?raw=true "ESP32")
Building for the common esp32 "node32s" board for example requires the following configuration:
```
[env:node32s]
@ -99,39 +194,64 @@ platform = espressif32
board = node32s
```
Microcontroller ESP8266
Frequency 80MHz
Flash 4MBl
## Customizing and theming
**NB: If building under Windows you need to delete .piolibdeps/Time/Time.h - due [filesystem case insensitivity](https://github.com/me-no-dev/ESPAsyncWebServer/issues/96)*
The framework, and MaterialUI allows for a good degree of custoimzation with little effort.
## Configuration & Deployment
### Theming the app
Standard configuration settings, such as build flags, libraries and device configuration can be found in platformio.ini. See the [PlatformIO docs](http://docs.platformio.org/en/latest/projectconf.html) for full details on what you can do with this.
The app can be easily themed by editing the [MaterialUI theme](https://material-ui.com/customization/themes/). Edit the theme in ['interface/src/App.js'](interface/src/App.js) as you desire:
By default, the target device is "esp12e". This is a common ESP8266 variant with 4mb of flash though any device with at least 2mb of flash should be fine. The settings configure the interface to upload via serial by default, you can change the upload mechanism to OTA by uncommenting the relevant lines.
```js
const theme = createMuiTheme({
palette: {
primary: red,
secondary: deepOrange,
highlight_idle: blueGrey[900],
highlight_warn: orange[500],
highlight_error: red[500],
highlight_success: green[500],
},
});
```
As well as containing the interface, the SPIFFS image (in the ./data folder) contains a JSON settings file for each of the configurable features. The config files can be found in the ./data/config directory:
### Changing the app icon
File | Description
---- | -----------
apSettings.json | Access point settings
ntpSettings.json | NTP synchronization settings
otaSettings.json | OTA Update configuration
wifiSettings.json | WiFi connection settings
You can replace the app icon is located at ['interface/public/app/icon.png'](interface/public/app/icon.png) with one of your preference. A 256 x 256 PNG is recommended for best compatibility.
The default settings configure the device to bring up an access point on start up which can be used to configure the device:
* SSID: ESP8266-React
* Password: esp-react
### Changing the app name
## Software Overview
The app name displayed on the login page and on the menu bar can be modified by editing the REACT_APP_NAME property in ['interface/.env'](interface/.env)
### Back End
```js
REACT_APP_NAME=Funky IoT Project
```
There is also a manifest file which contains the app name to use when adding the app to a mobile device, so you may wish to also edit ['interface/public/app/manifest.json'](interface/public/app/manifest.json):
```json
{
"name":"Funky IoT Project",
"icons":[
{
"src":"/app/icon.png",
"sizes":"48x48 72x72 96x96 128x128 256x256"
}
],
"start_url":"/",
"display":"fullscreen",
"orientation":"any"
}
```
## Back End Overview
The back end is a set of REST endpoints hosted by a [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) instance. The source is split up by feature, for example [WiFiScanner.h](src/WiFiScanner.h) implements the end points for scanning for available networks.
There is an abstract class [SettingsService.h](src/SettingsService.h) that provides an easy means of adding configurable services/features to the device. It takes care of writing the settings as JSON to SPIFFS. All you need to do is extend the class with your required configuration and implement the functions which serialize the settings to/from JSON. JSON serialization utilizes the excellent [ArduinoJson](https://github.com/bblanchon/ArduinoJson) library. Here is a example of a service with username and password settings:
There is an abstract class [SettingsService.h](src/SettingsService.h) that provides an easy means of adding configurable services/features to the device. It takes care of writing the settings as JSON to SPIFFS. All you need to do is extend the class with your required configuration and implement the functions which serialize the settings to/from JSON. JSON serialization utilizes the excellent [ArduinoJson](https://github.com/bblanchon/ArduinoJson) library.
Here is a example of a service with username and password settings:
```cpp
#include <SettingsService.h>
@ -195,21 +315,6 @@ void reconfigureTheService() {
```
### Front End
The front end is a bit of a work in progress (as are my react skills), but it has been designed to be a "mobile first" interface and as such should feel very much like an App.
I've tried to keep the use of libraries to a minimum to reduce the artefact size (it's about 150k gzipped ATM).
## Future Improvements
- [x] Reduce boilerplate in interface
- [ ] Provide an emergency config reset feature, via a pin held low for a few seconds
- [x] Access point should provide captive portal
- [ ] Perhaps have more configuration options for Access point: IP address, Subnet, etc
- [ ] Enable configurable mDNS
- [ ] Introduce authentication to secure the device
## Libraries Used
* [React](https://reactjs.org/)

View File

@ -1,4 +1,4 @@
{
"server":"pool.ntp.org",
"interval":60
"interval":3600
}

View File

@ -0,0 +1,15 @@
{
"jwt_secret":"esp8266-react",
"users": [
{
"username": "admin",
"password": "admin",
"admin": true
},
{
"username": "guest",
"password": "guest",
"admin": false
}
]
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><link rel="stylesheet" href="/css/roboto.css"><link rel="manifest" href="/app/manifest.json"><title>ESP8266 React</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script src="/js/1.b351.js"></script><script src="/js/2.9881.js"></script><script src="/js/0.da55.js"></script></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><link rel="stylesheet" href="/css/roboto.css"><link rel="manifest" href="/app/manifest.json"><title>ESP8266 React</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script src="/js/1.b351.js"></script><script src="/js/2.8ca9.js"></script><script src="/js/0.439a.js"></script></body></html>

BIN
data/www/js/0.439a.js.gz Normal file

Binary file not shown.

Binary file not shown.

BIN
data/www/js/2.8ca9.js.gz Normal file

Binary file not shown.

Binary file not shown.

1
interface/.env Normal file
View File

@ -0,0 +1 @@
REACT_APP_NAME=ESP8266 React

View File

@ -1 +1 @@
REACT_APP_ENDPOINT_ROOT=http://192.168.0.4/rest/
REACT_APP_ENDPOINT_ROOT=http://192.168.0.11/rest/

View File

@ -890,68 +890,105 @@
"resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz",
"integrity": "sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw=="
},
"@emotion/hash": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.1.tgz",
"integrity": "sha512-OYpa/Sg+2GDX+jibUfpZVn1YqSVRpYmTLF2eyAfrFTIJSbwyIrc+YscayoykvaOME/wV4BV0Sa0yqdMrgse6mA=="
},
"@material-ui/core": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-3.9.3.tgz",
"integrity": "sha512-REIj62+zEvTgI/C//YL4fZxrCVIySygmpZglsu/Nl5jPqy3CDjZv1F9ubBYorHqmRgeVPh64EghMMWqk4egmfg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.0.0.tgz",
"integrity": "sha512-mLEGTuzgUALRKFI3hkRcS0gi/cB3XV0JA4F5PT3rGUt7Dc4liu8/IGiHF7iQh+p337FMk8vkEMxMVdYd9JXKMQ==",
"requires": {
"@babel/runtime": "^7.2.0",
"@material-ui/system": "^3.0.0-alpha.0",
"@material-ui/utils": "^3.0.0-alpha.2",
"@types/jss": "^9.5.6",
"@types/react-transition-group": "^2.0.8",
"brcast": "^3.0.1",
"classnames": "^2.2.5",
"@material-ui/styles": "^4.0.0",
"@material-ui/system": "^4.0.0",
"@material-ui/types": "^4.0.0",
"@material-ui/utils": "^4.0.0",
"@types/react-transition-group": "^2.0.16",
"clsx": "^1.0.2",
"convert-css-length": "^1.0.2",
"csstype": "^2.5.2",
"debounce": "^1.1.0",
"deepmerge": "^3.0.0",
"dom-helpers": "^3.2.1",
"hoist-non-react-statics": "^3.2.1",
"is-plain-object": "^2.0.4",
"jss": "^9.8.7",
"jss-camel-case": "^6.0.0",
"jss-default-unit": "^8.0.2",
"jss-global": "^3.0.0",
"jss-nested": "^6.0.1",
"jss-props-sort": "^6.0.0",
"jss-vendor-prefixer": "^7.0.0",
"normalize-scroll-left": "^0.1.2",
"popper.js": "^1.14.1",
"prop-types": "^15.6.0",
"react-event-listener": "^0.6.2",
"react-transition-group": "^2.2.1",
"recompose": "0.28.0 - 0.30.0",
"prop-types": "^15.7.2",
"react-event-listener": "^0.6.6",
"react-transition-group": "^4.0.0",
"warning": "^4.0.1"
}
},
"@material-ui/icons": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-3.0.2.tgz",
"integrity": "sha512-QY/3gJnObZQ3O/e6WjH+0ah2M3MOgLOzCy8HTUoUx9B6dDrS18vP7Ycw3qrDEKlB6q1KNxy6CZHm5FCauWGy2g==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.0.0.tgz",
"integrity": "sha512-hXoKnVLmVer+kic84ypoyG3Amym3a8q3pvDg4KYjeKW9fxGru7x/IkelBJODQL0jO+nAPz1+9RNpFWC75v35dg==",
"requires": {
"@babel/runtime": "^7.2.0"
}
},
"@material-ui/styles": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.0.0.tgz",
"integrity": "sha512-TUpmXlyZDVOl6E2//+UzsZxgi2E+2L753QY02nNkbAC6PPx8FUBqvnjYSGqX0V/BjTJ/fD4CkoS6ZpY3lHf+Gg==",
"requires": {
"@babel/runtime": "^7.2.0",
"recompose": "0.28.0 - 0.30.0"
"@emotion/hash": "^0.7.1",
"@material-ui/types": "^4.0.0",
"@material-ui/utils": "^4.0.0",
"clsx": "^1.0.2",
"deepmerge": "^3.0.0",
"hoist-non-react-statics": "^3.2.1",
"jss": "^10.0.0-alpha.16",
"jss-plugin-camel-case": "^10.0.0-alpha.16",
"jss-plugin-default-unit": "^10.0.0-alpha.16",
"jss-plugin-global": "^10.0.0-alpha.16",
"jss-plugin-nested": "^10.0.0-alpha.16",
"jss-plugin-props-sort": "^10.0.0-alpha.16",
"jss-plugin-rule-value-function": "^10.0.0-alpha.16",
"jss-plugin-vendor-prefixer": "^10.0.0-alpha.16",
"prop-types": "^15.7.2",
"warning": "^4.0.1"
},
"dependencies": {
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"@material-ui/system": {
"version": "3.0.0-alpha.2",
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-3.0.0-alpha.2.tgz",
"integrity": "sha512-odmxQ0peKpP7RQBQ8koly06YhsPzcoVib1vByVPBH4QhwqBXuYoqlCjt02846fYspAqkrWzjxnWUD311EBbxOA==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.0.0.tgz",
"integrity": "sha512-SIsqIwjix98Mqw9LVAmRqTs10E4S/SP5n5mlBlhHVHI+2XG2c+MaCPzOF2Zxq0KdqOMgTb7/aevR3mG9UmODxg==",
"requires": {
"@babel/runtime": "^7.2.0",
"deepmerge": "^3.0.0",
"prop-types": "^15.6.0",
"prop-types": "^15.7.2",
"warning": "^4.0.1"
}
},
"@material-ui/types": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-4.0.0.tgz",
"integrity": "sha512-wuiQMo8nSljZR1oWh57UQYssdtFqaU+Cbhr16uLohzzTllpCAK4LkH0slnH3n+5vCa2dgOdNlZTrmsIDDwvRJQ=="
},
"@material-ui/utils": {
"version": "3.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-3.0.0-alpha.3.tgz",
"integrity": "sha512-rwMdMZptX0DivkqBuC+Jdq7BYTXwqKai5G5ejPpuEDKpWzi1Oxp+LygGw329FrKpuKeiqpcymlqJTjmy+quWng==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.0.0.tgz",
"integrity": "sha512-gjz52hO1hkIbKPMng1diQybVgtfgCptOCrulUs4emSCHHKUoR1zfT+IUrjgOaKIpYZNOgS/CI7KDMp689+FzeQ==",
"requires": {
"@babel/runtime": "^7.2.0",
"prop-types": "^15.6.0",
"react-is": "^16.6.3"
"prop-types": "^15.7.2",
"react-is": "^16.8.0"
}
},
"@mrmlnc/readdir-enhanced": {
@ -1107,24 +1144,15 @@
"loader-utils": "^1.1.0"
}
},
"@types/jss": {
"version": "9.5.8",
"resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.5.8.tgz",
"integrity": "sha512-bBbHvjhm42UKki+wZpR89j73ykSXg99/bhuKuYYePtpma3ZAnmeGnl0WxXiZhPGsIfzKwCUkpPC0jlrVMBfRxA==",
"requires": {
"csstype": "^2.0.0",
"indefinite-observable": "^1.0.1"
}
},
"@types/node": {
"version": "11.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-11.13.4.tgz",
"integrity": "sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ=="
},
"@types/prop-types": {
"version": "15.7.0",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.0.tgz",
"integrity": "sha512-eItQyV43bj4rR3JPV0Skpl1SncRCdziTEK9/v8VwXmV6d/qOUO8/EuWeHBbCZcsfSHfzI5UyMJLCSXtxxznyZg=="
"version": "15.7.1",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.1.tgz",
"integrity": "sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg=="
},
"@types/q": {
"version": "1.5.2",
@ -1132,18 +1160,18 @@
"integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw=="
},
"@types/react": {
"version": "16.8.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.13.tgz",
"integrity": "sha512-otJ4ntMuHGrvm67CdDJMAls4WqotmAmW0g3HmWi9LCjSWXrxoXY/nHXrtmMfvPEEmGFNm6NdgMsJmnfH820Qaw==",
"version": "16.8.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.18.tgz",
"integrity": "sha512-lUXdKzRqWR4FebR5tGHkLCqnvQJS4fdXKCBrNGGbglqZg2gpU+J82pMONevQODUotATs9fc9k66bx3/St8vReg==",
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
}
},
"@types/react-transition-group": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.9.0.tgz",
"integrity": "sha512-hP7vUaZMVSWKxo133P8U51U6UZ7+pbY+eAQb8+p6SZ2rB1rj3mOTDgTzhhi+R2SCB4S+sWekAAGoxdiZPG0ReQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.9.1.tgz",
"integrity": "sha512-1usq4DRUVBFnxc9KGJAlJO9EpQrLZGDDEC8wDOn2+2ODSyudYo8FiIzPDRaX/hfQjHqGeeoNaNdA2bj0l35hZQ==",
"requires": {
"@types/react": "*"
}
@ -2686,11 +2714,6 @@
"repeat-element": "^1.1.2"
}
},
"brcast": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/brcast/-/brcast-3.0.1.tgz",
"integrity": "sha512-eI3yqf9YEqyGl9PCNTR46MGvDylGtaHjalcz6Q3fAPnP/PhpKkkve52vFdfGpwp4VUvK6LUr4TQN+2stCrEwTg=="
},
"brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@ -2960,11 +2983,6 @@
"supports-color": "^5.3.0"
}
},
"change-emitter": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/change-emitter/-/change-emitter-0.1.6.tgz",
"integrity": "sha1-6LL+PX8at9aaMhma/5HqaTFAlRU="
},
"chardet": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
@ -3616,11 +3634,6 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"clean-css": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz",
@ -3679,6 +3692,11 @@
"shallow-clone": "^0.1.2"
}
},
"clsx": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.0.4.tgz",
"integrity": "sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg=="
},
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -3859,6 +3877,11 @@
"date-now": "^0.1.4"
}
},
"console-polyfill": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/console-polyfill/-/console-polyfill-0.1.2.tgz",
"integrity": "sha1-ls/tUcr3gYn2mVcubxgnHcN8DjA="
},
"constants-browserify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
@ -3879,6 +3902,15 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
"integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
},
"convert-css-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/convert-css-length/-/convert-css-length-1.0.2.tgz",
"integrity": "sha512-ecV7j3hXyXN1X2XfJBzhMR0o1Obv0v3nHmn0UiS3ACENrzbxE/EknkiunS/fCwQva0U62X1GChi8GaPh4oTlLg==",
"requires": {
"console-polyfill": "^0.1.2",
"parse-unit": "^1.0.1"
}
},
"convert-source-map": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz",
@ -4257,14 +4289,6 @@
"resolved": "https://registry.npmjs.org/css-url-regex/-/css-url-regex-1.1.0.tgz",
"integrity": "sha1-g4NCMMyfdMRX3lnuvRVD/uuDt+w="
},
"css-vendor": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-0.3.8.tgz",
"integrity": "sha1-ZCHP0wNM5mT+dnOXL9ARn8KJQfo=",
"requires": {
"is-in-browser": "^1.0.2"
}
},
"css-what": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz",
@ -4449,9 +4473,9 @@
}
},
"csstype": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.3.tgz",
"integrity": "sha512-rINUZXOkcBmoHWEyu7JdHu5JMzkGRoMX4ov9830WNgxf5UYxcBUO0QTKAqeJ5EZfSdlrcJYkC8WwfVW7JYi4yg=="
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.4.tgz",
"integrity": "sha512-lAJUJP3M6HxFXbqtGRc0iZrdyeN+WzOWeY0q/VnFzI+kqVrYIzC7bWlKqCW7oCIdzoPkvfp82EVvrTlQ8zsWQg=="
},
"cyclist": {
"version": "0.2.2",
@ -6624,8 +6648,7 @@
},
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"optional": true
"bundled": true
},
"concat-map": {
"version": "0.0.1",
@ -6634,8 +6657,7 @@
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"optional": true
"bundled": true
},
"core-util-is": {
"version": "1.0.2",
@ -6738,8 +6760,7 @@
},
"inherits": {
"version": "2.0.3",
"bundled": true,
"optional": true
"bundled": true
},
"ini": {
"version": "1.3.5",
@ -6749,7 +6770,6 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -6769,13 +6789,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 +6810,6 @@
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -6865,8 +6882,7 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"optional": true
"bundled": true
},
"object-assign": {
"version": "4.1.1",
@ -6876,7 +6892,6 @@
"once": {
"version": "1.4.0",
"bundled": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -6982,7 +6997,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",
@ -8003,14 +8017,6 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o="
},
"indefinite-observable": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/indefinite-observable/-/indefinite-observable-1.0.2.tgz",
"integrity": "sha512-Mps0898zEduHyPhb7UCgNmfzlqNZknVmaFz5qzr0mm04YQ5FGLhAyK/dJ+NaRxGyR6juQXIxh5Ev0xx+qq0nYA==",
"requires": {
"symbol-observable": "1.2.0"
}
},
"indexes-of": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
@ -8240,11 +8246,6 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
},
"is-function": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz",
"integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU="
},
"is-generator-fn": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-1.0.0.tgz",
@ -9205,148 +9206,242 @@
}
},
"jss": {
"version": "9.8.7",
"resolved": "https://registry.npmjs.org/jss/-/jss-9.8.7.tgz",
"integrity": "sha512-awj3XRZYxbrmmrx9LUSj5pXSUfm12m8xzi/VKeqI1ZwWBtQ0kVPTs3vYs32t4rFw83CgFDukA8wKzOE9sMQnoQ==",
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"symbol-observable": "^1.1.0",
"warning": "^3.0.0"
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-camel-case": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.0.0-alpha.16.tgz",
"integrity": "sha512-nki+smHEsFyoZ0OlOYtaxVqcQA0ZHVJCE1slRnk+1TklbmxbBiO4TwITMTEaNIDv0U0Uyb0Z8wVgFgRwCCIFog==",
"requires": {
"@babel/runtime": "^7.3.1",
"hyphenate-style-name": "^1.0.3",
"jss": "10.0.0-alpha.16"
},
"dependencies": {
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"loose-envify": "^1.0.0"
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-camel-case": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jss-camel-case/-/jss-camel-case-6.1.0.tgz",
"integrity": "sha512-HPF2Q7wmNW1t79mCqSeU2vdd/vFFGpkazwvfHMOhPlMgXrJDzdj9viA2SaHk9ZbD5pfL63a8ylp4++irYbbzMQ==",
"jss-plugin-compose": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-compose/-/jss-plugin-compose-10.0.0-alpha.16.tgz",
"integrity": "sha512-MeOc5RuDSqB3czoUFM32pBq370+sKKjG1K4aamVWpAUWpsphLi/YlotrFOkk/FCb2So1ga4W7/zrCc/50OeRAQ==",
"requires": {
"hyphenate-style-name": "^1.0.2"
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16",
"tiny-warning": "^1.0.2"
}
},
"jss-compose": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jss-compose/-/jss-compose-5.0.0.tgz",
"integrity": "sha512-YofRYuiA0+VbeOw0VjgkyO380sA4+TWDrW52nSluD9n+1FWOlDzNbgpZ/Sb3Y46+DcAbOS21W5jo6SAqUEiuwA==",
"jss-plugin-default-unit": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.0.0-alpha.16.tgz",
"integrity": "sha512-jjGW4F/r9yKvoyUk22M8nWhdMfvoWzJw/oFO2cDRXCk2onnWFiRALfqeUsEDyocwdZbyVF9WhZbSHn4GL03kSw==",
"requires": {
"warning": "^3.0.0"
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16"
},
"dependencies": {
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"loose-envify": "^1.0.0"
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-default-unit": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/jss-default-unit/-/jss-default-unit-8.0.2.tgz",
"integrity": "sha512-WxNHrF/18CdoAGw2H0FqOEvJdREXVXLazn7PQYU7V6/BWkCV0GkmWsppNiExdw8dP4TU1ma1dT9zBNJ95feLmg=="
},
"jss-expand": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/jss-expand/-/jss-expand-5.3.0.tgz",
"integrity": "sha512-NiM4TbDVE0ykXSAw6dfFmB1LIqXP/jdd0ZMnlvlGgEMkMt+weJIl8Ynq1DsuBY9WwkNyzWktdqcEW2VN0RAtQg=="
},
"jss-extend": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jss-extend/-/jss-extend-6.2.0.tgz",
"integrity": "sha512-YszrmcB6o9HOsKPszK7NeDBNNjVyiW864jfoiHoMlgMIg2qlxKw70axZHqgczXHDcoyi/0/ikP1XaHDPRvYtEA==",
"jss-plugin-expand": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-expand/-/jss-plugin-expand-10.0.0-alpha.16.tgz",
"integrity": "sha512-Q3m0PDWGojfcmWBCkegRJxonq2q9lI6ZfixoFgvTvi+b9zKza0KXkHBUzGjeFyM36U/WRWj43SC33dajcI9jAg==",
"requires": {
"warning": "^3.0.0"
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16"
}
},
"jss-plugin-extend": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-extend/-/jss-plugin-extend-10.0.0-alpha.16.tgz",
"integrity": "sha512-nJ8H5b/dBZlqaPYCLNmcaHRQgzSlnAwhZUcIo30s0IgvhTtN/TaiRtEbrJZjfXPzatTsnFoRwZzJqs8Sakev+A==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16",
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-global": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.0.0-alpha.16.tgz",
"integrity": "sha512-B1mm2ZF9OEsWPmzkG5ZUXqV88smDqpc4unILLXhWVuj0U5JeT0DNitH+QbXFrSueDJzkWVfvqyckvWDR/0qeDg==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16"
},
"dependencies": {
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"loose-envify": "^1.0.0"
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-global": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/jss-global/-/jss-global-3.0.0.tgz",
"integrity": "sha512-wxYn7vL+TImyQYGAfdplg7yaxnPQ9RaXY/cIA8hawaVnmmWxDHzBK32u1y+RAvWboa3lW83ya3nVZ/C+jyjZ5Q=="
},
"jss-nested": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/jss-nested/-/jss-nested-6.0.1.tgz",
"integrity": "sha512-rn964TralHOZxoyEgeq3hXY8hyuCElnvQoVrQwKHVmu55VRDd6IqExAx9be5HgK0yN/+hQdgAXQl/GUrBbbSTA==",
"jss-plugin-nested": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.0.0-alpha.16.tgz",
"integrity": "sha512-3l/MB6COnIpq4GOXQFae6UydoaIPa81UxhuBTEQuiAojgTeUla9L7nB3h18Q4zAhQQpjxaEsyppAKuEzIP7kPQ==",
"requires": {
"warning": "^3.0.0"
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16",
"tiny-warning": "^1.0.2"
},
"dependencies": {
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"loose-envify": "^1.0.0"
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-plugin-props-sort": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.0.0-alpha.16.tgz",
"integrity": "sha512-+Yn9nugHAH58nf/d43H2uxMvlCFPDgLKRSmKO4Q4m1IGYjMbHsWt1Rk2HfC9IiCanqcqpc8hstwtzf+HG7PWFQ==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16"
},
"dependencies": {
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-plugin-rule-value-function": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.0.0-alpha.16.tgz",
"integrity": "sha512-MQap9ne6ZGZH0NlpSQTMSm6QalBTF0hYpd2uaGQwam+GlT7IKeO+sTjd46I1WgO3kyOmwb0pIY6CnuLQGXKtSA==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16"
},
"dependencies": {
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-plugin-rule-value-observable": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-rule-value-observable/-/jss-plugin-rule-value-observable-10.0.0-alpha.16.tgz",
"integrity": "sha512-Gmj1sVKWM2KVZpG0Wn3Z+SArvskdXEtSCrww43g/OO+j8DN9O+UEV47tM/HYfdiyLICnvKHc2XGmhNz9LHcpNQ==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16",
"symbol-observable": "^1.2.0"
}
},
"jss-plugin-template": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-template/-/jss-plugin-template-10.0.0-alpha.16.tgz",
"integrity": "sha512-L1epTMTDINJPUZkFuyohCXQtJDTMj1CNTBv9ysqVyMc3qjkifAvPEws6XuoRSC9jy1ZvqDTWlxPfbmoJ2r6BWg==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16",
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-vendor-prefixer": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.0.0-alpha.16.tgz",
"integrity": "sha512-70yJ6QE5dN8VlPUGKld5jK2SKyrteheEL/ismexpybIufunMs6iJgkhDndbOfv8ia13yZgUVqeakMdhRKYwK1A==",
"requires": {
"@babel/runtime": "^7.3.1",
"css-vendor": "^2.0.1",
"jss": "10.0.0-alpha.16"
},
"dependencies": {
"css-vendor": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.2.tgz",
"integrity": "sha512-Xn5ZAlI00d8HaQ8/oQ8d+iBzSF//NCc77LPzsucM32X/R/yTqmXy6otVsAM0XleXk6HjPuXoVZwXsayky/fsFQ==",
"requires": {
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.0.2"
}
},
"jss": {
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-HmKNNnr82TR5jkWjBcbrx/uim2ief588pWp7zsf4GQpL125zRkEaWYL1SXv5bR6bBvAoTtvJsTAOxDIlLxUNZg==",
"requires": {
"@babel/runtime": "^7.3.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
}
}
},
"jss-preset-default": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-4.5.0.tgz",
"integrity": "sha512-qZbpRVtHT7hBPpZEBPFfafZKWmq3tA/An5RNqywDsZQGrlinIF/mGD9lmj6jGqu8GrED2SMHZ3pPKLmjCZoiaQ==",
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-10.0.0-alpha.16.tgz",
"integrity": "sha512-YBq2XE4iJdl16klxfw0xTaKksfAIXSoC2kPZQ4dmw4n/KMFOz/A26eN30FwWixyObfDMKyZp94vwCKal7711IQ==",
"requires": {
"jss-camel-case": "^6.1.0",
"jss-compose": "^5.0.0",
"jss-default-unit": "^8.0.2",
"jss-expand": "^5.3.0",
"jss-extend": "^6.2.0",
"jss-global": "^3.0.0",
"jss-nested": "^6.0.1",
"jss-props-sort": "^6.0.0",
"jss-template": "^1.0.1",
"jss-vendor-prefixer": "^7.0.0"
}
},
"jss-props-sort": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/jss-props-sort/-/jss-props-sort-6.0.0.tgz",
"integrity": "sha512-E89UDcrphmI0LzmvYk25Hp4aE5ZBsXqMWlkFXS0EtPkunJkRr+WXdCNYbXbksIPnKlBenGB9OxzQY+mVc70S+g=="
},
"jss-template": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/jss-template/-/jss-template-1.0.1.tgz",
"integrity": "sha512-m5BqEWha17fmIVXm1z8xbJhY6GFJxNB9H68GVnCWPyGYfxiAgY9WTQyvDAVj+pYRgrXSOfN5V1T4+SzN1sJTeg==",
"requires": {
"warning": "^3.0.0"
},
"dependencies": {
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"requires": {
"loose-envify": "^1.0.0"
}
}
}
},
"jss-vendor-prefixer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/jss-vendor-prefixer/-/jss-vendor-prefixer-7.0.0.tgz",
"integrity": "sha512-Agd+FKmvsI0HLcYXkvy8GYOw3AAASBUpsmIRvVQheps+JWaN892uFOInTr0DRydwaD91vSSUCU4NssschvF7MA==",
"requires": {
"css-vendor": "^0.3.8"
"@babel/runtime": "^7.3.1",
"jss": "10.0.0-alpha.16",
"jss-plugin-camel-case": "10.0.0-alpha.16",
"jss-plugin-compose": "10.0.0-alpha.16",
"jss-plugin-default-unit": "10.0.0-alpha.16",
"jss-plugin-expand": "10.0.0-alpha.16",
"jss-plugin-extend": "10.0.0-alpha.16",
"jss-plugin-global": "10.0.0-alpha.16",
"jss-plugin-nested": "10.0.0-alpha.16",
"jss-plugin-props-sort": "10.0.0-alpha.16",
"jss-plugin-rule-value-function": "10.0.0-alpha.16",
"jss-plugin-rule-value-observable": "10.0.0-alpha.16",
"jss-plugin-template": "10.0.0-alpha.16",
"jss-plugin-vendor-prefixer": "10.0.0-alpha.16"
}
},
"jsx-ast-utils": {
@ -9357,6 +9452,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",
@ -10460,6 +10560,11 @@
"json-parse-better-errors": "^1.0.1"
}
},
"parse-unit": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz",
"integrity": "sha1-fhu21b7zh0wo45JSaiVBFwKR7s8="
},
"parse5": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz",
@ -13018,6 +13123,11 @@
}
}
},
"react-display-name": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/react-display-name/-/react-display-name-0.2.4.tgz",
"integrity": "sha512-zvU6iouW+SWwHTyThwxGICjJYCMZFk/6r/+jmOdC7ntQoPlS/Pqb81MkxaMf2bHTSq9TN3K3zX2/ayMW/jCtyA=="
},
"react-dom": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz",
@ -13059,22 +13169,17 @@
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA=="
},
"react-jss": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/react-jss/-/react-jss-8.6.1.tgz",
"integrity": "sha512-SH6XrJDJkAphp602J14JTy3puB2Zxz1FkM3bKVE8wON+va99jnUTKWnzGECb3NfIn9JPR5vHykge7K3/A747xQ==",
"version": "10.0.0-alpha.16",
"resolved": "https://registry.npmjs.org/react-jss/-/react-jss-10.0.0-alpha.16.tgz",
"integrity": "sha512-nGIerGVDV9V6cpRXhkJZgoV0MsoJbKMdAiCoPzCDnsdR+om6zLyhQEvVHNtd0mB16dO+pzNaovhBvElhdj/3ug==",
"requires": {
"hoist-non-react-statics": "^2.5.0",
"jss": "^9.7.0",
"jss-preset-default": "^4.3.0",
"@babel/runtime": "^7.3.1",
"hoist-non-react-statics": "^3.2.0",
"jss": "10.0.0-alpha.16",
"jss-preset-default": "10.0.0-alpha.16",
"prop-types": "^15.6.0",
"theming": "^1.3.0"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
}
"theming": "^3.0.3",
"tiny-warning": "^1.0.2"
}
},
"react-lifecycles-compat": {
@ -13176,14 +13281,13 @@
}
},
"react-transition-group": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
"integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.0.1.tgz",
"integrity": "sha512-SsLcBYhO4afXJC9esL8XMxi/y0ZvEc7To0TvtrBELqzpjXQHPZOTxvuPh2/4EhYc0uSMfp2SExIxsyJ0pBdNzg==",
"requires": {
"dom-helpers": "^3.4.0",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2",
"react-lifecycles-compat": "^3.0.4"
"prop-types": "^15.6.2"
}
},
"read-pkg": {
@ -13519,26 +13623,6 @@
"util.promisify": "^1.0.0"
}
},
"recompose": {
"version": "0.30.0",
"resolved": "https://registry.npmjs.org/recompose/-/recompose-0.30.0.tgz",
"integrity": "sha512-ZTrzzUDa9AqUIhRk4KmVFihH0rapdCSMFXjhHbNrjAWxBuUD/guYlyysMnuHjlZC/KRiOKRtB4jf96yYSkKE8w==",
"requires": {
"@babel/runtime": "^7.0.0",
"change-emitter": "^0.1.2",
"fbjs": "^0.8.1",
"hoist-non-react-statics": "^2.3.1",
"react-lifecycles-compat": "^3.0.2",
"symbol-observable": "^1.0.4"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
}
}
},
"recursive-readdir": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz",
@ -15189,14 +15273,14 @@
"integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ="
},
"theming": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/theming/-/theming-1.3.0.tgz",
"integrity": "sha512-ya5Ef7XDGbTPBv5ENTwrwkPUexrlPeiAg/EI9kdlUAZhNlRbCdhMKRgjNX1IcmsmiPcqDQZE6BpSaH+cr31FKw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/theming/-/theming-3.2.0.tgz",
"integrity": "sha512-n0fSNYXkX63rcFBBeAthy14IcgPZLHp0OGkGZheaj64j7cBoP7INLd6+7HIXqWVjFn1M5cYSiZ1nszi+jo/Szg==",
"requires": {
"brcast": "^3.0.1",
"is-function": "^1.0.1",
"is-plain-object": "^2.0.1",
"prop-types": "^15.5.8"
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.8",
"react-display-name": "^0.2.4",
"tiny-warning": "^1.0.2"
}
},
"throat": {

View File

@ -3,15 +3,16 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^3.9.3",
"@material-ui/icons": "^3.0.2",
"@material-ui/core": "^4.0.0",
"@material-ui/icons": "^4.0.0",
"compression-webpack-plugin": "^2.0.0",
"jwt-decode": "^2.2.0",
"moment": "^2.24.0",
"prop-types": "^15.7.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-form-validator-core": "^0.6.2",
"react-jss": "^8.6.1",
"react-jss": "^10.0.0-alpha.16",
"react-material-ui-form-validator": "^2.0.7",
"react-router": "^5.0.0",
"react-router-dom": "^5.0.0",

View File

@ -1,4 +1,5 @@
import React, { Component } from 'react';
import { Redirect, Route, Switch } from 'react-router';
import AppRouting from './AppRouting';
import SnackbarNotification from './components/SnackbarNotification';
@ -10,14 +11,12 @@ import orange from '@material-ui/core/colors/orange';
import red from '@material-ui/core/colors/red';
import green from '@material-ui/core/colors/green';
import JssProvider from 'react-jss/lib/JssProvider';
import { create } from 'jss';
import { StylesProvider, jssPreset } from '@material-ui/styles';
import {
MuiThemeProvider,
createMuiTheme,
createGenerateClassName,
jssPreset,
createMuiTheme
} from '@material-ui/core/styles';
// Our theme
@ -35,22 +34,25 @@ const theme = createMuiTheme({
// JSS instance
const jss = create(jssPreset());
// Class name generator.
const generateClassName = createGenerateClassName();
// this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid.
const unauthorizedRedirect = () => <Redirect to="/" />;
class App extends Component {
render() {
return (
<JssProvider jss={jss} generateClassName={generateClassName}>
<MuiThemeProvider theme={theme}>
<SnackbarNotification>
<CssBaseline />
<AppRouting />
</SnackbarNotification>
</MuiThemeProvider>
</JssProvider>
)
}
render() {
return (
<StylesProvider jss={jss}>
<MuiThemeProvider theme={theme}>
<SnackbarNotification>
<CssBaseline />
<Switch>
<Route exact path="/unauthorized" component={unauthorizedRedirect} />
<Route component={AppRouting} />
</Switch>
</SnackbarNotification>
</MuiThemeProvider>
</StylesProvider>
);
}
}
export default App

View File

@ -1,25 +1,41 @@
import React, { Component } from 'react';
import { Route, Redirect, Switch } from 'react-router';
import { Redirect, Switch } from 'react-router';
// containers
import WiFiConfiguration from './containers/WiFiConfiguration';
import NTPConfiguration from './containers/NTPConfiguration';
import OTAConfiguration from './containers/OTAConfiguration';
import APConfiguration from './containers/APConfiguration';
import * as Authentication from './authentication/Authentication';
import AuthenticationWrapper from './authentication/AuthenticationWrapper';
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
import SignInPage from './containers/SignInPage';
import WiFiConnection from './sections/WiFiConnection';
import AccessPoint from './sections/AccessPoint';
import NetworkTime from './sections/NetworkTime';
import Security from './sections/Security';
import System from './sections/System';
class AppRouting extends Component {
render() {
return (
<Switch>
<Route exact path="/wifi-configuration" component={WiFiConfiguration} />
<Route exact path="/ap-configuration" component={APConfiguration} />
<Route exact path="/ntp-configuration" component={NTPConfiguration} />
<Route exact path="/ota-configuration" component={OTAConfiguration} />
<Redirect to="/wifi-configuration" />
</Switch>
)
}
componentWillMount() {
Authentication.clearLoginRedirect();
}
render() {
return (
<AuthenticationWrapper>
<Switch>
<UnauthenticatedRoute exact path="/" component={SignInPage} />
<AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} />
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
<AuthenticatedRoute exact path="/security/*" component={Security} />
<AuthenticatedRoute exact path="/system/*" component={System} />
<Redirect to="/" />
</Switch>
</AuthenticationWrapper>
)
}
}
export default AppRouting;

View File

@ -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.isAuthenticated()) {
return (
<Component {...props} />
);
}
Authentication.storeLoginRedirect(location);
raiseNotification("Please log in to continue.");
return (
<Redirect to='/' />
);
}
return (
<Route {...rest} render={renderComponent} />
);
}
}
export default withNotifier(withAuthenticationContext(AuthenticatedRoute));

View File

@ -0,0 +1,58 @@
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 || "/wifi/",
search: (loginPathname && loginSearch) || undefined
};
}
/**
* Wraps the normal fetch routene with one with provides the access token if present.
*/
export function authorizedFetch(url, params) {
const accessToken = localStorage.getItem(ACCESS_TOKEN);
if (accessToken) {
params = params || {};
params.credentials = 'include';
params.headers = params.headers || {};
params.headers.Authorization = 'Bearer ' + accessToken;
}
return fetch(url, params);
}
/**
* Wraps the normal fetch routene which redirects on 401 response.
*/
export function redirectingAuthorizedFetch(url, params) {
return new Promise(function (resolve, reject) {
authorizedFetch(url, params).then(response => {
if (response.status === 401) {
history.push("/unauthorized");
} else {
resolve(response);
}
}).catch(error => {
reject(error);
});
});
}

View File

@ -0,0 +1,123 @@
import * as React from 'react';
import history from '../history'
import { withNotifier } from '../components/SnackbarNotification';
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../constants/Endpoints';
import { ACCESS_TOKEN, authorizedFetch } from './Authentication';
import { AuthenticationContext } from './Context';
import jwtDecode from 'jwt-decode';
import CircularProgress from '@material-ui/core/CircularProgress';
import Typography from '@material-ui/core/Typography';
import { withStyles } from '@material-ui/core/styles';
const styles = theme => ({
loadingPanel: {
padding: theme.spacing(2),
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
flexDirection: "column"
},
progress: {
margin: theme.spacing(4),
}
});
class AuthenticationWrapper extends React.Component {
constructor(props) {
super(props);
this.state = {
context: {
refresh: this.refresh,
signIn: this.signIn,
signOut: this.signOut,
isAuthenticated: this.isAuthenticated,
isAdmin: this.isAdmin
},
initialized: false
};
}
componentDidMount() {
this.refresh();
}
render() {
return (
<React.Fragment>
{this.state.initialized ? this.renderContent() : this.renderContentLoading()}
</React.Fragment>
);
}
renderContent() {
return (
<AuthenticationContext.Provider value={this.state.context}>
{this.props.children}
</AuthenticationContext.Provider>
);
}
renderContentLoading() {
const { classes } = this.props;
return (
<div className={classes.loadingPanel}>
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4" >
Loading...
</Typography>
</div>
);
}
refresh = () => {
var accessToken = localStorage.getItem(ACCESS_TOKEN);
if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
.then(response => {
const user = response.status === 200 ? jwtDecode(accessToken) : undefined;
this.setState({ initialized: true, context: { ...this.state.context, user } });
}).catch(error => {
this.setState({ initialized: true, context: { ...this.state.context, user: undefined } });
this.props.raiseNotification("Error verifying authorization: " + error.message);
});
} else {
this.setState({ initialized: true, context: { ...this.state.context, user: undefined } });
}
}
signIn = (accessToken) => {
try {
localStorage.setItem(ACCESS_TOKEN, accessToken);
this.setState({ context: { ...this.state.context, user: jwtDecode(accessToken) } });
} catch (err) {
this.setState({ initialized: true, context: { ...this.state.context, user: undefined } });
throw new Error("Failed to parse JWT " + err.message);
}
}
signOut = () => {
localStorage.removeItem(ACCESS_TOKEN);
this.setState({
context: {
...this.state.context,
user: undefined
}
});
this.props.raiseNotification("You have signed out.");
history.push('/');
}
isAuthenticated = () => {
return this.state.context.user;
}
isAdmin = () => {
const { context } = this.state;
return context.user && context.user.admin;
}
}
export default withStyles(styles)(withNotifier(AuthenticationWrapper))

View File

@ -0,0 +1,15 @@
import * as React from "react";
export const AuthenticationContext = React.createContext(
{}
);
export function withAuthenticationContext(Component) {
return function AuthenticationContextComponent(props) {
return (
<AuthenticationContext.Consumer>
{authenticationContext => <Component {...props} authenticationContext={authenticationContext} />}
</AuthenticationContext.Consumer>
);
};
}

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import {
Redirect, Route
} from "react-router-dom";
import { withAuthenticationContext } from './Context.js';
import * as Authentication from './Authentication';
class UnauthenticatedRoute extends React.Component {
render() {
const { authenticationContext, component:Component, ...rest } = this.props;
const renderComponent = (props) => {
if (authenticationContext.isAuthenticated()) {
return (<Redirect to={Authentication.fetchLoginRedirect()} />);
}
return (<Component {...props} />);
}
return (
<Route {...rest} render={renderComponent} />
);
}
}
export default withAuthenticationContext(UnauthenticatedRoute);

View File

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Link, withRouter } from 'react-router-dom';
import { withStyles } from '@material-ui/core/styles';
import Drawer from '@material-ui/core/Drawer';
@ -10,122 +10,143 @@ import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton';
import Hidden from '@material-ui/core/Hidden';
import Divider from '@material-ui/core/Divider';
import Button from '@material-ui/core/Button';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Popper from '@material-ui/core/Popper';
import MenuIcon from '@material-ui/icons/Menu';
import WifiIcon from '@material-ui/icons/Wifi';
import SystemUpdateIcon from '@material-ui/icons/SystemUpdate';
import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import LockIcon from '@material-ui/icons/Lock';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardActions from '@material-ui/core/CardActions';
import Avatar from '@material-ui/core/Avatar';
import { APP_NAME } from '../constants/App';
import { withAuthenticationContext } from '../authentication/Context.js';
const drawerWidth = 290;
const styles = theme => ({
root: {
zIndex: 1,
width: '100%',
height: '100%',
},
toolbar: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
[theme.breakpoints.up('md')]: {
paddingLeft: theme.spacing.unit * 3,
paddingRight: theme.spacing.unit * 3,
}
},
appFrame: {
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
},
drawer: {
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0,
},
},
title: {
flexGrow: 1
},
appBar: {
position: 'absolute',
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`,
},
},
navIconHide: {
menuButton: {
marginRight: theme.spacing(2),
[theme.breakpoints.up('md')]: {
display: 'none',
},
},
toolbar: theme.mixins.toolbar,
drawerPaper: {
width: drawerWidth,
height: '100%',
[theme.breakpoints.up('md')]: {
width: drawerWidth,
position:'fixed',
left:0,
top:0,
overflow:'auto'
},
},
content: {
backgroundColor: theme.palette.background.default,
width:"100%",
marginTop: 56,
[theme.breakpoints.up('md')]: {
paddingLeft: drawerWidth
},
[theme.breakpoints.up('sm')]: {
height: 'calc(100% - 64px)',
marginTop: 64,
},
flexGrow: 1,
padding: theme.spacing(),
},
authMenu: {
zIndex: theme.zIndex.tooltip,
maxWidth: 400,
},
authMenuActions: {
padding: theme.spacing(2),
"& > * + *": {
marginLeft: theme.spacing(2),
}
},
});
class MenuAppBar extends React.Component {
state = {
mobileOpen: false,
authMenuOpen: false
};
anchorRef = React.createRef();
handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen });
}
handleClose = (event) => {
if (this.anchorRef.current && this.anchorRef.current.contains(event.target)) {
return;
}
this.setState({ authMenuOpen: false });
}
handleDrawerToggle = () => {
this.setState({ mobileOpen: !this.state.mobileOpen });
};
render() {
const { classes, theme, children, sectionTitle } = this.props;
const { classes, theme, children, sectionTitle, authenticationContext } = this.props;
const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url;
const drawer = (
<div>
<Toolbar>
<Typography variant="title" color="primary">
ESP8266 React
</Typography>
<Typography variant="h6" color="primary">
{APP_NAME}
</Typography>
<Divider absolute />
</Toolbar>
<Divider />
<List>
<ListItem button component={Link} to='/wifi-configuration'>
<ListItem to='/wifi/' selected={path.startsWith('/wifi/')} button component={Link}>
<ListItemIcon>
<WifiIcon />
</ListItemIcon>
<ListItemText primary="WiFi Configuration" />
<ListItemText primary="WiFi Connection" />
</ListItem>
<ListItem button component={Link} to='/ap-configuration'>
<ListItem to='/ap/' selected={path.startsWith('/ap/')} button component={Link}>
<ListItemIcon>
<SettingsInputAntennaIcon />
</ListItemIcon>
<ListItemText primary="AP Configuration" />
<ListItemText primary="Access Point" />
</ListItem>
<ListItem button component={Link} to='/ntp-configuration'>
<ListItem to='/ntp/' selected={path.startsWith('/ntp/')} button component={Link}>
<ListItemIcon>
<AccessTimeIcon />
</ListItemIcon>
<ListItemText primary="NTP Configuration" />
<ListItemText primary="Network Time" />
</ListItem>
<ListItem button component={Link} to='/ota-configuration'>
<ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticationContext.isAdmin()}>
<ListItemIcon>
<SystemUpdateIcon />
<LockIcon />
</ListItemIcon>
<ListItemText primary="OTA Configuration" />
<ListItemText primary="Security" />
</ListItem>
<ListItem to='/system/' selected={path.startsWith('/system/')} button component={Link} >
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="System" />
</ListItem>
</List>
</div>
@ -133,33 +154,67 @@ class MenuAppBar extends React.Component {
return (
<div className={classes.root}>
<div className={classes.appFrame}>
<AppBar className={classes.appBar}>
<Toolbar className={classes.toolbar} disableGutters={true}>
<AppBar position="fixed" className={classes.appBar}>
<Toolbar>
<IconButton
color="inherit"
aria-label="Open drawer"
edge="start"
onClick={this.handleDrawerToggle}
className={classes.menuButton}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" color="inherit" noWrap className={classes.title}>
{sectionTitle}
</Typography>
<div>
<IconButton
ref={this.anchorRef}
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={this.handleToggle}
color="inherit"
aria-label="open drawer"
onClick={this.handleDrawerToggle}
className={classes.navIconHide}
>
<MenuIcon />
<AccountCircleIcon />
</IconButton>
<Typography variant="title" color="inherit" noWrap>
{sectionTitle}
</Typography>
</Toolbar>
</AppBar>
<Hidden mdUp>
<Popper open={authMenuOpen} anchorEl={this.anchorRef.current} transition className={classes.authMenu}>
<ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow">
<CardContent>
<List disablePadding>
<ListItem disableGutters>
<ListItemAvatar>
<Avatar>
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={"Signed in as: " + authenticationContext.user.username} secondary={authenticationContext.isAdmin() ? "Admin User" : undefined} />
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button className={classes.authMenuButtons} variant="contained" color="primary" onClick={authenticationContext.signOut}>Sign Out</Button>
</CardActions>
</Card>
</ClickAwayListener>
</Popper>
</div>
</Toolbar>
</AppBar>
<nav className={classes.drawer}>
<Hidden mdUp implementation="css">
<Drawer
variant="temporary"
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
open={this.state.mobileOpen}
open={mobileOpen}
onClose={this.handleDrawerToggle}
classes={{
paper: classes.drawerPaper,
}}
onClose={this.handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
keepMounted: true,
}}
>
{drawer}
@ -167,19 +222,20 @@ class MenuAppBar extends React.Component {
</Hidden>
<Hidden smDown implementation="css">
<Drawer
variant="permanent"
open
classes={{
paper: classes.drawerPaper,
}}
variant="permanent"
open
>
{drawer}
</Drawer>
</Hidden>
<main className={classes.content}>
{children}
</main>
</div>
</nav>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
}
@ -191,4 +247,8 @@ MenuAppBar.propTypes = {
sectionTitle: PropTypes.string.isRequired,
};
export default withStyles(styles, { withTheme: true })(MenuAppBar);
export default withAuthenticationContext(
withRouter(
withStyles(styles, { withTheme: true })(MenuAppBar)
)
);

View File

@ -1,6 +1,6 @@
import React from 'react';
import {withNotifier} from '../components/SnackbarNotification';
import { withNotifier } from '../components/SnackbarNotification';
import { redirectingAuthorizedFetch } from '../authentication/Authentication';
/*
* It is unlikely this application will grow complex enough to require redux.
*
@ -16,11 +16,11 @@ export const restComponent = (endpointUrl, FormComponent) => {
constructor(props) {
super(props);
this.state={
data:null,
fetched: false,
errorMessage:null
};
this.state = {
data: null,
fetched: false,
errorMessage: null
};
this.setState = this.setState.bind(this);
this.loadData = this.loadData.bind(this);
@ -30,78 +30,78 @@ export const restComponent = (endpointUrl, FormComponent) => {
setData(data) {
this.setState({
data:data,
fetched: true,
errorMessage:null
});
data: data,
fetched: true,
errorMessage: null
});
}
loadData() {
this.setState({
data:null,
fetched: false,
errorMessage:null
});
fetch(endpointUrl)
data: null,
fetched: false,
errorMessage: null
});
redirectingAuthorizedFetch(endpointUrl)
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => {this.setState({data: json, fetched:true})})
.catch(error =>{
.then(json => { this.setState({ data: json, fetched: true }) })
.catch(error => {
this.props.raiseNotification("Problem fetching: " + error.message);
this.setState({data: null, fetched:true, errorMessage:error.message});
this.setState({ data: null, fetched: true, errorMessage: error.message });
});
}
saveData(e) {
this.setState({fetched: false});
fetch(endpointUrl, {
this.setState({ fetched: false });
redirectingAuthorizedFetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(this.state.data),
headers: new Headers({
headers: {
'Content-Type': 'application/json'
})
})
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => {
this.props.raiseNotification("Changes successfully applied.");
this.setState({data: json, fetched:true});
}).catch(error => {
this.props.raiseNotification("Problem saving: " + error.message);
this.setState({data: null, fetched:true, errorMessage:error.message});
});
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => {
this.props.raiseNotification("Changes successfully applied.");
this.setState({ data: json, fetched: true });
}).catch(error => {
this.props.raiseNotification("Problem saving: " + error.message);
this.setState({ data: null, fetched: true, errorMessage: error.message });
});
}
handleValueChange = name => event => {
const { data } = this.state;
data[name] = event.target.value;
this.setState({data});
this.setState({ data });
};
handleCheckboxChange = name => event => {
const { data } = this.state;
data[name] = event.target.checked;
this.setState({data});
this.setState({ data });
}
render() {
return <FormComponent
handleValueChange={this.handleValueChange}
handleCheckboxChange={this.handleCheckboxChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
{...this.state}
{...this.props}
/>;
handleValueChange={this.handleValueChange}
handleCheckboxChange={this.handleCheckboxChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
{...this.state}
{...this.props}
/>;
}
}

View File

@ -7,8 +7,8 @@ import Typography from '@material-ui/core/Typography';
const styles = theme => ({
content: {
padding: theme.spacing.unit * 2,
margin: theme.spacing.unit * 2,
padding: theme.spacing(2),
margin: theme.spacing(2),
}
});
@ -16,7 +16,7 @@ function SectionContent(props) {
const { children, classes, title } = props;
return (
<Paper className={classes.content}>
<Typography variant="display1">
<Typography variant="h6">
{title}
</Typography>
{children}

View File

@ -7,7 +7,7 @@ import CloseIcon from '@material-ui/icons/Close';
const styles = theme => ({
close: {
padding: theme.spacing.unit / 2,
padding: theme.spacing(0.5),
},
});
@ -54,7 +54,7 @@ class SnackbarNotification extends React.Component {
open={this.state.open}
autoHideDuration={6000}
onClose={this.handleClose}
SnackbarContentProps={{
ContentProps={{
'aria-describedby': 'message-id',
}}
message={<span id="message-id">{this.state.message}</span>}

View File

@ -0,0 +1 @@
export const APP_NAME = process.env.REACT_APP_NAME;

View File

@ -9,3 +9,7 @@ export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + "listNetworks";
export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings";
export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus";
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "otaSettings";
export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + "systemStatus";
export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + "signIn";
export const VERIFY_AUTHORIZATION_ENDPOINT = ENDPOINT_ROOT + "verifyAuthorization";
export const SECURITY_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "securitySettings";

View File

@ -1,39 +0,0 @@
import React, { Component } from 'react';
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import MenuAppBar from '../components/MenuAppBar';
import APSettings from './APSettings';
import APStatus from './APStatus';
class APConfiguration extends Component {
constructor(props) {
super(props);
this.state = {
selectedTab: "apStatus",
selectedNetwork: null
};
}
handleTabChange = (event, selectedTab) => {
this.setState({ selectedTab });
};
render() {
const { selectedTab } = this.state;
return (
<MenuAppBar sectionTitle="AP Configuration">
<Tabs value={selectedTab} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="scrollable">
<Tab value="apStatus" label="AP Status" />
<Tab value="apSettings" label="AP Settings" />
</Tabs>
{selectedTab === "apStatus" && <APStatus />}
{selectedTab === "apSettings" && <APSettings />}
</MenuAppBar>
)
}
}
export default APConfiguration;

View File

@ -7,17 +7,18 @@ import Typography from '@material-ui/core/Typography';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import Divider from '@material-ui/core/Divider';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import ComputerIcon from '@material-ui/icons/Computer';
import {restComponent} from '../components/RestComponent';
import { restComponent } from '../components/RestComponent';
import SectionContent from '../components/SectionContent'
import * as Highlight from '../constants/Highlight';
import { AP_STATUS_ENDPOINT } from '../constants/Endpoints';
import { AP_STATUS_ENDPOINT } from '../constants/Endpoints';
const styles = theme => ({
["apStatus_" + Highlight.SUCCESS]: {
@ -27,12 +28,12 @@ const styles = theme => ({
backgroundColor: theme.palette.highlight_idle
},
fetching: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -42,40 +43,48 @@ class APStatus extends Component {
this.props.loadData();
}
apStatusHighlight(data){
apStatusHighlight(data) {
return data.active ? Highlight.SUCCESS : Highlight.IDLE;
}
apStatus(data){
apStatus(data) {
return data.active ? "Active" : "Inactive";
}
createListItems(data, classes){
createListItems(data, classes) {
return (
<Fragment>
<ListItem>
<Avatar className={classes["apStatus_" + this.apStatusHighlight(data)]}>
<SettingsInputAntennaIcon />
</Avatar>
<ListItemAvatar>
<Avatar className={classes["apStatus_" + this.apStatusHighlight(data)]}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={this.apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>IP</Avatar>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<DeviceHubIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<ComputerIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="AP Clients" secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
@ -83,8 +92,8 @@ class APStatus extends Component {
);
}
renderAPStatus(data, classes){
return (
renderAPStatus(data, classes) {
return (
<div>
<List>
<Fragment>
@ -99,30 +108,30 @@ class APStatus extends Component {
}
render() {
const { data, fetched, errorMessage, classes } = this.props;
const { data, fetched, errorMessage, classes } = this.props;
return (
<SectionContent title="AP Status">
{
!fetched ?
<div>
<LinearProgress className={classes.fetching}/>
<Typography variant="display1" className={classes.fetching}>
Loading...
</Typography>
</div>
:
data ? this.renderAPStatus(data, classes)
:
<div>
<Typography variant="display1" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>
Refresh
</Button>
</div>
}
!fetched ?
<div>
<LinearProgress className={classes.fetching} />
<Typography variant="h4" className={classes.fetching}>
Loading...
</Typography>
</div>
:
data ? this.renderAPStatus(data, classes)
:
<div>
<Typography variant="h4" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>
Refresh
</Button>
</div>
}
</SectionContent>
)
}

View File

@ -0,0 +1,33 @@
import React, { Component } from 'react';
import { SECURITY_SETTINGS_ENDPOINT } from '../constants/Endpoints';
import { restComponent } from '../components/RestComponent';
import ManageUsersForm from '../forms/ManageUsersForm';
import SectionContent from '../components/SectionContent';
class ManageUsers extends Component {
componentDidMount() {
this.props.loadData();
}
render() {
const { data, fetched, errorMessage } = this.props;
return (
<SectionContent title="Manage Users">
<ManageUsersForm
userData={data}
userDataFetched={fetched}
errorMessage={errorMessage}
onSubmit={this.props.saveData}
onReset={this.props.loadData}
setData={this.props.setData}
handleValueChange={this.props.handleValueChange}
/>
</SectionContent>
)
}
}
export default restComponent(SECURITY_SETTINGS_ENDPOINT, ManageUsers);

View File

@ -1,37 +0,0 @@
import React, { Component } from 'react';
import MenuAppBar from '../components/MenuAppBar';
import NTPSettings from './NTPSettings';
import NTPStatus from './NTPStatus';
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
class NTPConfiguration extends Component {
constructor(props) {
super(props);
this.state = {
selectedTab: "ntpStatus"
};
}
handleTabChange = (event, selectedTab) => {
this.setState({ selectedTab });
};
render() {
const { selectedTab } = this.state;
return (
<MenuAppBar sectionTitle="NTP Configuration">
<Tabs value={selectedTab} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="scrollable">
<Tab value="ntpStatus" label="NTP Status" />
<Tab value="ntpSettings" label="NTP Settings" />
</Tabs>
{selectedTab === "ntpStatus" && <NTPStatus />}
{selectedTab === "ntpSettings" && <NTPSettings />}
</MenuAppBar>
)
}
}
export default NTPConfiguration

View File

@ -6,6 +6,7 @@ import LinearProgress from '@material-ui/core/LinearProgress';
import Typography from '@material-ui/core/Typography';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemText from '@material-ui/core/ListItemText';
import Avatar from '@material-ui/core/Avatar';
import Divider from '@material-ui/core/Divider';
@ -16,10 +17,10 @@ import TimerIcon from '@material-ui/icons/Timer';
import UpdateIcon from '@material-ui/icons/Update';
import AvTimerIcon from '@material-ui/icons/AvTimer';
import { isSynchronized, ntpStatusHighlight, ntpStatus } from '../constants/NTPStatus';
import { isSynchronized, ntpStatusHighlight, ntpStatus } from '../constants/NTPStatus';
import * as Highlight from '../constants/Highlight';
import { unixTimeToTimeAndDate } from '../constants/TimeFormat';
import { NTP_STATUS_ENDPOINT } from '../constants/Endpoints';
import { NTP_STATUS_ENDPOINT } from '../constants/Endpoints';
import { restComponent } from '../components/RestComponent';
import SectionContent from '../components/SectionContent';
@ -36,12 +37,12 @@ const styles = theme => ({
backgroundColor: theme.palette.highlight_warn
},
fetching: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -51,52 +52,64 @@ class NTPStatus extends Component {
this.props.loadData();
}
createListItems(data, classes){
createListItems(data, classes) {
return (
<Fragment>
<ListItem>
<Avatar className={classes["ntpStatus_" + ntpStatusHighlight(data)]}>
<UpdateIcon />
</Avatar>
<ListItem >
<ListItemAvatar>
<Avatar className={classes["ntpStatus_" + ntpStatusHighlight(data)]}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{ isSynchronized(data) &&
{isSynchronized(data) &&
<Fragment>
<ListItem>
<Avatar>
<AccessTimeIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Time Now" secondary={unixTimeToTimeAndDate(data.now)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
<ListItemText primary="Last Sync" secondary={data.last_sync > 0 ? unixTimeToTimeAndDate(data.last_sync) : "never" } />
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Last Sync" secondary={data.last_sync > 0 ? unixTimeToTimeAndDate(data.last_sync) : "never"} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
}
<ListItem>
<Avatar>
<DNSIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<DNSIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="NTP Server" secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<TimerIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<TimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Sync Interval" secondary={moment.duration(data.interval, 'seconds').humanize()} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<AvTimerIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<AvTimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Uptime" secondary={moment.duration(data.uptime, 'seconds').humanize()} />
</ListItem>
<Divider variant="inset" component="li" />
@ -104,8 +117,8 @@ class NTPStatus extends Component {
);
}
renderNTPStatus(data, classes){
return (
renderNTPStatus(data, classes) {
return (
<div>
<List>
{this.createListItems(data, classes)}
@ -118,30 +131,30 @@ class NTPStatus extends Component {
}
render() {
const { data, fetched, errorMessage, classes } = this.props;
const { data, fetched, errorMessage, classes } = this.props;
return (
<SectionContent title="NTP Status">
{
!fetched ?
<div>
<LinearProgress className={classes.fetching}/>
<Typography variant="display1" className={classes.fetching}>
Loading...
</Typography>
</div>
:
data ? this.renderNTPStatus(data, classes)
:
<div>
<Typography variant="display1" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>
Refresh
</Button>
</div>
}
!fetched ?
<div>
<LinearProgress className={classes.fetching} />
<Typography variant="h4" className={classes.fetching}>
Loading...
</Typography>
</div>
:
data ? this.renderNTPStatus(data, classes)
:
<div>
<Typography variant="h4" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>
Refresh
</Button>
</div>
}
</SectionContent>
)
}

View File

@ -1,15 +0,0 @@
import React, { Component } from 'react';
import MenuAppBar from '../components/MenuAppBar';
import OTASettings from './OTASettings';
class OTAConfiguration extends Component {
render() {
return (
<MenuAppBar sectionTitle="OTA Configuration">
<OTASettings />
</MenuAppBar>
)
}
}
export default OTAConfiguration

View File

@ -0,0 +1,32 @@
import React, { Component } from 'react';
import { SECURITY_SETTINGS_ENDPOINT } from '../constants/Endpoints';
import { restComponent } from '../components/RestComponent';
import SecuritySettingsForm from '../forms/SecuritySettingsForm';
import SectionContent from '../components/SectionContent';
class SecuritySettings extends Component {
componentDidMount() {
this.props.loadData();
}
render() {
const { data, fetched, errorMessage } = this.props;
return (
<SectionContent title="Security Settings">
<SecuritySettingsForm
securitySettings={data}
securitySettingsFetched={fetched}
errorMessage={errorMessage}
onSubmit={this.props.saveData}
onReset={this.props.loadData}
handleValueChange={this.props.handleValueChange}
/>
</SectionContent>
)
}
}
export default restComponent(SECURITY_SETTINGS_ENDPOINT, SecuritySettings);

View File

@ -0,0 +1,136 @@
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';
import { withNotifier } from '../components/SnackbarNotification';
import { SIGN_IN_ENDPOINT } from '../constants/Endpoints';
import { withAuthenticationContext } from '../authentication/Context';
import PasswordValidator from '../components/PasswordValidator';
const styles = theme => {
return {
loginPage: {
display: "flex",
height: "100vh",
margin: "auto",
padding: theme.spacing(2),
justifyContent: "center",
flexDirection: "column",
maxWidth: theme.breakpoints.values.sm
},
loginPanel: {
textAlign: "center",
padding: theme.spacing(2),
paddingTop: "200px",
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: "no-repeat",
backgroundPosition: "50% " + theme.spacing(2) + "px",
backgroundSize: "auto 150px",
width: "100%"
},
extendedIcon: {
marginRight: theme.spacing(0.5),
},
textField: {
width: "100%"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
}
}
class SignInPage extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
password: '',
processing: false
};
}
handleValueChange = name => event => {
this.setState({ [name]: event.target.value });
};
onSubmit = () => {
const { username, password } = this.state;
const { authenticationContext } = this.props;
this.setState({ processing: true });
fetch(SIGN_IN_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: new Headers({
'Content-Type': 'application/json'
})
})
.then(response => {
if (response.status === 200) {
return response.json();
} else if (response.status === 401) {
throw Error("Invalid login details.");
} else {
throw Error("Invalid status code: " + response.status);
}
}).then(json => {
authenticationContext.signIn(json.access_token);
})
.catch(error => {
this.props.raiseNotification(error.message);
this.setState({ processing: false });
});
};
render() {
const { username, password, processing } = this.state;
const { classes } = this.props;
return (
<div className={classes.loginPage}>
<Paper className={classes.loginPanel}>
<Typography variant="h4">{APP_NAME}</Typography>
<ValidatorForm onSubmit={this.onSubmit}>
<TextValidator
disabled={processing}
validators={['required']}
errorMessages={['Username is required']}
name="username"
label="Username"
className={classes.textField}
value={username}
onChange={this.handleValueChange('username')}
margin="normal"
/>
<PasswordValidator
disabled={processing}
validators={['required']}
errorMessages={['Password is required']}
name="password"
label="Password"
className={classes.textField}
value={password}
onChange={this.handleValueChange('password')}
margin="normal"
/>
<Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}>
<ForwardIcon className={classes.extendedIcon} />
Sign In
</Fab>
</ValidatorForm>
</Paper>
</div>
);
}
}
export default withAuthenticationContext(
withNotifier(withStyles(styles)(SignInPage))
);

View File

@ -0,0 +1,135 @@
import React, { Component, Fragment } from 'react';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import LinearProgress from '@material-ui/core/LinearProgress';
import Typography from '@material-ui/core/Typography';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemText from '@material-ui/core/ListItemText';
import Avatar from '@material-ui/core/Avatar';
import Divider from '@material-ui/core/Divider';
import DevicesIcon from '@material-ui/icons/Devices';
import MemoryIcon from '@material-ui/icons/Memory';
import ShowChartIcon from '@material-ui/icons/ShowChart';
import SdStorageIcon from '@material-ui/icons/SdStorage';
import DataUsageIcon from '@material-ui/icons/DataUsage';
import { SYSTEM_STATUS_ENDPOINT } from '../constants/Endpoints';
import { restComponent } from '../components/RestComponent';
import SectionContent from '../components/SectionContent';
const styles = theme => ({
fetching: {
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
class SystemStatus extends Component {
componentDidMount() {
this.props.loadData();
}
createListItems(data, classes) {
return (
<Fragment>
<ListItem >
<ListItemAvatar>
<Avatar>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Platform" secondary={data.esp_platform} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem >
<ListItemAvatar>
<Avatar>
<ShowChartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="CPU Frequency" secondary={data.cpu_freq_mhz + ' MHz'} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem >
<ListItemAvatar>
<Avatar>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Free Heap" secondary={data.free_heap + ' bytes'} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem >
<ListItemAvatar>
<Avatar>
<DataUsageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Sketch Size (used/max)" secondary={data.sketch_size + ' / ' + data.free_sketch_space + ' bytes'} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem >
<ListItemAvatar>
<Avatar>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Flash Chip Size" secondary={data.flash_chip_size + ' bytes'} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
);
}
renderNTPStatus(data, classes) {
return (
<div>
<List>
{this.createListItems(data, classes)}
</List>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>
Refresh
</Button>
</div>
);
}
render() {
const { data, fetched, errorMessage, classes } = this.props;
return (
<SectionContent title="System Status">
{
!fetched ?
<div>
<LinearProgress className={classes.fetching} />
<Typography variant="h4" className={classes.fetching}>
Loading...
</Typography>
</div>
:
data ? this.renderNTPStatus(data, classes)
:
<div>
<Typography variant="h4" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>
Refresh
</Button>
</div>
}
</SectionContent>
)
}
}
export default restComponent(SYSTEM_STATUS_ENDPOINT, withStyles(styles)(SystemStatus));

View File

@ -1,54 +0,0 @@
import React, { Component } from 'react';
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import MenuAppBar from '../components/MenuAppBar';
import WiFiNetworkScanner from './WiFiNetworkScanner';
import WiFiSettings from './WiFiSettings';
import WiFiStatus from './WiFiStatus';
class WiFiConfiguration extends Component {
constructor(props) {
super(props);
this.state = {
selectedTab: "wifiStatus",
selectedNetwork: null
};
this.selectNetwork = this.selectNetwork.bind(this);
this.deselectNetwork = this.deselectNetwork.bind(this);
}
// TODO - slightly inapproperate use of callback ref possibly.
selectNetwork(network) {
this.setState({ selectedTab: "wifiSettings", selectedNetwork:network });
}
// deselects the network after the settings component mounts.
deselectNetwork(network) {
this.setState({ selectedNetwork:null });
}
handleTabChange = (event, selectedTab) => {
this.setState({ selectedTab });
};
render() {
const { selectedTab } = this.state;
return (
<MenuAppBar sectionTitle="WiFi Configuration">
<Tabs value={selectedTab} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="scrollable">
<Tab value="wifiStatus" label="WiFi Status" />
<Tab value="networkScanner" label="Network Scanner" />
<Tab value="wifiSettings" label="WiFi Settings" />
</Tabs>
{selectedTab === "wifiStatus" && <WiFiStatus />}
{selectedTab === "networkScanner" && <WiFiNetworkScanner selectNetwork={this.selectNetwork} />}
{selectedTab === "wifiSettings" && <WiFiSettings deselectNetwork={this.deselectNetwork} selectedNetwork={this.state.selectedNetwork} />}
</MenuAppBar>
)
}
}
export default WiFiConfiguration;

View File

@ -5,6 +5,7 @@ import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../constants/E
import SectionContent from '../components/SectionContent';
import WiFiNetworkSelector from '../forms/WiFiNetworkSelector';
import {withNotifier} from '../components/SnackbarNotification';
import { redirectingAuthorizedFetch } from '../authentication/Authentication';
const NUM_POLLS = 10
const POLLING_FREQUENCY = 500
@ -38,7 +39,7 @@ class WiFiNetworkScanner extends Component {
scanNetworks() {
this.pollCount = 0;
this.setState({scanningForNetworks:true, networkList: null, errorMessage:null});
fetch(SCAN_NETWORKS_ENDPOINT).then(response => {
redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT).then(response => {
if (response.status === 202) {
this.schedulePollTimeout();
return;
@ -70,7 +71,7 @@ class WiFiNetworkScanner extends Component {
}
pollNetworkList() {
fetch(LIST_NETWORKS_ENDPOINT)
redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT)
.then(response => {
if (response.status === 200) {
return response.json();
@ -90,7 +91,6 @@ class WiFiNetworkScanner extends Component {
this.setState({scanningForNetworks:false, networkList: json, errorMessage:null})
})
.catch(error => {
console.log(error.message);
if (error.name !== RETRY_EXCEPTION_TYPE) {
this.props.raiseNotification("Problem scanning: " + error.message);
this.setState({scanningForNetworks:false, networkList: null, errorMessage:error.message});

View File

@ -8,6 +8,7 @@ import Typography from '@material-ui/core/Typography';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import Divider from '@material-ui/core/Divider';
@ -37,12 +38,12 @@ const styles = theme => ({
backgroundColor: theme.palette.highlight_warn
},
fetching: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -63,9 +64,11 @@ class WiFiStatus extends Component {
return (
<Fragment>
<ListItem>
<Avatar className={classes["wifiStatus_" + connectionStatusHighlight(data)]}>
<WifiIcon />
</Avatar>
<ListItemAvatar>
<Avatar className={classes["wifiStatus_" + connectionStatusHighlight(data)]}>
<WifiIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Connection Status" secondary={connectionStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
@ -73,40 +76,52 @@ class WiFiStatus extends Component {
isConnected(data) &&
<Fragment>
<ListItem>
<Avatar>
<SettingsInputAntennaIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="SSID" secondary={data.ssid} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>IP</Avatar>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={data.local_ip} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<DeviceHubIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>#</Avatar>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary="Subnet Mask" secondary={data.subnet_mask} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Gateway IP" secondary={data.gateway_ip ? data.gateway_ip : "none"} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<Avatar>
<DNSIcon />
</Avatar>
<ListItemAvatar>
<Avatar>
<DNSIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="DNS Server IP" secondary={this.dnsServers(data)} />
</ListItem>
<Divider variant="inset" component="li" />
@ -137,7 +152,7 @@ class WiFiStatus extends Component {
!fetched ?
<div>
<LinearProgress className={classes.fetching} />
<Typography variant="display1" className={classes.fetching}>
<Typography variant="h4" className={classes.fetching}>
Loading...
</Typography>
</div>
@ -145,7 +160,7 @@ class WiFiStatus extends Component {
data ? this.renderWiFiStatus(data, classes)
:
<div>
<Typography variant="display1" className={classes.fetching}>
<Typography variant="h4" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}>

View File

@ -14,10 +14,10 @@ import PasswordValidator from '../components/PasswordValidator';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
textField: {
@ -25,12 +25,12 @@ const styles = theme => ({
},
selectField:{
width: "100%",
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit
marginTop: theme.spacing(2),
marginBottom: theme.spacing(0.5)
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -45,7 +45,7 @@ class APSettingsForm extends React.Component {
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
@ -65,7 +65,7 @@ class APSettingsForm extends React.Component {
isAPEnabled(apSettings.provision_mode) &&
<Fragment>
<TextValidator
validators={['required', 'matchRegexp:^.{0,32}$']}
validators={['required', 'matchRegexp:^.{1,32}$']}
errorMessages={['Access Point SSID is required', 'Access Point SSID must be 32 characeters or less']}
name="ssid"
label="Access Point SSID"
@ -75,7 +75,7 @@ class APSettingsForm extends React.Component {
margin="normal"
/>
<PasswordValidator
validators={['required', 'matchRegexp:^.{0,64}$']}
validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['Access Point Password is required', 'Access Point Password must be 64 characters or less']}
name="password"
label="Access Point Password"
@ -99,7 +99,7 @@ class APSettingsForm extends React.Component {
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>

View File

@ -0,0 +1,246 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { ValidatorForm } from 'react-material-ui-form-validator';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import LinearProgress from '@material-ui/core/LinearProgress';
import Typography from '@material-ui/core/Typography';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableFooter from '@material-ui/core/TableFooter';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Box from '@material-ui/core/Box';
import EditIcon from '@material-ui/icons/Edit';
import DeleteIcon from '@material-ui/icons/Delete';
import CloseIcon from '@material-ui/icons/Close';
import CheckIcon from '@material-ui/icons/Check';
import IconButton from '@material-ui/core/IconButton';
import UserForm from './UserForm';
import { withAuthenticationContext } from '../authentication/Context';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
},
table: {
'& td, & th': { padding: theme.spacing(0.5) }
},
actions: {
whiteSpace: "nowrap"
}
});
function compareUsers(a, b) {
if (a.username < b.username) {
return -1;
}
if (a.username > b.username) {
return 1;
}
return 0;
}
class ManageUsersForm extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
createUser = () => {
this.setState({
creating: true,
user: {
username: "",
password: "",
admin: true
}
});
};
uniqueUsername = username => {
return !this.props.userData.users.find(u => u.username === username);
}
noAdminConfigured = () => {
return !this.props.userData.users.find(u => u.admin);
}
removeUser = user => {
const { userData } = this.props;
const users = userData.users.filter(u => u.username !== user.username);
this.props.setData({ ...userData, users });
}
startEditingUser = user => {
this.setState({
creating: false,
user
});
};
cancelEditingUser = () => {
this.setState({
user: undefined
});
}
doneEditingUser = () => {
const { user } = this.state;
const { userData } = this.props;
const users = userData.users.filter(u => u.username !== user.username);
users.push(user);
this.props.setData({ ...userData, users });
this.setState({
user: undefined
});
};
handleUserValueChange = name => event => {
const { user } = this.state;
this.setState({
user: {
...user, [name]: event.target.value
}
});
};
handleUserCheckboxChange = name => event => {
const { user } = this.state;
this.setState({
user: {
...user, [name]: event.target.checked
}
});
}
onSubmit = () => {
this.props.onSubmit();
this.props.authenticationContext.refresh();
}
render() {
const { classes, userData, userDataFetched, errorMessage, onReset } = this.props;
const { user, creating } = this.state;
return (
!userDataFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h4" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
:
userData ?
<Fragment>
<ValidatorForm onSubmit={this.onSubmit}>
<Table className={classes.table}>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell align="center">Admin?</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{userData.users.sort(compareUsers).map(user => (
<TableRow key={user.username}>
<TableCell component="th" scope="row">
{user.username}
</TableCell>
<TableCell align="center">
{
user.admin ? <CheckIcon /> : <CloseIcon />
}
</TableCell>
<TableCell align="center">
<IconButton aria-label="Delete" onClick={() => this.removeUser(user)}>
<DeleteIcon />
</IconButton>
<IconButton aria-label="Edit" onClick={() => this.startEditingUser(user)}>
<EditIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="center">
<Button variant="contained" color="secondary" onClick={this.createUser}>
Add User
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
{
this.noAdminConfigured() &&
<Typography component="div" variant="body1">
<Box bgcolor="error.main" color="error.contrastText" p={2} mt={2} mb={2}>
You must have at least one admin user configured.
</Box>
</Typography>
}
<Button variant="contained" color="primary" className={classes.button} type="submit" disabled={this.noAdminConfigured()}>
Save
</Button>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
{
user &&
<UserForm
user={user}
creating={creating}
onDoneEditing={this.doneEditingUser}
onCancelEditing={this.cancelEditingUser}
handleValueChange={this.handleUserValueChange}
handleCheckboxChange={this.handleUserCheckboxChange}
uniqueUsername={this.uniqueUsername}
/>
}
</Fragment>
:
<div className={classes.loadingSettings}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
);
}
}
ManageUsersForm.propTypes = {
classes: PropTypes.object.isRequired,
userData: PropTypes.object,
userDataFetched: PropTypes.bool.isRequired,
errorMessage: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
setData: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired,
authenticationContext: PropTypes.object.isRequired,
};
export default withAuthenticationContext(withStyles(styles)(ManageUsersForm));

View File

@ -13,18 +13,18 @@ import or from '../validators/or';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
textField: {
width: "100%"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -43,7 +43,7 @@ class NTPSettingsForm extends React.Component {
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
@ -87,7 +87,7 @@ class NTPSettingsForm extends React.Component {
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>

View File

@ -16,23 +16,23 @@ import PasswordValidator from '../components/PasswordValidator';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
switchControl: {
width: "100%",
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit
marginTop: theme.spacing(2),
marginBottom: theme.spacing(0.5)
},
textField: {
width: "100%"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -51,7 +51,7 @@ class OTASettingsForm extends React.Component {
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
@ -85,7 +85,7 @@ class OTASettingsForm extends React.Component {
/>
<PasswordValidator
validators={['required', 'matchRegexp:^.{0,64}$']}
validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['OTA Password is required', 'OTA Point Password must be 64 characters or less']}
name="password"
label="Password"
@ -107,7 +107,7 @@ class OTASettingsForm extends React.Component {
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>

View File

@ -0,0 +1,97 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ValidatorForm } from 'react-material-ui-form-validator';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import LinearProgress from '@material-ui/core/LinearProgress';
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';
import PasswordValidator from '../components/PasswordValidator';
import { withAuthenticationContext } from '../authentication/Context';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: "center"
},
textField: {
width: "100%"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
class SecuritySettingsForm extends React.Component {
onSubmit = () => {
this.props.onSubmit();
this.props.authenticationContext.refresh();
}
render() {
const { classes, securitySettingsFetched, securitySettings, errorMessage, handleValueChange, onReset } = this.props;
return (
!securitySettingsFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h4" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
:
securitySettings ?
<ValidatorForm onSubmit={this.onSubmit} ref="SecuritySettingsForm">
<PasswordValidator
validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['JWT Secret Required', 'JWT Secret must be 64 characters or less']}
name="jwt_secret"
label="JWT Secret"
className={classes.textField}
value={securitySettings.jwt_secret}
onChange={handleValueChange('jwt_secret')}
margin="normal"
/>
<Typography component="div" variant="body1">
<Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}>
If you modify the JWT Secret, all users will be logged out.
</Box>
</Typography>
<Button variant="contained" color="primary" className={classes.button} type="submit">
Save
</Button>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
:
<div className={classes.loadingSettings}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
);
}
}
SecuritySettingsForm.propTypes = {
classes: PropTypes.object.isRequired,
securitySettingsFetched: PropTypes.bool.isRequired,
securitySettings: PropTypes.object,
errorMessage: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired,
authenticationContext: PropTypes.object.isRequired,
};
export default withAuthenticationContext(withStyles(styles)(SecuritySettingsForm));

View File

@ -0,0 +1,102 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Switch from '@material-ui/core/Switch';
import FormGroup from '@material-ui/core/FormGroup';
import DialogTitle from '@material-ui/core/DialogTitle';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogActions from '@material-ui/core/DialogActions';
import PasswordValidator from '../components/PasswordValidator';
const styles = theme => ({
textField: {
width: "100%"
},
button: {
margin: theme.spacing(0.5)
}
});
class UserForm extends React.Component {
constructor(props) {
super(props);
this.formRef = React.createRef();
}
componentWillMount() {
ValidatorForm.addValidationRule('uniqueUsername', this.props.uniqueUsername);
}
submit = () => {
this.formRef.current.submit();
}
render() {
const { classes, user, creating, handleValueChange, handleCheckboxChange, onDoneEditing, onCancelEditing } = this.props;
return (
<ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}>
<Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open={true}>
<DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle>
<DialogContent dividers={true}>
<TextValidator
validators={creating ? ['required', 'uniqueUsername', 'matchRegexp:^[a-zA-Z0-9_\\.]{1,24}$'] : []}
errorMessages={creating ? ['Username is required', "Username already exists", "Must be 1-24 characters: alpha numeric, '_' or '.'"] : []}
name="username"
label="Username"
className={classes.textField}
value={user.username}
disabled={!creating}
onChange={handleValueChange('username')}
margin="normal"
/>
<PasswordValidator
validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['Password is required', 'Password must be 64 characters or less']}
name="password"
label="Password"
className={classes.textField}
value={user.password}
onChange={handleValueChange('password')}
margin="normal"
/>
<FormGroup>
<FormControlLabel
control={<Switch checked={user.admin} onChange={handleCheckboxChange('admin')} id="admin" />}
label="Admin?"
/>
</FormGroup>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" className={classes.button} type="submit" onClick={this.submit}>
Done
</Button>
<Button variant="contained" color="secondary" className={classes.button} type="submit" onClick={onCancelEditing}>
Cancel
</Button>
</DialogActions>
</Dialog>
</ValidatorForm>
);
}
}
UserForm.propTypes = {
classes: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
creating: PropTypes.bool.isRequired,
onDoneEditing: PropTypes.func.isRequired,
onCancelEditing: PropTypes.func.isRequired,
uniqueUsername: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired,
handleCheckboxChange: PropTypes.func.isRequired
};
export default withStyles(styles)(UserForm);

View File

@ -23,12 +23,12 @@ import { isNetworkOpen, networkSecurityMode } from '../constants/WiFiSecurityMod
const styles = theme => ({
scanningProgress: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -69,7 +69,7 @@ class WiFiNetworkSelector extends Component {
scanningForNetworks ?
<div>
<LinearProgress className={classes.scanningProgress}/>
<Typography variant="display1" className={classes.scanningProgress}>
<Typography variant="h4" className={classes.scanningProgress}>
Scanning...
</Typography>
</div>
@ -80,7 +80,7 @@ class WiFiNetworkSelector extends Component {
</List>
:
<div>
<Typography variant="display1" className={classes.scanningProgress}>
<Typography variant="h4" className={classes.scanningProgress}>
{errorMessage}
</Typography>
</div>

View File

@ -1,4 +1,4 @@
import React, {Fragment} from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
@ -29,10 +29,10 @@ import PasswordValidator from '../components/PasswordValidator';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
margin: theme.spacing(4),
textAlign: "center"
},
textField: {
@ -42,8 +42,8 @@ const styles = theme => ({
width: "100%"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
@ -67,7 +67,7 @@ class WiFiSettingsForm extends React.Component {
</ListItemAvatar>
<ListItemText
primary={selectedNetwork.ssid}
secondary={"Security: "+ networkSecurityMode(selectedNetwork) + ", Ch: " + selectedNetwork.channel}
secondary={"Security: " + networkSecurityMode(selectedNetwork) + ", Ch: " + selectedNetwork.channel}
/>
<ListItemSecondaryAction>
<IconButton aria-label="Manual Config" onClick={deselectNetwork}>
@ -84,57 +84,57 @@ class WiFiSettingsForm extends React.Component {
return (
<div>
{
!wifiSettingsFetched ?
!wifiSettingsFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h4" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
: wifiSettings ?
: wifiSettings ?
<ValidatorForm onSubmit={onSubmit} ref="WiFiSettingsForm">
{
selectedNetwork ? this.renderSelectedNetwork() :
<TextValidator
validators={['required', 'matchRegexp:^.{0,32}$']}
errorMessages={['SSID is required', 'SSID must be 32 characeters or less']}
name="ssid"
label="SSID"
className={classes.textField}
value={wifiSettings.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
}
{
!isNetworkOpen(selectedNetwork) &&
<PasswordValidator
validators={['matchRegexp:^.{0,64}$']}
errorMessages={['Password must be 64 characters or less']}
name="password"
label="Password"
className={classes.textField}
value={wifiSettings.password}
onChange={handleValueChange('password')}
margin="normal"
/>
}
<TextValidator
validators={['required', 'isHostname']}
errorMessages={['Hostname is required', "Not a valid hostname"]}
name="hostname"
label="Hostname"
<ValidatorForm onSubmit={onSubmit} ref="WiFiSettingsForm">
{
selectedNetwork ? this.renderSelectedNetwork() :
<TextValidator
validators={['required', 'matchRegexp:^.{1,32}$']}
errorMessages={['SSID is required', 'SSID must be 32 characeters or less']}
name="ssid"
label="SSID"
className={classes.textField}
value={wifiSettings.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
}
{
!isNetworkOpen(selectedNetwork) &&
<PasswordValidator
validators={['matchRegexp:^.{0,64}$']}
errorMessages={['Password must be 64 characters or less']}
name="password"
label="Password"
className={classes.textField}
value={wifiSettings.hostname}
onChange={handleValueChange('hostname')}
value={wifiSettings.password}
onChange={handleValueChange('password')}
margin="normal"
/>
}
<FormControlLabel className={classes.checkboxControl}
<TextValidator
validators={['required', 'isHostname']}
errorMessages={['Hostname is required', "Not a valid hostname"]}
name="hostname"
label="Hostname"
className={classes.textField}
value={wifiSettings.hostname}
onChange={handleValueChange('hostname')}
margin="normal"
/>
<FormControlLabel className={classes.checkboxControl}
control={
<Checkbox
value="static_ip_config"
@ -145,82 +145,82 @@ class WiFiSettingsForm extends React.Component {
label="Static IP Config?"
/>
{
wifiSettings.static_ip_config &&
<Fragment>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Local IP is required', 'Must be an IP address']}
name="local_ip"
label="Local IP"
className={classes.textField}
value={wifiSettings.local_ip}
onChange={handleValueChange('local_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Gateway IP is required', 'Must be an IP address']}
name="gateway_ip"
label="Gateway"
className={classes.textField}
value={wifiSettings.gateway_ip}
onChange={handleValueChange('gateway_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Subnet mask is required', 'Must be an IP address']}
name="subnet_mask"
label="Subnet"
className={classes.textField}
value={wifiSettings.subnet_mask}
onChange={handleValueChange('subnet_mask')}
margin="normal"
/>
<TextValidator
validators={['isOptionalIP']}
errorMessages={['Must be an IP address']}
name="dns_ip_1"
label="DNS IP #1"
className={classes.textField}
value={wifiSettings.dns_ip_1}
onChange={handleValueChange('dns_ip_1')}
margin="normal"
/>
<TextValidator
validators={['isOptionalIP']}
errorMessages={['Must be an IP address']}
name="dns_ip_2"
label="DNS IP #2"
className={classes.textField}
value={wifiSettings.dns_ip_2}
onChange={handleValueChange('dns_ip_2')}
margin="normal"
/>
</Fragment>
}
{
wifiSettings.static_ip_config &&
<Fragment>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Local IP is required', 'Must be an IP address']}
name="local_ip"
label="Local IP"
className={classes.textField}
value={wifiSettings.local_ip}
onChange={handleValueChange('local_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Gateway IP is required', 'Must be an IP address']}
name="gateway_ip"
label="Gateway"
className={classes.textField}
value={wifiSettings.gateway_ip}
onChange={handleValueChange('gateway_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Subnet mask is required', 'Must be an IP address']}
name="subnet_mask"
label="Subnet"
className={classes.textField}
value={wifiSettings.subnet_mask}
onChange={handleValueChange('subnet_mask')}
margin="normal"
/>
<TextValidator
validators={['isOptionalIP']}
errorMessages={['Must be an IP address']}
name="dns_ip_1"
label="DNS IP #1"
className={classes.textField}
value={wifiSettings.dns_ip_1}
onChange={handleValueChange('dns_ip_1')}
margin="normal"
/>
<TextValidator
validators={['isOptionalIP']}
errorMessages={['Must be an IP address']}
name="dns_ip_2"
label="DNS IP #2"
className={classes.textField}
value={wifiSettings.dns_ip_2}
onChange={handleValueChange('dns_ip_2')}
margin="normal"
/>
</Fragment>
}
<Button variant="contained" color="primary" className={classes.button} type="submit">
Save
<Button variant="contained" color="primary" className={classes.button} type="submit">
Save
</Button>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
</ValidatorForm>
:
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
<div className={classes.loadingSettings}>
<Typography variant="h4" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
}
</div>
}
</div>
);
}

View File

@ -2,15 +2,12 @@ import React from 'react';
import { render } from 'react-dom';
import history from './history';
import { Router, Route, Redirect, Switch } from 'react-router';
import { Router } from 'react-router';
import App from './App';
render((
<Router history={history}>
<Switch>
<Redirect exact from='/' to='/home'/>
<Route path="/" component={App} />
</Switch>
<App/>
</Router>
), document.getElementById("root"))

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import { Redirect, Switch } from 'react-router-dom'
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import AuthenticatedRoute from '../authentication/AuthenticatedRoute';
import MenuAppBar from '../components/MenuAppBar';
import APSettings from '../containers/APSettings';
import APStatus from '../containers/APStatus';
import { withAuthenticationContext } from '../authentication/Context.js';
class AccessPoint extends Component {
handleTabChange = (event, path) => {
this.props.history.push(path);
};
render() {
const { authenticationContext } = this.props;
return (
<MenuAppBar sectionTitle="Access Point">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth">
<Tab value="/ap/status" label="Access Point Status" />
<Tab value="/ap/settings" label="Access Point Settings" disabled={!authenticationContext.isAdmin()} />
</Tabs>
<Switch>
<AuthenticatedRoute exact={true} path="/ap/status" component={APStatus} />
<AuthenticatedRoute exact={true} path="/ap/settings" component={APSettings} />
<Redirect to="/ap/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticationContext(AccessPoint);

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import { Redirect, Switch } from 'react-router-dom'
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import AuthenticatedRoute from '../authentication/AuthenticatedRoute';
import MenuAppBar from '../components/MenuAppBar';
import NTPSettings from '../containers/NTPSettings';
import NTPStatus from '../containers/NTPStatus';
import { withAuthenticationContext } from '../authentication/Context.js';
class NetworkTime extends Component {
handleTabChange = (event, path) => {
this.props.history.push(path);
};
render() {
const { authenticationContext } = this.props;
return (
<MenuAppBar sectionTitle="Network Time">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth">
<Tab value="/ntp/status" label="NTP Status" />
<Tab value="/ntp/settings" label="NTP Settings" disabled={!authenticationContext.isAdmin()} />
</Tabs>
<Switch>
<AuthenticatedRoute exact={true} path="/ntp/status" component={NTPStatus} />
<AuthenticatedRoute exact={true} path="/ntp/settings" component={NTPSettings} />
<Redirect to="/ntp/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticationContext(NetworkTime)

View File

@ -0,0 +1,35 @@
import React, { Component } from 'react';
import { Redirect, Switch } from 'react-router-dom'
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import AuthenticatedRoute from '../authentication/AuthenticatedRoute';
import MenuAppBar from '../components/MenuAppBar';
import ManageUsers from '../containers/ManageUsers';
import SecuritySettings from '../containers/SecuritySettings';
class Security extends Component {
handleTabChange = (event, path) => {
this.props.history.push(path);
};
render() {
return (
<MenuAppBar sectionTitle="Security">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth">
<Tab value="/security/users" label="Manage Users" />
<Tab value="/security/settings" label="Security Settings" />
</Tabs>
<Switch>
<AuthenticatedRoute exact={true} path="/security/users" component={ManageUsers} />
<AuthenticatedRoute exact={true} path="/security/settings" component={SecuritySettings} />
<Redirect to="/security/users" />
</Switch>
</MenuAppBar>
)
}
}
export default Security;

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import { Redirect, Switch } from 'react-router-dom'
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import AuthenticatedRoute from '../authentication/AuthenticatedRoute';
import MenuAppBar from '../components/MenuAppBar';
import OTASettings from '../containers/OTASettings';
import SystemStatus from '../containers/SystemStatus';
import { withAuthenticationContext } from '../authentication/Context.js';
class System extends Component {
handleTabChange = (event, path) => {
this.props.history.push(path);
};
render() {
const { authenticationContext } = this.props;
return (
<MenuAppBar sectionTitle="System">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth">
<Tab value="/system/status" label="System Status" />
<Tab value="/system/ota" label="OTA Settings" disabled={!authenticationContext.isAdmin()} />
</Tabs>
<Switch>
<AuthenticatedRoute exact={true} path="/system/status" component={SystemStatus} />
<AuthenticatedRoute exact={true} path="/system/ota" component={OTASettings} />
<Redirect to="/system/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticationContext(System);

View File

@ -0,0 +1,74 @@
import React, { Component } from 'react';
import { Redirect, Switch } from 'react-router-dom'
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import AuthenticatedRoute from '../authentication/AuthenticatedRoute';
import MenuAppBar from '../components/MenuAppBar';
import WiFiNetworkScanner from '../containers/WiFiNetworkScanner';
import WiFiSettings from '../containers/WiFiSettings';
import WiFiStatus from '../containers/WiFiStatus';
import { withAuthenticationContext } from '../authentication/Context.js';
class WiFiConnection extends Component {
constructor(props) {
super(props);
this.state = {
selectedNetwork: null
};
this.selectNetwork = this.selectNetwork.bind(this);
this.deselectNetwork = this.deselectNetwork.bind(this);
}
selectNetwork(network) {
this.setState({ selectedNetwork: network });
this.props.history.push('/wifi/settings');
}
deselectNetwork(network) {
this.setState({ selectedNetwork: null });
}
handleTabChange = (event, path) => {
this.props.history.push(path);
};
render() {
const { authenticationContext } = this.props;
const ConfiguredWiFiNetworkScanner = (props) => {
return (
<WiFiNetworkScanner
selectNetwork={this.selectNetwork}
{...props}
/>
);
};
const ConfiguredWiFiSettings = (props) => {
return (
<WiFiSettings
deselectNetwork={this.deselectNetwork} selectedNetwork={this.state.selectedNetwork}
{...props}
/>
);
};
return (
<MenuAppBar sectionTitle="WiFi Connection">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth">
<Tab value="/wifi/status" label="WiFi Status" />
<Tab value="/wifi/scan" label="Scan Networks" disabled={!authenticationContext.isAdmin()} />
<Tab value="/wifi/settings" label="WiFi Settings" disabled={!authenticationContext.isAdmin()} />
</Tabs>
<Switch>
<AuthenticatedRoute exact={true} path="/wifi/status" component={WiFiStatus} />
<AuthenticatedRoute exact={true} path="/wifi/scan" component={ConfiguredWiFiNetworkScanner} />
<AuthenticatedRoute exact={true} path="/wifi/settings" component={ConfiguredWiFiSettings} />
<Redirect to="/wifi/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticationContext(WiFiConnection);

BIN
media/build.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
media/esp12e.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
media/esp32.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
media/screenshots.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
media/uploadfs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
media/uploadfw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -15,13 +15,19 @@ board_build.f_cpu = 160000000L
extra_scripts = pre:timelib_fix.py
framework = arduino
;upload_flags = --port=8266 --auth=esp-react
;upload_port = 192.168.0.6
monitor_speed = 115200
; Uncomment & modify the lines below in order to configure OTA updates
;upload_flags =
; --port=8266
; --auth=esp-react
;upload_port = 192.168.0.11
build_flags=
-D NO_GLOBAL_ARDUINOOTA
; -D ENABLE_CORS
; Uncomment ENABLE_CORS to enable Cross-Origin Resource Sharing (required for local React development)
;-D ENABLE_CORS
-D CORS_ORIGIN=\"http://localhost:3000\"
lib_deps =
NtpClientLib@>=2.5.1,<3.0.0
ArduinoJson@>=6.0.0,<7.0.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@ -1,6 +1,6 @@
#include <APSettingsService.h>
APSettingsService::APSettingsService(AsyncWebServer* server, FS* fs) : SettingsService(server, fs, AP_SETTINGS_SERVICE_PATH, AP_SETTINGS_FILE) {
APSettingsService::APSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : AdminSettingsService(server, fs, securityManager, AP_SETTINGS_SERVICE_PATH, AP_SETTINGS_FILE) {
onConfigUpdated();
}
@ -46,7 +46,7 @@ void APSettingsService::stopAP() {
Serial.println("Stopping captive portal");
_dnsServer->stop();
delete _dnsServer;
_dnsServer = NULL;
_dnsServer = nullptr;
}
Serial.println("Stopping software access point");
WiFi.softAPdisconnect(true);

View File

@ -19,11 +19,11 @@
#define AP_SETTINGS_FILE "/config/apSettings.json"
#define AP_SETTINGS_SERVICE_PATH "/rest/apSettings"
class APSettingsService : public SettingsService {
class APSettingsService : public AdminSettingsService {
public:
APSettingsService(AsyncWebServer* server, FS* fs);
APSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager);
~APSettingsService();
void loop();

View File

@ -1,7 +1,9 @@
#include <APStatus.h>
APStatus::APStatus(AsyncWebServer *server) : _server(server) {
_server->on(AP_STATUS_SERVICE_PATH, HTTP_GET, std::bind(&APStatus::apStatus, this, std::placeholders::_1));
APStatus::APStatus(AsyncWebServer *server, SecurityManager* securityManager) : _server(server), _securityManager(securityManager) {
_server->on(AP_STATUS_SERVICE_PATH, HTTP_GET,
_securityManager->wrapRequest(std::bind(&APStatus::apStatus, this, std::placeholders::_1), AuthenticationPredicates::IS_AUTHENTICATED)
);
}
void APStatus::apStatus(AsyncWebServerRequest *request) {

View File

@ -13,6 +13,7 @@
#include <ArduinoJson.h>
#include <AsyncArduinoJson6.h>
#include <IPAddress.h>
#include <SecurityManager.h>
#define MAX_AP_STATUS_SIZE 1024
#define AP_STATUS_SERVICE_PATH "/rest/apStatus"
@ -21,11 +22,12 @@ class APStatus {
public:
APStatus(AsyncWebServer *server);
APStatus(AsyncWebServer *server, SecurityManager* securityManager);
private:
AsyncWebServer* _server;
SecurityManager* _securityManager;
void apStatus(AsyncWebServerRequest *request);

143
src/ArduinoJsonJWT.cpp Normal file
View File

@ -0,0 +1,143 @@
#include "ArduinoJsonJWT.h"
ArduinoJsonJWT::ArduinoJsonJWT(String secret) : _secret(secret) { }
void ArduinoJsonJWT::setSecret(String secret){
_secret = secret;
}
String ArduinoJsonJWT::getSecret(){
return _secret;
}
/*
* ESP32 uses mbedtls, ESP2866 uses bearssl.
*
* Both come with decent HMAC implmentations supporting sha256, as well as others.
*
* No need to pull in additional crypto libraries - lets use what we already have.
*/
String ArduinoJsonJWT::sign(String &payload) {
unsigned char hmacResult[32];
{
#if defined(ESP_PLATFORM)
mbedtls_md_context_t ctx;
mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 1);
mbedtls_md_hmac_starts(&ctx, (unsigned char *) _secret.c_str(), _secret.length());
mbedtls_md_hmac_update(&ctx, (unsigned char *) payload.c_str(), payload.length());
mbedtls_md_hmac_finish(&ctx, hmacResult);
mbedtls_md_free(&ctx);
#else
br_hmac_key_context keyCtx;
br_hmac_key_init(&keyCtx, &br_sha256_vtable, _secret.c_str(), _secret.length());
br_hmac_context hmacCtx;
br_hmac_init(&hmacCtx, &keyCtx, 0);
br_hmac_update(&hmacCtx, payload.c_str(), payload.length());
br_hmac_out(&hmacCtx, hmacResult);
#endif
}
return encode((char *) hmacResult, 32);
}
String ArduinoJsonJWT::buildJWT(JsonObject &payload) {
// serialize, then encode payload
String jwt;
serializeJson(payload, jwt);
jwt = encode(jwt.c_str(), jwt.length());
// add the header to payload
jwt = JWT_HEADER + '.' + jwt;
// add signature
jwt += '.' + sign(jwt);
return jwt;
}
void ArduinoJsonJWT::parseJWT(String jwt, JsonDocument &jsonDocument) {
// clear json document before we begin, jsonDocument wil be null on failure
jsonDocument.clear();
// must have the correct header and delimiter
if (!jwt.startsWith(JWT_HEADER) || jwt.indexOf('.') != JWT_HEADER_SIZE) {
return;
}
// check there is a signature delimieter
int signatureDelimiterIndex = jwt.lastIndexOf('.');
if (signatureDelimiterIndex == JWT_HEADER_SIZE) {
return;
}
// check the signature is valid
String signature = jwt.substring(signatureDelimiterIndex + 1);
jwt = jwt.substring(0, signatureDelimiterIndex);
if (sign(jwt) != signature){
return;
}
// decode payload
jwt = jwt.substring(JWT_HEADER_SIZE + 1);
jwt = decode(jwt);
// parse payload, clearing json document after failure
DeserializationError error = deserializeJson(jsonDocument, jwt);
if (error != DeserializationError::Ok || !jsonDocument.is<JsonObject>()){
jsonDocument.clear();
}
}
String ArduinoJsonJWT::encode(const char *cstr, int inputLen) {
// prepare encoder
base64_encodestate _state;
#if defined(ESP8266)
base64_init_encodestate_nonewlines(&_state);
size_t encodedLength = base64_encode_expected_len_nonewlines(inputLen) + 1;
#elif defined(ESP_PLATFORM)
base64_init_encodestate(&_state);
size_t encodedLength = base64_encode_expected_len(inputLen) + 1;
#endif
// prepare buffer of correct length, returning an empty string on failure
char* buffer = (char*) malloc(encodedLength * sizeof(char));
if (buffer == nullptr) {
return "";
}
// encode to buffer
int len = base64_encode_block(cstr, inputLen, &buffer[0], &_state);
len += base64_encode_blockend(&buffer[len], &_state);
buffer[len] = 0;
// convert to arduino string, freeing buffer
String value = String(buffer);
free(buffer);
buffer=nullptr;
// remove padding and convert to URL safe form
while (value.length() > 0 && value.charAt(value.length() - 1) == '='){
value.remove(value.length() - 1);
}
value.replace('+', '-');
value.replace('/', '_');
// return as string
return value;
}
String ArduinoJsonJWT::decode(String value) {
// convert to standard base64
value.replace('-', '+');
value.replace( '_', '/');
// prepare buffer of correct length
char buffer[base64_decode_expected_len(value.length()) + 1];
// decode
int len = base64_decode_chars(value.c_str(), value.length(), &buffer[0]);
buffer[len] = 0;
// return as string
return String(buffer);
}

38
src/ArduinoJsonJWT.h Normal file
View File

@ -0,0 +1,38 @@
#ifndef ArduinoJsonJWT_H
#define ArduinoJsonJWT_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <libb64/cdecode.h>
#include <libb64/cencode.h>
#if defined(ESP_PLATFORM)
#include <mbedtls/md.h>
#else
#include <bearssl/bearssl_hmac.h>
#endif
class ArduinoJsonJWT {
private:
String _secret;
const String JWT_HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const size_t JWT_HEADER_SIZE = JWT_HEADER.length();
String sign(String &value);
static String encode(const char *cstr, int len);
static String decode(String value);
public:
ArduinoJsonJWT(String secret);
void setSecret(String secret);
String getSecret();
String buildJWT(JsonObject &payload);
void parseJWT(String jwt, JsonDocument &jsonDocument);
};
#endif

View File

@ -109,7 +109,7 @@ public:
}
virtual void handleRequest(AsyncWebServerRequest *request) override final {
if(_onRequest) {
if (request->_tempObject != NULL) {
if (request->_tempObject != nullptr) {
DynamicJsonDocument _jsonDocument(_maxContentLength);
DeserializationError err = deserializeJson(_jsonDocument, (uint8_t*)(request->_tempObject));
if (err == DeserializationError::Ok) {
@ -127,10 +127,10 @@ public:
virtual void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override final {
if (_onRequest) {
_contentLength = total;
if (total > 0 && request->_tempObject == NULL && total < _maxContentLength) {
if (total > 0 && request->_tempObject == nullptr && total < _maxContentLength) {
request->_tempObject = malloc(total);
}
if (request->_tempObject != NULL) {
if (request->_tempObject != nullptr) {
memcpy((uint8_t*)(request->_tempObject) + index, data, len);
}
}

View File

@ -1,6 +1,7 @@
#ifndef Async_Json_Request_Web_Handler_H_
#define Async_Json_Request_Web_Handler_H_
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#define ASYNC_JSON_REQUEST_DEFAULT_MAX_SIZE 1024
@ -16,24 +17,25 @@
typedef std::function<void(AsyncWebServerRequest *request, JsonDocument &jsonDocument)> JsonRequestCallback;
class AsyncJsonRequestWebHandler: public AsyncWebHandler {
class AsyncJsonWebHandler: public AsyncWebHandler {
private:
String _uri;
WebRequestMethodComposite _method;
JsonRequestCallback _onRequest;
size_t _maxContentLength;
protected:
String _uri;
public:
AsyncJsonRequestWebHandler() :
_uri(),
AsyncJsonWebHandler() :
_method(HTTP_POST|HTTP_PUT|HTTP_PATCH),
_onRequest(NULL),
_maxContentLength(ASYNC_JSON_REQUEST_DEFAULT_MAX_SIZE) {}
_onRequest(nullptr),
_maxContentLength(ASYNC_JSON_REQUEST_DEFAULT_MAX_SIZE),
_uri() {}
~AsyncJsonRequestWebHandler() {}
~AsyncJsonWebHandler() {}
void setUri(const String& uri) { _uri = uri; }
void setMethod(WebRequestMethodComposite method) { _method = method; }
@ -60,7 +62,9 @@ class AsyncJsonRequestWebHandler: public AsyncWebHandler {
virtual void handleRequest(AsyncWebServerRequest *request) override final {
// no request configured
if(!_onRequest) {
request->send(404);
Serial.print("No request callback was configured for endpoint: ");
Serial.println(_uri);
request->send(500);
return;
}

View File

@ -0,0 +1,45 @@
#include <AuthenticationService.h>
AuthenticationService::AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager):
_server(server), _securityManager(securityManager) {
server->on(VERIFY_AUTHORIZATION_PATH, HTTP_GET, std::bind(&AuthenticationService::verifyAuthorization, this, std::placeholders::_1));
_signInHandler.setUri(SIGN_IN_PATH);
_signInHandler.setMethod(HTTP_POST);
_signInHandler.setMaxContentLength(MAX_AUTHENTICATION_SIZE);
_signInHandler.onRequest(std::bind(&AuthenticationService::signIn, this, std::placeholders::_1, std::placeholders::_2));
server->addHandler(&_signInHandler);
}
AuthenticationService::~AuthenticationService() {}
/**
* Verifys that the request supplied a valid JWT.
*/
void AuthenticationService::verifyAuthorization(AsyncWebServerRequest *request) {
Authentication authentication = _securityManager->authenticateRequest(request);
request->send(authentication.isAuthenticated() ? 200: 401);
}
/**
* Signs in a user if the username and password match. Provides a JWT to be used in the Authorization header in subsequent requests.
*/
void AuthenticationService::signIn(AsyncWebServerRequest *request, JsonDocument &jsonDocument){
if (jsonDocument.is<JsonObject>()) {
String username = jsonDocument["username"];
String password = jsonDocument["password"];
Authentication authentication = _securityManager->authenticate(username, password);
if (authentication.isAuthenticated()) {
User* user = authentication.getUser();
AsyncJsonResponse * response = new AsyncJsonResponse(MAX_AUTHENTICATION_SIZE);
JsonObject jsonObject = response->getRoot();
jsonObject["access_token"] = _securityManager->generateJWT(user);
response->setLength();
request->send(response);
return;
}
}
AsyncWebServerResponse *response = request->beginResponse(401);
request->send(response);
}

View File

@ -0,0 +1,33 @@
#ifndef AuthenticationService_H_
#define AuthenticationService_H_
#include <SecurityManager.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJsonWebHandler.h>
#include <AsyncArduinoJson6.h>
#define VERIFY_AUTHORIZATION_PATH "/rest/verifyAuthorization"
#define SIGN_IN_PATH "/rest/signIn"
#define MAX_AUTHENTICATION_SIZE 256
class AuthenticationService {
public:
AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager) ;
~AuthenticationService();
private:
// server instance
AsyncWebServer* _server;
SecurityManager* _securityManager;
AsyncJsonWebHandler _signInHandler;
// endpoint functions
void signIn(AsyncWebServerRequest *request, JsonDocument &jsonDocument);
void verifyAuthorization(AsyncWebServerRequest *request);
};
#endif // end SecurityManager_h

View File

@ -1,6 +1,6 @@
#include <NTPSettingsService.h>
NTPSettingsService::NTPSettingsService(AsyncWebServer* server, FS* fs) : SettingsService(server, fs, NTP_SETTINGS_SERVICE_PATH, NTP_SETTINGS_FILE) {
NTPSettingsService::NTPSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : AdminSettingsService(server, fs, securityManager, NTP_SETTINGS_SERVICE_PATH, NTP_SETTINGS_FILE) {
#if defined(ESP8266)
_onStationModeDisconnectedHandler = WiFi.onStationModeDisconnected(std::bind(&NTPSettingsService::onStationModeDisconnected, this, std::placeholders::_1));

View File

@ -17,11 +17,11 @@
#define NTP_SETTINGS_FILE "/config/ntpSettings.json"
#define NTP_SETTINGS_SERVICE_PATH "/rest/ntpSettings"
class NTPSettingsService : public SettingsService {
class NTPSettingsService : public AdminSettingsService {
public:
NTPSettingsService(AsyncWebServer* server, FS* fs);
NTPSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager);
~NTPSettingsService();
void loop();

View File

@ -1,7 +1,9 @@
#include <NTPStatus.h>
NTPStatus::NTPStatus(AsyncWebServer *server) : _server(server) {
_server->on(NTP_STATUS_SERVICE_PATH, HTTP_GET, std::bind(&NTPStatus::ntpStatus, this, std::placeholders::_1));
NTPStatus::NTPStatus(AsyncWebServer *server, SecurityManager* securityManager) : _server(server), _securityManager(securityManager) {
_server->on(NTP_STATUS_SERVICE_PATH, HTTP_GET,
_securityManager->wrapRequest(std::bind(&NTPStatus::ntpStatus, this, std::placeholders::_1), AuthenticationPredicates::IS_AUTHENTICATED)
);
}
void NTPStatus::ntpStatus(AsyncWebServerRequest *request) {

View File

@ -14,6 +14,7 @@
#include <AsyncArduinoJson6.h>
#include <TimeLib.h>
#include <NtpClientLib.h>
#include <SecurityManager.h>
#define MAX_NTP_STATUS_SIZE 1024
#define NTP_STATUS_SERVICE_PATH "/rest/ntpStatus"
@ -22,11 +23,12 @@ class NTPStatus {
public:
NTPStatus(AsyncWebServer *server);
NTPStatus(AsyncWebServer *server, SecurityManager* securityManager);
private:
AsyncWebServer* _server;
SecurityManager* _securityManager;
void ntpStatus(AsyncWebServerRequest *request);

View File

@ -1,6 +1,6 @@
#include <OTASettingsService.h>
OTASettingsService::OTASettingsService(AsyncWebServer* server, FS* fs) : SettingsService(server, fs, OTA_SETTINGS_SERVICE_PATH, OTA_SETTINGS_FILE) {
OTASettingsService::OTASettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : AdminSettingsService(server, fs, securityManager, OTA_SETTINGS_SERVICE_PATH, OTA_SETTINGS_FILE) {
#if defined(ESP8266)
_onStationModeGotIPHandler = WiFi.onStationModeGotIP(std::bind(&OTASettingsService::onStationModeGotIP, this, std::placeholders::_1));
#elif defined(ESP_PLATFORM)
@ -40,7 +40,7 @@ void OTASettingsService::writeToJsonObject(JsonObject& root) {
void OTASettingsService::configureArduinoOTA() {
if (_arduinoOTA){
delete _arduinoOTA;
_arduinoOTA = NULL;
_arduinoOTA = nullptr;
}
if (_enabled) {
Serial.println("Starting OTA Update Service");

View File

@ -19,11 +19,11 @@
#define OTA_SETTINGS_FILE "/config/otaSettings.json"
#define OTA_SETTINGS_SERVICE_PATH "/rest/otaSettings"
class OTASettingsService : public SettingsService {
class OTASettingsService : public AdminSettingsService {
public:
OTASettingsService(AsyncWebServer* server, FS* fs);
OTASettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager);
~OTASettingsService();
void loop();

68
src/SecurityManager.cpp Normal file
View File

@ -0,0 +1,68 @@
#include <SecurityManager.h>
Authentication SecurityManager::authenticateRequest(AsyncWebServerRequest *request) {
AsyncWebHeader* authorizationHeader = request->getHeader(AUTHORIZATION_HEADER);
if (authorizationHeader) {
String value = authorizationHeader->value();
if (value.startsWith(AUTHORIZATION_HEADER_PREFIX)){
value = value.substring(AUTHORIZATION_HEADER_PREFIX_LEN);
return authenticateJWT(value);
}
}
return Authentication();
}
Authentication SecurityManager::authenticateJWT(String jwt) {
DynamicJsonDocument payloadDocument(MAX_JWT_SIZE);
_jwtHandler.parseJWT(jwt, payloadDocument);
if (payloadDocument.is<JsonObject>()) {
JsonObject parsedPayload = payloadDocument.as<JsonObject>();
String username = parsedPayload["username"];
for (User _user : _users) {
if (_user.getUsername() == username && validatePayload(parsedPayload, &_user)){
return Authentication(_user);
}
}
}
return Authentication();
}
Authentication SecurityManager::authenticate(String username, String password) {
for (User _user : _users) {
if (_user.getUsername() == username && _user.getPassword() == password){
return Authentication(_user);
}
}
return Authentication();
}
inline void populateJWTPayload(JsonObject &payload, User *user) {
payload["username"] = user->getUsername();
payload["admin"] = user -> isAdmin();
}
boolean SecurityManager::validatePayload(JsonObject &parsedPayload, User *user) {
DynamicJsonDocument _jsonDocument(MAX_JWT_SIZE);
JsonObject payload = _jsonDocument.to<JsonObject>();
populateJWTPayload(payload, user);
return payload == parsedPayload;
}
String SecurityManager::generateJWT(User *user) {
DynamicJsonDocument _jsonDocument(MAX_JWT_SIZE);
JsonObject payload = _jsonDocument.to<JsonObject>();
populateJWTPayload(payload, user);
return _jwtHandler.buildJWT(payload);
}
ArRequestHandlerFunction SecurityManager::wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate) {
return [this, onRequest, predicate](AsyncWebServerRequest *request){
Authentication authentication = authenticateRequest(request);
if (!predicate(authentication)) {
request->send(401);
return;
}
onRequest(request);
};
}

110
src/SecurityManager.h Normal file
View File

@ -0,0 +1,110 @@
#ifndef SecurityManager_h
#define SecurityManager_h
#include <list>
#include <ArduinoJsonJWT.h>
#include <ESPAsyncWebServer.h>
#define DEFAULT_JWT_SECRET "esp8266-react"
#define AUTHORIZATION_HEADER "Authorization"
#define AUTHORIZATION_HEADER_PREFIX "Bearer "
#define AUTHORIZATION_HEADER_PREFIX_LEN 7
#define MAX_JWT_SIZE 128
class User {
private:
String _username;
String _password;
bool _admin;
public:
User(String username, String password, bool admin): _username(username), _password(password), _admin(admin) {}
String getUsername() {
return _username;
}
String getPassword() {
return _password;
}
bool isAdmin() {
return _admin;
}
};
class Authentication {
private:
User *_user;
boolean _authenticated;
public:
Authentication(User& user): _user(new User(user)), _authenticated(true) {}
Authentication() : _user(nullptr), _authenticated(false) {}
~Authentication() {
delete(_user);
}
User* getUser() {
return _user;
}
bool isAuthenticated() {
return _authenticated;
}
};
typedef std::function<boolean(Authentication &authentication)> AuthenticationPredicate;
class AuthenticationPredicates {
public:
static bool NONE_REQUIRED(Authentication &authentication) {
return true;
};
static bool IS_AUTHENTICATED(Authentication &authentication) {
return authentication.isAuthenticated();
};
static bool IS_ADMIN(Authentication &authentication) {
return authentication.isAuthenticated() && authentication.getUser()->isAdmin();
};
};
class SecurityManager {
public:
/*
* Authenticate, returning the user if found
*/
Authentication authenticate(String username, String password);
/*
* Check the request header for the Authorization token
*/
Authentication authenticateRequest(AsyncWebServerRequest *request);
/*
* Generate a JWT for the user provided
*/
String generateJWT(User *user);
/**
* Wrap the provided request to provide validation against an AuthenticationPredicate.
*/
ArRequestHandlerFunction wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate);
protected:
ArduinoJsonJWT _jwtHandler = ArduinoJsonJWT(DEFAULT_JWT_SECRET);
std::list<User> _users;
private:
/*
* Lookup the user by JWT
*/
Authentication authenticateJWT(String jwt);
/*
* Verify the payload is correct
*/
boolean validatePayload(JsonObject &parsedPayload, User *user);
};
#endif // end SecurityManager_h

View File

@ -0,0 +1,35 @@
#include <SecuritySettingsService.h>
SecuritySettingsService::SecuritySettingsService(AsyncWebServer* server, FS* fs) : AdminSettingsService(server, fs, this, SECURITY_SETTINGS_PATH, SECURITY_SETTINGS_FILE), SecurityManager() {}
SecuritySettingsService::~SecuritySettingsService() {}
void SecuritySettingsService::readFromJsonObject(JsonObject& root) {
// secret
_jwtHandler.setSecret(root["jwt_secret"] | DEFAULT_JWT_SECRET);
// users
_users.clear();
if (root["users"].is<JsonArray>()) {
for (JsonVariant user : root["users"].as<JsonArray>()) {
_users.push_back(User(user["username"], user["password"], user["admin"]));
}
}
}
void SecuritySettingsService::writeToJsonObject(JsonObject& root) {
// secret
root["jwt_secret"] = _jwtHandler.getSecret();
// users
JsonArray users = root.createNestedArray("users");
for (User _user : _users) {
JsonObject user = users.createNestedObject();
user["username"] = _user.getUsername();
user["password"] = _user.getPassword();
user["admin"] = _user.isAdmin();
}
}
void SecuritySettingsService::begin() {
readFromFS();
}

View File

@ -0,0 +1,26 @@
#ifndef SecuritySettingsService_h
#define SecuritySettingsService_h
#include <SettingsService.h>
#include <SecurityManager.h>
#define SECURITY_SETTINGS_FILE "/config/securitySettings.json"
#define SECURITY_SETTINGS_PATH "/rest/securitySettings"
class SecuritySettingsService : public AdminSettingsService, public SecurityManager {
public:
SecuritySettingsService(AsyncWebServer* server, FS* fs);
~SecuritySettingsService();
void begin();
protected:
void readFromJsonObject(JsonObject& root);
void writeToJsonObject(JsonObject& root);
};
#endif // end SecuritySettingsService_h

View File

@ -4,13 +4,13 @@
#include <ESPAsyncWebServer.h>
#include <FS.h>
#include <ArduinoJson.h>
#include <AsyncJsonRequestWebHandler.h>
#include <AsyncJsonWebHandler.h>
#include <AsyncArduinoJson6.h>
/**
* At the moment, not expecting settings service to have to deal with large JSON
* files this could be made configurable fairly simply, it's exposed on
* AsyncJsonRequestWebHandler with a setter.
* AsyncJsonWebHandler with a setter.
*/
#define MAX_SETTINGS_SIZE 1024

View File

@ -9,54 +9,19 @@
#include <AsyncTCP.h>
#endif
#include <SecurityManager.h>
#include <SettingsPersistence.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <AsyncJsonRequestWebHandler.h>
#include <AsyncJsonWebHandler.h>
#include <AsyncArduinoJson6.h>
/*
* Abstraction of a service which stores it's settings as JSON in a file system.
*/
class SettingsService : public SettingsPersistence {
private:
AsyncJsonRequestWebHandler _updateHandler;
void fetchConfig(AsyncWebServerRequest *request){
AsyncJsonResponse * response = new AsyncJsonResponse(MAX_SETTINGS_SIZE);
JsonObject jsonObject = response->getRoot();
writeToJsonObject(jsonObject);
response->setLength();
request->send(response);
}
void updateConfig(AsyncWebServerRequest *request, JsonDocument &jsonDocument){
if (jsonDocument.is<JsonObject>()){
JsonObject newConfig = jsonDocument.as<JsonObject>();
readFromJsonObject(newConfig);
writeToFS();
// write settings back with a callback to reconfigure the wifi
AsyncJsonCallbackResponse * response = new AsyncJsonCallbackResponse([this] () {onConfigUpdated();}, MAX_SETTINGS_SIZE);
JsonObject jsonObject = response->getRoot();
writeToJsonObject(jsonObject);
response->setLength();
request->send(response);
} else {
request->send(400);
}
}
protected:
// will serve setting endpoints from here
AsyncWebServer* _server;
// implement to perform action when config has been updated
virtual void onConfigUpdated(){}
public:
SettingsService(AsyncWebServer* server, FS* fs, char const* servicePath, char const* filePath):
@ -79,6 +44,81 @@ private:
readFromFS();
}
protected:
// will serve setting endpoints from here
AsyncWebServer* _server;
AsyncJsonWebHandler _updateHandler;
virtual void fetchConfig(AsyncWebServerRequest *request) {
// handle the request
AsyncJsonResponse * response = new AsyncJsonResponse(MAX_SETTINGS_SIZE);
JsonObject jsonObject = response->getRoot();
writeToJsonObject(jsonObject);
response->setLength();
request->send(response);
}
virtual void updateConfig(AsyncWebServerRequest *request, JsonDocument &jsonDocument) {
// handle the request
if (jsonDocument.is<JsonObject>()){
JsonObject newConfig = jsonDocument.as<JsonObject>();
readFromJsonObject(newConfig);
writeToFS();
// write settings back with a callback to reconfigure the wifi
AsyncJsonCallbackResponse * response = new AsyncJsonCallbackResponse([this] () {onConfigUpdated();}, MAX_SETTINGS_SIZE);
JsonObject jsonObject = response->getRoot();
writeToJsonObject(jsonObject);
response->setLength();
request->send(response);
} else {
request->send(400);
}
}
// implement to perform action when config has been updated
virtual void onConfigUpdated(){}
};
class AdminSettingsService : public SettingsService {
public:
AdminSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager, char const* servicePath, char const* filePath):
SettingsService(server, fs, servicePath, filePath), _securityManager(securityManager) {
}
protected:
// will validate the requests with the security manager
SecurityManager* _securityManager;
void fetchConfig(AsyncWebServerRequest *request) {
// verify the request against the predicate
Authentication authentication = _securityManager->authenticateRequest(request);
if (!getAuthenticationPredicate()(authentication)) {
request->send(401);
return;
}
// delegate to underlying implemetation
SettingsService::fetchConfig(request);
}
void updateConfig(AsyncWebServerRequest *request, JsonDocument &jsonDocument) {
// verify the request against the predicate
Authentication authentication = _securityManager->authenticateRequest(request);
if (!getAuthenticationPredicate()(authentication)) {
request->send(401);
return;
}
// delegate to underlying implemetation
SettingsService::updateConfig(request, jsonDocument);
}
// override to override the default authentication predicate, IS_ADMIN
AuthenticationPredicate getAuthenticationPredicate() {
return AuthenticationPredicates::IS_ADMIN;
}
};
#endif // end SettingsService

View File

@ -12,12 +12,12 @@
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <AsyncArduinoJson6.h>
#include <AsyncJsonRequestWebHandler.h>
#include <AsyncJsonWebHandler.h>
/**
* At the moment, not expecting services to have to deal with large JSON
* files this could be made configurable fairly simply, it's exposed on
* AsyncJsonRequestWebHandler with a setter.
* AsyncJsonWebHandler with a setter.
*/
#define MAX_SETTINGS_SIZE 1024
@ -31,7 +31,7 @@ class SimpleService {
private:
AsyncJsonRequestWebHandler _updateHandler;
AsyncJsonWebHandler _updateHandler;
void fetchConfig(AsyncWebServerRequest *request){
AsyncJsonResponse * response = new AsyncJsonResponse(MAX_SETTINGS_SIZE);

26
src/SystemStatus.cpp Normal file
View File

@ -0,0 +1,26 @@
#include <SystemStatus.h>
SystemStatus::SystemStatus(AsyncWebServer *server, SecurityManager* securityManager) : _server(server), _securityManager(securityManager) {
_server->on(SYSTEM_STATUS_SERVICE_PATH, HTTP_GET,
_securityManager->wrapRequest(std::bind(&SystemStatus::systemStatus, this, std::placeholders::_1), AuthenticationPredicates::IS_AUTHENTICATED)
);
}
void SystemStatus::systemStatus(AsyncWebServerRequest *request) {
AsyncJsonResponse * response = new AsyncJsonResponse(MAX_ESP_STATUS_SIZE);
JsonObject root = response->getRoot();
#if defined(ESP8266)
root["esp_platform"] = "esp8266";
#elif defined(ESP_PLATFORM)
root["esp_platform"] = "esp32";
#endif
root["cpu_freq_mhz"] = ESP.getCpuFreqMHz();
root["free_heap"] = ESP.getFreeHeap();
root["sketch_size"] = ESP.getSketchSize();
root["free_sketch_space"] = ESP.getFreeSketchSpace();
root["sdk_version"] = ESP.getSdkVersion();
root["flash_chip_size"] = ESP.getFlashChipSize();
root["flash_chip_speed"] = ESP.getFlashChipSpeed();
response->setLength();
request->send(response);
}

35
src/SystemStatus.h Normal file
View File

@ -0,0 +1,35 @@
#ifndef SystemStatus_h
#define SystemStatus_h
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#elif defined(ESP_PLATFORM)
#include <WiFi.h>
#include <AsyncTCP.h>
#endif
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <AsyncArduinoJson6.h>
#include <SecurityManager.h>
#define MAX_ESP_STATUS_SIZE 1024
#define SYSTEM_STATUS_SERVICE_PATH "/rest/systemStatus"
class SystemStatus {
public:
SystemStatus(AsyncWebServer *server, SecurityManager* securityManager);
private:
AsyncWebServer* _server;
SecurityManager* _securityManager;
void systemStatus(AsyncWebServerRequest *request);
};
#endif // end SystemStatus_h

View File

@ -1,8 +1,12 @@
#include <WiFiScanner.h>
WiFiScanner::WiFiScanner(AsyncWebServer *server) : _server(server) {
_server->on(SCAN_NETWORKS_SERVICE_PATH, HTTP_GET, std::bind(&WiFiScanner::scanNetworks, this, std::placeholders::_1));
_server->on(LIST_NETWORKS_SERVICE_PATH, HTTP_GET, std::bind(&WiFiScanner::listNetworks, this, std::placeholders::_1));
WiFiScanner::WiFiScanner(AsyncWebServer *server, SecurityManager* securityManager) : _server(server) {
_server->on(SCAN_NETWORKS_SERVICE_PATH, HTTP_GET,
securityManager->wrapRequest(std::bind(&WiFiScanner::scanNetworks, this, std::placeholders::_1), AuthenticationPredicates::IS_ADMIN)
);
_server->on(LIST_NETWORKS_SERVICE_PATH, HTTP_GET,
securityManager->wrapRequest(std::bind(&WiFiScanner::listNetworks, this, std::placeholders::_1), AuthenticationPredicates::IS_ADMIN)
);
}
void WiFiScanner::scanNetworks(AsyncWebServerRequest *request) {

View File

@ -13,6 +13,7 @@
#include <ArduinoJson.h>
#include <AsyncArduinoJson6.h>
#include <TimeLib.h>
#include <SecurityManager.h>
#define SCAN_NETWORKS_SERVICE_PATH "/rest/scanNetworks"
#define LIST_NETWORKS_SERVICE_PATH "/rest/listNetworks"
@ -23,7 +24,7 @@ class WiFiScanner {
public:
WiFiScanner(AsyncWebServer *server);
WiFiScanner(AsyncWebServer *server, SecurityManager* securityManager);
private:

View File

@ -1,6 +1,6 @@
#include <WiFiSettingsService.h>
WiFiSettingsService::WiFiSettingsService(AsyncWebServer* server, FS* fs) : SettingsService(server, fs, WIFI_SETTINGS_SERVICE_PATH, WIFI_SETTINGS_FILE) {}
WiFiSettingsService::WiFiSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : AdminSettingsService(server, fs, securityManager, WIFI_SETTINGS_SERVICE_PATH, WIFI_SETTINGS_FILE) {}
WiFiSettingsService::~WiFiSettingsService() {}

View File

@ -7,11 +7,11 @@
#define WIFI_SETTINGS_FILE "/config/wifiSettings.json"
#define WIFI_SETTINGS_SERVICE_PATH "/rest/wifiSettings"
class WiFiSettingsService : public SettingsService {
class WiFiSettingsService : public AdminSettingsService {
public:
WiFiSettingsService(AsyncWebServer* server, FS* fs);
WiFiSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager);
~WiFiSettingsService();
void begin();

View File

@ -1,7 +1,9 @@
#include <WiFiStatus.h>
WiFiStatus::WiFiStatus(AsyncWebServer *server) : _server(server) {
_server->on(WIFI_STATUS_SERVICE_PATH, HTTP_GET, std::bind(&WiFiStatus::wifiStatus, this, std::placeholders::_1));
WiFiStatus::WiFiStatus(AsyncWebServer *server, SecurityManager* securityManager) : _server(server), _securityManager(securityManager) {
_server->on(WIFI_STATUS_SERVICE_PATH, HTTP_GET,
_securityManager->wrapRequest(std::bind(&WiFiStatus::wifiStatus, this, std::placeholders::_1), AuthenticationPredicates::IS_AUTHENTICATED)
);
#if defined(ESP8266)
_onStationModeConnectedHandler = WiFi.onStationModeConnected(onStationModeConnected);
_onStationModeDisconnectedHandler = WiFi.onStationModeDisconnected(onStationModeDisconnected);

View File

@ -13,6 +13,7 @@
#include <ArduinoJson.h>
#include <AsyncArduinoJson6.h>
#include <IPAddress.h>
#include <SecurityManager.h>
#define MAX_WIFI_STATUS_SIZE 1024
#define WIFI_STATUS_SERVICE_PATH "/rest/wifiStatus"
@ -21,11 +22,12 @@ class WiFiStatus {
public:
WiFiStatus(AsyncWebServer *server);
WiFiStatus(AsyncWebServer *server, SecurityManager* securityManager);
private:
AsyncWebServer* _server;
SecurityManager* _securityManager;
#if defined(ESP8266)
// handler refrences for logging important WiFi events over serial

View File

@ -10,28 +10,35 @@
#endif
#include <FS.h>
#include <SecuritySettingsService.h>
#include <WiFiSettingsService.h>
#include <WiFiStatus.h>
#include <WiFiScanner.h>
#include <APSettingsService.h>
#include <NTPSettingsService.h>
#include <NTPStatus.h>
#include <OTASettingsService.h>
#include <AuthenticationService.h>
#include <WiFiScanner.h>
#include <WiFiStatus.h>
#include <NTPStatus.h>
#include <APStatus.h>
#include <SystemStatus.h>
#define SERIAL_BAUD_RATE 115200
AsyncWebServer server(80);
WiFiSettingsService wifiSettingsService = WiFiSettingsService(&server, &SPIFFS);
APSettingsService apSettingsService = APSettingsService(&server, &SPIFFS);
NTPSettingsService ntpSettingsService = NTPSettingsService(&server, &SPIFFS);
OTASettingsService otaSettingsService = OTASettingsService(&server, &SPIFFS);
SecuritySettingsService securitySettingsService = SecuritySettingsService(&server, &SPIFFS);
WiFiSettingsService wifiSettingsService = WiFiSettingsService(&server, &SPIFFS, &securitySettingsService);
APSettingsService apSettingsService = APSettingsService(&server, &SPIFFS, &securitySettingsService);
NTPSettingsService ntpSettingsService = NTPSettingsService(&server, &SPIFFS, &securitySettingsService);
OTASettingsService otaSettingsService = OTASettingsService(&server, &SPIFFS, &securitySettingsService);
AuthenticationService authenticationService = AuthenticationService(&server, &securitySettingsService);
WiFiScanner wifiScanner = WiFiScanner(&server);
WiFiStatus wifiStatus = WiFiStatus(&server);
NTPStatus ntpStatus = NTPStatus(&server);
APStatus apStatus = APStatus(&server);
WiFiScanner wifiScanner = WiFiScanner(&server, &securitySettingsService);
WiFiStatus wifiStatus = WiFiStatus(&server, &securitySettingsService);
NTPStatus ntpStatus = NTPStatus(&server, &securitySettingsService);
APStatus apStatus = APStatus(&server, &securitySettingsService);
SystemStatus systemStatus = SystemStatus(&server, &securitySettingsService);;
void setup() {
// Disable wifi config persistance
@ -40,6 +47,9 @@ void setup() {
Serial.begin(SERIAL_BAUD_RATE);
SPIFFS.begin();
// start security settings service first
securitySettingsService.begin();
// start services
ntpSettingsService.begin();
otaSettingsService.begin();
@ -67,8 +77,9 @@ void setup() {
// Disable CORS if required
#if defined(ENABLE_CORS)
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Credentials", "true");
#endif
server.begin();