Allow features to be disabled at build time (#143)
* Add framework for built-time feature selection * Allow MQTT, NTP, OTA features to be disabled at build time * Allow Project screens to be disabled at build time * Allow security features to be disabled at build time * Switch to std::function for StatefulService function aliases for greater flexibility * Bump various UI lib versions * Update docs
This commit is contained in:
		| @@ -1,4 +1,4 @@ | ||||
| # Change the IP address to that of your ESP device to enable local development of the UI. | ||||
| # Remember to also enable CORS in platformio.ini before uploading the code to the device. | ||||
| REACT_APP_HTTP_ROOT=http://192.168.0.99 | ||||
| REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.99 | ||||
| REACT_APP_HTTP_ROOT=http://192.168.0.88 | ||||
| REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.88 | ||||
|   | ||||
							
								
								
									
										208
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										208
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1403,6 +1403,21 @@ | ||||
|       "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", | ||||
|       "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" | ||||
|     }, | ||||
|     "@npmcli/move-file": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.0.1.tgz", | ||||
|       "integrity": "sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw==", | ||||
|       "requires": { | ||||
|         "mkdirp": "^1.0.4" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "mkdirp": { | ||||
|           "version": "1.0.4", | ||||
|           "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", | ||||
|           "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "@svgr/babel-plugin-add-jsx-attribute": { | ||||
|       "version": "4.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz", | ||||
| @@ -3324,11 +3339,6 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "classnames": { | ||||
|       "version": "2.2.6", | ||||
|       "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", | ||||
|       "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" | ||||
|     }, | ||||
|     "clean-css": { | ||||
|       "version": "4.2.3", | ||||
|       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", | ||||
| @@ -3538,16 +3548,122 @@ | ||||
|       } | ||||
|     }, | ||||
|     "compression-webpack-plugin": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-3.1.0.tgz", | ||||
|       "integrity": "sha512-iqTHj3rADN4yHwXMBrQa/xrncex/uEQy8QHlaTKxGchT/hC0SdlJlmL/5eRqffmWq2ep0/Romw6Ld39JjTR/ug==", | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-4.0.0.tgz", | ||||
|       "integrity": "sha512-DRoFQNTkQ8gadlk117Y2wxANU+MDY56b1FIZj/yJXucBOTViTHXjthM7G9ocnitksk4kLzt1N2RLF0gDjxI+hg==", | ||||
|       "requires": { | ||||
|         "cacache": "^13.0.1", | ||||
|         "find-cache-dir": "^3.0.0", | ||||
|         "neo-async": "^2.5.0", | ||||
|         "schema-utils": "^2.6.1", | ||||
|         "serialize-javascript": "^2.1.2", | ||||
|         "webpack-sources": "^1.0.1" | ||||
|         "cacache": "^15.0.3", | ||||
|         "find-cache-dir": "^3.3.1", | ||||
|         "schema-utils": "^2.6.6", | ||||
|         "serialize-javascript": "^3.0.0", | ||||
|         "webpack-sources": "^1.4.3" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "ajv": { | ||||
|           "version": "6.12.2", | ||||
|           "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz", | ||||
|           "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==", | ||||
|           "requires": { | ||||
|             "fast-deep-equal": "^3.1.1", | ||||
|             "fast-json-stable-stringify": "^2.0.0", | ||||
|             "json-schema-traverse": "^0.4.1", | ||||
|             "uri-js": "^4.2.2" | ||||
|           } | ||||
|         }, | ||||
|         "cacache": { | ||||
|           "version": "15.0.4", | ||||
|           "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.0.4.tgz", | ||||
|           "integrity": "sha512-YlnKQqTbD/6iyoJvEY3KJftjrdBYroCbxxYXzhOzsFLWlp6KX4BOlEf4mTx0cMUfVaTS3ENL2QtDWeRYoGLkkw==", | ||||
|           "requires": { | ||||
|             "@npmcli/move-file": "^1.0.1", | ||||
|             "chownr": "^2.0.0", | ||||
|             "fs-minipass": "^2.0.0", | ||||
|             "glob": "^7.1.4", | ||||
|             "infer-owner": "^1.0.4", | ||||
|             "lru-cache": "^5.1.1", | ||||
|             "minipass": "^3.1.1", | ||||
|             "minipass-collect": "^1.0.2", | ||||
|             "minipass-flush": "^1.0.5", | ||||
|             "minipass-pipeline": "^1.2.2", | ||||
|             "mkdirp": "^1.0.3", | ||||
|             "p-map": "^4.0.0", | ||||
|             "promise-inflight": "^1.0.1", | ||||
|             "rimraf": "^3.0.2", | ||||
|             "ssri": "^8.0.0", | ||||
|             "tar": "^6.0.2", | ||||
|             "unique-filename": "^1.1.1" | ||||
|           } | ||||
|         }, | ||||
|         "chownr": { | ||||
|           "version": "2.0.0", | ||||
|           "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", | ||||
|           "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" | ||||
|         }, | ||||
|         "find-cache-dir": { | ||||
|           "version": "3.3.1", | ||||
|           "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz", | ||||
|           "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==", | ||||
|           "requires": { | ||||
|             "commondir": "^1.0.1", | ||||
|             "make-dir": "^3.0.2", | ||||
|             "pkg-dir": "^4.1.0" | ||||
|           } | ||||
|         }, | ||||
|         "make-dir": { | ||||
|           "version": "3.1.0", | ||||
|           "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", | ||||
|           "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", | ||||
|           "requires": { | ||||
|             "semver": "^6.0.0" | ||||
|           } | ||||
|         }, | ||||
|         "mkdirp": { | ||||
|           "version": "1.0.4", | ||||
|           "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", | ||||
|           "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" | ||||
|         }, | ||||
|         "p-map": { | ||||
|           "version": "4.0.0", | ||||
|           "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", | ||||
|           "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", | ||||
|           "requires": { | ||||
|             "aggregate-error": "^3.0.0" | ||||
|           } | ||||
|         }, | ||||
|         "rimraf": { | ||||
|           "version": "3.0.2", | ||||
|           "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", | ||||
|           "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", | ||||
|           "requires": { | ||||
|             "glob": "^7.1.3" | ||||
|           } | ||||
|         }, | ||||
|         "schema-utils": { | ||||
|           "version": "2.7.0", | ||||
|           "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", | ||||
|           "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", | ||||
|           "requires": { | ||||
|             "@types/json-schema": "^7.0.4", | ||||
|             "ajv": "^6.12.2", | ||||
|             "ajv-keywords": "^3.4.1" | ||||
|           } | ||||
|         }, | ||||
|         "serialize-javascript": { | ||||
|           "version": "3.1.0", | ||||
|           "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz", | ||||
|           "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==", | ||||
|           "requires": { | ||||
|             "randombytes": "^2.1.0" | ||||
|           } | ||||
|         }, | ||||
|         "ssri": { | ||||
|           "version": "8.0.0", | ||||
|           "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.0.tgz", | ||||
|           "integrity": "sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==", | ||||
|           "requires": { | ||||
|             "minipass": "^3.1.1" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "concat-map": { | ||||
| @@ -8122,6 +8238,15 @@ | ||||
|         "minipass": "^3.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "minizlib": { | ||||
|       "version": "2.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.0.tgz", | ||||
|       "integrity": "sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA==", | ||||
|       "requires": { | ||||
|         "minipass": "^3.0.0", | ||||
|         "yallist": "^4.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "mississippi": { | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", | ||||
| @@ -8183,9 +8308,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "moment": { | ||||
|       "version": "2.24.0", | ||||
|       "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", | ||||
|       "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" | ||||
|       "version": "2.26.0", | ||||
|       "resolved": "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz", | ||||
|       "integrity": "sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw==" | ||||
|     }, | ||||
|     "move-concurrently": { | ||||
|       "version": "1.0.1", | ||||
| @@ -8415,14 +8540,12 @@ | ||||
|       } | ||||
|     }, | ||||
|     "notistack": { | ||||
|       "version": "0.9.7", | ||||
|       "resolved": "https://registry.npmjs.org/notistack/-/notistack-0.9.7.tgz", | ||||
|       "integrity": "sha512-OztbtaIiCMR7QdcDGXTcYu96Uuvu26k41d7cnMGdf4NaKkAX06fsLPAlodGPj4HrMjMBUl8nvUQ3LmQOS6kHWQ==", | ||||
|       "version": "0.9.16", | ||||
|       "resolved": "https://registry.npmjs.org/notistack/-/notistack-0.9.16.tgz", | ||||
|       "integrity": "sha512-+q1KKj2XkU+mKnbp9PbVkRLSLfVYnPJGi+MHT+N9Pm3nZUMVtbjDFodwdv/RoEldvkXKCROnecayUFMwLOiIQA==", | ||||
|       "requires": { | ||||
|         "classnames": "^2.2.6", | ||||
|         "hoist-non-react-statics": "^3.3.0", | ||||
|         "prop-types": "^15.7.2", | ||||
|         "react-is": "^16.8.6" | ||||
|         "clsx": "^1.1.0", | ||||
|         "hoist-non-react-statics": "^3.3.0" | ||||
|       } | ||||
|     }, | ||||
|     "npm-run-path": { | ||||
| @@ -10227,9 +10350,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "react-app-rewired": { | ||||
|       "version": "2.1.5", | ||||
|       "resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.1.5.tgz", | ||||
|       "integrity": "sha512-Gr8KfCeL9/PTQs8Vvxc7v8wQ9vCFMnYPhcAkrMlzkLiMFXS+BgSwm11MoERjZm7dpA2WjTi+Pvbu/w7rujAV+A==", | ||||
|       "version": "2.1.6", | ||||
|       "resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.1.6.tgz", | ||||
|       "integrity": "sha512-06flj0kK5tf/RN4naRv/sn6j3sQd7rsURoRLKLpffXDzJeNiAaTNic+0I8Basojy5WDwREkTqrMLewSAjcb13w==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "semver": "^5.6.0" | ||||
| @@ -12099,6 +12222,31 @@ | ||||
|       "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", | ||||
|       "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" | ||||
|     }, | ||||
|     "tar": { | ||||
|       "version": "6.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.2.tgz", | ||||
|       "integrity": "sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg==", | ||||
|       "requires": { | ||||
|         "chownr": "^2.0.0", | ||||
|         "fs-minipass": "^2.0.0", | ||||
|         "minipass": "^3.0.0", | ||||
|         "minizlib": "^2.1.0", | ||||
|         "mkdirp": "^1.0.3", | ||||
|         "yallist": "^4.0.0" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "chownr": { | ||||
|           "version": "2.0.0", | ||||
|           "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", | ||||
|           "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" | ||||
|         }, | ||||
|         "mkdirp": { | ||||
|           "version": "1.0.4", | ||||
|           "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", | ||||
|           "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "terser": { | ||||
|       "version": "4.6.7", | ||||
|       "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.7.tgz", | ||||
| @@ -12354,9 +12502,9 @@ | ||||
|       "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" | ||||
|     }, | ||||
|     "typescript": { | ||||
|       "version": "3.7.5", | ||||
|       "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.5.tgz", | ||||
|       "integrity": "sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==" | ||||
|       "version": "3.9.5", | ||||
|       "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", | ||||
|       "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==" | ||||
|     }, | ||||
|     "unicode-canonical-property-names-ecmascript": { | ||||
|       "version": "1.0.4", | ||||
|   | ||||
| @@ -13,12 +13,12 @@ | ||||
|     "@types/react-material-ui-form-validator": "^2.0.5", | ||||
|     "@types/react-router": "^5.1.3", | ||||
|     "@types/react-router-dom": "^5.1.3", | ||||
|     "compression-webpack-plugin": "^3.0.1", | ||||
|     "compression-webpack-plugin": "^4.0.0", | ||||
|     "jwt-decode": "^2.2.0", | ||||
|     "lodash": "^4.17.15", | ||||
|     "mime-types": "^2.1.25", | ||||
|     "moment": "^2.24.0", | ||||
|     "notistack": "^0.9.7", | ||||
|     "moment": "^2.26.0", | ||||
|     "notistack": "^0.9.16", | ||||
|     "react": "^16.13.1", | ||||
|     "react-dom": "^16.13.1", | ||||
|     "react-form-validator-core": "^0.6.4", | ||||
| @@ -27,7 +27,7 @@ | ||||
|     "react-router-dom": "^5.1.2", | ||||
|     "react-scripts": "3.4.1", | ||||
|     "sockette": "^2.0.6", | ||||
|     "typescript": "^3.7.5", | ||||
|     "typescript": "^3.9.5", | ||||
|     "zlib": "^1.0.5" | ||||
|   }, | ||||
|   "scripts": { | ||||
| @@ -51,6 +51,6 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "react-app-rewired": "^2.1.5" | ||||
|     "react-app-rewired": "^2.1.6" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import CloseIcon from '@material-ui/icons/Close'; | ||||
| 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="/" />; | ||||
| @@ -34,10 +35,12 @@ class App extends Component { | ||||
|               <CloseIcon /> | ||||
|             </IconButton> | ||||
|           )}> | ||||
|           <Switch> | ||||
|             <Route exact path="/unauthorized" component={unauthorizedRedirect} /> | ||||
|             <Route component={AppRouting} /> | ||||
|           </Switch> | ||||
|           <FeaturesWrapper> | ||||
|             <Switch> | ||||
|               <Route exact path="/unauthorized" component={unauthorizedRedirect} /> | ||||
|               <Route component={AppRouting} /> | ||||
|             </Switch> | ||||
|           </FeaturesWrapper> | ||||
|         </SnackbarProvider> | ||||
|       </CustomMuiTheme> | ||||
|     ); | ||||
|   | ||||
| @@ -16,30 +16,45 @@ import System from './system/System'; | ||||
|  | ||||
| import { PROJECT_PATH } from './api'; | ||||
| import Mqtt from './mqtt/Mqtt'; | ||||
| import { withFeatures, WithFeaturesProps } from './features/FeaturesContext'; | ||||
| import { Features } from './features/types'; | ||||
|  | ||||
| class AppRouting extends Component { | ||||
| export const getDefaultRoute = (features: Features) => features.project ? `/${PROJECT_PATH}/` : "/wifi/"; | ||||
|  | ||||
| class AppRouting extends Component<WithFeaturesProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     Authentication.clearLoginRedirect(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { features } = this.props; | ||||
|     return ( | ||||
|       <AuthenticationWrapper> | ||||
|         <Switch> | ||||
|           <UnauthenticatedRoute exact path="/" component={SignIn} /> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> | ||||
|           <AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} />          | ||||
|           {features.security && ( | ||||
|             <UnauthenticatedRoute exact path="/" component={SignIn} /> | ||||
|           )} | ||||
|           {features.project && ( | ||||
|             <AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> | ||||
|           )} | ||||
|           <AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} /> | ||||
|           <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> | ||||
|           {features.ntp && ( | ||||
|           <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> | ||||
|           <AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} /> | ||||
|           <AuthenticatedRoute exact path="/security/*" component={Security} />  | ||||
|           <AuthenticatedRoute exact path="/system/*" component={System} />           | ||||
|           <Redirect to="/" /> | ||||
|           )} | ||||
|           {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> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default AppRouting; | ||||
| export default withFeatures(AppRouting); | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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 AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "apSettings"; | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import * as H from 'history'; | ||||
|  | ||||
| import history from '../history'; | ||||
| import { PROJECT_PATH } from '../api'; | ||||
| import { Features } from '../features/types'; | ||||
| import { getDefaultRoute } from '../AppRouting'; | ||||
|  | ||||
| export const ACCESS_TOKEN = 'access_token'; | ||||
| export const LOGIN_PATHNAME = 'loginPathname'; | ||||
| @@ -26,12 +27,12 @@ export function clearLoginRedirect() { | ||||
|   getStorage().removeItem(LOGIN_SEARCH); | ||||
| } | ||||
|  | ||||
| export function fetchLoginRedirect(): H.LocationDescriptorObject { | ||||
| export function fetchLoginRedirect(features: Features): H.LocationDescriptorObject { | ||||
|   const loginPathname = getStorage().getItem(LOGIN_PATHNAME); | ||||
|   const loginSearch = getStorage().getItem(LOGIN_SEARCH); | ||||
|   clearLoginRedirect(); | ||||
|   return { | ||||
|     pathname: loginPathname || `/${PROJECT_PATH}/`, | ||||
|     pathname: loginPathname || getDefaultRoute(features), | ||||
|     search: (loginPathname && loginSearch) || undefined | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -2,37 +2,21 @@ import * as React from 'react'; | ||||
| import { withSnackbar, WithSnackbarProps } from 'notistack'; | ||||
| import jwtDecode from 'jwt-decode'; | ||||
|  | ||||
| import CircularProgress from '@material-ui/core/CircularProgress'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| import { withStyles, Theme, createStyles, WithStyles } from '@material-ui/core/styles'; | ||||
|  | ||||
| import history from '../history' | ||||
| import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api'; | ||||
| import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication'; | ||||
| import { AuthenticationContext, Me } from './AuthenticationContext'; | ||||
| import FullScreenLoading from '../components/FullScreenLoading'; | ||||
| import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext'; | ||||
|  | ||||
| export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken); | ||||
|  | ||||
| const styles = (theme: Theme) => createStyles({ | ||||
|   loadingPanel: { | ||||
|     padding: theme.spacing(2), | ||||
|     display: "flex", | ||||
|     alignItems: "center", | ||||
|     justifyContent: "center", | ||||
|     height: "100vh", | ||||
|     flexDirection: "column" | ||||
|   }, | ||||
|   progress: { | ||||
|     margin: theme.spacing(4), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| interface AuthenticationWrapperState { | ||||
|   context: AuthenticationContext; | ||||
|   initialized: boolean; | ||||
| } | ||||
|  | ||||
| type AuthenticationWrapperProps = WithSnackbarProps & WithStyles<typeof styles>; | ||||
| type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps; | ||||
|  | ||||
| class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> { | ||||
|  | ||||
| @@ -69,18 +53,16 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, | ||||
|   } | ||||
|  | ||||
|   renderContentLoading() { | ||||
|     const { classes } = this.props; | ||||
|     return ( | ||||
|       <div className={classes.loadingPanel}> | ||||
|         <CircularProgress className={classes.progress} size={100} /> | ||||
|         <Typography variant="h4" > | ||||
|           Loading... | ||||
|         </Typography> | ||||
|       </div> | ||||
|       <FullScreenLoading /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   refresh = () => { | ||||
|     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) | ||||
| @@ -124,4 +106,4 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withStyles(styles)(withSnackbar(AuthenticationWrapper)) | ||||
| export default withFeatures(withSnackbar(AuthenticationWrapper)) | ||||
|   | ||||
| @@ -3,19 +3,21 @@ import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-d | ||||
|  | ||||
| import { withAuthenticationContext, AuthenticationContextProps } from './AuthenticationContext'; | ||||
| import * as Authentication from './Authentication'; | ||||
| import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext'; | ||||
|  | ||||
| interface UnauthenticatedRouteProps extends RouteProps { | ||||
| interface UnauthenticatedRouteProps extends RouteProps, AuthenticationContextProps, WithFeaturesProps { | ||||
|   component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>; | ||||
| } | ||||
|  | ||||
| type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode; | ||||
|  | ||||
| class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps & AuthenticationContextProps> { | ||||
| class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> { | ||||
|  | ||||
|   public render() { | ||||
|     const { authenticationContext, component:Component, ...rest } = this.props; | ||||
|     const { authenticationContext, component: Component, features, ...rest } = this.props; | ||||
|     const renderComponent: RenderComponent = (props) => { | ||||
|       if (authenticationContext.me) { | ||||
|         return (<Redirect to={Authentication.fetchLoginRedirect()} />); | ||||
|         return (<Redirect to={Authentication.fetchLoginRedirect(features)} />); | ||||
|       } | ||||
|       return (<Component {...props} />); | ||||
|     } | ||||
| @@ -25,4 +27,4 @@ class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps & Authenticat | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(UnauthenticatedRoute); | ||||
| export default withFeatures(withAuthenticationContext(UnauthenticatedRoute)); | ||||
|   | ||||
							
								
								
									
										57
									
								
								interface/src/components/ApplicationError.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								interface/src/components/ApplicationError.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| 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"> | ||||
|           <WarningIcon fontSize="large" color="error" /> | ||||
|           <Typography variant="h4" gutterBottom> | ||||
|              Application error | ||||
|           </Typography> | ||||
|         </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; | ||||
							
								
								
									
										32
									
								
								interface/src/components/FullScreenLoading.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								interface/src/components/FullScreenLoading.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| 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,4 +1,4 @@ | ||||
| import React, { RefObject } from 'react'; | ||||
| 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'; | ||||
| @@ -20,6 +20,7 @@ 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; | ||||
|  | ||||
| @@ -82,7 +83,7 @@ interface MenuAppBarState { | ||||
|   authMenuOpen: boolean; | ||||
| } | ||||
|  | ||||
| interface MenuAppBarProps extends AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps { | ||||
| interface MenuAppBarProps extends WithFeaturesProps, AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps { | ||||
|   sectionTitle: string; | ||||
| } | ||||
|  | ||||
| @@ -114,7 +115,7 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { classes, theme, children, sectionTitle, authenticatedContext } = this.props; | ||||
|     const { classes, theme, children, sectionTitle, authenticatedContext, features } = this.props; | ||||
|     const { mobileOpen, authMenuOpen } = this.state; | ||||
|     const path = this.props.match.url; | ||||
|     const drawer = ( | ||||
| @@ -128,9 +129,12 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|           </Typography> | ||||
|           <Divider absolute /> | ||||
|         </Toolbar> | ||||
|         <Divider /> | ||||
|         <ProjectMenu /> | ||||
|         <Divider /> | ||||
|         {features.project && ( | ||||
|           <Fragment> | ||||
|             <ProjectMenu /> | ||||
|             <Divider /> | ||||
|           </Fragment> | ||||
|         )} | ||||
|         <List> | ||||
|           <ListItem to='/wifi/' selected={path.startsWith('/wifi/')} button component={Link}> | ||||
|             <ListItemIcon> | ||||
| @@ -144,24 +148,30 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|             </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> | ||||
|           <ListItem to='/mqtt/' selected={path.startsWith('/mqtt/')} button component={Link}> | ||||
|             <ListItemIcon> | ||||
|               <DeviceHubIcon /> | ||||
|             </ListItemIcon> | ||||
|             <ListItemText primary="MQTT" /> | ||||
|           </ListItem> | ||||
|           <ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}> | ||||
|             <ListItemIcon> | ||||
|               <LockIcon /> | ||||
|             </ListItemIcon> | ||||
|             <ListItemText primary="Security" /> | ||||
|           </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 /> | ||||
| @@ -172,6 +182,42 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|       </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}> | ||||
| @@ -188,39 +234,7 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|             <Typography variant="h6" color="inherit" noWrap className={classes.title}> | ||||
|               {sectionTitle} | ||||
|             </Typography> | ||||
|             <div> | ||||
|               <IconButton | ||||
|                 ref={this.anchorRef} | ||||
|                 aria-owns={authMenuOpen ? 'menu-list-grow' : undefined} | ||||
|                 aria-haspopup="true" | ||||
|                 onClick={this.handleToggle} | ||||
|                 color="inherit" | ||||
|               > | ||||
|                 <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> | ||||
|             {features.security && userMenu} | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|         <nav className={classes.drawer}> | ||||
| @@ -263,8 +277,10 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|  | ||||
| export default withRouter( | ||||
|   withTheme( | ||||
|     withAuthenticatedContext( | ||||
|       withStyles(styles)(MenuAppBar) | ||||
|     withFeatures( | ||||
|       withAuthenticatedContext( | ||||
|         withStyles(styles)(MenuAppBar) | ||||
|       ) | ||||
|     ) | ||||
|   ) | ||||
| ); | ||||
|   | ||||
| @@ -100,8 +100,8 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl: | ||||
|  | ||||
|       render() { | ||||
|         return <RestController | ||||
|           {...this.props as P} | ||||
|           {...this.state} | ||||
|           {...this.props as P} | ||||
|           handleValueChange={this.handleValueChange} | ||||
|           setData={this.setData} | ||||
|           saveData={this.saveData} | ||||
|   | ||||
							
								
								
									
										23
									
								
								interface/src/features/ApplicationContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								interface/src/features/ApplicationContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import React from 'react'; | ||||
| import { Features } from './types'; | ||||
|  | ||||
| export interface ApplicationContext { | ||||
|   features: Features; | ||||
| } | ||||
|  | ||||
| const ApplicationContextDefaultValue = {} as ApplicationContext | ||||
| export const ApplicationContext = React.createContext( | ||||
|   ApplicationContextDefaultValue | ||||
| ); | ||||
|  | ||||
| export function withAuthenticatedContexApplicationContext<T extends ApplicationContext>(Component: React.ComponentType<T>) { | ||||
|   return class extends React.Component<Omit<T, keyof ApplicationContext>> { | ||||
|     render() { | ||||
|       return ( | ||||
|         <ApplicationContext.Consumer> | ||||
|           {authenticatedContext => <Component {...this.props as T} features={authenticatedContext} />} | ||||
|         </ApplicationContext.Consumer> | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										27
									
								
								interface/src/features/FeaturesContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								interface/src/features/FeaturesContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import React from 'react'; | ||||
| import { Features } from './types'; | ||||
|  | ||||
| export interface FeaturesContext { | ||||
|   features: Features; | ||||
| } | ||||
|  | ||||
| const FeaturesContextDefaultValue = {} as FeaturesContext | ||||
| export const FeaturesContext = React.createContext( | ||||
|   FeaturesContextDefaultValue | ||||
| ); | ||||
|  | ||||
| export interface WithFeaturesProps { | ||||
|   features: Features; | ||||
| } | ||||
|  | ||||
| export function withFeatures<T extends WithFeaturesProps>(Component: React.ComponentType<T>) { | ||||
|   return class extends React.Component<Omit<T, keyof WithFeaturesProps>> { | ||||
|     render() { | ||||
|       return ( | ||||
|         <FeaturesContext.Consumer> | ||||
|           {featuresContext => <Component {...this.props as T} features={featuresContext.features} />} | ||||
|         </FeaturesContext.Consumer> | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										61
									
								
								interface/src/features/FeaturesWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								interface/src/features/FeaturesWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { Features } from './types'; | ||||
| import { FeaturesContext } from './FeaturesContext'; | ||||
| import FullScreenLoading from '../components/FullScreenLoading'; | ||||
| import ApplicationError from '../components/ApplicationError'; | ||||
| import { FEATURES_ENDPOINT } from '../api'; | ||||
|  | ||||
| interface FeaturesWrapperState { | ||||
|   features?: Features; | ||||
|   error?: string; | ||||
| }; | ||||
|  | ||||
| class FeaturesWrapper extends Component<{}, FeaturesWrapperState> { | ||||
|  | ||||
|   state: FeaturesWrapperState = {}; | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.fetchFeaturesDetails(); | ||||
|   } | ||||
|  | ||||
|   fetchFeaturesDetails = () => { | ||||
|     fetch(FEATURES_ENDPOINT) | ||||
|       .then(response => { | ||||
|         if (response.status === 200) { | ||||
|           return response.json(); | ||||
|         } else { | ||||
|           throw Error("Unexpected status code: " + response.status); | ||||
|         } | ||||
|       }).then(features => { | ||||
|         this.setState({ features }); | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         this.setState({ error: error.message }); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { features, error } = this.state; | ||||
|     if (features) { | ||||
|       return ( | ||||
|         <FeaturesContext.Provider value={{ | ||||
|           features | ||||
|         }}> | ||||
|           {this.props.children} | ||||
|         </FeaturesContext.Provider> | ||||
|       ); | ||||
|     } | ||||
|     if (error) { | ||||
|       return ( | ||||
|         <ApplicationError error={error} /> | ||||
|       ); | ||||
|     } | ||||
|     return ( | ||||
|       <FullScreenLoading /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default FeaturesWrapper; | ||||
							
								
								
									
										7
									
								
								interface/src/features/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								interface/src/features/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| export interface Features { | ||||
|   project: boolean; | ||||
|   security: boolean; | ||||
|   mqtt: boolean; | ||||
|   ntp: boolean; | ||||
|   ota: boolean; | ||||
| } | ||||
| @@ -8,8 +8,9 @@ import { MenuAppBar } from '../components'; | ||||
|  | ||||
| import SystemStatusController from './SystemStatusController'; | ||||
| import OTASettingsController from './OTASettingsController'; | ||||
| import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext'; | ||||
|  | ||||
| type SystemProps = AuthenticatedContextProps & RouteComponentProps; | ||||
| type SystemProps = AuthenticatedContextProps & RouteComponentProps & WithFeaturesProps; | ||||
|  | ||||
| class System extends Component<SystemProps> { | ||||
|  | ||||
| @@ -18,16 +19,20 @@ class System extends Component<SystemProps> { | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticatedContext } = this.props; | ||||
|     const { authenticatedContext, features } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="System"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> | ||||
|           <Tab value="/system/status" label="System Status" /> | ||||
|           <Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} /> | ||||
|           {features.ota && ( | ||||
|             <Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} /> | ||||
|           )} | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact path="/system/status" component={SystemStatusController} /> | ||||
|           <AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} /> | ||||
|           {features.ota && ( | ||||
|             <AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} /> | ||||
|           )} | ||||
|           <Redirect to="/system/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
| @@ -35,4 +40,4 @@ class System extends Component<SystemProps> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticatedContext(System); | ||||
| export default withFeatures(withAuthenticatedContext(System)); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user