mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 07:49:52 +03:00
Merge remote-tracking branch 'origin/v3.4' into dev
This commit is contained in:
62
CHANGELOG.md
62
CHANGELOG.md
@@ -5,68 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
# [3.3.0] - November 28 2021
|
||||
|
||||
## Added
|
||||
|
||||
- Add system commands for syslog level and watch [#98](https://github.com/emsesp/EMS-ESP32/issues/98)
|
||||
- Added pool data to telegrams 0x494 & 0x495 [#102](https://github.com/emsesp/EMS-ESP32/issues/102)
|
||||
- Add RC300 second summermode telegram [#108](https://github.com/emsesp/EMS-ESP32/issues/108)
|
||||
- Add support for the RC25 thermostat [#106](https://github.com/emsesp/EMS-ESP32/issues/106)
|
||||
- Add new command 'entities' for a device, e.g. http://ems-esp/api/boiler/entities to show the shortname, description and HA Entity name (if HA enabled) [#116](https://github.com/emsesp/EMS-ESP32/issues/116)
|
||||
- Support for Junkers program and remote (fb10/fb110) temperature
|
||||
- Home Assistant `state_class` attribute for Wh, kWh, W and KW [#129](https://github.com/emsesp/EMS-ESP32/issues/129)
|
||||
- Add current room influence for RC300 [#136](https://github.com/emsesp/EMS-ESP32/issues/136)
|
||||
- Added Home Assistant device_class to sensor entities
|
||||
- Added another Buderus RC10 thermostat with Product ID 65 [#160](https://github.com/emsesp/EMS-ESP32/issues/160)
|
||||
- Added support for mDNS [#161](https://github.com/emsesp/EMS-ESP32/issues/161)
|
||||
- Added last system ESP32 reset code to log (and `system info` output)
|
||||
- Firmware Checker in WebUI [#168](https://github.com/emsesp/EMS-ESP32/issues/168)
|
||||
- Added new MQTT setting for enabling 'response' topic
|
||||
- Support for non-standard Thermostats like Tado [#174](https://github.com/emsesp/EMS-ESP32/issues/174)
|
||||
- Include MQTT connection status in 'api/system/info'
|
||||
- Include Network status in 'api/system/info' and also the MQTT topic `info` [#202](https://github.com/emsesp/EMS-ESP32/issues/202)
|
||||
- Added Ethernet PHY module as an option in the Board Profile [#210](https://github.com/emsesp/EMS-ESP32/issues/210)
|
||||
|
||||
## Fixed
|
||||
|
||||
- MQTT reconnecting after WiFi reconnect [#99](https://github.com/emsesp/EMS-ESP32/issues/99)
|
||||
- Manually Controlling Solar Circuit [#107](https://github.com/emsesp/EMS-ESP32/issues/107)
|
||||
- Fix thermostat commands not defaulting to the master thermostat [#110](https://github.com/emsesp/EMS-ESP32/issues/110)
|
||||
- Enlarge parse-buffer for long names like `cylinderpumpmodulation`
|
||||
- MQTT not subscribing to all device entities [#166](https://github.com/emsesp/EMS-ESP32/issues/166)
|
||||
- Help fix issues with WebUI unable to fully load UI over Ethernet [#177](https://github.com/emsesp/EMS-ESP32/issues/177)
|
||||
- Shower alert never reset after limit reached when enabled [(PR #185)]
|
||||
- Remove HA entity entries when a device value goes dormant [#196](https://github.com/emsesp/EMS-ESP32/issues/196)
|
||||
- deciphering last error code dates on 0xC2 telegram [#204](https://github.com/emsesp/EMS-ESP32/issues/204)
|
||||
|
||||
## Changed
|
||||
|
||||
- Syslog BOM only for utf-8 messages [#91](https://github.com/emsesp/EMS-ESP32/issues/91)
|
||||
- Check for KM200 by device-id 0x48, remove tx-delay [#90](https://github.com/emsesp/EMS-ESP32/issues/90)
|
||||
- rename `fastheatupfactor` to `fastheatup` and add percent [#122](https://github.com/emsesp/EMS-ESP32/issues/122)
|
||||
- "unit" renamed to "uom" in API call to recall a Device Value
|
||||
- initial backend React changes to replace the class components (HOCs) with React Hooks
|
||||
- Use program-names instead of numbers
|
||||
- Boiler's maintenancemessage always published in MQTT (to prevent HA missing entity)
|
||||
- Unit of Measure 'times' added to MQTT Fails, Rx fails, Rx received, Tx fails, Tx reads & Tx writes
|
||||
- Improved API. Restful HTTP API works in the same way as MQTT calls
|
||||
- Removed settings for MQTT subscribe format [#173](https://github.com/emsesp/EMS-ESP32/issues/173)
|
||||
- Improve Nefit Moduline 200 functionality [#183](https://github.com/emsesp/EMS-ESP32/issues/183)
|
||||
- `status` in the MQTT heartbeat renamed to `bus_status`
|
||||
- Layout changes in the WebUI, showing stripped table rows in Dashboard
|
||||
- Alternative font for log window [#219](https://github.com/emsesp/EMS-ESP32/issues/219)
|
||||
|
||||
## **BREAKING CHANGES**
|
||||
|
||||
- API: "unit" renamed to "uom" in API call to recall a Device Value
|
||||
- HA: `sensor.boiler_boiler_temperature` renamed to `sensor.actual_boiler_temperature`
|
||||
- HA: `binary_sensor.boiler_ww_disinfecting` renamed to `binary_sensor.boiler_ww_disinfection`
|
||||
- HA: # removed from counts in MQTT Fails, Rx fails, Rx received, Tx fails, Tx reads & Tx writes
|
||||
- `txread` renamed to `txreads` and `txwrite` renamed to `txwrites` in MQTT heartbeat payload
|
||||
- 'dallas sensors' in api/system/info moved to the "System" section. Renamed "uptime (seconds)" and "reset reason"
|
||||
- `status` in the MQTT heartbeat renamed to `bus_status`
|
||||
|
||||
# [3.2.1] August 8 2021
|
||||
|
||||
## Added
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
# [3.3.1]
|
||||
# [3.4.0]
|
||||
|
||||
## Added
|
||||
|
||||
- WebUI optimizations, updated look&feel and better performance [#124](https://github.com/emsesp/EMS-ESP32/issues/124)
|
||||
- Auto refresh of WebUI after successful firmware upload [#178](https://github.com/emsesp/EMS-ESP32/issues/178)
|
||||
- New Customization Service in WebUI. First feature is the ability to enable/disabled Enitites (device values) from EMS devices [#206](https://github.com/emsesp/EMS-ESP32/issues/206)
|
||||
- Option to disable Telnet Console [#209](https://github.com/emsesp/EMS-ESP32/issues/209)
|
||||
- Added Hide SSID, Max Clients and Preferred Channel to Access Point
|
||||
- Merged in MichaelDvP's changes like Fahrenheit conversion, publish single (for IOBroker) and a few other critical optimizations
|
||||
- Enabled bi-directional read/write with Home Assistant, so values can be changed automatically from the UI without scripting [#265](https://github.com/emsesp/EMS-ESP32/issues/265)
|
||||
- Added GC7000F Boiler [#270](https://github.com/emsesp/EMS-ESP32/issues/270)
|
||||
- Revised LED flash sequence on boot up to show system health (1 flash=no ems, 2 flashes=no wifi) [#224](https://github.com/emsesp/EMS-ESP32/issues/224)
|
||||
- Analog Sensor support [#271](https://github.com/emsesp/EMS-ESP32/issues/271)
|
||||
- Solar cylinder priority [#247](https://github.com/emsesp/EMS-ESP32/issues/247)
|
||||
- Read only mode in Settings, where EMS Tx/Write commands are blocked [#286](https://github.com/emsesp/EMS-ESP32/issues/286)
|
||||
- Added 8700i Boiler device
|
||||
- Added Cascade CM10 Controller device
|
||||
- Add Olimex ESP32-POE-ISO to board profiles plus settings to customize Ethernet modules [#301](https://github.com/emsesp/EMS-ESP32/issues/301)
|
||||
|
||||
## Fixed
|
||||
|
||||
- lastcode broke MQTT JSON structure [#228](https://github.com/emsesp/EMS-ESP32/issues/228)
|
||||
- overlapping while reading sequence of EMS1.0 telegrams
|
||||
- redundant telegram readings (because of offset overflow)
|
||||
- added missing RC30/Moduline400 [#243](https://github.com/emsesp/EMS-ESP32/issues/243)
|
||||
- check received status before toggling fetch on empty telegram [#268][#282]
|
||||
- fixed issue with overlapping while reading sequence of EMS1.0 telegrams
|
||||
- fixed redundant telegram readings (because of offset overflow)
|
||||
- added missing RC30/Moduline 400 [#243](https://github.com/emsesp/EMS-ESP32/issues/243)
|
||||
- Correct modes for RC25 [#106](https://github.com/emsesp/EMS-ESP32/issues/106)
|
||||
- Clean up old HA config's in MQTT before publishing data. This will prevent HA giving the 'dict' warnings [#229](https://github.com/emsesp/EMS-ESP32/issues/229)
|
||||
- RC25 temperature setting [#272](https://github.com/emsesp/EMS-ESP32/issues/272)
|
||||
- Buderus RC25 - "hc1 mode type" incorrect value [#273](https://github.com/emsesp/EMS-ESP32/issues/273)
|
||||
- Increased number of Mixers and Heating Circuits [#294](https://github.com/emsesp/EMS-ESP32/issues/294)
|
||||
- Check receive status before removing a telegram fetch [#268](https://github.com/emsesp/EMS-ESP32/issues/268), [#282](https://github.com/emsesp/EMS-ESP32/issues/282)
|
||||
|
||||
## Changed
|
||||
|
||||
## **BREAKING CHANGES**
|
||||
- Use flash system to show system health (1 flash=no ems, 2 flashes=no wifi) [#224](https://github.com/emsesp/EMS-ESP32/issues/224)
|
||||
- Renamed Dallas Sensor to Temperature Sensor in UI
|
||||
- Dallas Format removed. Use the name to give each sensor an alias
|
||||
- No longer MQTT subscribes to topic `/thermostat_hc<n>` as it supports a path similar to the API endpoint construct
|
||||
- Show Sensors quality in WebUI
|
||||
- Controller not shown in WebUI dashboard
|
||||
- renamed "Home Assistant Integration" to "MQTT Discovery" in MQTT Settings [#290](https://github.com/emsesp/EMS-ESP32/issues/290)
|
||||
- Show ems tx reads and writes separatly
|
||||
- Show ems device handlers separated for received, fetched and pending handlers.
|
||||
|
||||
## **BREAKING CHANGES:**
|
||||
|
||||
- Settings:
|
||||
- order of Boolean Format has changed in Application Settings - check your settings
|
||||
- Dallas Format setting removed. Now customize name of each Dallas sensor via the UI
|
||||
- MQTT/API
|
||||
- Boiler `wwheat` renamed to `ww3wayon` [#211](https://github.com/emsesp/EMS-ESP32/issues/211)
|
||||
- Boiler `ww` tag renamed to `dhw`. Any custom Home Assistant lovelace dashboards will need updating.
|
||||
- Renamed description of `wwtapactivated` to "turn on/off DHW". Otherwise would have looked like "boiler_dhw_turn_on_off_dhw" in HA.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<img src="/media/EMS-ESP_logo_dark.png" alt="Logo" align="right" height="76"/>
|
||||
|
||||
# Contributing
|
||||
|
||||
**Any contribution helps EMS-ESP get better for the entire community!**
|
||||
@@ -94,7 +92,7 @@ References:
|
||||
|
||||
- <https://www.conventionalcommits.org/>
|
||||
|
||||
--------------------------------------
|
||||
---
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
@@ -133,9 +131,9 @@ A CLA is a legal document in which you state _you are entitled to contribute the
|
||||
|
||||
CLA is a safety because it also ensures that once you have provided a contribution, you cannot try to withdraw permission for its use at a later date. People can therefore use that software, confident that they will not be asked to stop using pieces of the code at a later date.
|
||||
|
||||
A __license__ grants "outbound" rights to the user of project.
|
||||
A **license** grants "outbound" rights to the user of project.
|
||||
|
||||
A __CLA__ enables a contributor to grant "inbound" rights to a project.
|
||||
A **CLA** enables a contributor to grant "inbound" rights to a project.
|
||||
|
||||
<Other>
|
||||
<A table should be maintained for relating maintainers and components. When triaging, this is essential to figure out if someone in particular should be consulted about specific changes.>
|
||||
|
||||
2
Makefile
2
Makefile
@@ -33,7 +33,7 @@ CXX_STANDARD := -std=c++11
|
||||
#----------------------------------------------------------------------
|
||||
# Defined Symbols
|
||||
#----------------------------------------------------------------------
|
||||
DEFINES += -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_DEFAULT_BOARD_PROFILE=\"LOLIN\"
|
||||
DEFINES += -DFACTORY_WIFI_HOSTNAME=\"ems-esp\" -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_DEFAULT_BOARD_PROFILE=\"LOLIN\"
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Sources & Files
|
||||
|
||||
@@ -33,16 +33,16 @@ Note, EMS-ESP requires a small hardware circuit that can convert the EMS bus dat
|
||||
|
||||
# **Features**
|
||||
|
||||
- A multi-user secure web interface to change settings and monitor the data
|
||||
- A console, accessible via Serial and Telnet for more monitoring
|
||||
- Native support for Home Assistant via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
|
||||
- A multi-user secure web interface to change settings and monitor incoming data
|
||||
- A console, accessible via Serial and Telnet for more advanced monitoring
|
||||
- Native support for Home Assistant and Domoticz via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
|
||||
- Can run standalone as an independent WiFi Access Point or join an existing WiFi network
|
||||
- Easy first-time configuration via a web Captive Portal
|
||||
- Support for more than [80 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways)
|
||||
|
||||
## **Demo**
|
||||
|
||||
See a live demo [here](https://ems-esp.derbyshire.nl) using fake data. Log in with any username/password.
|
||||
See a demo [here](https://ems-esp.derbyshire.nl). Log in with any username/password.
|
||||
|
||||
# **Screenshots**
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# This enables lint extensions
|
||||
EXTEND_ESLINT=true
|
||||
|
||||
# This is the name of your project. It appears on the sign-in page and in the menu bar.
|
||||
REACT_APP_PROJECT_NAME=EMS-ESP
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Change the IP address to that of your ESP device to enable local development of the UI
|
||||
|
||||
# REACT_APP_HTTP_ROOT=http://localhost:3000
|
||||
# REACT_APP_WEB_SOCKET_ROOT=ws://localhost:3000
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# don't ever lint node_modules
|
||||
node_modules
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
// 0 = ignore, 1 = warning, 2 = error
|
||||
"no-console": 0,
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
"explicit-function-return-type": 0,
|
||||
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"@typescript-eslint/ban-types": 0,
|
||||
"@typescript-eslint/no-non-null-asserted-optional-chain": 0,
|
||||
"@typescript-eslint/no-non-null-assertion": 0,
|
||||
"@typescript-eslint/no-explicit-any": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80
|
||||
"printWidth": 120
|
||||
}
|
||||
|
||||
@@ -1,52 +1,30 @@
|
||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
||||
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
||||
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CompressionPlugin = require('compression-webpack-plugin');
|
||||
const ProgmemGenerator = require('./progmem-generator.js');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
module.exports = function override(config, env) {
|
||||
const hosted = process.env.REACT_APP_HOSTED;
|
||||
|
||||
if (env === 'production' && !hosted) {
|
||||
console.log('Custom webpack...');
|
||||
|
||||
// rename the output file, we need it's path to be short for LittleFS
|
||||
// rename the ouput file, we need it's path to be short, for embedded FS
|
||||
config.output.filename = 'js/[id].[chunkhash:4].js';
|
||||
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
|
||||
|
||||
// take out the manifest and service worker plugins
|
||||
config.plugins = config.plugins.filter(
|
||||
(plugin) => !(plugin instanceof ManifestPlugin)
|
||||
);
|
||||
config.plugins = config.plugins.filter(
|
||||
(plugin) => !(plugin instanceof WorkboxWebpackPlugin.GenerateSW)
|
||||
);
|
||||
// take out the manifest plugin
|
||||
config.plugins = config.plugins.filter((plugin) => !(plugin instanceof WebpackManifestPlugin));
|
||||
|
||||
// shorten css filenames
|
||||
const miniCssExtractPlugin = config.plugins.find(
|
||||
(plugin) => plugin instanceof MiniCssExtractPlugin
|
||||
);
|
||||
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
|
||||
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
|
||||
miniCssExtractPlugin.options.chunkFilename =
|
||||
'css/[id].[contenthash:4].c.css';
|
||||
miniCssExtractPlugin.options.chunkFilename = 'css/[id].[contenthash:4].c.css';
|
||||
|
||||
// don't emit license file
|
||||
const terserPlugin = config.optimization.minimizer.find((plugin) => plugin instanceof TerserPlugin);
|
||||
terserPlugin.options.extractComments = false;
|
||||
|
||||
// build progmem data files
|
||||
config.plugins.push(
|
||||
new ProgmemGenerator({
|
||||
outputPath: '../lib/framework/WWWData.h',
|
||||
bytesPerLine: 20
|
||||
})
|
||||
);
|
||||
|
||||
// add compression plugin, compress javascript
|
||||
config.plugins.push(
|
||||
new CompressionPlugin({
|
||||
filename: '[path].gz[query]',
|
||||
algorithm: 'gzip',
|
||||
test: /\.(js)$/,
|
||||
deleteOriginalAssets: true
|
||||
})
|
||||
);
|
||||
config.plugins.push(new ProgmemGenerator({ outputPath: '../lib/framework/WWWData.h', bytesPerLine: 20 }));
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
35336
interface/package-lock.json
generated
35336
interface/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,41 @@
|
||||
{
|
||||
"name": "emsesp-react",
|
||||
"version": "0.1.0",
|
||||
"name": "EMS-ESP",
|
||||
"version": "3.4.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:3080",
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.3",
|
||||
"@material-ui/icons": "^4.11.2",
|
||||
"@msgpack/msgpack": "^2.7.0",
|
||||
"@types/lodash": "^4.14.172",
|
||||
"@types/node": "^12.20.20",
|
||||
"@types/react": "^17.0.19",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-material-ui-form-validator": "^2.1.0",
|
||||
"@types/react-router": "^5.1.13",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"compression-webpack-plugin": "^5.0.2",
|
||||
"env-cmd": "^10.1.0",
|
||||
"express": "^4.17.1",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@msgpack/msgpack": "^2.7.1",
|
||||
"@mui/icons-material": "^5.3.0",
|
||||
"@mui/material": "^5.3.0",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/node": "^17.0.10",
|
||||
"@types/react": "^17.0.38",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"async-validator": "^4.0.7",
|
||||
"axios": "^0.25.0",
|
||||
"http-proxy-middleware": "^2.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.30",
|
||||
"notistack": "^1.0.6",
|
||||
"notistack": "^2.0.3",
|
||||
"parse-ms": "^3.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-app-rewired": "^2.1.11",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-form-validator-core": "^1.1.1",
|
||||
"react-material-ui-form-validator": "^2.1.4",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-dropzone": "^11.5.1",
|
||||
"react-icons": "^4.3.1",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"react-scripts": "5.0.0",
|
||||
"sockette": "^2.0.6",
|
||||
"typescript": "4.3.5",
|
||||
"zlib": "^1.0.5"
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject",
|
||||
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"build-hosted": "env-cmd -f .env.hosted npm run build",
|
||||
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
|
||||
@@ -44,7 +44,44 @@
|
||||
"lint": "eslint . --ext .ts,.tsx"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
],
|
||||
"rules": {
|
||||
"eol-last": 1,
|
||||
"react/jsx-closing-bracket-location": 1,
|
||||
"react/jsx-closing-tag-location": 1,
|
||||
"react/jsx-wrap-multilines": 1,
|
||||
"react/jsx-curly-newline": 1,
|
||||
"no-multiple-empty-lines": [
|
||||
1,
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"no-trailing-spaces": 1,
|
||||
"semi": 1,
|
||||
"no-extra-semi": 1,
|
||||
"react/jsx-max-props-per-line": [
|
||||
1,
|
||||
{
|
||||
"when": "multiline"
|
||||
}
|
||||
],
|
||||
"react/jsx-first-prop-new-line": [
|
||||
1,
|
||||
"multiline"
|
||||
],
|
||||
"@typescript-eslint/no-shadow": 1,
|
||||
"max-len": [
|
||||
1,
|
||||
{
|
||||
"code": 150
|
||||
}
|
||||
],
|
||||
"arrow-parens": 1
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -59,13 +96,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^6.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"http-proxy-middleware": "^1.1.1",
|
||||
"nodemon": "^2.0.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.0.5",
|
||||
"react-app-rewired": "^2.1.8"
|
||||
"nodemon": "^2.0.15",
|
||||
"npm-run-all": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
const { resolve, relative, sep } = require('path');
|
||||
const {
|
||||
readdirSync,
|
||||
existsSync,
|
||||
unlinkSync,
|
||||
readFileSync,
|
||||
createWriteStream
|
||||
} = require('fs');
|
||||
const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
|
||||
var zlib = require('zlib');
|
||||
var mime = require('mime-types');
|
||||
|
||||
@@ -36,24 +30,15 @@ function cleanAndOpen(path) {
|
||||
|
||||
class ProgmemGenerator {
|
||||
constructor(options = {}) {
|
||||
const {
|
||||
outputPath,
|
||||
bytesPerLine = 20,
|
||||
indent = ' ',
|
||||
includes = ARDUINO_INCLUDES
|
||||
} = options;
|
||||
const { outputPath, bytesPerLine = 20, indent = ' ', includes = ARDUINO_INCLUDES } = options;
|
||||
this.options = { outputPath, bytesPerLine, indent, includes };
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.emit.tapAsync(
|
||||
{ name: 'ProgmemGenerator' },
|
||||
(compilation, callback) => {
|
||||
compiler.hooks.emit.tapAsync({ name: 'ProgmemGenerator' }, (compilation, callback) => {
|
||||
const { outputPath, bytesPerLine, indent, includes } = this.options;
|
||||
const fileInfo = [];
|
||||
const writeStream = cleanAndOpen(
|
||||
resolve(compilation.options.context, outputPath)
|
||||
);
|
||||
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
|
||||
try {
|
||||
const writeIncludes = () => {
|
||||
writeStream.write(includes);
|
||||
@@ -70,9 +55,7 @@ class ProgmemGenerator {
|
||||
writeStream.write('\n');
|
||||
writeStream.write(indent);
|
||||
}
|
||||
writeStream.write(
|
||||
'0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ','
|
||||
);
|
||||
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ',');
|
||||
size++;
|
||||
});
|
||||
if (size % bytesPerLine) {
|
||||
@@ -98,28 +81,19 @@ class ProgmemGenerator {
|
||||
// process assets
|
||||
const { assets } = compilation;
|
||||
Object.keys(assets).forEach((relativeFilePath) => {
|
||||
writeFile(
|
||||
relativeFilePath,
|
||||
coherseToBuffer(assets[relativeFilePath].source())
|
||||
);
|
||||
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
|
||||
});
|
||||
};
|
||||
|
||||
const generateWWWClass = () => {
|
||||
// eslint-disable-next-line max-len
|
||||
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
|
||||
|
||||
class WWWData {
|
||||
${indent}public:
|
||||
${indent.repeat(
|
||||
2
|
||||
)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo
|
||||
.map(
|
||||
(file) =>
|
||||
`${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${
|
||||
file.variable
|
||||
}, ${file.size});`
|
||||
)
|
||||
.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`)
|
||||
.join('\n')}
|
||||
${indent.repeat(2)}}
|
||||
};
|
||||
@@ -140,8 +114,7 @@ ${indent.repeat(2)}}
|
||||
} finally {
|
||||
writeStream.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
/* Just supporting latin due to size constrains on the esp chip */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local('Roboto Light'), local('Roboto-Light'),
|
||||
url(../fonts/li.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
|
||||
}
|
||||
/*
|
||||
* Just supporting latin due to size constrains on the esp chip
|
||||
*
|
||||
* The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
|
||||
*
|
||||
* If using light or strong typography variants you will need to add additional fonts.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Roboto'), local('Roboto-Regular'),
|
||||
url(../fonts/re.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
|
||||
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC,
|
||||
U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local('Roboto Medium'), local('Roboto-Medium'),
|
||||
url(../fonts/me.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
|
||||
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/md.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC,
|
||||
U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
interface/public/fonts/md.woff2
Normal file
BIN
interface/public/fonts/md.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json">
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, user-scalable=0, maximum-scale=1, minimum-scale=1"
|
||||
/>
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json" />
|
||||
<title>EMS-ESP</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,57 +1,45 @@
|
||||
import React, { Component, RefObject } from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router';
|
||||
import { FC, createRef, createContext, useContext, RefObject } from 'react';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import { IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { FeaturesLoader } from './contexts/features';
|
||||
|
||||
import CustomTheme from './CustomTheme';
|
||||
import AppRouting from './AppRouting';
|
||||
import CustomMuiTheme from './CustomMuiTheme';
|
||||
import { PROJECT_NAME } from './api';
|
||||
import FeaturesWrapper from './features/FeaturesWrapper';
|
||||
|
||||
// this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid.
|
||||
const unauthorizedRedirect = () => <Redirect to="/" />;
|
||||
const App: FC = () => {
|
||||
const notistackRef: RefObject<any> = createRef();
|
||||
|
||||
class App extends Component {
|
||||
notistackRef: RefObject<any> = React.createRef();
|
||||
|
||||
componentDidMount() {
|
||||
document.title = PROJECT_NAME;
|
||||
}
|
||||
|
||||
onClickDismiss = (key: string | number | undefined) => () => {
|
||||
this.notistackRef.current.closeSnackbar(key);
|
||||
const onClickDismiss = (key: string | number | undefined) => () => {
|
||||
notistackRef.current.closeSnackbar(key);
|
||||
};
|
||||
|
||||
render() {
|
||||
const ColorModeContext = createContext({ toggleColorMode: () => {} });
|
||||
|
||||
const colorMode = useContext(ColorModeContext);
|
||||
|
||||
return (
|
||||
<CustomMuiTheme>
|
||||
<ColorModeContext.Provider value={colorMode}>
|
||||
<CustomTheme>
|
||||
<SnackbarProvider
|
||||
autoHideDuration={3000}
|
||||
maxSnack={3}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
ref={this.notistackRef}
|
||||
ref={notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={this.onClickDismiss(key)} size="small">
|
||||
<IconButton onClick={onClickDismiss(key)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
<FeaturesWrapper>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/unauthorized"
|
||||
component={unauthorizedRedirect}
|
||||
/>
|
||||
<Route component={AppRouting} />
|
||||
</Switch>
|
||||
</FeaturesWrapper>
|
||||
<FeaturesLoader>
|
||||
<AppRouting />
|
||||
</FeaturesLoader>
|
||||
</SnackbarProvider>
|
||||
</CustomMuiTheme>
|
||||
</CustomTheme>
|
||||
</ColorModeContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,67 +1,77 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Switch, Redirect } from 'react-router';
|
||||
import { FC, useContext, useEffect } from 'react';
|
||||
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useSnackbar, VariantType } from 'notistack';
|
||||
|
||||
import * as Authentication from './authentication/Authentication';
|
||||
import AuthenticationWrapper from './authentication/AuthenticationWrapper';
|
||||
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
|
||||
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
|
||||
import { Authentication, AuthenticationContext } from './contexts/authentication';
|
||||
import { FeaturesContext } from './contexts/features';
|
||||
import { RequireAuthenticated, RequireUnauthenticated } from './components';
|
||||
|
||||
import SignIn from './SignIn';
|
||||
import ProjectRouting from './project/ProjectRouting';
|
||||
import NetworkConnection from './network/NetworkConnection';
|
||||
import AccessPoint from './ap/AccessPoint';
|
||||
import NetworkTime from './ntp/NetworkTime';
|
||||
import Security from './security/Security';
|
||||
import System from './system/System';
|
||||
import AuthenticatedRouting from './AuthenticatedRouting';
|
||||
|
||||
import { PROJECT_PATH } from './api';
|
||||
import Mqtt from './mqtt/Mqtt';
|
||||
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
|
||||
import { Features } from './features/types';
|
||||
|
||||
export const getDefaultRoute = (features: Features) =>
|
||||
features.project ? `/${PROJECT_PATH}/` : '/network/';
|
||||
class AppRouting extends Component<WithFeaturesProps> {
|
||||
componentDidMount() {
|
||||
Authentication.clearLoginRedirect();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { features } = this.props;
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<Switch>
|
||||
{features.security && (
|
||||
<UnauthenticatedRoute exact path="/" component={SignIn} />
|
||||
)}
|
||||
{features.project && (
|
||||
<AuthenticatedRoute
|
||||
exact
|
||||
path={`/${PROJECT_PATH}/*`}
|
||||
component={ProjectRouting}
|
||||
/>
|
||||
)}
|
||||
<AuthenticatedRoute
|
||||
exact
|
||||
path="/network/*"
|
||||
component={NetworkConnection}
|
||||
/>
|
||||
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
|
||||
{features.ntp && (
|
||||
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
|
||||
)}
|
||||
{features.mqtt && (
|
||||
<AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} />
|
||||
)}
|
||||
{features.security && (
|
||||
<AuthenticatedRoute exact path="/security/*" component={Security} />
|
||||
)}
|
||||
<AuthenticatedRoute exact path="/system/*" component={System} />
|
||||
<Redirect to={getDefaultRoute(features)} />
|
||||
</Switch>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
interface SecurityRedirectProps {
|
||||
message: string;
|
||||
variant?: VariantType;
|
||||
signOut?: boolean;
|
||||
}
|
||||
|
||||
export default withFeatures(AppRouting);
|
||||
const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
useEffect(() => {
|
||||
signOut && authenticationContext.signOut(false);
|
||||
enqueueSnackbar(message, { variant });
|
||||
}, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
|
||||
export const RemoveTrailingSlashes = () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
location.pathname.match('/.*/$') && (
|
||||
<Navigate
|
||||
to={{
|
||||
pathname: location.pathname.replace(/\/+$/, ''),
|
||||
search: location.search
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const AppRouting: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
|
||||
return (
|
||||
<Authentication>
|
||||
<RemoveTrailingSlashes />
|
||||
<Routes>
|
||||
<Route path="/unauthorized" element={<RootRedirect message="Please sign in to continue" signOut />} />
|
||||
<Route
|
||||
path="/firmwareUpdated"
|
||||
element={<RootRedirect message="Firmware update successful" variant="success" />}
|
||||
/>
|
||||
{features.security && (
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireUnauthenticated>
|
||||
<SignIn />
|
||||
</RequireUnauthenticated>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuthenticated>
|
||||
<AuthenticatedRouting />
|
||||
</RequireAuthenticated>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Authentication>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRouting;
|
||||
|
||||
66
interface/src/AuthenticatedRouting.tsx
Normal file
66
interface/src/AuthenticatedRouting.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FC, useCallback, useContext, useEffect } from 'react';
|
||||
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { FeaturesContext } from './contexts/features';
|
||||
import * as AuthenticationApi from './api/authentication';
|
||||
import { PROJECT_PATH } from './api/env';
|
||||
import { AXIOS } from './api/endpoints';
|
||||
import { Layout, RequireAdmin } from './components';
|
||||
|
||||
import ProjectRouting from './project/ProjectRouting';
|
||||
|
||||
import NetworkConnection from './framework/network/NetworkConnection';
|
||||
import AccessPoint from './framework/ap/AccessPoint';
|
||||
import NetworkTime from './framework/ntp/NetworkTime';
|
||||
import Mqtt from './framework/mqtt/Mqtt';
|
||||
import System from './framework/system/System';
|
||||
import Security from './framework/security/Security';
|
||||
|
||||
const AuthenticatedRouting: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleApiResponseError = useCallback(
|
||||
(error: AxiosError) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
AuthenticationApi.storeLoginRedirect(location);
|
||||
navigate('/unauthorized');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
[location, navigate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
|
||||
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
|
||||
}, [handleApiResponseError]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
|
||||
<Route path="/network/*" element={<NetworkConnection />} />
|
||||
<Route path="/ap/*" element={<AccessPoint />} />
|
||||
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
|
||||
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
|
||||
{features.security && (
|
||||
<Route
|
||||
path="/security/*"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Security />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Route path="/system/*" element={<System />} />
|
||||
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatedRouting;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import { CssBaseline } from '@material-ui/core';
|
||||
import {
|
||||
MuiThemeProvider,
|
||||
createTheme,
|
||||
StylesProvider
|
||||
} from '@material-ui/core/styles';
|
||||
import { blueGrey, orange, red, green } from '@material-ui/core/colors';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
type: 'dark',
|
||||
primary: {
|
||||
main: '#33bfff'
|
||||
},
|
||||
secondary: {
|
||||
main: '#3d5afe'
|
||||
},
|
||||
info: {
|
||||
main: blueGrey[500]
|
||||
},
|
||||
warning: {
|
||||
main: orange[500]
|
||||
},
|
||||
error: {
|
||||
main: red[500]
|
||||
},
|
||||
success: {
|
||||
main: green[500]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default class CustomMuiTheme extends Component {
|
||||
render() {
|
||||
return (
|
||||
<StylesProvider>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{this.props.children}
|
||||
</MuiThemeProvider>
|
||||
</StylesProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
47
interface/src/CustomTheme.tsx
Normal file
47
interface/src/CustomTheme.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
|
||||
import { blueGrey, blue } from '@mui/material/colors';
|
||||
|
||||
const theme = responsiveFontSizes(
|
||||
createTheme({
|
||||
typography: {
|
||||
fontSize: 13
|
||||
},
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
// background: {
|
||||
// default: grey[900], // #212121
|
||||
// // paper: grey[800]
|
||||
// },
|
||||
// primary: {
|
||||
// main: '#33bfff'
|
||||
// },
|
||||
secondary: {
|
||||
main: blue[500] // in buttons
|
||||
},
|
||||
info: {
|
||||
main: blueGrey[500] // used in icons
|
||||
}
|
||||
// warning: {
|
||||
// main: orange[500]
|
||||
// },
|
||||
// error: {
|
||||
// main: red[200]
|
||||
// },
|
||||
// success: {
|
||||
// main: green[700]
|
||||
// }
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const CustomTheme: FC = ({ children }) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
export default CustomTheme;
|
||||
@@ -1,165 +1,113 @@
|
||||
import React, { Component } from 'react';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
||||
import { FC, useContext, useState } from 'react';
|
||||
import { ValidateFieldsError } from 'async-validator';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import {
|
||||
withStyles,
|
||||
createStyles,
|
||||
Theme,
|
||||
WithStyles
|
||||
} from '@material-ui/core/styles';
|
||||
import { Paper, Typography, Fab } from '@material-ui/core';
|
||||
import ForwardIcon from '@material-ui/icons/Forward';
|
||||
import { Box, Fab, Paper, Typography } from '@mui/material';
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
|
||||
import {
|
||||
withAuthenticationContext,
|
||||
AuthenticationContextProps
|
||||
} from './authentication/AuthenticationContext';
|
||||
import { PasswordValidator } from './components';
|
||||
import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
|
||||
import * as AuthenticationApi from './api/authentication';
|
||||
import { PROJECT_NAME } from './api/env';
|
||||
import { AuthenticationContext } from './contexts/authentication';
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
signInPage: {
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
margin: 'auto',
|
||||
padding: theme.spacing(2),
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
maxWidth: theme.breakpoints.values.sm
|
||||
},
|
||||
signInPanel: {
|
||||
import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
|
||||
import { SignInRequest } from './types';
|
||||
import { ValidatedTextField } from './components';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
|
||||
|
||||
const SignIn: FC = () => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||
|
||||
const validateAndSignIn = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||
signIn();
|
||||
} catch (errors: any) {
|
||||
setFieldErrors(errors);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
|
||||
authenticationContext.signIn(loginResponse.access_token);
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401) {
|
||||
enqueueSnackbar('Invalid login details', { variant: 'warning' });
|
||||
} else {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Unexpected error, please try again'), { variant: 'error' });
|
||||
}
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitOnEnter = onEnterCallback(signIn);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
height="100vh"
|
||||
margin="auto"
|
||||
padding={2}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
maxWidth={(theme) => theme.breakpoints.values.sm}
|
||||
>
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(2),
|
||||
paddingTop: '200px',
|
||||
backgroundImage: 'url("/app/icon.png")',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '50% ' + theme.spacing(2) + 'px',
|
||||
backgroundPosition: '50% ' + theme.spacing(2),
|
||||
backgroundSize: 'auto 150px',
|
||||
width: '100%'
|
||||
},
|
||||
extendedIcon: {
|
||||
marginRight: theme.spacing(0.5)
|
||||
},
|
||||
button: {
|
||||
marginRight: theme.spacing(2),
|
||||
marginTop: theme.spacing(2)
|
||||
}
|
||||
});
|
||||
|
||||
type SignInProps = WithSnackbarProps &
|
||||
WithStyles<typeof styles> &
|
||||
AuthenticationContextProps;
|
||||
|
||||
interface SignInState {
|
||||
username: string;
|
||||
password: string;
|
||||
processing: boolean;
|
||||
}
|
||||
|
||||
class SignIn extends Component<SignInProps, SignInState> {
|
||||
constructor(props: SignInProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: '',
|
||||
password: '',
|
||||
processing: false
|
||||
};
|
||||
}
|
||||
|
||||
updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { name, value } = event.currentTarget;
|
||||
this.setState((prevState) => ({
|
||||
...prevState,
|
||||
[name]: 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 credentials.');
|
||||
} else {
|
||||
throw Error('Invalid status code: ' + response.status);
|
||||
}
|
||||
})
|
||||
.then((json) => {
|
||||
authenticationContext.signIn(json.access_token);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.props.enqueueSnackbar(error.message, {
|
||||
variant: 'warning'
|
||||
});
|
||||
this.setState({ processing: false });
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { username, password, processing } = this.state;
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.signInPage}>
|
||||
<Paper className={classes.signInPanel}>
|
||||
})}
|
||||
>
|
||||
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||
<ValidatorForm onSubmit={this.onSubmit}>
|
||||
<TextValidator
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
validators={['required']}
|
||||
errorMessages={['Username is required']}
|
||||
name="username"
|
||||
label="Username"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={this.updateInputElement}
|
||||
value={signInRequest.username}
|
||||
onChange={updateLoginRequestValue}
|
||||
margin="normal"
|
||||
inputProps={{
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off'
|
||||
}}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
<PasswordValidator
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
validators={['required']}
|
||||
errorMessages={['Password is required']}
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={this.updateInputElement}
|
||||
value={signInRequest.password}
|
||||
onChange={updateLoginRequestValue}
|
||||
onKeyDown={submitOnEnter}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
<Fab
|
||||
variant="extended"
|
||||
color="primary"
|
||||
className={classes.button}
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
<ForwardIcon className={classes.extendedIcon} />
|
||||
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
|
||||
<ForwardIcon sx={{ mr: 1 }} />
|
||||
Sign In
|
||||
</Fab>
|
||||
</ValidatorForm>
|
||||
</Paper>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default withAuthenticationContext(
|
||||
withSnackbar(withStyles(styles)(SignIn))
|
||||
);
|
||||
export default SignIn;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { APSettings, APProvisionMode } from './types';
|
||||
|
||||
export const isAPEnabled = ({ provision_mode }: APSettings) => {
|
||||
return (
|
||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import { AP_SETTINGS_ENDPOINT } from '../api';
|
||||
import {
|
||||
restController,
|
||||
RestControllerProps,
|
||||
RestFormLoader,
|
||||
SectionContent
|
||||
} from '../components';
|
||||
|
||||
import APSettingsForm from './APSettingsForm';
|
||||
import { APSettings } from './types';
|
||||
|
||||
type APSettingsControllerProps = RestControllerProps<APSettings>;
|
||||
|
||||
class APSettingsController extends Component<APSettingsControllerProps> {
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="Access Point Settings" titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={(formProps) => <APSettingsForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default restController(AP_SETTINGS_ENDPOINT, APSettingsController);
|
||||
@@ -1,134 +0,0 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import {
|
||||
TextValidator,
|
||||
ValidatorForm,
|
||||
SelectValidator
|
||||
} from 'react-material-ui-form-validator';
|
||||
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import {
|
||||
PasswordValidator,
|
||||
RestFormProps,
|
||||
FormActions,
|
||||
FormButton
|
||||
} from '../components';
|
||||
|
||||
import { isAPEnabled } from './APModes';
|
||||
import { APSettings, APProvisionMode } from './types';
|
||||
import { isIP } from '../validators';
|
||||
|
||||
type APSettingsFormProps = RestFormProps<APSettings>;
|
||||
|
||||
class APSettingsForm extends React.Component<APSettingsFormProps> {
|
||||
componentDidMount() {
|
||||
ValidatorForm.addValidationRule('isIP', isIP);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, handleValueChange, saveData } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={saveData} ref="APSettingsForm">
|
||||
<SelectValidator
|
||||
name="provision_mode"
|
||||
label="Provide Access Point…"
|
||||
value={data.provision_mode}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onChange={handleValueChange('provision_mode')}
|
||||
margin="normal"
|
||||
>
|
||||
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>Always</MenuItem>
|
||||
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
||||
When Network Disconnected
|
||||
</MenuItem>
|
||||
<MenuItem value={APProvisionMode.AP_NEVER}>Never</MenuItem>
|
||||
</SelectValidator>
|
||||
{isAPEnabled(data) && (
|
||||
<Fragment>
|
||||
<TextValidator
|
||||
validators={['required', 'matchRegexp:^.{1,32}$']}
|
||||
errorMessages={[
|
||||
'Access Point SSID is required',
|
||||
'Access Point SSID must be 32 characters or less'
|
||||
]}
|
||||
name="ssid"
|
||||
label="Access Point SSID"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.ssid}
|
||||
onChange={handleValueChange('ssid')}
|
||||
margin="normal"
|
||||
/>
|
||||
<PasswordValidator
|
||||
validators={['required', 'matchRegexp:^.{8,64}$']}
|
||||
errorMessages={[
|
||||
'Access Point Password is required',
|
||||
'Access Point Password must be 8-64 characters'
|
||||
]}
|
||||
name="password"
|
||||
label="Access Point Password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.password}
|
||||
onChange={handleValueChange('password')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isIP']}
|
||||
errorMessages={['Local IP is required', 'Must be an IP address']}
|
||||
name="local_ip"
|
||||
label="Local IP"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.local_ip}
|
||||
onChange={handleValueChange('local_ip')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isIP']}
|
||||
errorMessages={[
|
||||
'Gateway IP is required',
|
||||
'Must be an IP address'
|
||||
]}
|
||||
name="gateway_ip"
|
||||
label="Gateway"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.gateway_ip}
|
||||
onChange={handleValueChange('gateway_ip')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isIP']}
|
||||
errorMessages={[
|
||||
'Subnet mask is required',
|
||||
'Must be an IP address'
|
||||
]}
|
||||
name="subnet_mask"
|
||||
label="Subnet"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.subnet_mask}
|
||||
onChange={handleValueChange('subnet_mask')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
<FormActions>
|
||||
<FormButton
|
||||
startIcon={<SaveIcon />}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default APSettingsForm;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Theme } from '@material-ui/core';
|
||||
import { APStatus, APNetworkStatus } from './types';
|
||||
|
||||
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
|
||||
switch (status) {
|
||||
case APNetworkStatus.ACTIVE:
|
||||
return theme.palette.success.main;
|
||||
case APNetworkStatus.INACTIVE:
|
||||
return theme.palette.info.main;
|
||||
case APNetworkStatus.LINGERING:
|
||||
return theme.palette.warning.main;
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
};
|
||||
|
||||
export const apStatus = ({ status }: APStatus) => {
|
||||
switch (status) {
|
||||
case APNetworkStatus.ACTIVE:
|
||||
return 'Active';
|
||||
case APNetworkStatus.INACTIVE:
|
||||
return 'Inactive';
|
||||
case APNetworkStatus.LINGERING:
|
||||
return 'Lingering until idle';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import {
|
||||
restController,
|
||||
RestControllerProps,
|
||||
RestFormLoader,
|
||||
SectionContent
|
||||
} from '../components';
|
||||
import { AP_STATUS_ENDPOINT } from '../api';
|
||||
|
||||
import APStatusForm from './APStatusForm';
|
||||
import { APStatus } from './types';
|
||||
|
||||
type APStatusControllerProps = RestControllerProps<APStatus>;
|
||||
|
||||
class APStatusController extends Component<APStatusControllerProps> {
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="Access Point Status">
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={(formProps) => <APStatusForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default restController(AP_STATUS_ENDPOINT, APStatusController);
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import { WithTheme, withTheme } from '@material-ui/core/styles';
|
||||
import {
|
||||
Avatar,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText
|
||||
} from '@material-ui/core';
|
||||
|
||||
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
|
||||
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
|
||||
import ComputerIcon from '@material-ui/icons/Computer';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
|
||||
import {
|
||||
RestFormProps,
|
||||
FormActions,
|
||||
FormButton,
|
||||
HighlightAvatar
|
||||
} from '../components';
|
||||
import { apStatusHighlight, apStatus } from './APStatus';
|
||||
import { APStatus } from './types';
|
||||
|
||||
type APStatusFormProps = RestFormProps<APStatus> & WithTheme;
|
||||
|
||||
class APStatusForm extends Component<APStatusFormProps> {
|
||||
createListItems() {
|
||||
const { data, theme } = this.props;
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<HighlightAvatar color={apStatusHighlight(data, theme)}>
|
||||
<SettingsInputAntennaIcon />
|
||||
</HighlightAvatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Status" secondary={apStatus(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>IP</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="IP Address" secondary={data.ip_address} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<DeviceHubIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="MAC Address" secondary={data.mac_address} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<ComputerIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="AP Clients" secondary={data.station_num} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<List>{this.createListItems()}</List>
|
||||
<FormActions>
|
||||
<FormButton
|
||||
startIcon={<RefreshIcon />}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={this.props.loadData}
|
||||
>
|
||||
Refresh
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTheme(APStatusForm);
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
import {
|
||||
AuthenticatedContextProps,
|
||||
withAuthenticatedContext,
|
||||
AuthenticatedRoute
|
||||
} from '../authentication';
|
||||
import { MenuAppBar } from '../components';
|
||||
|
||||
import APSettingsController from './APSettingsController';
|
||||
import APStatusController from './APStatusController';
|
||||
|
||||
type AccessPointProps = AuthenticatedContextProps & RouteComponentProps;
|
||||
|
||||
class AccessPoint extends Component<AccessPointProps> {
|
||||
handleTabChange = (path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticatedContext } = this.props;
|
||||
return (
|
||||
<MenuAppBar sectionTitle="Access Point">
|
||||
<Tabs
|
||||
value={this.props.match.url}
|
||||
onChange={(e, path) => this.handleTabChange(path)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab value="/ap/status" label="Access Point Status" />
|
||||
<Tab
|
||||
value="/ap/settings"
|
||||
label="Access Point Settings"
|
||||
disabled={!authenticatedContext.me.admin}
|
||||
/>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute
|
||||
exact
|
||||
path="/ap/status"
|
||||
component={APStatusController}
|
||||
/>
|
||||
<AuthenticatedRoute
|
||||
exact
|
||||
path="/ap/settings"
|
||||
component={APSettingsController}
|
||||
/>
|
||||
<Redirect to="/ap/status" />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(AccessPoint);
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ENDPOINT_ROOT } from './Env';
|
||||
|
||||
export const FEATURES_ENDPOINT = ENDPOINT_ROOT + 'features';
|
||||
export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'ntpStatus';
|
||||
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'ntpSettings';
|
||||
export const TIME_ENDPOINT = ENDPOINT_ROOT + 'time';
|
||||
export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'apSettings';
|
||||
export const AP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'apStatus';
|
||||
export const SCAN_NETWORKS_ENDPOINT = ENDPOINT_ROOT + 'scanNetworks';
|
||||
export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + 'listNetworks';
|
||||
export const NETWORK_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'networkSettings';
|
||||
export const NETWORK_STATUS_ENDPOINT = ENDPOINT_ROOT + 'networkStatus';
|
||||
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'otaSettings';
|
||||
export const UPLOAD_FIRMWARE_ENDPOINT = ENDPOINT_ROOT + 'uploadFirmware';
|
||||
export const MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'mqttSettings';
|
||||
export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + 'mqttStatus';
|
||||
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';
|
||||
export const GENERATE_TOKEN_ENDPOINT = ENDPOINT_ROOT + 'generateToken';
|
||||
export const RESTART_ENDPOINT = ENDPOINT_ROOT + 'restart';
|
||||
export const FACTORY_RESET_ENDPOINT = ENDPOINT_ROOT + 'factoryReset';
|
||||
@@ -1,26 +0,0 @@
|
||||
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME!;
|
||||
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH!;
|
||||
|
||||
export const ENDPOINT_ROOT = calculateEndpointRoot('/rest/');
|
||||
export const WEB_SOCKET_ROOT = calculateWebSocketRoot('/ws/');
|
||||
export const EVENT_SOURCE_ROOT = calculateEndpointRoot('/es/');
|
||||
export const API_ENDPOINT_ROOT = calculateEndpointRoot('/api/');
|
||||
|
||||
function calculateEndpointRoot(endpointPath: string) {
|
||||
const httpRoot = process.env.REACT_APP_HTTP_ROOT;
|
||||
if (httpRoot) {
|
||||
return httpRoot + endpointPath;
|
||||
}
|
||||
const location = window.location;
|
||||
return location.protocol + '//' + location.host + endpointPath;
|
||||
}
|
||||
|
||||
function calculateWebSocketRoot(webSocketPath: string) {
|
||||
const webSocketRoot = process.env.REACT_APP_WEB_SOCKET_ROOT;
|
||||
if (webSocketRoot) {
|
||||
return webSocketRoot + webSocketPath;
|
||||
}
|
||||
const location = window.location;
|
||||
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return webProtocol + '//' + location.host + webSocketPath;
|
||||
}
|
||||
16
interface/src/api/ap.ts
Normal file
16
interface/src/api/ap.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { APSettings, APStatus } from '../types';
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readAPStatus(): AxiosPromise<APStatus> {
|
||||
return AXIOS.get('/apStatus');
|
||||
}
|
||||
|
||||
export function readAPSettings(): AxiosPromise<APSettings> {
|
||||
return AXIOS.get('/apSettings');
|
||||
}
|
||||
|
||||
export function updateAPSettings(apSettings: APSettings): AxiosPromise<APSettings> {
|
||||
return AXIOS.post('/apSettings', apSettings);
|
||||
}
|
||||
64
interface/src/api/authentication.ts
Normal file
64
interface/src/api/authentication.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
import * as H from 'history';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { Path } from 'react-router-dom';
|
||||
|
||||
import { Features, Me, SignInRequest, SignInResponse } from '../types';
|
||||
|
||||
import { ACCESS_TOKEN, AXIOS } from './endpoints';
|
||||
import { PROJECT_PATH } from './env';
|
||||
|
||||
export const SIGN_IN_PATHNAME = 'loginPathname';
|
||||
export const SIGN_IN_SEARCH = 'loginSearch';
|
||||
|
||||
export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
|
||||
|
||||
export function verifyAuthorization(): AxiosPromise<void> {
|
||||
return AXIOS.get('/verifyAuthorization');
|
||||
}
|
||||
|
||||
export function signIn(request: SignInRequest): AxiosPromise<SignInResponse> {
|
||||
return AXIOS.post('/signIn', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
|
||||
*/
|
||||
export function getStorage() {
|
||||
return localStorage || sessionStorage;
|
||||
}
|
||||
|
||||
export function storeLoginRedirect(location?: H.Location) {
|
||||
if (location) {
|
||||
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
||||
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginRedirect() {
|
||||
getStorage().removeItem(SIGN_IN_PATHNAME);
|
||||
getStorage().removeItem(SIGN_IN_SEARCH);
|
||||
}
|
||||
|
||||
export function fetchLoginRedirect(features: Features): Partial<Path> {
|
||||
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
|
||||
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
|
||||
clearLoginRedirect();
|
||||
return {
|
||||
pathname: signInPathname || getDefaultRoute(features),
|
||||
search: (signInPathname && signInSearch) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN);
|
||||
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
|
||||
|
||||
export function addAccessTokenParameter(url: string) {
|
||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||
if (!accessToken) {
|
||||
return url;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
105
interface/src/api/endpoints.ts
Normal file
105
interface/src/api/endpoints.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import axios, { AxiosPromise, CancelToken } from 'axios';
|
||||
|
||||
import { decode } from '@msgpack/msgpack';
|
||||
|
||||
export const WS_BASE_URL = '/ws/';
|
||||
export const API_BASE_URL = '/rest/';
|
||||
export const ES_BASE_URL = '/es/';
|
||||
export const EMSESP_API_BASE_URL = '/api/';
|
||||
export const ACCESS_TOKEN = 'access_token';
|
||||
export const WEB_SOCKET_ROOT = calculateWebSocketRoot(WS_BASE_URL);
|
||||
export const EVENT_SOURCE_ROOT = calculateEventSourceRoot(ES_BASE_URL);
|
||||
|
||||
export const AXIOS = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
if (headers) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
if (headers['Content-Type'] !== 'application/json') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const AXIOS_API = axios.create({
|
||||
baseURL: EMSESP_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
if (headers) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
if (headers['Content-Type'] !== 'application/json') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const AXIOS_BIN = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
if (headers) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
if (headers['Content-Type'] !== 'application/json') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
],
|
||||
transformResponse: [
|
||||
(data) => {
|
||||
return decode(data);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function calculateWebSocketRoot(webSocketPath: string) {
|
||||
const location = window.location;
|
||||
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return webProtocol + '//' + location.host + webSocketPath;
|
||||
}
|
||||
|
||||
function calculateEventSourceRoot(endpointPath: string) {
|
||||
const location = window.location;
|
||||
return location.protocol + '//' + location.host + endpointPath;
|
||||
}
|
||||
|
||||
export interface FileUploadConfig {
|
||||
cancelToken?: CancelToken;
|
||||
onUploadProgress?: (progressEvent: ProgressEvent) => void;
|
||||
}
|
||||
|
||||
export const uploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise<void> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return AXIOS.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
...(config || {})
|
||||
});
|
||||
};
|
||||
2
interface/src/api/env.ts
Normal file
2
interface/src/api/env.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME || 'EMS-ESP';
|
||||
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH || 'project';
|
||||
9
interface/src/api/features.ts
Normal file
9
interface/src/api/features.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { Features } from '../types';
|
||||
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readFeatures(): AxiosPromise<Features> {
|
||||
return AXIOS.get('/features');
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Env';
|
||||
export * from './Endpoints';
|
||||
16
interface/src/api/mqtt.ts
Normal file
16
interface/src/api/mqtt.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { MqttSettings, MqttStatus } from '../types';
|
||||
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readMqttStatus(): AxiosPromise<MqttStatus> {
|
||||
return AXIOS.get('/mqttStatus');
|
||||
}
|
||||
|
||||
export function readMqttSettings(): AxiosPromise<MqttSettings> {
|
||||
return AXIOS.get('/mqttSettings');
|
||||
}
|
||||
|
||||
export function updateMqttSettings(ntpSettings: MqttSettings): AxiosPromise<MqttSettings> {
|
||||
return AXIOS.post('/mqttSettings', ntpSettings);
|
||||
}
|
||||
25
interface/src/api/network.ts
Normal file
25
interface/src/api/network.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
|
||||
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
|
||||
return AXIOS.get('/networkStatus');
|
||||
}
|
||||
|
||||
export function scanNetworks(): AxiosPromise<void> {
|
||||
return AXIOS.get('/scanNetworks');
|
||||
}
|
||||
|
||||
export function listNetworks(): AxiosPromise<WiFiNetworkList> {
|
||||
return AXIOS.get('/listNetworks');
|
||||
}
|
||||
|
||||
export function readNetworkSettings(): AxiosPromise<NetworkSettings> {
|
||||
return AXIOS.get('/networkSettings');
|
||||
}
|
||||
|
||||
export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise<NetworkSettings> {
|
||||
return AXIOS.post('/networkSettings', wifiSettings);
|
||||
}
|
||||
20
interface/src/api/ntp.ts
Normal file
20
interface/src/api/ntp.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { NTPSettings, NTPStatus, Time } from '../types';
|
||||
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readNTPStatus(): AxiosPromise<NTPStatus> {
|
||||
return AXIOS.get('/ntpStatus');
|
||||
}
|
||||
|
||||
export function readNTPSettings(): AxiosPromise<NTPSettings> {
|
||||
return AXIOS.get('/ntpSettings');
|
||||
}
|
||||
|
||||
export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
|
||||
return AXIOS.post('/ntpSettings', ntpSettings);
|
||||
}
|
||||
|
||||
export function updateTime(time: Time): AxiosPromise<Time> {
|
||||
return AXIOS.post('/time', time);
|
||||
}
|
||||
17
interface/src/api/security.ts
Normal file
17
interface/src/api/security.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { SecuritySettings, Token } from '../types';
|
||||
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readSecuritySettings(): AxiosPromise<SecuritySettings> {
|
||||
return AXIOS.get('/securitySettings');
|
||||
}
|
||||
|
||||
export function updateSecuritySettings(securitySettings: SecuritySettings): AxiosPromise<SecuritySettings> {
|
||||
return AXIOS.post('/securitySettings', securitySettings);
|
||||
}
|
||||
|
||||
export function generateToken(username?: string): AxiosPromise<Token> {
|
||||
return AXIOS.get('/generateToken', { params: { username } });
|
||||
}
|
||||
40
interface/src/api/system.ts
Normal file
40
interface/src/api/system.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
|
||||
|
||||
import { AXIOS, AXIOS_BIN, FileUploadConfig, uploadFile } from './endpoints';
|
||||
|
||||
export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
|
||||
return AXIOS.get('/systemStatus', { timeout });
|
||||
}
|
||||
|
||||
export function restart(): AxiosPromise<void> {
|
||||
return AXIOS.post('/restart');
|
||||
}
|
||||
|
||||
export function factoryReset(): AxiosPromise<void> {
|
||||
return AXIOS.post('/factoryReset');
|
||||
}
|
||||
|
||||
export function readOTASettings(): AxiosPromise<OTASettings> {
|
||||
return AXIOS.get('/otaSettings');
|
||||
}
|
||||
|
||||
export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
|
||||
return AXIOS.post('/otaSettings', otaSettings);
|
||||
}
|
||||
|
||||
export const uploadFirmware = (file: File, config?: FileUploadConfig): AxiosPromise<void> =>
|
||||
uploadFile('/uploadFirmware', file, config);
|
||||
|
||||
export function readLogSettings(): AxiosPromise<LogSettings> {
|
||||
return AXIOS.get('/logSettings');
|
||||
}
|
||||
|
||||
export function updateLogSettings(logSettings: LogSettings): AxiosPromise<LogSettings> {
|
||||
return AXIOS.post('/logSettings', logSettings);
|
||||
}
|
||||
|
||||
export function readLogEntries(): AxiosPromise<LogEntries> {
|
||||
return AXIOS_BIN.get('/fetchLog');
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Redirect,
|
||||
Route,
|
||||
RouteProps,
|
||||
RouteComponentProps
|
||||
} from 'react-router-dom';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
|
||||
import * as Authentication from './Authentication';
|
||||
import {
|
||||
withAuthenticationContext,
|
||||
AuthenticationContextProps,
|
||||
AuthenticatedContext,
|
||||
AuthenticatedContextValue
|
||||
} from './AuthenticationContext';
|
||||
|
||||
interface AuthenticatedRouteProps
|
||||
extends RouteProps,
|
||||
WithSnackbarProps,
|
||||
AuthenticationContextProps {
|
||||
component:
|
||||
| React.ComponentType<RouteComponentProps<any>>
|
||||
| React.ComponentType<any>;
|
||||
}
|
||||
|
||||
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
|
||||
|
||||
export class AuthenticatedRoute extends React.Component<AuthenticatedRouteProps> {
|
||||
render() {
|
||||
const {
|
||||
enqueueSnackbar,
|
||||
authenticationContext,
|
||||
component: Component,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { location } = this.props;
|
||||
const renderComponent: RenderComponent = (props) => {
|
||||
if (authenticationContext.me) {
|
||||
return (
|
||||
<AuthenticatedContext.Provider
|
||||
value={authenticationContext as AuthenticatedContextValue}
|
||||
>
|
||||
<Component {...props} />
|
||||
</AuthenticatedContext.Provider>
|
||||
);
|
||||
}
|
||||
Authentication.storeLoginRedirect(location);
|
||||
enqueueSnackbar('Please sign in to continue', { variant: 'info' });
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
return <Route {...rest} render={renderComponent} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default withSnackbar(withAuthenticationContext(AuthenticatedRoute));
|
||||
@@ -1,129 +0,0 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import history from '../history';
|
||||
import { Features } from '../features/types';
|
||||
import { getDefaultRoute } from '../AppRouting';
|
||||
|
||||
export const ACCESS_TOKEN = 'access_token';
|
||||
export const SIGN_IN_PATHNAME = 'signInPathname';
|
||||
export const SIGN_IN_SEARCH = 'signInSearch';
|
||||
|
||||
/**
|
||||
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
|
||||
*/
|
||||
export function getStorage() {
|
||||
return localStorage || sessionStorage;
|
||||
}
|
||||
|
||||
export function storeLoginRedirect(location?: H.Location) {
|
||||
if (location) {
|
||||
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
||||
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginRedirect() {
|
||||
getStorage().removeItem(SIGN_IN_PATHNAME);
|
||||
getStorage().removeItem(SIGN_IN_SEARCH);
|
||||
}
|
||||
|
||||
export function fetchLoginRedirect(
|
||||
features: Features
|
||||
): H.LocationDescriptorObject {
|
||||
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
|
||||
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
|
||||
clearLoginRedirect();
|
||||
return {
|
||||
pathname: signInPathname || getDefaultRoute(features),
|
||||
search: (signInPathname && signInSearch) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the normal fetch routine with one with provides the access token if present.
|
||||
*/
|
||||
export function authorizedFetch(
|
||||
url: RequestInfo,
|
||||
params?: RequestInit
|
||||
): Promise<Response> {
|
||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
params = params || {};
|
||||
params.credentials = 'include';
|
||||
params.headers = {
|
||||
...params.headers,
|
||||
Authorization: 'Bearer ' + accessToken
|
||||
};
|
||||
}
|
||||
return fetch(url, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request
|
||||
* for a single file upload and takes care of adding the Authorization header and redirecting on
|
||||
* authorization errors as we do for normal fetch operations.
|
||||
*/
|
||||
export function redirectingAuthorizedUpload(
|
||||
xhr: XMLHttpRequest,
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress: (event: ProgressEvent<EventTarget>) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.open('POST', url, true);
|
||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
|
||||
}
|
||||
xhr.upload.onprogress = onProgress;
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 401 || xhr.status === 403) {
|
||||
history.push('/unauthorized');
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
reject(new DOMException('Error', 'UploadError'));
|
||||
};
|
||||
xhr.onabort = function () {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the normal fetch routine which redirects on 401 response.
|
||||
*/
|
||||
export function redirectingAuthorizedFetch(
|
||||
url: RequestInfo,
|
||||
params?: RequestInit
|
||||
): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
authorizedFetch(url, params)
|
||||
.then((response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
history.push('/unauthorized');
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function addAccessTokenParameter(url: string) {
|
||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||
if (!accessToken) {
|
||||
return url;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface Me {
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticationContextValue {
|
||||
refresh: () => void;
|
||||
signIn: (accessToken: string) => void;
|
||||
signOut: () => void;
|
||||
me?: Me;
|
||||
}
|
||||
|
||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
||||
export const AuthenticationContext = React.createContext(
|
||||
AuthenticationContextDefaultValue
|
||||
);
|
||||
|
||||
export interface AuthenticationContextProps {
|
||||
authenticationContext: AuthenticationContextValue;
|
||||
}
|
||||
|
||||
export function withAuthenticationContext<T extends AuthenticationContextProps>(
|
||||
Component: React.ComponentType<T>
|
||||
) {
|
||||
return class extends React.Component<
|
||||
Omit<T, keyof AuthenticationContextProps>
|
||||
> {
|
||||
render() {
|
||||
return (
|
||||
<AuthenticationContext.Consumer>
|
||||
{(authenticationContext) => (
|
||||
<Component
|
||||
{...(this.props as T)}
|
||||
authenticationContext={authenticationContext}
|
||||
/>
|
||||
)}
|
||||
</AuthenticationContext.Consumer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthenticatedContextValue extends AuthenticationContextValue {
|
||||
me: Me;
|
||||
}
|
||||
|
||||
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
|
||||
export const AuthenticatedContext = React.createContext(
|
||||
AuthenticatedContextDefaultValue
|
||||
);
|
||||
|
||||
export interface AuthenticatedContextProps {
|
||||
authenticatedContext: AuthenticatedContextValue;
|
||||
}
|
||||
|
||||
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(
|
||||
Component: React.ComponentType<T>
|
||||
) {
|
||||
return class extends React.Component<
|
||||
Omit<T, keyof AuthenticatedContextProps>
|
||||
> {
|
||||
render() {
|
||||
return (
|
||||
<AuthenticatedContext.Consumer>
|
||||
{(authenticatedContext) => (
|
||||
<Component
|
||||
{...(this.props as T)}
|
||||
authenticatedContext={authenticatedContext}
|
||||
/>
|
||||
)}
|
||||
</AuthenticatedContext.Consumer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
|
||||
import history from '../history';
|
||||
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
|
||||
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
|
||||
import {
|
||||
AuthenticationContext,
|
||||
AuthenticationContextValue,
|
||||
Me
|
||||
} from './AuthenticationContext';
|
||||
import FullScreenLoading from '../components/FullScreenLoading';
|
||||
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
|
||||
|
||||
export const decodeMeJWT = (accessToken: string): Me =>
|
||||
jwtDecode(accessToken) as Me;
|
||||
|
||||
interface AuthenticationWrapperState {
|
||||
context: AuthenticationContextValue;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
|
||||
|
||||
class AuthenticationWrapper extends React.Component<
|
||||
AuthenticationWrapperProps,
|
||||
AuthenticationWrapperState
|
||||
> {
|
||||
constructor(props: AuthenticationWrapperProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
context: {
|
||||
refresh: this.refresh,
|
||||
signIn: this.signIn,
|
||||
signOut: this.signOut
|
||||
},
|
||||
initialized: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.state.initialized
|
||||
? this.renderContent()
|
||||
: this.renderContentLoading()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
return (
|
||||
<AuthenticationContext.Provider value={this.state.context}>
|
||||
{this.props.children}
|
||||
</AuthenticationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
renderContentLoading() {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
// commented out, always need security - proddy
|
||||
// if (!this.props.features.security) {
|
||||
// this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
|
||||
// return;
|
||||
// }
|
||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
|
||||
.then((response) => {
|
||||
const me =
|
||||
response.status === 200 ? decodeMeJWT(accessToken) : undefined;
|
||||
this.setState({
|
||||
initialized: true,
|
||||
context: { ...this.state.context, me }
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({
|
||||
initialized: true,
|
||||
context: { ...this.state.context, me: undefined }
|
||||
});
|
||||
this.props.enqueueSnackbar(
|
||||
'Error verifying authorization: ' + error.message,
|
||||
{
|
||||
variant: 'error'
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
initialized: true,
|
||||
context: { ...this.state.context, me: undefined }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
signIn = (accessToken: string) => {
|
||||
try {
|
||||
getStorage().setItem(ACCESS_TOKEN, accessToken);
|
||||
const me: Me = decodeMeJWT(accessToken);
|
||||
this.setState({ context: { ...this.state.context, me } });
|
||||
this.props.enqueueSnackbar(`Logged in as ${me.username}`, {
|
||||
variant: 'success'
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
initialized: true,
|
||||
context: { ...this.state.context, me: undefined }
|
||||
});
|
||||
throw new Error('Failed to parse JWT ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
signOut = () => {
|
||||
getStorage().removeItem(ACCESS_TOKEN);
|
||||
this.setState({
|
||||
context: {
|
||||
...this.state.context,
|
||||
me: undefined
|
||||
}
|
||||
});
|
||||
this.props.enqueueSnackbar('You have signed out', { variant: 'success' });
|
||||
history.push('/');
|
||||
};
|
||||
}
|
||||
|
||||
export default withFeatures(withSnackbar(AuthenticationWrapper));
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Redirect,
|
||||
Route,
|
||||
RouteProps,
|
||||
RouteComponentProps
|
||||
} from 'react-router-dom';
|
||||
|
||||
import {
|
||||
withAuthenticationContext,
|
||||
AuthenticationContextProps
|
||||
} from './AuthenticationContext';
|
||||
import * as Authentication from './Authentication';
|
||||
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
|
||||
|
||||
interface UnauthenticatedRouteProps
|
||||
extends RouteProps,
|
||||
AuthenticationContextProps,
|
||||
WithFeaturesProps {
|
||||
component:
|
||||
| React.ComponentType<RouteComponentProps<any>>
|
||||
| React.ComponentType<any>;
|
||||
}
|
||||
|
||||
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> {
|
||||
public render() {
|
||||
const { authenticationContext, features, ...rest } = this.props;
|
||||
if (authenticationContext.me) {
|
||||
return <Redirect to={Authentication.fetchLoginRedirect(features)} />;
|
||||
}
|
||||
return <Route {...rest} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default withFeatures(withAuthenticationContext(UnauthenticatedRoute));
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as AuthenticatedRoute } from './AuthenticatedRoute';
|
||||
export { default as AuthenticationWrapper } from './AuthenticationWrapper';
|
||||
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
|
||||
|
||||
export * from './Authentication';
|
||||
export * from './AuthenticationContext';
|
||||
@@ -1,59 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import { Paper, Typography, Box, CssBaseline } from '@material-ui/core';
|
||||
import WarningIcon from '@material-ui/icons/Warning';
|
||||
|
||||
const styles = makeStyles({
|
||||
siteErrorPage: {
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
siteErrorPagePanel: {
|
||||
textAlign: 'center',
|
||||
padding: '280px 0 40px 0',
|
||||
backgroundImage: 'url("/app/icon.png")',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '50% 40px',
|
||||
backgroundSize: '200px auto',
|
||||
width: '100%'
|
||||
}
|
||||
});
|
||||
|
||||
interface ApplicationErrorProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ApplicationError: FC<ApplicationErrorProps> = ({ error }) => {
|
||||
const classes = styles();
|
||||
return (
|
||||
<div className={classes.siteErrorPage}>
|
||||
<CssBaseline />
|
||||
<Paper className={classes.siteErrorPagePanel} elevation={10}>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
mb={2}
|
||||
>
|
||||
<WarningIcon fontSize="large" color="error" />
|
||||
<Box ml={2}>
|
||||
<Typography variant="h4">Application error</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Failed to configure the application, please refresh to try again.
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Error: {error}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationError;
|
||||
26
interface/src/components/ButtonRow.tsx
Normal file
26
interface/src/components/ButtonRow.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FC } from 'react';
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
|
||||
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'& button, & a, & .MuiCard-root': {
|
||||
mt: 2,
|
||||
mx: 0.6,
|
||||
'&:last-child': {
|
||||
mr: 0
|
||||
},
|
||||
'&:first-of-type': {
|
||||
ml: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonRow;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Button, styled } from '@material-ui/core';
|
||||
|
||||
const ErrorButton = styled(Button)(({ theme }) => ({
|
||||
color: theme.palette.getContrastText(theme.palette.error.main),
|
||||
backgroundColor: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.error.dark
|
||||
}
|
||||
}));
|
||||
|
||||
export default ErrorButton;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { styled, Box } from '@material-ui/core';
|
||||
|
||||
const FormActions = styled(Box)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1)
|
||||
}));
|
||||
|
||||
export default FormActions;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Button, styled } from '@material-ui/core';
|
||||
|
||||
const FormButton = styled(Button)(({ theme }) => ({
|
||||
margin: theme.spacing(0, 1),
|
||||
'&:last-child': {
|
||||
marginRight: 0
|
||||
},
|
||||
'&:first-child': {
|
||||
marginLeft: 0
|
||||
}
|
||||
}));
|
||||
|
||||
export default FormButton;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
||||
import { Button, LinearProgress, Typography } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
loadingSettings: {
|
||||
margin: theme.spacing(0.5)
|
||||
},
|
||||
loadingSettingsDetails: {
|
||||
margin: theme.spacing(4),
|
||||
textAlign: 'center'
|
||||
},
|
||||
button: {
|
||||
marginRight: theme.spacing(2),
|
||||
marginTop: theme.spacing(2)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
interface FormLoaderProps {
|
||||
errorMessage?: string;
|
||||
loadData: () => void;
|
||||
}
|
||||
|
||||
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, loadData }) => {
|
||||
const classes = useStyles();
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className={classes.loadingSettings}>
|
||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={loadData}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={classes.loadingSettings}>
|
||||
<LinearProgress className={classes.loadingSettingsDetails} />
|
||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
||||
Loading…
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormLoader;
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import { Typography, Theme } from '@material-ui/core';
|
||||
import { makeStyles, createStyles } from '@material-ui/styles';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
fullScreenLoading: {
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
progress: {
|
||||
margin: theme.spacing(4)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const FullScreenLoading = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className={classes.fullScreenLoading}>
|
||||
<CircularProgress className={classes.progress} size={100} />
|
||||
<Typography variant="h4">Loading…</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullScreenLoading;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Avatar, makeStyles } from '@material-ui/core';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface HighlightAvatarProps {
|
||||
color: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: (props: HighlightAvatarProps) => ({
|
||||
backgroundColor: props.color
|
||||
})
|
||||
});
|
||||
|
||||
const HighlightAvatar: FC<HighlightAvatarProps> = (props) => {
|
||||
const classes = useStyles(props);
|
||||
return <Avatar className={classes.root}>{props.children}</Avatar>;
|
||||
};
|
||||
|
||||
export default HighlightAvatar;
|
||||
@@ -1,390 +0,0 @@
|
||||
import React, { RefObject, Fragment } from 'react';
|
||||
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Avatar,
|
||||
Divider,
|
||||
Button,
|
||||
Box,
|
||||
IconButton
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
ClickAwayListener,
|
||||
Popper,
|
||||
Hidden,
|
||||
Typography
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemAvatar
|
||||
} from '@material-ui/core';
|
||||
import { Card, CardContent, CardActions } from '@material-ui/core';
|
||||
|
||||
import {
|
||||
withStyles,
|
||||
createStyles,
|
||||
Theme,
|
||||
WithTheme,
|
||||
WithStyles,
|
||||
withTheme
|
||||
} from '@material-ui/core/styles';
|
||||
|
||||
import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet';
|
||||
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 DeviceHubIcon from '@material-ui/icons/DeviceHub';
|
||||
import LockIcon from '@material-ui/icons/Lock';
|
||||
import MenuIcon from '@material-ui/icons/Menu';
|
||||
|
||||
import ProjectMenu from '../project/ProjectMenu';
|
||||
import { PROJECT_NAME } from '../api';
|
||||
import {
|
||||
withAuthenticatedContext,
|
||||
AuthenticatedContextProps
|
||||
} from '../authentication';
|
||||
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
|
||||
|
||||
const drawerWidth = 290;
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
display: 'flex'
|
||||
},
|
||||
drawer: {
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0
|
||||
}
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1
|
||||
},
|
||||
appBar: {
|
||||
marginLeft: drawerWidth,
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: `calc(100% - ${drawerWidth}px)`
|
||||
}
|
||||
},
|
||||
toolbarImage: {
|
||||
[theme.breakpoints.up('xs')]: {
|
||||
height: 24,
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
height: 36,
|
||||
marginRight: theme.spacing(3)
|
||||
}
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: theme.spacing(2),
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'none'
|
||||
}
|
||||
},
|
||||
toolbar: theme.mixins.toolbar,
|
||||
drawerPaper: {
|
||||
width: drawerWidth
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1
|
||||
},
|
||||
authMenu: {
|
||||
zIndex: theme.zIndex.tooltip,
|
||||
maxWidth: 400
|
||||
},
|
||||
authMenuActions: {
|
||||
padding: theme.spacing(2),
|
||||
'& > * + *': {
|
||||
marginLeft: theme.spacing(2)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface MenuAppBarState {
|
||||
mobileOpen: boolean;
|
||||
authMenuOpen: boolean;
|
||||
}
|
||||
|
||||
interface MenuAppBarProps
|
||||
extends WithFeaturesProps,
|
||||
AuthenticatedContextProps,
|
||||
WithTheme,
|
||||
WithStyles<typeof styles>,
|
||||
RouteComponentProps {
|
||||
sectionTitle: string;
|
||||
}
|
||||
|
||||
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
|
||||
constructor(props: MenuAppBarProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
mobileOpen: false,
|
||||
authMenuOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
anchorRef: RefObject<HTMLButtonElement> = React.createRef();
|
||||
|
||||
handleToggle = () => {
|
||||
this.setState({ authMenuOpen: !this.state.authMenuOpen });
|
||||
};
|
||||
|
||||
handleClose = (event: React.MouseEvent<Document>) => {
|
||||
if (
|
||||
this.anchorRef.current &&
|
||||
this.anchorRef.current.contains(event.currentTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.setState({ authMenuOpen: false });
|
||||
};
|
||||
|
||||
handleDrawerToggle = () => {
|
||||
this.setState({ mobileOpen: !this.state.mobileOpen });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
classes,
|
||||
theme,
|
||||
children,
|
||||
sectionTitle,
|
||||
authenticatedContext,
|
||||
features
|
||||
} = this.props;
|
||||
const { mobileOpen, authMenuOpen } = this.state;
|
||||
const path = this.props.match.url;
|
||||
const drawer = (
|
||||
<div>
|
||||
<Toolbar>
|
||||
<Box display="flex">
|
||||
<img
|
||||
src="/app/icon.png"
|
||||
className={classes.toolbarImage}
|
||||
alt={PROJECT_NAME}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" color="textPrimary">
|
||||
{PROJECT_NAME}
|
||||
</Typography>
|
||||
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
|
||||
{features.project && (
|
||||
<Fragment>
|
||||
<ProjectMenu />
|
||||
<Divider />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
to="/network/"
|
||||
selected={path.startsWith('/network/')}
|
||||
button
|
||||
component={Link}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SettingsEthernetIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Network Connection" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
to="/ap/"
|
||||
selected={path.startsWith('/ap/')}
|
||||
button
|
||||
component={Link}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SettingsInputAntennaIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Access Point" />
|
||||
</ListItem>
|
||||
{features.ntp && (
|
||||
<ListItem
|
||||
to="/ntp/"
|
||||
selected={path.startsWith('/ntp/')}
|
||||
button
|
||||
component={Link}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AccessTimeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Network Time" />
|
||||
</ListItem>
|
||||
)}
|
||||
{features.mqtt && (
|
||||
<ListItem
|
||||
to="/mqtt/"
|
||||
selected={path.startsWith('/mqtt/')}
|
||||
button
|
||||
component={Link}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DeviceHubIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="MQTT" />
|
||||
</ListItem>
|
||||
)}
|
||||
{features.security && (
|
||||
<ListItem
|
||||
to="/security/"
|
||||
selected={path.startsWith('/security/')}
|
||||
button
|
||||
component={Link}
|
||||
disabled={!authenticatedContext.me.admin}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<LockIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Security" />
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem
|
||||
to="/system/"
|
||||
selected={path.startsWith('/system/')}
|
||||
button
|
||||
component={Link}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="System" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
|
||||
const userMenu = (
|
||||
<div>
|
||||
<IconButton
|
||||
ref={this.anchorRef}
|
||||
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
|
||||
aria-haspopup="true"
|
||||
onClick={this.handleToggle}
|
||||
color="inherit"
|
||||
>
|
||||
<AccountCircleIcon />
|
||||
</IconButton>
|
||||
<Popper
|
||||
open={authMenuOpen}
|
||||
anchorEl={this.anchorRef.current}
|
||||
transition
|
||||
className={classes.authMenu}
|
||||
>
|
||||
<ClickAwayListener onClickAway={this.handleClose}>
|
||||
<Card id="menu-list-grow">
|
||||
<CardContent>
|
||||
<List disablePadding>
|
||||
<ListItem disableGutters>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AccountCircleIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
'Signed in as: ' + authenticatedContext.me.username
|
||||
}
|
||||
secondary={
|
||||
authenticatedContext.me.admin ? 'Admin User' : undefined
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
<Divider />
|
||||
<CardActions className={classes.authMenuActions}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
color="primary"
|
||||
onClick={authenticatedContext.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<AppBar position="fixed" className={classes.appBar} elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="Open drawer"
|
||||
edge="start"
|
||||
onClick={this.handleDrawerToggle}
|
||||
className={classes.menuButton}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
noWrap
|
||||
className={classes.title}
|
||||
>
|
||||
{sectionTitle}
|
||||
</Typography>
|
||||
{features.security && userMenu}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<nav className={classes.drawer}>
|
||||
<Hidden mdUp implementation="css">
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
|
||||
open={mobileOpen}
|
||||
onClose={this.handleDrawerToggle}
|
||||
classes={{
|
||||
paper: classes.drawerPaper
|
||||
}}
|
||||
ModalProps={{
|
||||
keepMounted: true
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Hidden>
|
||||
<Hidden smDown implementation="css">
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.drawerPaper
|
||||
}}
|
||||
variant="permanent"
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Hidden>
|
||||
</nav>
|
||||
<main className={classes.content}>
|
||||
<div className={classes.toolbar} />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(
|
||||
withTheme(
|
||||
withFeatures(withAuthenticatedContext(withStyles(styles)(MenuAppBar)))
|
||||
)
|
||||
);
|
||||
48
interface/src/components/MessageBox.tsx
Normal file
48
interface/src/components/MessageBox.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Box, BoxProps, SvgIconProps, Theme, Typography, useTheme } from '@mui/material';
|
||||
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
|
||||
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||
|
||||
export interface MessageBoxProps extends BoxProps {
|
||||
level: MessageBoxLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const LEVEL_ICONS: { [type in MessageBoxLevel]: React.ComponentType<SvgIconProps> } = {
|
||||
success: CheckCircleOutlineOutlinedIcon,
|
||||
info: InfoOutlinedIcon,
|
||||
warning: ReportProblemOutlinedIcon,
|
||||
error: ErrorIcon
|
||||
};
|
||||
|
||||
const LEVEL_BACKGROUNDS: { [type in MessageBoxLevel]: (theme: Theme) => string } = {
|
||||
success: (theme: Theme) => theme.palette.success.dark,
|
||||
info: (theme: Theme) => theme.palette.info.main,
|
||||
warning: (theme: Theme) => theme.palette.warning.dark,
|
||||
error: (theme: Theme) => theme.palette.error.dark
|
||||
};
|
||||
|
||||
const MessageBox: FC<MessageBoxProps> = ({ level, message, sx, children, ...rest }) => {
|
||||
const theme = useTheme();
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
|
||||
// const color = theme.palette.getContrastText(backgroundColor);
|
||||
const color = 'white';
|
||||
return (
|
||||
<Box p={2} display="flex" alignItems="center" borderRadius={1} sx={{ backgroundColor, color, ...sx }} {...rest}>
|
||||
<Icon />
|
||||
<Typography sx={{ ml: 2, flexGrow: 1 }} variant="body1">
|
||||
{message}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBox;
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TextValidator,
|
||||
ValidatorComponentProps
|
||||
} from 'react-material-ui-form-validator';
|
||||
|
||||
import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
|
||||
import { InputAdornment, IconButton } from '@material-ui/core';
|
||||
import { Visibility, VisibilityOff } from '@material-ui/icons';
|
||||
|
||||
const styles = createStyles({
|
||||
input: {
|
||||
'&::-ms-reveal': {
|
||||
display: 'none'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type PasswordValidatorProps = WithStyles<typeof styles> &
|
||||
Exclude<ValidatorComponentProps, 'type' | 'InputProps'>;
|
||||
|
||||
interface PasswordValidatorState {
|
||||
showPassword: boolean;
|
||||
}
|
||||
|
||||
class PasswordValidator extends React.Component<
|
||||
PasswordValidatorProps,
|
||||
PasswordValidatorState
|
||||
> {
|
||||
state = {
|
||||
showPassword: false
|
||||
};
|
||||
|
||||
toggleShowPassword = () => {
|
||||
this.setState({
|
||||
showPassword: !this.state.showPassword
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { classes, ...rest } = this.props;
|
||||
return (
|
||||
<TextValidator
|
||||
{...rest}
|
||||
type={this.state.showPassword ? 'text' : 'password'}
|
||||
InputProps={{
|
||||
classes,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="Toggle password visibility"
|
||||
onClick={this.toggleShowPassword}
|
||||
>
|
||||
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(PasswordValidator);
|
||||
@@ -1,141 +0,0 @@
|
||||
import React from 'react';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
|
||||
import { redirectingAuthorizedFetch } from '../authentication';
|
||||
|
||||
export interface RestControllerProps<D> extends WithSnackbarProps {
|
||||
handleValueChange: (
|
||||
name: keyof D
|
||||
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
|
||||
setData: (data: D, callback?: () => void) => void;
|
||||
saveData: () => void;
|
||||
loadData: () => void;
|
||||
|
||||
data?: D;
|
||||
loading: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const extractEventValue = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
switch (event.target.type) {
|
||||
case 'number':
|
||||
return event.target.valueAsNumber;
|
||||
case 'checkbox':
|
||||
return event.target.checked;
|
||||
default:
|
||||
return event.target.value;
|
||||
}
|
||||
};
|
||||
|
||||
interface RestControllerState<D> {
|
||||
data?: D;
|
||||
loading: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function restController<D, P extends RestControllerProps<D>>(
|
||||
endpointUrl: string,
|
||||
RestController: React.ComponentType<P & RestControllerProps<D>>
|
||||
) {
|
||||
return withSnackbar(
|
||||
class extends React.Component<
|
||||
Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps,
|
||||
RestControllerState<D>
|
||||
> {
|
||||
state: RestControllerState<D> = {
|
||||
data: undefined,
|
||||
loading: false,
|
||||
errorMessage: undefined
|
||||
};
|
||||
|
||||
setData = (data: D, callback?: () => void) => {
|
||||
this.setState(
|
||||
{
|
||||
data,
|
||||
loading: false,
|
||||
errorMessage: undefined
|
||||
},
|
||||
callback
|
||||
);
|
||||
};
|
||||
|
||||
loadData = () => {
|
||||
this.setState({
|
||||
data: undefined,
|
||||
loading: true,
|
||||
errorMessage: undefined
|
||||
});
|
||||
redirectingAuthorizedFetch(endpointUrl)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error('Invalid status code: ' + response.status);
|
||||
})
|
||||
.then((json) => {
|
||||
this.setState({ data: json, loading: false });
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
|
||||
variant: 'error'
|
||||
});
|
||||
this.setState({ data: undefined, loading: false, errorMessage });
|
||||
});
|
||||
};
|
||||
|
||||
saveData = () => {
|
||||
this.setState({ loading: true });
|
||||
redirectingAuthorizedFetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.state.data),
|
||||
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.enqueueSnackbar('Update successful.', {
|
||||
variant: 'success'
|
||||
});
|
||||
this.setState({ data: json, loading: false });
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
this.props.enqueueSnackbar('Problem updating: ' + errorMessage, {
|
||||
variant: 'error'
|
||||
});
|
||||
this.setState({ data: undefined, loading: false, errorMessage });
|
||||
});
|
||||
};
|
||||
|
||||
handleValueChange = (name: keyof D) => (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const data = { ...this.state.data!, [name]: extractEventValue(event) };
|
||||
this.setState({ data });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RestController
|
||||
{...this.state}
|
||||
{...(this.props as P)}
|
||||
handleValueChange={this.handleValueChange}
|
||||
setData={this.setData}
|
||||
saveData={this.saveData}
|
||||
loadData={this.loadData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
||||
import { Button, LinearProgress, Typography } from '@material-ui/core';
|
||||
|
||||
import { RestControllerProps } from '.';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
loadingSettings: {
|
||||
margin: theme.spacing(0.5)
|
||||
},
|
||||
loadingSettingsDetails: {
|
||||
margin: theme.spacing(4),
|
||||
textAlign: 'center'
|
||||
},
|
||||
button: {
|
||||
marginRight: theme.spacing(2),
|
||||
marginTop: theme.spacing(2)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export type RestFormProps<D> = Omit<
|
||||
RestControllerProps<D>,
|
||||
'loading' | 'errorMessage'
|
||||
> & { data: D };
|
||||
|
||||
interface RestFormLoaderProps<D> extends RestControllerProps<D> {
|
||||
render: (props: RestFormProps<D>) => JSX.Element;
|
||||
}
|
||||
|
||||
export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
|
||||
const { loading, errorMessage, loadData, render, data, ...rest } = props;
|
||||
const classes = useStyles();
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className={classes.loadingSettings}>
|
||||
<LinearProgress className={classes.loadingSettingsDetails} />
|
||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
||||
Loading…
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className={classes.loadingSettings}>
|
||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={loadData}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return render({ ...rest, loadData, data });
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
import React from 'react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Typography, Paper } from '@material-ui/core';
|
||||
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
content: {
|
||||
padding: theme.spacing(2),
|
||||
margin: theme.spacing(3)
|
||||
}
|
||||
})
|
||||
);
|
||||
import { Paper, Divider } from '@mui/material';
|
||||
|
||||
interface SectionContentProps {
|
||||
title: string;
|
||||
@@ -18,14 +8,11 @@ interface SectionContentProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const SectionContent: React.FC<SectionContentProps> = (props) => {
|
||||
const { children, title, titleGutter, id } = props;
|
||||
const classes = useStyles();
|
||||
const SectionContent: FC<SectionContentProps> = (props) => {
|
||||
const { children, title, id } = props;
|
||||
return (
|
||||
<Paper id={id} className={classes.content}>
|
||||
<Typography variant="h6" gutterBottom={titleGutter}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Paper id={id} sx={{ p: 2, m: 2 }}>
|
||||
<Divider sx={{ pb: 2, borderColor: 'primary.main', fontSize: 20, color: 'primary.main' }}>{title}</Divider>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { FC, Fragment } from 'react';
|
||||
import { useDropzone, DropzoneState } from 'react-dropzone';
|
||||
|
||||
import { makeStyles, createStyles } from '@material-ui/styles';
|
||||
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
|
||||
import CancelIcon from '@material-ui/icons/Cancel';
|
||||
import {
|
||||
Theme,
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Button
|
||||
} from '@material-ui/core';
|
||||
|
||||
interface SingleUploadStyleProps extends DropzoneState {
|
||||
uploading: boolean;
|
||||
}
|
||||
|
||||
const progressPercentage = (progress: ProgressEvent) =>
|
||||
Math.round((progress.loaded * 100) / progress.total);
|
||||
|
||||
const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
|
||||
if (props.isDragAccept) {
|
||||
return theme.palette.success.main;
|
||||
}
|
||||
if (props.isDragReject) {
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
if (props.isDragActive) {
|
||||
return theme.palette.info.main;
|
||||
}
|
||||
return theme.palette.grey[700];
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
dropzone: {
|
||||
padding: theme.spacing(8, 2),
|
||||
borderWidth: 2,
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
color: theme.palette.grey[700],
|
||||
transition: 'border .24s ease-in-out',
|
||||
cursor: (props: SingleUploadStyleProps) =>
|
||||
props.uploading ? 'default' : 'pointer',
|
||||
width: '100%',
|
||||
borderColor: (props: SingleUploadStyleProps) =>
|
||||
getBorderColor(theme, props)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export interface SingleUploadProps {
|
||||
onDrop: (acceptedFiles: File[]) => void;
|
||||
onCancel: () => void;
|
||||
accept?: string | string[];
|
||||
uploading: boolean;
|
||||
progress?: ProgressEvent;
|
||||
}
|
||||
|
||||
const SingleUpload: FC<SingleUploadProps> = ({
|
||||
onDrop,
|
||||
onCancel,
|
||||
accept,
|
||||
uploading,
|
||||
progress
|
||||
}) => {
|
||||
const dropzoneState = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
disabled: uploading,
|
||||
multiple: false
|
||||
});
|
||||
const { getRootProps, getInputProps } = dropzoneState;
|
||||
const classes = useStyles({ ...dropzoneState, uploading });
|
||||
|
||||
const renderProgressText = () => {
|
||||
if (uploading) {
|
||||
if (progress?.lengthComputable) {
|
||||
return `Uploading: ${progressPercentage(progress)}%`;
|
||||
}
|
||||
return 'Uploading\u2026';
|
||||
}
|
||||
return 'Drop file or click here';
|
||||
};
|
||||
|
||||
const renderProgress = (progress?: ProgressEvent) => (
|
||||
<LinearProgress
|
||||
variant={
|
||||
!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'
|
||||
}
|
||||
value={
|
||||
!progress
|
||||
? 0
|
||||
: progress.lengthComputable
|
||||
? progressPercentage(progress)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...getRootProps({ className: classes.dropzone })}>
|
||||
<input {...getInputProps()} />
|
||||
<Box flexDirection="column" display="flex" alignItems="center">
|
||||
<CloudUploadIcon fontSize="large" />
|
||||
<Typography variant="h6">{renderProgressText()}</Typography>
|
||||
{uploading && (
|
||||
<Fragment>
|
||||
<Box width="100%" p={2}>
|
||||
{renderProgress(progress)}
|
||||
</Box>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Fragment>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleUpload;
|
||||
@@ -1,158 +0,0 @@
|
||||
import React from 'react';
|
||||
import Sockette from 'sockette';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
|
||||
import { addAccessTokenParameter } from '../authentication';
|
||||
import { extractEventValue } from '.';
|
||||
|
||||
export interface WebSocketControllerProps<D> extends WithSnackbarProps {
|
||||
handleValueChange: (
|
||||
name: keyof D
|
||||
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
|
||||
setData: (data: D, callback?: () => void) => void;
|
||||
saveData: () => void;
|
||||
saveDataAndClear(): () => void;
|
||||
|
||||
connected: boolean;
|
||||
data?: D;
|
||||
}
|
||||
|
||||
interface WebSocketControllerState<D> {
|
||||
ws: Sockette;
|
||||
connected: boolean;
|
||||
clientId?: string;
|
||||
data?: D;
|
||||
}
|
||||
|
||||
enum WebSocketMessageType {
|
||||
ID = 'id',
|
||||
PAYLOAD = 'payload'
|
||||
}
|
||||
|
||||
interface WebSocketIdMessage {
|
||||
type: typeof WebSocketMessageType.ID;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface WebSocketPayloadMessage<D> {
|
||||
type: typeof WebSocketMessageType.PAYLOAD;
|
||||
origin_id: string;
|
||||
payload: D;
|
||||
}
|
||||
|
||||
export type WebSocketMessage<D> =
|
||||
| WebSocketIdMessage
|
||||
| WebSocketPayloadMessage<D>;
|
||||
|
||||
export function webSocketController<D, P extends WebSocketControllerProps<D>>(
|
||||
wsUrl: string,
|
||||
wsThrottle: number,
|
||||
WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>
|
||||
) {
|
||||
return withSnackbar(
|
||||
class extends React.Component<
|
||||
Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps,
|
||||
WebSocketControllerState<D>
|
||||
> {
|
||||
constructor(
|
||||
props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps
|
||||
) {
|
||||
super(props);
|
||||
this.state = {
|
||||
ws: new Sockette(addAccessTokenParameter(wsUrl), {
|
||||
onmessage: this.onMessage,
|
||||
onopen: this.onOpen,
|
||||
onclose: this.onClose
|
||||
}),
|
||||
connected: false
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.state.ws.close();
|
||||
}
|
||||
|
||||
onMessage = (event: MessageEvent) => {
|
||||
const rawData = event.data;
|
||||
if (typeof rawData === 'string' || rawData instanceof String) {
|
||||
this.handleMessage(
|
||||
JSON.parse(rawData as string) as WebSocketMessage<D>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleMessage = (message: WebSocketMessage<D>) => {
|
||||
const { clientId, data } = this.state;
|
||||
|
||||
switch (message.type) {
|
||||
case WebSocketMessageType.ID:
|
||||
this.setState({ clientId: message.id });
|
||||
break;
|
||||
case WebSocketMessageType.PAYLOAD:
|
||||
if (clientId && (!data || clientId !== message.origin_id)) {
|
||||
this.setState({ data: message.payload });
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onOpen = () => {
|
||||
this.setState({ connected: true });
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.setState({
|
||||
connected: false,
|
||||
clientId: undefined,
|
||||
data: undefined
|
||||
});
|
||||
};
|
||||
|
||||
setData = (data: D, callback?: () => void) => {
|
||||
this.setState({ data }, callback);
|
||||
};
|
||||
|
||||
saveData = throttle(() => {
|
||||
const { ws, connected, data } = this.state;
|
||||
if (connected) {
|
||||
ws.json(data);
|
||||
}
|
||||
}, wsThrottle);
|
||||
|
||||
saveDataAndClear = throttle(() => {
|
||||
const { ws, connected, data } = this.state;
|
||||
if (connected) {
|
||||
this.setState(
|
||||
{
|
||||
data: undefined
|
||||
},
|
||||
() => ws.json(data)
|
||||
);
|
||||
}
|
||||
}, wsThrottle);
|
||||
|
||||
handleValueChange = (name: keyof D) => (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const data = { ...this.state.data!, [name]: extractEventValue(event) };
|
||||
this.setState({ data });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<WebSocketController
|
||||
{...(this.props as P)}
|
||||
handleValueChange={this.handleValueChange}
|
||||
setData={this.setData}
|
||||
saveData={this.saveData}
|
||||
saveDataAndClear={this.saveDataAndClear}
|
||||
connected={this.state.connected}
|
||||
data={this.state.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
||||
import { LinearProgress, Typography } from '@material-ui/core';
|
||||
|
||||
import { WebSocketControllerProps } from '.';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
loadingSettings: {
|
||||
margin: theme.spacing(0.5)
|
||||
},
|
||||
loadingSettingsDetails: {
|
||||
margin: theme.spacing(4),
|
||||
textAlign: 'center'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export type WebSocketFormProps<D> = Omit<
|
||||
WebSocketControllerProps<D>,
|
||||
'connected'
|
||||
> & { data: D };
|
||||
|
||||
interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> {
|
||||
render: (props: WebSocketFormProps<D>) => JSX.Element;
|
||||
}
|
||||
|
||||
export default function WebSocketFormLoader<D>(
|
||||
props: WebSocketFormLoaderProps<D>
|
||||
) {
|
||||
const { connected, render, data, ...rest } = props;
|
||||
const classes = useStyles();
|
||||
if (!connected || !data) {
|
||||
return (
|
||||
<div className={classes.loadingSettings}>
|
||||
<LinearProgress className={classes.loadingSettingsDetails} />
|
||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
||||
Connecting to WebSocket...
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return render({ ...rest, data });
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState([0, 0]);
|
||||
useLayoutEffect(() => {
|
||||
function updateSize() {
|
||||
setSize([window.innerWidth, window.innerHeight]);
|
||||
}
|
||||
window.addEventListener('resize', updateSize);
|
||||
updateSize();
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
return size;
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
|
||||
export { default as FormActions } from './FormActions';
|
||||
export { default as FormButton } from './FormButton';
|
||||
export { default as HighlightAvatar } from './HighlightAvatar';
|
||||
export { default as MenuAppBar } from './MenuAppBar';
|
||||
export { default as PasswordValidator } from './PasswordValidator';
|
||||
export { default as RestFormLoader } from './RestFormLoader';
|
||||
export { default as FormLoader } from './FormLoader';
|
||||
export * from './inputs';
|
||||
export * from './layout';
|
||||
export * from './loading';
|
||||
export * from './routing';
|
||||
export * from './upload';
|
||||
export { default as SectionContent } from './SectionContent';
|
||||
export { default as WebSocketFormLoader } from './WebSocketFormLoader';
|
||||
export { default as ErrorButton } from './ErrorButton';
|
||||
export { default as SingleUpload } from './SingleUpload';
|
||||
|
||||
export * from './RestFormLoader';
|
||||
export * from './RestController';
|
||||
|
||||
export * from './WebSocketFormLoader';
|
||||
export * from './WebSocketController';
|
||||
|
||||
export * from './WindowSize';
|
||||
export { default as ButtonRow } from './ButtonRow';
|
||||
export { default as MessageBox } from './MessageBox';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC } from 'react';
|
||||
import { FormControlLabel, FormControlLabelProps } from '@material-ui/core';
|
||||
import { FormControlLabel, FormControlLabelProps } from '@mui/material';
|
||||
|
||||
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
||||
<div>
|
||||
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { IconButton, InputAdornment } from '@mui/material';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
|
||||
|
||||
type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
||||
|
||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ InputProps, ...props }) => {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<ValidatedTextField
|
||||
{...props}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
InputProps={{
|
||||
...InputProps,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedPasswordField;
|
||||
24
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
24
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FC } from 'react';
|
||||
import { ValidateFieldsError } from 'async-validator';
|
||||
|
||||
import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
|
||||
|
||||
interface ValidatedFieldProps {
|
||||
fieldErrors?: ValidateFieldsError;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
|
||||
|
||||
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({ fieldErrors, ...rest }) => {
|
||||
const errors = fieldErrors && fieldErrors[rest.name];
|
||||
const renderErrors = () => errors && errors.map((e, i) => <FormHelperText key={i}>{e.message}</FormHelperText>);
|
||||
return (
|
||||
<>
|
||||
<TextField error={!!errors} {...rest} />
|
||||
{renderErrors()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedTextField;
|
||||
3
interface/src/components/inputs/index.ts
Normal file
3
interface/src/components/inputs/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
|
||||
export { default as ValidatedPasswordField } from './ValidatedPasswordField';
|
||||
export { default as ValidatedTextField } from './ValidatedTextField';
|
||||
36
interface/src/components/layout/Layout.tsx
Normal file
36
interface/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { Box, Toolbar } from '@mui/material';
|
||||
|
||||
import { PROJECT_NAME } from '../../api/env';
|
||||
import LayoutDrawer from './LayoutDrawer';
|
||||
import LayoutAppBar from './LayoutAppBar';
|
||||
import { LayoutContext } from './context';
|
||||
|
||||
export const DRAWER_WIDTH = 240;
|
||||
|
||||
const Layout: FC = ({ children }) => {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [title, setTitle] = useState(PROJECT_NAME);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
useEffect(() => setMobileOpen(false), [pathname]);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{ title, setTitle }}>
|
||||
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
|
||||
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
|
||||
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
51
interface/src/components/layout/LayoutAppBar.tsx
Normal file
51
interface/src/components/layout/LayoutAppBar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
|
||||
import LayoutAuthMenu from './LayoutAuthMenu';
|
||||
|
||||
import { FeaturesContext } from '../../contexts/features';
|
||||
|
||||
export const DRAWER_WIDTH = 240;
|
||||
|
||||
interface LayoutAppBarProps {
|
||||
title: string;
|
||||
onToggleDrawer: () => void;
|
||||
}
|
||||
|
||||
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
ml: { md: `${DRAWER_WIDTH}px` },
|
||||
boxShadow: 'none',
|
||||
backgroundColor: '#2e586a'
|
||||
// color: "#2196f3",
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={onToggleDrawer}
|
||||
sx={{ mr: 2, display: { md: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
{title}
|
||||
</Typography>
|
||||
<Box flexGrow={1} />
|
||||
{features.security && <LayoutAuthMenu />}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutAppBar;
|
||||
73
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
73
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FC, useState, useContext } from 'react';
|
||||
|
||||
import { Box, Button, Divider, IconButton, Popover, Typography, Avatar, styled, TypographyProps } from '@mui/material';
|
||||
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
|
||||
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||
|
||||
const ItemTypography = styled(Typography)<TypographyProps>({
|
||||
maxWidth: '250px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
});
|
||||
|
||||
const LayoutAuthMenu: FC = () => {
|
||||
const { me, signOut } = useContext(AuthenticatedContext);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const id = anchorEl ? 'app-menu-popover' : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton id="open-auth-menu" sx={{ padding: 0 }} aria-describedby={id} color="inherit" onClick={handleClick}>
|
||||
<AccountCircleIcon />
|
||||
</IconButton>
|
||||
<Popover
|
||||
id="app-menu-popover"
|
||||
sx={{ mt: 1 }}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="row" alignItems="center" p={2}>
|
||||
<Avatar sx={{ width: 80, height: 80 }}>
|
||||
<PersonIcon fontSize="large" />
|
||||
</Avatar>
|
||||
<Box pl={2}>
|
||||
<ItemTypography variant="h6">{me.username}</ItemTypography>
|
||||
<ItemTypography variant="body1">{me.admin ? 'Admin User' : 'Guest User'}</ItemTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box p={1.5}>
|
||||
<Button variant="outlined" fullWidth color="primary" onClick={() => signOut(true)}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutAuthMenu;
|
||||
73
interface/src/components/layout/LayoutDrawer.tsx
Normal file
73
interface/src/components/layout/LayoutDrawer.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||
|
||||
import { PROJECT_NAME } from '../../api/env';
|
||||
|
||||
import LayoutMenu from './LayoutMenu';
|
||||
import { DRAWER_WIDTH } from './Layout';
|
||||
|
||||
const LayoutDrawerLogo = styled('img')(({ theme }) => ({
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
height: 24,
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
height: 36,
|
||||
marginRight: theme.spacing(2)
|
||||
}
|
||||
}));
|
||||
|
||||
interface LayoutDrawerProps {
|
||||
mobileOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LayoutDrawer: FC<LayoutDrawerProps> = ({ mobileOpen, onClose }) => {
|
||||
const drawer = (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box display="flex" alignItems="center" px={2}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Typography variant="h6" color="textPrimary">
|
||||
{PROJECT_NAME}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box component="nav" sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}>
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={onClose}
|
||||
ModalProps={{
|
||||
keepMounted: true // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutDrawer;
|
||||
42
interface/src/components/layout/LayoutMenu.tsx
Normal file
42
interface/src/components/layout/LayoutMenu.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import { Divider, List } from '@mui/material';
|
||||
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||
|
||||
import { FeaturesContext } from '../../contexts/features';
|
||||
import ProjectMenu from '../../project/ProjectMenu';
|
||||
|
||||
import LayoutMenuItem from './LayoutMenuItem';
|
||||
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||
|
||||
const LayoutMenu: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{features.project && (
|
||||
<List disablePadding component="nav">
|
||||
<ProjectMenu />
|
||||
<Divider />
|
||||
</List>
|
||||
)}
|
||||
<List disablePadding component="nav">
|
||||
<LayoutMenuItem icon={SettingsEthernetIcon} label="Network Connection" to="/network" />
|
||||
<LayoutMenuItem icon={SettingsInputAntennaIcon} label="Access Point" to="/ap" />
|
||||
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />}
|
||||
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
|
||||
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
|
||||
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutMenu;
|
||||
32
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
32
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FC } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
|
||||
|
||||
import { grey } from '@mui/material/colors';
|
||||
|
||||
import { routeMatches } from '../../utils';
|
||||
|
||||
interface LayoutMenuItemProps {
|
||||
icon: React.ComponentType<SvgIconProps>;
|
||||
label: string;
|
||||
to: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<ListItem disablePadding selected={routeMatches(to, pathname)}>
|
||||
<ListItemButton component={Link} to={to} disabled={disabled}>
|
||||
<ListItemIcon sx={{ color: grey[500] }}>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{label}</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutMenuItem;
|
||||
25
interface/src/components/layout/context.ts
Normal file
25
interface/src/components/layout/context.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useRef, useEffect, createContext, useContext } from 'react';
|
||||
|
||||
export interface LayoutContextValue {
|
||||
title: string;
|
||||
setTitle: (title: string) => void;
|
||||
}
|
||||
|
||||
const LayoutContextDefaultValue = {} as LayoutContextValue;
|
||||
export const LayoutContext = createContext(LayoutContextDefaultValue);
|
||||
|
||||
export const useLayoutTitle = (myTitle: string) => {
|
||||
const { title, setTitle } = useContext(LayoutContext);
|
||||
const previousTitle = useRef(title);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(myTitle);
|
||||
}, [setTitle, myTitle]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
setTitle(previousTitle.current);
|
||||
},
|
||||
[setTitle]
|
||||
);
|
||||
};
|
||||
2
interface/src/components/layout/index.ts
Normal file
2
interface/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export { default as Layout } from './Layout';
|
||||
43
interface/src/components/loading/ApplicationError.tsx
Normal file
43
interface/src/components/loading/ApplicationError.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
|
||||
interface ApplicationErrorProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const ApplicationError: FC<ApplicationErrorProps> = ({ message }) => (
|
||||
<Box display="flex" height="100vh" justifyContent="center" flexDirection="column">
|
||||
<Paper
|
||||
elevation={10}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
padding: '280px 0 40px 0',
|
||||
backgroundImage: 'url("/app/icon.png")',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '50% 40px',
|
||||
backgroundSize: '200px auto',
|
||||
width: '100%',
|
||||
borderRadius: 0
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="row" justifyContent="center" alignItems="center" mb={2}>
|
||||
<WarningIcon fontSize="large" color="error" />
|
||||
<Box ml={2}>
|
||||
<Typography variant="h4">Application Error</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Failed to configure the application, please refresh to try again.
|
||||
</Typography>
|
||||
{message && (
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default ApplicationError;
|
||||
38
interface/src/components/loading/FormLoader.tsx
Normal file
38
interface/src/components/loading/FormLoader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
|
||||
import { MessageBox } from '..';
|
||||
|
||||
interface FormLoaderProps {
|
||||
message?: string;
|
||||
errorMessage?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, onRetry, message = 'Loading…' }) => {
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<MessageBox my={2} level="error" message={errorMessage}>
|
||||
{onRetry && (
|
||||
<Button startIcon={<RefreshIcon />} variant="contained" color="error" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</MessageBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box m={2} py={2} display="flex" alignItems="center" flexDirection="column">
|
||||
<Box py={2}>
|
||||
<CircularProgress size={100} />
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight={400} textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormLoader;
|
||||
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { CircularProgress, Box, Typography, Theme } from '@mui/material';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => (
|
||||
<Box display="flex" alignItems="center" justifyContent="center" flexDirection="column" padding={2} height={height}>
|
||||
<CircularProgress
|
||||
sx={(theme: Theme) => ({
|
||||
margin: theme.spacing(4),
|
||||
color: theme.palette.text.secondary
|
||||
})}
|
||||
size={100}
|
||||
/>
|
||||
<Typography variant="h4" color="textSecondary">
|
||||
Loading…
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
||||
3
interface/src/components/loading/index.ts
Normal file
3
interface/src/components/loading/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ApplicationError } from './ApplicationError';
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
export { default as FormLoader } from './FormLoader';
|
||||
11
interface/src/components/routing/RequireAdmin.tsx
Normal file
11
interface/src/components/routing/RequireAdmin.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FC, useContext } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||
|
||||
const RequireAdmin: FC = ({ children }) => {
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
return authenticatedContext.me.admin ? <>{children}</> : <Navigate replace to="/" />;
|
||||
};
|
||||
|
||||
export default RequireAdmin;
|
||||
30
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
30
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FC, useContext, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
AuthenticatedContext,
|
||||
AuthenticatedContextValue,
|
||||
AuthenticationContext
|
||||
} from '../../contexts/authentication/context';
|
||||
import { storeLoginRedirect } from '../../api/authentication';
|
||||
|
||||
const RequireAuthenticated: FC = ({ children }) => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticationContext.me) {
|
||||
storeLoginRedirect(location);
|
||||
}
|
||||
});
|
||||
|
||||
return authenticationContext.me ? (
|
||||
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContextValue}>
|
||||
{children}
|
||||
</AuthenticatedContext.Provider>
|
||||
) : (
|
||||
<Navigate to="/unauthorized" />
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireAuthenticated;
|
||||
15
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
15
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { FC, useContext } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import * as AuthenticationApi from '../../api/authentication';
|
||||
import { AuthenticationContext } from '../../contexts/authentication';
|
||||
import { FeaturesContext } from '../../contexts/features';
|
||||
|
||||
const RequireUnauthenticated: FC = ({ children }) => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
|
||||
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
|
||||
};
|
||||
|
||||
export default RequireUnauthenticated;
|
||||
27
interface/src/components/routing/RouterTabs.tsx
Normal file
27
interface/src/components/routing/RouterTabs.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { FC } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Tabs, useMediaQuery, useTheme } from '@mui/material';
|
||||
|
||||
interface RouterTabsProps {
|
||||
value: string | false;
|
||||
}
|
||||
|
||||
const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const theme = useTheme();
|
||||
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={value} onChange={handleTabChange} variant={smallDown ? 'scrollable' : 'fullWidth'}>
|
||||
{children}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouterTabs;
|
||||
6
interface/src/components/routing/index.ts
Normal file
6
interface/src/components/routing/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as RouterTabs } from './RouterTabs';
|
||||
export { default as RequireAdmin } from './RequireAdmin';
|
||||
export { default as RequireAuthenticated } from './RequireAuthenticated';
|
||||
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
|
||||
|
||||
export * from './useRouterTab';
|
||||
9
interface/src/components/routing/useRouterTab.ts
Normal file
9
interface/src/components/routing/useRouterTab.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useMatch, useResolvedPath } from 'react-router-dom';
|
||||
|
||||
export const useRouterTab = () => {
|
||||
const routerTabPath = useResolvedPath(':tab');
|
||||
const routerTabPathMatch = useMatch(routerTabPath.pathname);
|
||||
|
||||
const routerTab = routerTabPathMatch?.params?.tab || false;
|
||||
return { routerTab } as const;
|
||||
};
|
||||
86
interface/src/components/upload/SingleUpload.tsx
Normal file
86
interface/src/components/upload/SingleUpload.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FC, Fragment } from 'react';
|
||||
import { useDropzone, DropzoneState } from 'react-dropzone';
|
||||
|
||||
import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
|
||||
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
||||
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
|
||||
|
||||
const getBorderColor = (theme: Theme, props: DropzoneState) => {
|
||||
if (props.isDragAccept) {
|
||||
return theme.palette.success.main;
|
||||
}
|
||||
if (props.isDragReject) {
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
if (props.isDragActive) {
|
||||
return theme.palette.info.main;
|
||||
}
|
||||
return theme.palette.grey[700];
|
||||
};
|
||||
|
||||
export interface SingleUploadProps {
|
||||
onDrop: (acceptedFiles: File[]) => void;
|
||||
onCancel: () => void;
|
||||
accept?: string | string[];
|
||||
uploading: boolean;
|
||||
progress?: ProgressEvent;
|
||||
}
|
||||
|
||||
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, accept, uploading, progress }) => {
|
||||
const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false });
|
||||
const { getRootProps, getInputProps } = dropzoneState;
|
||||
const theme = useTheme();
|
||||
|
||||
const progressText = () => {
|
||||
if (uploading) {
|
||||
if (progress?.lengthComputable) {
|
||||
return `Uploading: ${progressPercentage(progress)}%`;
|
||||
}
|
||||
return 'Uploading\u2026';
|
||||
}
|
||||
return 'Drop file or click here';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getRootProps({
|
||||
sx: {
|
||||
py: 8,
|
||||
px: 2,
|
||||
borderWidth: 2,
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
color: theme.palette.grey[700],
|
||||
transition: 'border .24s ease-in-out',
|
||||
width: '100%',
|
||||
cursor: uploading ? 'default' : 'pointer',
|
||||
borderColor: getBorderColor(theme, dropzoneState)
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Box flexDirection="column" display="flex" alignItems="center">
|
||||
<CloudUploadIcon fontSize="large" />
|
||||
<Typography variant="h6">{progressText()}</Typography>
|
||||
{uploading && (
|
||||
<Fragment>
|
||||
<Box width="100%" p={2}>
|
||||
<LinearProgress
|
||||
variant={!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'}
|
||||
value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
|
||||
/>
|
||||
</Box>
|
||||
<Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Fragment>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleUpload;
|
||||
2
interface/src/components/upload/index.ts
Normal file
2
interface/src/components/upload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SingleUpload } from './SingleUpload';
|
||||
export { default as useFileUpload } from './useFileUpload';
|
||||
59
interface/src/components/upload/useFileUpload.ts
Normal file
59
interface/src/components/upload/useFileUpload.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import axios, { AxiosPromise, CancelTokenSource } from 'axios';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import { extractErrorMessage } from '../../utils';
|
||||
import { FileUploadConfig } from '../../api/endpoints';
|
||||
|
||||
interface MediaUploadOptions {
|
||||
upload: (file: File, config?: FileUploadConfig) => AxiosPromise<void>;
|
||||
}
|
||||
|
||||
const useFileUpload = ({ upload }: MediaUploadOptions) => {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<ProgressEvent>();
|
||||
const [uploadCancelToken, setUploadCancelToken] = useState<CancelTokenSource>();
|
||||
|
||||
const resetUploadingStates = () => {
|
||||
setUploading(false);
|
||||
setUploadProgress(undefined);
|
||||
setUploadCancelToken(undefined);
|
||||
};
|
||||
|
||||
const cancelUpload = useCallback(() => {
|
||||
uploadCancelToken?.cancel();
|
||||
resetUploadingStates();
|
||||
}, [uploadCancelToken]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
uploadCancelToken?.cancel();
|
||||
};
|
||||
}, [uploadCancelToken]);
|
||||
|
||||
const uploadFile = async (images: File[]) => {
|
||||
try {
|
||||
const cancelToken = axios.CancelToken.source();
|
||||
setUploadCancelToken(cancelToken);
|
||||
setUploading(true);
|
||||
await upload(images[0], {
|
||||
onUploadProgress: setUploadProgress,
|
||||
cancelToken: cancelToken.token
|
||||
});
|
||||
resetUploadingStates();
|
||||
enqueueSnackbar('Upload successful', { variant: 'success' });
|
||||
} catch (error: any) {
|
||||
if (axios.isCancel(error)) {
|
||||
enqueueSnackbar('Upload aborted', { variant: 'warning' });
|
||||
} else {
|
||||
resetUploadingStates();
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Upload failed'), { variant: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return [uploadFile, cancelUpload, uploading, uploadProgress] as const;
|
||||
};
|
||||
|
||||
export default useFileUpload;
|
||||
84
interface/src/contexts/authentication/Authentication.tsx
Normal file
84
interface/src/contexts/authentication/Authentication.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { FC, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import * as AuthenticationApi from '../../api/authentication';
|
||||
import { ACCESS_TOKEN } from '../../api/endpoints';
|
||||
import { LoadingSpinner } from '../../components';
|
||||
import { Me } from '../../types';
|
||||
import { FeaturesContext } from '../features';
|
||||
import { AuthenticationContext } from './context';
|
||||
|
||||
const Authentication: FC = ({ children }) => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [me, setMe] = useState<Me>();
|
||||
|
||||
const signIn = (accessToken: string) => {
|
||||
try {
|
||||
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
||||
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
||||
setMe(decodedMe);
|
||||
enqueueSnackbar(`Logged in as ${decodedMe.username}`, { variant: 'success' });
|
||||
} catch (error: any) {
|
||||
setMe(undefined);
|
||||
throw new Error('Failed to parse JWT ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = (redirect: boolean) => {
|
||||
AuthenticationApi.clearAccessToken();
|
||||
setMe(undefined);
|
||||
if (redirect) {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!features.security) {
|
||||
setMe({ admin: true, username: 'admin' });
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
try {
|
||||
await AuthenticationApi.verifyAuthorization();
|
||||
setMe(AuthenticationApi.decodeMeJWT(accessToken));
|
||||
setInitialized(true);
|
||||
} catch (error: any) {
|
||||
setMe(undefined);
|
||||
setInitialized(true);
|
||||
}
|
||||
} else {
|
||||
setMe(undefined);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [features]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (initialized) {
|
||||
return (
|
||||
<AuthenticationContext.Provider
|
||||
value={{
|
||||
signIn,
|
||||
signOut,
|
||||
me,
|
||||
refresh
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthenticationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return <LoadingSpinner height="100vh" />;
|
||||
};
|
||||
|
||||
export default Authentication;
|
||||
19
interface/src/contexts/authentication/context.ts
Normal file
19
interface/src/contexts/authentication/context.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext } from 'react';
|
||||
import { Me } from '../../types';
|
||||
|
||||
export interface AuthenticationContextValue {
|
||||
refresh: () => Promise<void>;
|
||||
signIn: (accessToken: string) => void;
|
||||
signOut: (redirect: boolean) => void;
|
||||
me?: Me;
|
||||
}
|
||||
|
||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
||||
export const AuthenticationContext = createContext(AuthenticationContextDefaultValue);
|
||||
|
||||
export interface AuthenticatedContextValue extends AuthenticationContextValue {
|
||||
me: Me;
|
||||
}
|
||||
|
||||
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
|
||||
export const AuthenticatedContext = createContext(AuthenticatedContextDefaultValue);
|
||||
2
interface/src/contexts/authentication/index.ts
Normal file
2
interface/src/contexts/authentication/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export { default as Authentication } from './Authentication';
|
||||
47
interface/src/contexts/features/FeaturesLoader.tsx
Normal file
47
interface/src/contexts/features/FeaturesLoader.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import * as FeaturesApi from '../../api/features';
|
||||
|
||||
import { extractErrorMessage } from '../../utils';
|
||||
import { Features } from '../../types';
|
||||
import { ApplicationError, LoadingSpinner } from '../../components';
|
||||
|
||||
import { FeaturesContext } from '.';
|
||||
|
||||
const FeaturesLoader: FC = (props) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [features, setFeatures] = useState<Features>();
|
||||
|
||||
const loadFeatures = useCallback(async () => {
|
||||
try {
|
||||
const response = await FeaturesApi.readFeatures();
|
||||
setFeatures(response.data);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(extractErrorMessage(error, 'Failed to fetch application details.'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatures();
|
||||
}, [loadFeatures]);
|
||||
|
||||
if (features) {
|
||||
return (
|
||||
<FeaturesContext.Provider
|
||||
value={{
|
||||
features
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</FeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return <ApplicationError message={errorMessage} />;
|
||||
}
|
||||
|
||||
return <LoadingSpinner height="100vh" />;
|
||||
};
|
||||
|
||||
export default FeaturesLoader;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user