diff --git a/README.md b/README.md index bfde374..5815b5e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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/) diff --git a/data/config/ntpSettings.json b/data/config/ntpSettings.json index 5767035..bd0147b 100644 --- a/data/config/ntpSettings.json +++ b/data/config/ntpSettings.json @@ -1,4 +1,4 @@ { "server":"pool.ntp.org", - "interval":60 + "interval":3600 } diff --git a/data/config/securitySettings.json b/data/config/securitySettings.json new file mode 100644 index 0000000..ce6cb6a --- /dev/null +++ b/data/config/securitySettings.json @@ -0,0 +1,15 @@ +{ + "jwt_secret":"esp8266-react", + "users": [ + { + "username": "admin", + "password": "admin", + "admin": true + }, + { + "username": "guest", + "password": "guest", + "admin": false + } + ] +} \ No newline at end of file diff --git a/data/www/index.html b/data/www/index.html index 44300bd..7abfc5e 100644 --- a/data/www/index.html +++ b/data/www/index.html @@ -1 +1 @@ -ESP8266 React
\ No newline at end of file +ESP8266 React
\ No newline at end of file diff --git a/data/www/js/0.439a.js.gz b/data/www/js/0.439a.js.gz new file mode 100644 index 0000000..a2b0ff8 Binary files /dev/null and b/data/www/js/0.439a.js.gz differ diff --git a/data/www/js/0.da55.js.gz b/data/www/js/0.da55.js.gz deleted file mode 100644 index d4d4441..0000000 Binary files a/data/www/js/0.da55.js.gz and /dev/null differ diff --git a/data/www/js/2.8ca9.js.gz b/data/www/js/2.8ca9.js.gz new file mode 100644 index 0000000..7d566a4 Binary files /dev/null and b/data/www/js/2.8ca9.js.gz differ diff --git a/data/www/js/2.9881.js.gz b/data/www/js/2.9881.js.gz deleted file mode 100644 index d92c214..0000000 Binary files a/data/www/js/2.9881.js.gz and /dev/null differ diff --git a/interface/.env b/interface/.env new file mode 100644 index 0000000..54f3b9d --- /dev/null +++ b/interface/.env @@ -0,0 +1 @@ +REACT_APP_NAME=ESP8266 React diff --git a/interface/.env.development b/interface/.env.development index fc000e6..a5bcdb0 100644 --- a/interface/.env.development +++ b/interface/.env.development @@ -1 +1 @@ -REACT_APP_ENDPOINT_ROOT=http://192.168.0.4/rest/ \ No newline at end of file +REACT_APP_ENDPOINT_ROOT=http://192.168.0.11/rest/ diff --git a/interface/.env.production b/interface/.env.production index e2075f0..5f7447a 100644 --- a/interface/.env.production +++ b/interface/.env.production @@ -1,2 +1,2 @@ REACT_APP_ENDPOINT_ROOT=/rest/ -GENERATE_SOURCEMAP=false \ No newline at end of file +GENERATE_SOURCEMAP=false diff --git a/interface/package-lock.json b/interface/package-lock.json index d0cff9c..ec051b7 100644 --- a/interface/package-lock.json +++ b/interface/package-lock.json @@ -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": { diff --git a/interface/package.json b/interface/package.json index 93dc77c..bd55442 100644 --- a/interface/package.json +++ b/interface/package.json @@ -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", diff --git a/interface/src/App.js b/interface/src/App.js index 25be835..231be0e 100644 --- a/interface/src/App.js +++ b/interface/src/App.js @@ -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 = () => ; -class App extends Component { - render() { - return ( - - - - - - - - - ) - } +class App extends Component { + render() { + return ( + + + + + + + + + + + + ); + } } export default App diff --git a/interface/src/AppRouting.js b/interface/src/AppRouting.js index fe5bfe6..10670f1 100644 --- a/interface/src/AppRouting.js +++ b/interface/src/AppRouting.js @@ -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 ( - - - - - - - - ) - } + + componentWillMount() { + Authentication.clearLoginRedirect(); + } + + render() { + return ( + + + + + + + + + + + + ) + } } export default AppRouting; diff --git a/interface/src/authentication/AuthenticatedRoute.js b/interface/src/authentication/AuthenticatedRoute.js new file mode 100644 index 0000000..8461bad --- /dev/null +++ b/interface/src/authentication/AuthenticatedRoute.js @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { + Redirect, Route +} from "react-router-dom"; + +import { withAuthenticationContext } from './Context.js'; +import * as Authentication from './Authentication'; +import { withNotifier } from '../components/SnackbarNotification'; + +export class AuthenticatedRoute extends React.Component { + + render() { + const { raiseNotification, authenticationContext, component: Component, ...rest } = this.props; + const { location } = this.props; + const renderComponent = (props) => { + if (authenticationContext.isAuthenticated()) { + return ( + + ); + } + Authentication.storeLoginRedirect(location); + raiseNotification("Please log in to continue."); + return ( + + ); + } + return ( + + ); + } + +} + +export default withNotifier(withAuthenticationContext(AuthenticatedRoute)); diff --git a/interface/src/authentication/Authentication.js b/interface/src/authentication/Authentication.js new file mode 100644 index 0000000..eb3acdc --- /dev/null +++ b/interface/src/authentication/Authentication.js @@ -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); + }); + }); +} diff --git a/interface/src/authentication/AuthenticationWrapper.js b/interface/src/authentication/AuthenticationWrapper.js new file mode 100644 index 0000000..c4429af --- /dev/null +++ b/interface/src/authentication/AuthenticationWrapper.js @@ -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 ( + + {this.state.initialized ? this.renderContent() : this.renderContentLoading()} + + ); + } + + renderContent() { + return ( + + {this.props.children} + + ); + } + + renderContentLoading() { + const { classes } = this.props; + return ( +
+ + + Loading... + +
+ ); + } + + 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)) diff --git a/interface/src/authentication/Context.js b/interface/src/authentication/Context.js new file mode 100644 index 0000000..571e0ce --- /dev/null +++ b/interface/src/authentication/Context.js @@ -0,0 +1,15 @@ +import * as React from "react"; + +export const AuthenticationContext = React.createContext( + {} +); + +export function withAuthenticationContext(Component) { + return function AuthenticationContextComponent(props) { + return ( + + {authenticationContext => } + + ); + }; +} diff --git a/interface/src/authentication/UnauthenticatedRoute.js b/interface/src/authentication/UnauthenticatedRoute.js new file mode 100644 index 0000000..321cdd2 --- /dev/null +++ b/interface/src/authentication/UnauthenticatedRoute.js @@ -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 (); + } + return (); + } + return ( + + ); + } +} + +export default withAuthenticationContext(UnauthenticatedRoute); diff --git a/interface/src/components/MenuAppBar.js b/interface/src/components/MenuAppBar.js index 82777b9..f3f0f15 100644 --- a/interface/src/components/MenuAppBar.js +++ b/interface/src/components/MenuAppBar.js @@ -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 = (
- - ESP8266 React - + + {APP_NAME} + - + - + - + - + - + - + - + - + - + + + + + + +
@@ -133,33 +154,67 @@ class MenuAppBar extends React.Component { return (
-
- - + + + + + + + {sectionTitle} + +
- + - - {sectionTitle} - - - - + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ {children} +
); } @@ -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) + ) +); diff --git a/interface/src/components/RestComponent.js b/interface/src/components/RestComponent.js index 4d29d40..3edfe1d 100644 --- a/interface/src/components/RestComponent.js +++ b/interface/src/components/RestComponent.js @@ -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 ; + handleValueChange={this.handleValueChange} + handleCheckboxChange={this.handleCheckboxChange} + setData={this.setData} + saveData={this.saveData} + loadData={this.loadData} + {...this.state} + {...this.props} + />; } } diff --git a/interface/src/components/SectionContent.js b/interface/src/components/SectionContent.js index 2bce92a..71e82c0 100644 --- a/interface/src/components/SectionContent.js +++ b/interface/src/components/SectionContent.js @@ -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 ( - + {title} {children} diff --git a/interface/src/components/SnackbarNotification.js b/interface/src/components/SnackbarNotification.js index f30c434..f62c661 100644 --- a/interface/src/components/SnackbarNotification.js +++ b/interface/src/components/SnackbarNotification.js @@ -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={{this.state.message}} diff --git a/interface/src/constants/App.js b/interface/src/constants/App.js new file mode 100644 index 0000000..da2a94d --- /dev/null +++ b/interface/src/constants/App.js @@ -0,0 +1 @@ +export const APP_NAME = process.env.REACT_APP_NAME; diff --git a/interface/src/constants/Endpoints.js b/interface/src/constants/Endpoints.js index efcd06e..e253c16 100644 --- a/interface/src/constants/Endpoints.js +++ b/interface/src/constants/Endpoints.js @@ -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"; diff --git a/interface/src/constants/WiFiSecurityModes.js b/interface/src/constants/WiFiSecurityModes.js index 4b6bafe..db33d4c 100644 --- a/interface/src/constants/WiFiSecurityModes.js +++ b/interface/src/constants/WiFiSecurityModes.js @@ -23,4 +23,4 @@ export const networkSecurityMode = selectedNetwork => { default: return "Unknown"; } -} \ No newline at end of file +} diff --git a/interface/src/containers/APConfiguration.js b/interface/src/containers/APConfiguration.js deleted file mode 100644 index e866595..0000000 --- a/interface/src/containers/APConfiguration.js +++ /dev/null @@ -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 ( - - - - - - {selectedTab === "apStatus" && } - {selectedTab === "apSettings" && } - - ) - } -} - -export default APConfiguration; diff --git a/interface/src/containers/APStatus.js b/interface/src/containers/APStatus.js index c66c359..95d3d64 100644 --- a/interface/src/containers/APStatus.js +++ b/interface/src/containers/APStatus.js @@ -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 ( - - - + + + + + - IP + + IP + - - - + + + + + - - - + + + + + @@ -83,8 +92,8 @@ class APStatus extends Component { ); } - renderAPStatus(data, classes){ - return ( + renderAPStatus(data, classes) { + return (
@@ -99,30 +108,30 @@ class APStatus extends Component { } render() { - const { data, fetched, errorMessage, classes } = this.props; + const { data, fetched, errorMessage, classes } = this.props; return ( { - !fetched ? -
- - - Loading... - -
- : - data ? this.renderAPStatus(data, classes) - : -
- - {errorMessage} - - -
- } + !fetched ? +
+ + + Loading... + +
+ : + data ? this.renderAPStatus(data, classes) + : +
+ + {errorMessage} + + +
+ }
) } diff --git a/interface/src/containers/ManageUsers.js b/interface/src/containers/ManageUsers.js new file mode 100644 index 0000000..6615054 --- /dev/null +++ b/interface/src/containers/ManageUsers.js @@ -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 ( + + + + ) + } + +} + +export default restComponent(SECURITY_SETTINGS_ENDPOINT, ManageUsers); diff --git a/interface/src/containers/NTPConfiguration.js b/interface/src/containers/NTPConfiguration.js deleted file mode 100644 index a0189ce..0000000 --- a/interface/src/containers/NTPConfiguration.js +++ /dev/null @@ -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 ( - - - - - - {selectedTab === "ntpStatus" && } - {selectedTab === "ntpSettings" && } - - ) - } -} - -export default NTPConfiguration diff --git a/interface/src/containers/NTPStatus.js b/interface/src/containers/NTPStatus.js index 3b7f687..a9b5270 100644 --- a/interface/src/containers/NTPStatus.js +++ b/interface/src/containers/NTPStatus.js @@ -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 ( - - - - + + + + + + - { isSynchronized(data) && + {isSynchronized(data) && - - - + + + + + - - - - 0 ? unixTimeToTimeAndDate(data.last_sync) : "never" } /> + + + + + + 0 ? unixTimeToTimeAndDate(data.last_sync) : "never"} /> } - - - + + + + + - - - + + + + + - - - + + + + + @@ -104,8 +117,8 @@ class NTPStatus extends Component { ); } - renderNTPStatus(data, classes){ - return ( + renderNTPStatus(data, classes) { + return (
{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 ( { - !fetched ? -
- - - Loading... - -
- : - data ? this.renderNTPStatus(data, classes) - : -
- - {errorMessage} - - -
- } + !fetched ? +
+ + + Loading... + +
+ : + data ? this.renderNTPStatus(data, classes) + : +
+ + {errorMessage} + + +
+ }
) } diff --git a/interface/src/containers/OTAConfiguration.js b/interface/src/containers/OTAConfiguration.js deleted file mode 100644 index 66dbf3f..0000000 --- a/interface/src/containers/OTAConfiguration.js +++ /dev/null @@ -1,15 +0,0 @@ -import React, { Component } from 'react'; -import MenuAppBar from '../components/MenuAppBar'; -import OTASettings from './OTASettings'; - -class OTAConfiguration extends Component { - render() { - return ( - - - - ) - } -} - -export default OTAConfiguration diff --git a/interface/src/containers/SecuritySettings.js b/interface/src/containers/SecuritySettings.js new file mode 100644 index 0000000..f6adb11 --- /dev/null +++ b/interface/src/containers/SecuritySettings.js @@ -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 ( + + + + ) + } + +} + +export default restComponent(SECURITY_SETTINGS_ENDPOINT, SecuritySettings); diff --git a/interface/src/containers/SignInPage.js b/interface/src/containers/SignInPage.js new file mode 100644 index 0000000..28cfa70 --- /dev/null +++ b/interface/src/containers/SignInPage.js @@ -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 ( +
+ + {APP_NAME} + + + + + + Sign In + + + +
+ ); + } + +} + +export default withAuthenticationContext( + withNotifier(withStyles(styles)(SignInPage)) +); diff --git a/interface/src/containers/SystemStatus.js b/interface/src/containers/SystemStatus.js new file mode 100644 index 0000000..20728f5 --- /dev/null +++ b/interface/src/containers/SystemStatus.js @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + renderNTPStatus(data, classes) { + return ( +
+ + {this.createListItems(data, classes)} + + +
+ ); + } + + render() { + const { data, fetched, errorMessage, classes } = this.props; + return ( + + { + !fetched ? +
+ + + Loading... + +
+ : + data ? this.renderNTPStatus(data, classes) + : +
+ + {errorMessage} + + +
+ } +
+ ) + } +} + +export default restComponent(SYSTEM_STATUS_ENDPOINT, withStyles(styles)(SystemStatus)); diff --git a/interface/src/containers/WiFiConfiguration.js b/interface/src/containers/WiFiConfiguration.js deleted file mode 100644 index 7d21952..0000000 --- a/interface/src/containers/WiFiConfiguration.js +++ /dev/null @@ -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 ( - - - - - - - {selectedTab === "wifiStatus" && } - {selectedTab === "networkScanner" && } - {selectedTab === "wifiSettings" && } - - ) - } -} - -export default WiFiConfiguration; diff --git a/interface/src/containers/WiFiNetworkScanner.js b/interface/src/containers/WiFiNetworkScanner.js index e32e222..fccfbc6 100644 --- a/interface/src/containers/WiFiNetworkScanner.js +++ b/interface/src/containers/WiFiNetworkScanner.js @@ -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}); diff --git a/interface/src/containers/WiFiStatus.js b/interface/src/containers/WiFiStatus.js index 7ab68b7..773e958 100644 --- a/interface/src/containers/WiFiStatus.js +++ b/interface/src/containers/WiFiStatus.js @@ -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 ( - - - + + + + + @@ -73,40 +76,52 @@ class WiFiStatus extends Component { isConnected(data) && - - - + + + + + - IP + + IP + - - - + + + + + - # + + # + - - - + + + + + - - - + + + + + @@ -137,7 +152,7 @@ class WiFiStatus extends Component { !fetched ?
- + Loading...
@@ -145,7 +160,7 @@ class WiFiStatus extends Component { data ? this.renderWiFiStatus(data, classes) :
- + {errorMessage} + + + { + user && + + } + + : +
+ + {errorMessage} + + +
+ ); + } + +} + +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)); diff --git a/interface/src/forms/NTPSettingsForm.js b/interface/src/forms/NTPSettingsForm.js index 0d94857..ba89b9c 100644 --- a/interface/src/forms/NTPSettingsForm.js +++ b/interface/src/forms/NTPSettingsForm.js @@ -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 {
- + Loading...
@@ -87,7 +87,7 @@ class NTPSettingsForm extends React.Component { :
- + {errorMessage} + + + : +
+ + {errorMessage} + + +
+ ); + } +} + +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)); diff --git a/interface/src/forms/UserForm.js b/interface/src/forms/UserForm.js new file mode 100644 index 0000000..364feb3 --- /dev/null +++ b/interface/src/forms/UserForm.js @@ -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 ( + + + {creating ? 'Add' : 'Modify'} User + + + + + } + label="Admin?" + /> + + + + + + + + + ); + } +} + +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); diff --git a/interface/src/forms/WiFiNetworkSelector.js b/interface/src/forms/WiFiNetworkSelector.js index c2e385f..cc4d75e 100644 --- a/interface/src/forms/WiFiNetworkSelector.js +++ b/interface/src/forms/WiFiNetworkSelector.js @@ -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 ?
- + Scanning...
@@ -80,7 +80,7 @@ class WiFiNetworkSelector extends Component { :
- + {errorMessage}
diff --git a/interface/src/forms/WiFiSettingsForm.js b/interface/src/forms/WiFiSettingsForm.js index afe62ba..bd43bec 100644 --- a/interface/src/forms/WiFiSettingsForm.js +++ b/interface/src/forms/WiFiSettingsForm.js @@ -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 { @@ -84,57 +84,57 @@ class WiFiSettingsForm extends React.Component { return (
{ - !wifiSettingsFetched ? + !wifiSettingsFetched ? -
- - - Loading... - -
+
+ + + Loading... + +
- : wifiSettings ? + : wifiSettings ? - - { - selectedNetwork ? this.renderSelectedNetwork() : - - } - { - !isNetworkOpen(selectedNetwork) && - - } - - + { + selectedNetwork ? this.renderSelectedNetwork() : + + } + { + !isNetworkOpen(selectedNetwork) && + + } - + + - { - wifiSettings.static_ip_config && - - - - - - - - } + { + wifiSettings.static_ip_config && + + + + + + + + } - - - + - : + : -
- - {errorMessage} - - -
- } +
+ }
); } diff --git a/interface/src/index.js b/interface/src/index.js index 5e3b43f..a0801fc 100644 --- a/interface/src/index.js +++ b/interface/src/index.js @@ -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(( - - - - + ), document.getElementById("root")) diff --git a/interface/src/sections/AccessPoint.js b/interface/src/sections/AccessPoint.js new file mode 100644 index 0000000..011b673 --- /dev/null +++ b/interface/src/sections/AccessPoint.js @@ -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 ( + + + + + + + + + + + + ) + } +} + +export default withAuthenticationContext(AccessPoint); diff --git a/interface/src/sections/NetworkTime.js b/interface/src/sections/NetworkTime.js new file mode 100644 index 0000000..d695859 --- /dev/null +++ b/interface/src/sections/NetworkTime.js @@ -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 ( + + + + + + + + + + + + ) + } +} + +export default withAuthenticationContext(NetworkTime) diff --git a/interface/src/sections/Security.js b/interface/src/sections/Security.js new file mode 100644 index 0000000..c2f619c --- /dev/null +++ b/interface/src/sections/Security.js @@ -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 ( + + + + + + + + + + + + ) + } +} + +export default Security; diff --git a/interface/src/sections/System.js b/interface/src/sections/System.js new file mode 100644 index 0000000..2aca1cd --- /dev/null +++ b/interface/src/sections/System.js @@ -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 ( + + + + + + + + + + + + ) + } +} + +export default withAuthenticationContext(System); diff --git a/interface/src/sections/WiFiConnection.js b/interface/src/sections/WiFiConnection.js new file mode 100644 index 0000000..1bb2b9b --- /dev/null +++ b/interface/src/sections/WiFiConnection.js @@ -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 ( + + ); + }; + const ConfiguredWiFiSettings = (props) => { + return ( + + ); + }; + return ( + + + + + + + + + + + + + + ) + } +} + +export default withAuthenticationContext(WiFiConnection); diff --git a/interface/src/validators/optional.js b/interface/src/validators/optional.js index 58bfda5..583bc45 100644 --- a/interface/src/validators/optional.js +++ b/interface/src/validators/optional.js @@ -1 +1 @@ -export default validator => value => !value || validator(value); \ No newline at end of file +export default validator => value => !value || validator(value); diff --git a/media/build.png b/media/build.png new file mode 100644 index 0000000..33e807a Binary files /dev/null and b/media/build.png differ diff --git a/media/esp12e.jpg b/media/esp12e.jpg new file mode 100644 index 0000000..5b9b58c Binary files /dev/null and b/media/esp12e.jpg differ diff --git a/media/esp32.jpg b/media/esp32.jpg new file mode 100644 index 0000000..f1da122 Binary files /dev/null and b/media/esp32.jpg differ diff --git a/media/screenshots.png b/media/screenshots.png new file mode 100644 index 0000000..27a504e Binary files /dev/null and b/media/screenshots.png differ diff --git a/media/uploadfs.png b/media/uploadfs.png new file mode 100644 index 0000000..5b5ee68 Binary files /dev/null and b/media/uploadfs.png differ diff --git a/media/uploadfw.png b/media/uploadfw.png new file mode 100644 index 0000000..0bd0849 Binary files /dev/null and b/media/uploadfw.png differ diff --git a/platformio.ini b/platformio.ini index bde98ba..c06a090 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/screenshots/screenshots.png b/screenshots/screenshots.png deleted file mode 100644 index 2aa03d6..0000000 Binary files a/screenshots/screenshots.png and /dev/null differ diff --git a/src/APSettingsService.cpp b/src/APSettingsService.cpp index 895766d..98ceb58 100644 --- a/src/APSettingsService.cpp +++ b/src/APSettingsService.cpp @@ -1,6 +1,6 @@ #include -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); diff --git a/src/APSettingsService.h b/src/APSettingsService.h index 3fcd2a7..252df7f 100644 --- a/src/APSettingsService.h +++ b/src/APSettingsService.h @@ -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(); diff --git a/src/APStatus.cpp b/src/APStatus.cpp index 35a7b4f..d11cb4d 100644 --- a/src/APStatus.cpp +++ b/src/APStatus.cpp @@ -1,7 +1,9 @@ #include -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) { diff --git a/src/APStatus.h b/src/APStatus.h index 8c32fc8..0b69a43 100644 --- a/src/APStatus.h +++ b/src/APStatus.h @@ -13,6 +13,7 @@ #include #include #include +#include #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); diff --git a/src/ArduinoJsonJWT.cpp b/src/ArduinoJsonJWT.cpp new file mode 100644 index 0000000..1018e52 --- /dev/null +++ b/src/ArduinoJsonJWT.cpp @@ -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()){ + 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); +} diff --git a/src/ArduinoJsonJWT.h b/src/ArduinoJsonJWT.h new file mode 100644 index 0000000..fdaeb0b --- /dev/null +++ b/src/ArduinoJsonJWT.h @@ -0,0 +1,38 @@ +#ifndef ArduinoJsonJWT_H +#define ArduinoJsonJWT_H + +#include +#include +#include +#include +#if defined(ESP_PLATFORM) + #include +#else + #include +#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 diff --git a/src/AsyncArduinoJson6.h b/src/AsyncArduinoJson6.h index f075d46..b95ea6c 100644 --- a/src/AsyncArduinoJson6.h +++ b/src/AsyncArduinoJson6.h @@ -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); } } diff --git a/src/AsyncJsonRequestWebHandler.h b/src/AsyncJsonWebHandler.h similarity index 88% rename from src/AsyncJsonRequestWebHandler.h rename to src/AsyncJsonWebHandler.h index aa8498a..dde3470 100644 --- a/src/AsyncJsonRequestWebHandler.h +++ b/src/AsyncJsonWebHandler.h @@ -1,6 +1,7 @@ #ifndef Async_Json_Request_Web_Handler_H_ #define Async_Json_Request_Web_Handler_H_ +#include #include #define ASYNC_JSON_REQUEST_DEFAULT_MAX_SIZE 1024 @@ -16,24 +17,25 @@ typedef std::function 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; } diff --git a/src/AuthenticationService.cpp b/src/AuthenticationService.cpp new file mode 100644 index 0000000..c67f091 --- /dev/null +++ b/src/AuthenticationService.cpp @@ -0,0 +1,45 @@ +#include + +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()) { + 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); +} + diff --git a/src/AuthenticationService.h b/src/AuthenticationService.h new file mode 100644 index 0000000..15d2941 --- /dev/null +++ b/src/AuthenticationService.h @@ -0,0 +1,33 @@ +#ifndef AuthenticationService_H_ +#define AuthenticationService_H_ + +#include +#include +#include +#include + +#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 \ No newline at end of file diff --git a/src/NTPSettingsService.cpp b/src/NTPSettingsService.cpp index 99962a2..b09e025 100644 --- a/src/NTPSettingsService.cpp +++ b/src/NTPSettingsService.cpp @@ -1,6 +1,6 @@ #include -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)); diff --git a/src/NTPSettingsService.h b/src/NTPSettingsService.h index c3624e7..e24f237 100644 --- a/src/NTPSettingsService.h +++ b/src/NTPSettingsService.h @@ -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(); diff --git a/src/NTPStatus.cpp b/src/NTPStatus.cpp index 50dbc37..21ac627 100644 --- a/src/NTPStatus.cpp +++ b/src/NTPStatus.cpp @@ -1,7 +1,9 @@ #include -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) { diff --git a/src/NTPStatus.h b/src/NTPStatus.h index 086407f..82c00fa 100644 --- a/src/NTPStatus.h +++ b/src/NTPStatus.h @@ -14,6 +14,7 @@ #include #include #include +#include #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); diff --git a/src/OTASettingsService.cpp b/src/OTASettingsService.cpp index 64e98c2..f891448 100644 --- a/src/OTASettingsService.cpp +++ b/src/OTASettingsService.cpp @@ -1,6 +1,6 @@ #include -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"); diff --git a/src/OTASettingsService.h b/src/OTASettingsService.h index 32e76ed..27993a8 100644 --- a/src/OTASettingsService.h +++ b/src/OTASettingsService.h @@ -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(); diff --git a/src/SecurityManager.cpp b/src/SecurityManager.cpp new file mode 100644 index 0000000..e973d0d --- /dev/null +++ b/src/SecurityManager.cpp @@ -0,0 +1,68 @@ +#include + +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 parsedPayload = payloadDocument.as(); + 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(); + populateJWTPayload(payload, user); + return payload == parsedPayload; +} + +String SecurityManager::generateJWT(User *user) { + DynamicJsonDocument _jsonDocument(MAX_JWT_SIZE); + JsonObject payload = _jsonDocument.to(); + 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); + }; +} + \ No newline at end of file diff --git a/src/SecurityManager.h b/src/SecurityManager.h new file mode 100644 index 0000000..b8c514f --- /dev/null +++ b/src/SecurityManager.h @@ -0,0 +1,110 @@ +#ifndef SecurityManager_h +#define SecurityManager_h + +#include +#include +#include + +#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 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 _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 \ No newline at end of file diff --git a/src/SecuritySettingsService.cpp b/src/SecuritySettingsService.cpp new file mode 100644 index 0000000..51c355d --- /dev/null +++ b/src/SecuritySettingsService.cpp @@ -0,0 +1,35 @@ +#include + +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()) { + for (JsonVariant user : root["users"].as()) { + _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(); +} diff --git a/src/SecuritySettingsService.h b/src/SecuritySettingsService.h new file mode 100644 index 0000000..7e5b622 --- /dev/null +++ b/src/SecuritySettingsService.h @@ -0,0 +1,26 @@ +#ifndef SecuritySettingsService_h +#define SecuritySettingsService_h + +#include +#include + +#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 \ No newline at end of file diff --git a/src/SettingsPersistence.h b/src/SettingsPersistence.h index 5b50126..03ca73d 100644 --- a/src/SettingsPersistence.h +++ b/src/SettingsPersistence.h @@ -4,13 +4,13 @@ #include #include #include -#include +#include #include /** * 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 diff --git a/src/SettingsService.h b/src/SettingsService.h index 65a771a..ecf0d6a 100644 --- a/src/SettingsService.h +++ b/src/SettingsService.h @@ -9,54 +9,19 @@ #include #endif +#include #include #include #include -#include +#include #include + /* * 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 newConfig = jsonDocument.as(); - 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 newConfig = jsonDocument.as(); + 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 diff --git a/src/SimpleService.h b/src/SimpleService.h index 9144dfc..83534b1 100644 --- a/src/SimpleService.h +++ b/src/SimpleService.h @@ -12,12 +12,12 @@ #include #include #include -#include +#include /** * 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); diff --git a/src/SystemStatus.cpp b/src/SystemStatus.cpp new file mode 100644 index 0000000..a529c4a --- /dev/null +++ b/src/SystemStatus.cpp @@ -0,0 +1,26 @@ +#include + + 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); +} diff --git a/src/SystemStatus.h b/src/SystemStatus.h new file mode 100644 index 0000000..4556ea8 --- /dev/null +++ b/src/SystemStatus.h @@ -0,0 +1,35 @@ +#ifndef SystemStatus_h +#define SystemStatus_h + +#if defined(ESP8266) + #include + #include +#elif defined(ESP_PLATFORM) + #include + #include +#endif + +#include +#include +#include +#include + +#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 \ No newline at end of file diff --git a/src/WiFiScanner.cpp b/src/WiFiScanner.cpp index 7bf1591..e3277a5 100644 --- a/src/WiFiScanner.cpp +++ b/src/WiFiScanner.cpp @@ -1,8 +1,12 @@ #include -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) { diff --git a/src/WiFiScanner.h b/src/WiFiScanner.h index 10c4d7a..ca14db0 100644 --- a/src/WiFiScanner.h +++ b/src/WiFiScanner.h @@ -13,6 +13,7 @@ #include #include #include +#include #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: diff --git a/src/WiFiSettingsService.cpp b/src/WiFiSettingsService.cpp index 2f65d5e..9e6c5c2 100644 --- a/src/WiFiSettingsService.cpp +++ b/src/WiFiSettingsService.cpp @@ -1,6 +1,6 @@ #include -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() {} diff --git a/src/WiFiSettingsService.h b/src/WiFiSettingsService.h index 18949c6..1675471 100644 --- a/src/WiFiSettingsService.h +++ b/src/WiFiSettingsService.h @@ -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(); diff --git a/src/WiFiStatus.cpp b/src/WiFiStatus.cpp index 5f27c38..3e6273c 100644 --- a/src/WiFiStatus.cpp +++ b/src/WiFiStatus.cpp @@ -1,7 +1,9 @@ #include -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); diff --git a/src/WiFiStatus.h b/src/WiFiStatus.h index dca96d8..d2372a6 100644 --- a/src/WiFiStatus.h +++ b/src/WiFiStatus.h @@ -13,6 +13,7 @@ #include #include #include +#include #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 diff --git a/src/main.cpp b/src/main.cpp index 23112a6..d3256fb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,28 +10,35 @@ #endif #include + +#include #include -#include -#include #include #include -#include #include +#include +#include +#include +#include #include +#include #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();