Re-engineer UI in TypeScript (#89)
* Re-engineer UI in TypeScript * Switch to named imports where possible * Restructure file system layout * Update depencencies * Update README.md * Change explicit colors for better support for dark theme
This commit is contained in:
		
							
								
								
									
										31
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								README.md
									
									
									
									
									
								
							| @@ -213,21 +213,36 @@ The framework, and MaterialUI allows for a reasonable degree of customization wi | ||||
|  | ||||
| ### Theming the app | ||||
|  | ||||
| The app can be easily themed by editing the [MaterialUI theme](https://material-ui.com/customization/themes/). Edit the theme in ['interface/src/App.js'](interface/src/App.js) as you desire: | ||||
| The app can be easily themed by editing the [MaterialUI theme](https://material-ui.com/customization/theming/). Edit the theme in ['interface/src/CustomMuiTheme.tsx'](interface/src/CustomMuiTheme.tsx) as you desire. For example, here is a dark theme: | ||||
|  | ||||
| ```js | ||||
| const theme = createMuiTheme({ | ||||
|   palette: { | ||||
|     primary: red, | ||||
|     secondary: deepOrange, | ||||
|     highlight_idle: blueGrey[900], | ||||
|     highlight_warn: orange[500], | ||||
|     highlight_error: red[500], | ||||
|     highlight_success: green[500], | ||||
|     type:"dark", | ||||
|     primary: { | ||||
|       main: '#222', | ||||
|     }, | ||||
|     secondary: { | ||||
|       main: '#666', | ||||
|     }, | ||||
|     info: { | ||||
|       main: blueGrey[900] | ||||
|     }, | ||||
|     warning: { | ||||
|       main: orange[500] | ||||
|     }, | ||||
|     error: { | ||||
|       main: red[500] | ||||
|     }, | ||||
|     success: { | ||||
|       main: green[500] | ||||
|     } | ||||
|   } | ||||
| }); | ||||
| ``` | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Changing the app icon | ||||
|  | ||||
| You can replace the app icon is located at ['interface/public/app/icon.png'](interface/public/app/icon.png) with one of your preference. A 256 x 256 PNG is recommended for best compatibility. | ||||
| @@ -448,7 +463,7 @@ Serial.println(wifiSettings.ssid); | ||||
| Configure the SSID and password: | ||||
|  | ||||
| ```cpp | ||||
| WiFiSettings wifiSettings = esp8266React->getWiFiSettingsService()->fetch(); | ||||
| WiFiSettings wifiSettings = esp8266React.getWiFiSettingsService()->fetch(); | ||||
| wifiSettings.ssid = "MyNetworkSSID"; | ||||
| wifiSettings.password = "MySuperSecretPassword"; | ||||
| esp8266React.getWiFiSettingsService()->update(wifiSettings); | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| # 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_ENDPOINT_ROOT=http://192.168.0.29/rest/ | ||||
| REACT_APP_ENDPOINT_ROOT=http://192.168.0.21/rest/ | ||||
|   | ||||
							
								
								
									
										7967
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7967
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,37 +3,51 @@ | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@material-ui/core": "^4.7.0", | ||||
|     "@material-ui/icons": "^4.5.1", | ||||
|     "compression-webpack-plugin": "^2.0.0", | ||||
|     "@material-ui/core": "^4.9.1", | ||||
|     "@material-ui/icons": "^4.9.1", | ||||
|     "@types/jwt-decode": "^2.2.1", | ||||
|     "@types/node": "^12.12.22", | ||||
|     "@types/react": "^16.9.17", | ||||
|     "@types/react-dom": "^16.9.4", | ||||
|     "@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", | ||||
|     "jwt-decode": "^2.2.0", | ||||
|     "mime-types": "^2.1.25", | ||||
|     "moment": "^2.24.0", | ||||
|     "notistack": "^0.9.6", | ||||
|     "prop-types": "^15.7.2", | ||||
|     "react": "^16.10.1", | ||||
|     "react-dom": "^16.10.1", | ||||
|     "notistack": "^0.9.7", | ||||
|     "react": "^16.12.0", | ||||
|     "react-dom": "^16.12.0", | ||||
|     "react-form-validator-core": "^0.6.4", | ||||
|     "react-jss": "^10.0.0", | ||||
|     "react-material-ui-form-validator": "^2.0.9", | ||||
|     "react-router": "^5.1.1", | ||||
|     "react-router-dom": "^5.1.1", | ||||
|     "react-scripts": "3.0.1", | ||||
|     "react-material-ui-form-validator": "^2.0.10", | ||||
|     "react-router": "^5.1.2", | ||||
|     "react-router-dom": "^5.1.2", | ||||
|     "react-scripts": "3.3.1", | ||||
|     "typescript": "^3.7.5", | ||||
|     "zlib": "^1.0.5" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "react-app-rewired start", | ||||
|     "build": "react-app-rewired build", | ||||
|     "test": "react-app-rewired test --env=jsdom", | ||||
|     "eject": "react-scripts eject" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "react-app-rewired": "^2.1.3" | ||||
|   "eslintConfig": { | ||||
|     "extends": "react-app" | ||||
|   }, | ||||
|   "browserslist": [ | ||||
|   "browserslist": { | ||||
|     "production": [ | ||||
|       ">0.2%", | ||||
|       "not dead", | ||||
|     "not ie <= 11", | ||||
|       "not op_mini all" | ||||
|     ], | ||||
|     "development": [ | ||||
|       "last 1 chrome version", | ||||
|       "last 1 firefox version", | ||||
|       "last 1 safari version" | ||||
|     ] | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "react-app-rewired": "^2.1.5" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,69 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Route, Switch } from 'react-router'; | ||||
|  | ||||
| import AppRouting from './AppRouting'; | ||||
| import { PROJECT_NAME } from './constants/Env'; | ||||
|  | ||||
| import { SnackbarProvider } from 'notistack'; | ||||
| import { create } from 'jss'; | ||||
|  | ||||
| import { CssBaseline, IconButton, MuiThemeProvider, createMuiTheme } from '@material-ui/core'; | ||||
| import { StylesProvider, jssPreset } from '@material-ui/styles'; | ||||
| import { blueGrey, indigo, orange, red, green } from '@material-ui/core/colors'; | ||||
| import CloseIcon from '@material-ui/icons/Close'; | ||||
|  | ||||
|  | ||||
| // Our theme | ||||
| const theme = createMuiTheme({ | ||||
|   palette: { | ||||
|     primary: indigo, | ||||
|     secondary: blueGrey, | ||||
|     highlight_idle: blueGrey[900], | ||||
|     highlight_warn: orange[500], | ||||
|     highlight_error: red[500], | ||||
|     highlight_success: green[500], | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| // JSS instance | ||||
| const jss = create(jssPreset()); | ||||
|  | ||||
| // this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid. | ||||
| const unauthorizedRedirect = () => <Redirect to="/" />; | ||||
|  | ||||
| class App extends Component { | ||||
|  | ||||
|   notistackRef = React.createRef(); | ||||
|  | ||||
|   componentDidMount() { | ||||
|     document.title = PROJECT_NAME; | ||||
|   } | ||||
|  | ||||
|   onClickDismiss = (key) => () => { | ||||
|     this.notistackRef.current.closeSnackbar(key); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <StylesProvider jss={jss}> | ||||
|         <MuiThemeProvider theme={theme}> | ||||
|           <SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} | ||||
|             ref={this.notistackRef} | ||||
|             action={(key) => ( | ||||
|               <IconButton onClick={this.onClickDismiss(key)} size="small"> | ||||
|                 <CloseIcon /> | ||||
|               </IconButton> | ||||
|             )}> | ||||
|             <CssBaseline /> | ||||
|             <Switch> | ||||
|               <Route exact path="/unauthorized" component={unauthorizedRedirect} /> | ||||
|               <Route component={AppRouting} /> | ||||
|             </Switch> | ||||
|           </SnackbarProvider> | ||||
|         </MuiThemeProvider> | ||||
|       </StylesProvider> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default App | ||||
							
								
								
									
										47
									
								
								interface/src/App.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								interface/src/App.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| import React, { Component, RefObject } from 'react'; | ||||
| import { Redirect, Route, Switch } from 'react-router'; | ||||
| import { SnackbarProvider } from 'notistack'; | ||||
|  | ||||
| import { IconButton } from '@material-ui/core'; | ||||
| import CloseIcon from '@material-ui/icons/Close'; | ||||
|  | ||||
| import AppRouting from './AppRouting'; | ||||
| import CustomMuiTheme from './CustomMuiTheme'; | ||||
| import { PROJECT_NAME } from './api'; | ||||
|  | ||||
| // this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid. | ||||
| const unauthorizedRedirect = () => <Redirect to="/" />; | ||||
|  | ||||
| class App extends Component { | ||||
|  | ||||
|   notistackRef: RefObject<any> = React.createRef(); | ||||
|  | ||||
|   componentDidMount() { | ||||
|     document.title = PROJECT_NAME; | ||||
|   } | ||||
|  | ||||
|   onClickDismiss = (key: string | number | undefined) => () => { | ||||
|     this.notistackRef.current.closeSnackbar(key); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <CustomMuiTheme> | ||||
|         <SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} | ||||
|           ref={this.notistackRef} | ||||
|           action={(key) => ( | ||||
|             <IconButton onClick={this.onClickDismiss(key)} size="small"> | ||||
|               <CloseIcon /> | ||||
|             </IconButton> | ||||
|           )}> | ||||
|           <Switch> | ||||
|             <Route exact path="/unauthorized" component={unauthorizedRedirect} /> | ||||
|             <Route component={AppRouting} /> | ||||
|           </Switch> | ||||
|         </SnackbarProvider> | ||||
|       </CustomMuiTheme> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default App | ||||
| @@ -1,23 +1,24 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Switch, Redirect } from 'react-router'; | ||||
| 
 | ||||
| import { Redirect, Switch } from 'react-router'; | ||||
| 
 | ||||
| import { PROJECT_PATH } from './constants/Env'; | ||||
| import * as Authentication from './authentication/Authentication'; | ||||
| import AuthenticationWrapper from './authentication/AuthenticationWrapper'; | ||||
| import AuthenticatedRoute from './authentication/AuthenticatedRoute'; | ||||
| import UnauthenticatedRoute from './authentication/UnauthenticatedRoute'; | ||||
| import SignInPage from './containers/SignInPage'; | ||||
| import WiFiConnection from './sections/WiFiConnection'; | ||||
| import AccessPoint from './sections/AccessPoint'; | ||||
| import NetworkTime from './sections/NetworkTime'; | ||||
| import Security from './sections/Security'; | ||||
| import System from './sections/System'; | ||||
| import AuthenticatedRoute from './authentication/AuthenticatedRoute'; | ||||
| 
 | ||||
| import SignIn from './SignIn'; | ||||
| import ProjectRouting from './project/ProjectRouting'; | ||||
| import WiFiConnection from './wifi/WiFiConnection'; | ||||
| import AccessPoint from './ap/AccessPoint'; | ||||
| import NetworkTime from './ntp/NetworkTime'; | ||||
| import Security from './security/Security'; | ||||
| import System from './system/System'; | ||||
| 
 | ||||
| import { PROJECT_PATH } from './api'; | ||||
| 
 | ||||
| class AppRouting extends Component { | ||||
| 
 | ||||
|   componentWillMount() { | ||||
|   componentDidMount() { | ||||
|     Authentication.clearLoginRedirect(); | ||||
|   } | ||||
| 
 | ||||
| @@ -25,13 +26,13 @@ class AppRouting extends Component { | ||||
|     return ( | ||||
|       <AuthenticationWrapper> | ||||
|         <Switch> | ||||
|           <UnauthenticatedRoute exact path="/" component={SignInPage} /> | ||||
|           <UnauthenticatedRoute exact path="/" component={SignIn} /> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> | ||||
|           <AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} />          | ||||
|           <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> | ||||
|           <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> | ||||
|           <AuthenticatedRoute exact path="/security/*" component={Security} />  | ||||
|           <AuthenticatedRoute exact path="/system/*" component={System} />           | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> | ||||
|           <Redirect to="/" /> | ||||
|         </Switch> | ||||
|       </AuthenticationWrapper> | ||||
							
								
								
									
										39
									
								
								interface/src/CustomMuiTheme.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								interface/src/CustomMuiTheme.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { CssBaseline } from '@material-ui/core'; | ||||
| import { MuiThemeProvider, createMuiTheme, StylesProvider } from '@material-ui/core/styles'; | ||||
| import { blueGrey, indigo, orange, red, green } from '@material-ui/core/colors'; | ||||
|  | ||||
| const theme = createMuiTheme({ | ||||
|   palette: { | ||||
|     primary: indigo, | ||||
|     secondary: blueGrey, | ||||
|     info: { | ||||
|       main: blueGrey[900] | ||||
|     }, | ||||
|     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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -1,18 +1,16 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import { withSnackbar, WithSnackbarProps } from 'notistack'; | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
| import Paper from '@material-ui/core/Paper'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| import Fab from '@material-ui/core/Fab'; | ||||
| import { PROJECT_NAME } from '../constants/Env'; | ||||
| import ForwardIcon from '@material-ui/icons/Forward'; | ||||
| import { withSnackbar } from 'notistack'; | ||||
| import { SIGN_IN_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { withAuthenticationContext } from '../authentication/Context'; | ||||
| import PasswordValidator from '../components/PasswordValidator'; | ||||
| 
 | ||||
| const styles = theme => { | ||||
|   return { | ||||
| 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 { withAuthenticationContext, AuthenticationContextProps } from './authentication/AuthenticationContext'; | ||||
| import {PasswordValidator} from './components'; | ||||
| import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api'; | ||||
| 
 | ||||
| const styles = (theme: Theme) => createStyles({ | ||||
|   loginPage: { | ||||
|     display: "flex", | ||||
|     height: "100vh", | ||||
| @@ -35,20 +33,23 @@ const styles = theme => { | ||||
|   extendedIcon: { | ||||
|     marginRight: theme.spacing(0.5), | ||||
|   }, | ||||
|     textField: { | ||||
|       width: "100%" | ||||
|     }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| type SignInPageProps = WithSnackbarProps & WithStyles<typeof styles> & AuthenticationContextProps; | ||||
| 
 | ||||
| interface SignInPageState { | ||||
|   username: string, | ||||
|   password: string, | ||||
|   processing: boolean | ||||
| } | ||||
| 
 | ||||
| class SignInPage extends Component<SignInPageProps, SignInPageState> { | ||||
| 
 | ||||
| class SignInPage extends Component { | ||||
| 
 | ||||
|   constructor(props) { | ||||
|   constructor(props: SignInPageProps) { | ||||
|     super(props); | ||||
|     this.state = { | ||||
|       username: '', | ||||
| @@ -57,8 +58,12 @@ class SignInPage extends Component { | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   handleValueChange = name => event => { | ||||
|     this.setState({ [name]: event.target.value }); | ||||
|   updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => { | ||||
|     const { name, value } = event.currentTarget; | ||||
|     this.setState(prevState => ({ | ||||
|       ...prevState, | ||||
|       [name]: value, | ||||
|     })) | ||||
|   }; | ||||
| 
 | ||||
|   onSubmit = () => { | ||||
| @@ -105,9 +110,10 @@ class SignInPage extends Component { | ||||
|               errorMessages={['Username is required']} | ||||
|               name="username" | ||||
|               label="Username" | ||||
|               className={classes.textField} | ||||
|               fullWidth | ||||
|               variant="outlined" | ||||
|               value={username} | ||||
|               onChange={this.handleValueChange('username')} | ||||
|               onChange={this.updateInputElement} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <PasswordValidator | ||||
| @@ -116,9 +122,10 @@ class SignInPage extends Component { | ||||
|               errorMessages={['Password is required']} | ||||
|               name="password" | ||||
|               label="Password" | ||||
|               className={classes.textField} | ||||
|               fullWidth | ||||
|               variant="outlined" | ||||
|               value={password} | ||||
|               onChange={this.handleValueChange('password')} | ||||
|               onChange={this.updateInputElement} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}> | ||||
| @@ -133,6 +140,4 @@ class SignInPage extends Component { | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default withAuthenticationContext( | ||||
|   withSnackbar(withStyles(styles)(SignInPage)) | ||||
| ); | ||||
| export default withAuthenticationContext(withSnackbar(withStyles(styles)(SignInPage))); | ||||
							
								
								
									
										7
									
								
								interface/src/ap/APModes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								interface/src/ap/APModes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { APSettings } from "./types"; | ||||
|  | ||||
| export const AP_MODE_ALWAYS = 0; | ||||
| export const AP_MODE_DISCONNECTED = 1; | ||||
| export const AP_NEVER = 2; | ||||
|  | ||||
| export const isAPEnabled = ({ provision_mode }: APSettings) => provision_mode === AP_MODE_ALWAYS || provision_mode === AP_MODE_DISCONNECTED; | ||||
							
								
								
									
										30
									
								
								interface/src/ap/APSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/ap/APSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { 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); | ||||
							
								
								
									
										71
									
								
								interface/src/ap/APSettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								interface/src/ap/APSettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| 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, AP_MODE_ALWAYS, AP_MODE_DISCONNECTED, AP_NEVER } from './APModes'; | ||||
| import { APSettings } from './types'; | ||||
|  | ||||
| type APSettingsFormProps = RestFormProps<APSettings>; | ||||
|  | ||||
| class APSettingsForm extends React.Component<APSettingsFormProps> { | ||||
|  | ||||
|   render() { | ||||
|     const { data, handleValueChange, saveData, loadData } = 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={AP_MODE_ALWAYS}>Always</MenuItem> | ||||
|           <MenuItem value={AP_MODE_DISCONNECTED}>When WiFi Disconnected</MenuItem> | ||||
|           <MenuItem value={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:^.{1,64}$']} | ||||
|               errorMessages={['Access Point Password is required', 'Access Point Password must be 64 characters or less']} | ||||
|               name="password" | ||||
|               label="Access Point Password" | ||||
|               fullWidth | ||||
|               variant="outlined" | ||||
|               value={data.password} | ||||
|               onChange={handleValueChange('password')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|           </Fragment> | ||||
|         } | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|             Save | ||||
|           </FormButton> | ||||
|           <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|             Reset | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default APSettingsForm; | ||||
							
								
								
									
										10
									
								
								interface/src/ap/APStatus.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								interface/src/ap/APStatus.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import { Theme } from "@material-ui/core"; | ||||
| import { APStatus } from "./types"; | ||||
|  | ||||
| export const apStatusHighlight = ({ active }: APStatus, theme: Theme) => { | ||||
|   return active ? theme.palette.success.main : theme.palette.info.main; | ||||
| } | ||||
|  | ||||
| export const apStatus = ({ active }: APStatus) => { | ||||
|   return active ? "Active" : "Inactive"; | ||||
| }; | ||||
							
								
								
									
										29
									
								
								interface/src/ap/APStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								interface/src/ap/APStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import React, { 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); | ||||
							
								
								
									
										78
									
								
								interface/src/ap/APStatusForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								interface/src/ap/APStatusForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| 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); | ||||
							
								
								
									
										38
									
								
								interface/src/ap/AccessPoint.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								interface/src/ap/AccessPoint.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import React, { 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 = (event: React.ChangeEvent<{}>, path: string) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticatedContext } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Access Point"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} 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={true} path="/ap/status" component={APStatusController} /> | ||||
|           <AuthenticatedRoute exact={true} path="/ap/settings" component={APSettingsController} /> | ||||
|           <Redirect to="/ap/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticatedContext(AccessPoint); | ||||
							
								
								
									
										12
									
								
								interface/src/ap/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								interface/src/ap/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| export interface APStatus { | ||||
|   active: boolean; | ||||
|   ip_address: string; | ||||
|   mac_address: string; | ||||
|   station_num: number; | ||||
| } | ||||
|  | ||||
| export interface APSettings { | ||||
|   provision_mode: number; | ||||
|   ssid: string; | ||||
|   password: string; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { ENDPOINT_ROOT } from '../constants/Env'; | ||||
| import { ENDPOINT_ROOT } from './Env'; | ||||
| 
 | ||||
| export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + "ntpStatus"; | ||||
| export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "ntpSettings"; | ||||
							
								
								
									
										3
									
								
								interface/src/api/Env.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								interface/src/api/Env.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME!; | ||||
| export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH!; | ||||
| export const ENDPOINT_ROOT = process.env.REACT_APP_ENDPOINT_ROOT!; | ||||
							
								
								
									
										2
									
								
								interface/src/api/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								interface/src/api/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| export * from './Env' | ||||
| export * from './Endpoints' | ||||
| @@ -1,36 +0,0 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   Redirect, Route | ||||
| } from "react-router-dom"; | ||||
|  | ||||
| import { withAuthenticationContext } from './Context.js'; | ||||
| import * as Authentication from './Authentication'; | ||||
| import { withSnackbar } from 'notistack'; | ||||
|  | ||||
| export class AuthenticatedRoute extends React.Component { | ||||
|  | ||||
|   render() { | ||||
|     const { enqueueSnackbar, authenticationContext, component: Component, ...rest } = this.props; | ||||
|     const { location } = this.props; | ||||
|     const renderComponent = (props) => { | ||||
|       if (authenticationContext.isAuthenticated()) { | ||||
|         return ( | ||||
|           <Component {...props} /> | ||||
|         ); | ||||
|       } | ||||
|       Authentication.storeLoginRedirect(location); | ||||
|       enqueueSnackbar("Please log in to continue.", { | ||||
|         variant: 'info', | ||||
|       }); | ||||
|       return ( | ||||
|         <Redirect to='/' /> | ||||
|       ); | ||||
|     } | ||||
|     return ( | ||||
|       <Route {...rest} render={renderComponent} /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withSnackbar(withAuthenticationContext(AuthenticatedRoute)); | ||||
							
								
								
									
										42
									
								
								interface/src/authentication/AuthenticatedRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								interface/src/authentication/AuthenticatedRoute.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| 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 } from './AuthenticationContext'; | ||||
|  | ||||
| type ChildComponent = React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>; | ||||
|  | ||||
| interface AuthenticatedRouteProps extends RouteProps, WithSnackbarProps, AuthenticationContextProps { | ||||
|   component: ChildComponent; | ||||
| } | ||||
|  | ||||
| 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 AuthenticatedContext}> | ||||
|             <Component {...props} /> | ||||
|           </AuthenticatedContext.Provider> | ||||
|         ); | ||||
|       } | ||||
|       Authentication.storeLoginRedirect(location); | ||||
|       enqueueSnackbar("Please log in to continue.", { variant: 'info' }); | ||||
|       return ( | ||||
|         <Redirect to='/' /> | ||||
|       ); | ||||
|     } | ||||
|     return ( | ||||
|       <Route {...rest} render={renderComponent} /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withSnackbar(withAuthenticationContext(AuthenticatedRoute)); | ||||
| @@ -1,11 +1,13 @@ | ||||
| import * as H from 'history'; | ||||
| 
 | ||||
| import history from '../history'; | ||||
| import { PROJECT_PATH } from '../constants/Env'; | ||||
| import { PROJECT_PATH } from '../api'; | ||||
| 
 | ||||
| export const ACCESS_TOKEN = 'access_token'; | ||||
| export const LOGIN_PATHNAME = 'loginPathname'; | ||||
| export const LOGIN_SEARCH = 'loginSearch'; | ||||
| 
 | ||||
| export function storeLoginRedirect(location) { | ||||
| export function storeLoginRedirect(location?: H.Location) { | ||||
|   if (location) { | ||||
|     localStorage.setItem(LOGIN_PATHNAME, location.pathname); | ||||
|     localStorage.setItem(LOGIN_SEARCH, location.search); | ||||
| @@ -17,7 +19,7 @@ export function clearLoginRedirect() { | ||||
|   localStorage.removeItem(LOGIN_SEARCH); | ||||
| } | ||||
| 
 | ||||
| export function fetchLoginRedirect() { | ||||
| export function fetchLoginRedirect(): H.LocationDescriptorObject { | ||||
|   const loginPathname = localStorage.getItem(LOGIN_PATHNAME); | ||||
|   const loginSearch = localStorage.getItem(LOGIN_SEARCH); | ||||
|   clearLoginRedirect(); | ||||
| @@ -30,13 +32,15 @@ export function fetchLoginRedirect() { | ||||
| /** | ||||
|  * Wraps the normal fetch routene with one with provides the access token if present. | ||||
|  */ | ||||
| export function authorizedFetch(url, params) { | ||||
| export function authorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> { | ||||
|   const accessToken = localStorage.getItem(ACCESS_TOKEN); | ||||
|   if (accessToken) { | ||||
|     params = params || {}; | ||||
|     params.credentials = 'include'; | ||||
|     params.headers = params.headers || {}; | ||||
|     params.headers.Authorization = 'Bearer ' + accessToken; | ||||
|     params.headers = { | ||||
|       ...params.headers, | ||||
|       "Authorization": 'Bearer ' + accessToken | ||||
|     }; | ||||
|   } | ||||
|   return fetch(url, params); | ||||
| } | ||||
| @@ -44,8 +48,8 @@ export function authorizedFetch(url, params) { | ||||
| /** | ||||
|  * Wraps the normal fetch routene which redirects on 401 response. | ||||
|  */ | ||||
| export function redirectingAuthorizedFetch(url, params) { | ||||
|   return new Promise(function (resolve, reject) { | ||||
| export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> { | ||||
|   return new Promise<Response>((resolve, reject) => { | ||||
|     authorizedFetch(url, params).then(response => { | ||||
|       if (response.status === 401) { | ||||
|         history.push("/unauthorized"); | ||||
							
								
								
									
										59
									
								
								interface/src/authentication/AuthenticationContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								interface/src/authentication/AuthenticationContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import * as React from "react"; | ||||
|  | ||||
| export interface Me { | ||||
|   username: string; | ||||
|   admin: boolean; | ||||
| } | ||||
|  | ||||
| export interface AuthenticationContext { | ||||
|   refresh: () => void; | ||||
|   signIn: (accessToken: string) => void; | ||||
|   signOut: () => void; | ||||
|   me?: Me; | ||||
| } | ||||
|  | ||||
| const AuthenticationContextDefaultValue = {} as AuthenticationContext | ||||
| export const AuthenticationContext = React.createContext( | ||||
|   AuthenticationContextDefaultValue | ||||
| ); | ||||
|  | ||||
| export interface AuthenticationContextProps { | ||||
|   authenticationContext: AuthenticationContext; | ||||
| } | ||||
|  | ||||
| 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 AuthenticatedContext extends AuthenticationContext { | ||||
|   me: Me; | ||||
| } | ||||
|  | ||||
| const AuthenticatedContextDefaultValue = {} as AuthenticatedContext | ||||
| export const AuthenticatedContext = React.createContext( | ||||
|   AuthenticatedContextDefaultValue | ||||
| ); | ||||
|  | ||||
| export interface AuthenticatedContextProps { | ||||
|   authenticatedContext: AuthenticatedContext; | ||||
| } | ||||
|  | ||||
| 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,15 +1,19 @@ | ||||
| import * as React from 'react'; | ||||
| import history from '../history' | ||||
| import { withSnackbar } from 'notistack'; | ||||
| import { VERIFY_AUTHORIZATION_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { ACCESS_TOKEN, authorizedFetch } from './Authentication'; | ||||
| import { AuthenticationContext } from './Context'; | ||||
| 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 } from '@material-ui/core/styles'; | ||||
| import { withStyles, Theme, createStyles, WithStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| const styles = theme => ({ | ||||
| import history from '../history' | ||||
| import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api'; | ||||
| import { ACCESS_TOKEN, authorizedFetch } from './Authentication'; | ||||
| import { AuthenticationContext, Me } from './AuthenticationContext'; | ||||
| 
 | ||||
| export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken); | ||||
| 
 | ||||
| const styles = (theme: Theme) => createStyles({ | ||||
|   loadingPanel: { | ||||
|     padding: theme.spacing(2), | ||||
|     display: "flex", | ||||
| @@ -23,17 +27,22 @@ const styles = theme => ({ | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| class AuthenticationWrapper extends React.Component { | ||||
| interface AuthenticationWrapperState { | ||||
|   context: AuthenticationContext; | ||||
|   initialized: boolean; | ||||
| } | ||||
| 
 | ||||
|   constructor(props) { | ||||
| type AuthenticationWrapperProps = WithSnackbarProps & WithStyles<typeof styles>; | ||||
| 
 | ||||
| class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> { | ||||
| 
 | ||||
|   constructor(props: AuthenticationWrapperProps) { | ||||
|     super(props); | ||||
|     this.state = { | ||||
|       context: { | ||||
|         refresh: this.refresh, | ||||
|         signIn: this.signIn, | ||||
|         signOut: this.signOut, | ||||
|         isAuthenticated: this.isAuthenticated, | ||||
|         isAdmin: this.isAdmin | ||||
|       }, | ||||
|       initialized: false | ||||
|     }; | ||||
| @@ -72,33 +81,31 @@ class AuthenticationWrapper extends React.Component { | ||||
|   } | ||||
| 
 | ||||
|   refresh = () => { | ||||
|     var accessToken = localStorage.getItem(ACCESS_TOKEN); | ||||
|     const accessToken = localStorage.getItem(ACCESS_TOKEN) | ||||
|     if (accessToken) { | ||||
|       authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT) | ||||
|         .then(response => { | ||||
|           const user = response.status === 200 ? jwtDecode(accessToken) : undefined; | ||||
|           this.setState({ initialized: true, context: { ...this.state.context, user } }); | ||||
|           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, user: undefined } }); | ||||
|           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, user: undefined } }); | ||||
|       this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   signIn = (accessToken) => { | ||||
|   signIn = (accessToken: string) => { | ||||
|     try { | ||||
|       localStorage.setItem(ACCESS_TOKEN, accessToken); | ||||
|       const user = jwtDecode(accessToken); | ||||
|       this.setState({ context: { ...this.state.context, user } }); | ||||
|       this.props.enqueueSnackbar(`Logged in as ${user.username}`, { | ||||
|         variant: 'success', | ||||
|       }); | ||||
|       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, user: undefined } }); | ||||
|       this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); | ||||
|       throw new Error("Failed to parse JWT " + err.message); | ||||
|     } | ||||
|   } | ||||
| @@ -108,24 +115,13 @@ class AuthenticationWrapper extends React.Component { | ||||
|     this.setState({ | ||||
|       context: { | ||||
|         ...this.state.context, | ||||
|         user: undefined | ||||
|         me: undefined | ||||
|       } | ||||
|     }); | ||||
|     this.props.enqueueSnackbar("You have signed out.", { | ||||
|       variant: 'success', | ||||
|     }); | ||||
|     this.props.enqueueSnackbar("You have signed out.", { variant: 'success', }); | ||||
|     history.push('/'); | ||||
|   } | ||||
| 
 | ||||
|   isAuthenticated = () => { | ||||
|     return this.state.context.user; | ||||
|   } | ||||
| 
 | ||||
|   isAdmin = () => { | ||||
|     const { context } = this.state; | ||||
|     return context.user && context.user.admin; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default withStyles(styles)(withSnackbar(AuthenticationWrapper)) | ||||
| @@ -1,15 +0,0 @@ | ||||
| import * as React from "react"; | ||||
|  | ||||
| export const AuthenticationContext = React.createContext( | ||||
|   {} | ||||
| ); | ||||
|  | ||||
| export function withAuthenticationContext(Component) { | ||||
|   return function AuthenticationContextComponent(props) { | ||||
|     return ( | ||||
|       <AuthenticationContext.Consumer> | ||||
|         {authenticationContext => <Component {...props} authenticationContext={authenticationContext} />} | ||||
|       </AuthenticationContext.Consumer> | ||||
|     ); | ||||
|   }; | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   Redirect, Route | ||||
| } from "react-router-dom"; | ||||
|  | ||||
| import { withAuthenticationContext } from './Context.js'; | ||||
| import * as Authentication from './Authentication'; | ||||
|  | ||||
| class UnauthenticatedRoute extends React.Component { | ||||
|   render() { | ||||
|     const { authenticationContext, component:Component, ...rest } = this.props; | ||||
|     const renderComponent = (props) => { | ||||
|       if (authenticationContext.isAuthenticated()) { | ||||
|         return (<Redirect to={Authentication.fetchLoginRedirect()} />); | ||||
|       } | ||||
|       return (<Component {...props} />); | ||||
|     } | ||||
|     return ( | ||||
|       <Route {...rest} render={renderComponent} /> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(UnauthenticatedRoute); | ||||
							
								
								
									
										28
									
								
								interface/src/authentication/UnauthenticatedRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								interface/src/authentication/UnauthenticatedRoute.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import * as React from 'react'; | ||||
| import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-dom"; | ||||
|  | ||||
| import { withAuthenticationContext, AuthenticationContextProps } from './AuthenticationContext'; | ||||
| import * as Authentication from './Authentication'; | ||||
|  | ||||
| interface UnauthenticatedRouteProps extends RouteProps { | ||||
|   component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>; | ||||
| } | ||||
|  | ||||
| type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode; | ||||
|  | ||||
| class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps & AuthenticationContextProps> { | ||||
|   public render() { | ||||
|     const { authenticationContext, component:Component, ...rest } = this.props; | ||||
|     const renderComponent: RenderComponent = (props) => { | ||||
|       if (authenticationContext.me) { | ||||
|         return (<Redirect to={Authentication.fetchLoginRedirect()} />); | ||||
|       } | ||||
|       return (<Component {...props} />); | ||||
|     } | ||||
|     return ( | ||||
|       <Route {...rest} render={renderComponent} /> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(UnauthenticatedRoute); | ||||
							
								
								
									
										6
									
								
								interface/src/authentication/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								interface/src/authentication/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| export { default as AuthenticatedRoute } from './AuthenticatedRoute'; | ||||
| export { default as AuthenticationWrapper } from './AuthenticationWrapper'; | ||||
| export { default as UnauthenticatedRoute } from './UnauthenticatedRoute'; | ||||
|  | ||||
| export * from './Authentication'; | ||||
| export * from './AuthenticationContext'; | ||||
							
								
								
									
										10
									
								
								interface/src/components/BlockFormControlLabel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								interface/src/components/BlockFormControlLabel.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import React, { FC } from "react"; | ||||
| import { FormControlLabel, FormControlLabelProps } from "@material-ui/core"; | ||||
|  | ||||
| const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => ( | ||||
|   <div> | ||||
|     <FormControlLabel {...props} /> | ||||
|   </div> | ||||
| ) | ||||
|  | ||||
| export default BlockFormControlLabel; | ||||
							
								
								
									
										7
									
								
								interface/src/components/FormActions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								interface/src/components/FormActions.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { styled, Box } from "@material-ui/core"; | ||||
|  | ||||
| const FormActions = styled(Box)(({ theme }) => ({ | ||||
|   marginTop: theme.spacing(1) | ||||
| })); | ||||
|  | ||||
| export default FormActions; | ||||
							
								
								
									
										13
									
								
								interface/src/components/FormButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								interface/src/components/FormButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| 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; | ||||
							
								
								
									
										23
									
								
								interface/src/components/HighlightAvatar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								interface/src/components/HighlightAvatar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import { Avatar, makeStyles } from "@material-ui/core"; | ||||
| import React, { 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,58 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import LinearProgress from '@material-ui/core/LinearProgress'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
|  | ||||
| const useStyles = makeStyles(theme => ({ | ||||
|   loadingSettings: { | ||||
|     margin: theme.spacing(0.5), | ||||
|   }, | ||||
|   loadingSettingsDetails: { | ||||
|     margin: theme.spacing(4), | ||||
|     textAlign: "center" | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| })); | ||||
|  | ||||
| export default function LoadingNotification(props) { | ||||
|   const classes = useStyles(); | ||||
|   const { fetched, errorMessage, onReset, render } = props; | ||||
|   return ( | ||||
|     <div> | ||||
|       { | ||||
|         fetched ? | ||||
|           errorMessage ? | ||||
|             <div className={classes.loadingSettings}> | ||||
|               <Typography variant="h6" className={classes.loadingSettingsDetails}> | ||||
|                 {errorMessage} | ||||
|               </Typography> | ||||
|               <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|                 Reset | ||||
|               </Button> | ||||
|             </div> | ||||
|             : | ||||
|             render() | ||||
|           : | ||||
|           <div className={classes.loadingSettings}> | ||||
|             <LinearProgress className={classes.loadingSettingsDetails} /> | ||||
|             <Typography variant="h6" className={classes.loadingSettingsDetails}> | ||||
|               Loading... | ||||
|             </Typography> | ||||
|           </div> | ||||
|       } | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| LoadingNotification.propTypes = { | ||||
|   fetched: PropTypes.bool.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   errorMessage: PropTypes.string, | ||||
|   render: PropTypes.func.isRequired | ||||
| }; | ||||
| @@ -1,42 +1,28 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { Link, withRouter } from 'react-router-dom'; | ||||
| import React, { RefObject } from 'react'; | ||||
| import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; | ||||
| 
 | ||||
| import { Drawer, AppBar, Toolbar, Avatar, Divider, Button, 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 { withStyles } from '@material-ui/core/styles'; | ||||
| import Drawer from '@material-ui/core/Drawer'; | ||||
| import AppBar from '@material-ui/core/AppBar'; | ||||
| import Toolbar from '@material-ui/core/Toolbar'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| import IconButton from '@material-ui/core/IconButton'; | ||||
| import Hidden from '@material-ui/core/Hidden'; | ||||
| import Divider from '@material-ui/core/Divider'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemIcon from '@material-ui/core/ListItemIcon'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import ListItemAvatar from '@material-ui/core/ListItemAvatar'; | ||||
| import Popper from '@material-ui/core/Popper'; | ||||
| import MenuIcon from '@material-ui/icons/Menu'; | ||||
| import WifiIcon from '@material-ui/icons/Wifi'; | ||||
| import SettingsIcon from '@material-ui/icons/Settings'; | ||||
| import AccessTimeIcon from '@material-ui/icons/AccessTime'; | ||||
| import AccountCircleIcon from '@material-ui/icons/AccountCircle'; | ||||
| import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; | ||||
| import LockIcon from '@material-ui/icons/Lock'; | ||||
| import ClickAwayListener from '@material-ui/core/ClickAwayListener'; | ||||
| import Card from '@material-ui/core/Card'; | ||||
| import CardContent from '@material-ui/core/CardContent'; | ||||
| import CardActions from '@material-ui/core/CardActions'; | ||||
| import Avatar from '@material-ui/core/Avatar'; | ||||
| import MenuIcon from '@material-ui/icons/Menu'; | ||||
| 
 | ||||
| import ProjectMenu from '../project/ProjectMenu'; | ||||
| import { PROJECT_NAME } from '../constants/Env'; | ||||
| import { withAuthenticationContext } from '../authentication/Context.js'; | ||||
| import { PROJECT_NAME } from '../api'; | ||||
| import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; | ||||
| 
 | ||||
| const drawerWidth = 290; | ||||
| 
 | ||||
| const styles = theme => ({ | ||||
| const styles = (theme: Theme) => createStyles({ | ||||
|   root: { | ||||
|     display: 'flex', | ||||
|   }, | ||||
| @@ -77,26 +63,38 @@ const styles = theme => ({ | ||||
|     "& > * + *": { | ||||
|       marginLeft: theme.spacing(2), | ||||
|     } | ||||
|   }, | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| class MenuAppBar extends React.Component { | ||||
|   state = { | ||||
| interface MenuAppBarState { | ||||
|   mobileOpen: boolean; | ||||
|   authMenuOpen: boolean; | ||||
| } | ||||
| 
 | ||||
| interface MenuAppBarProps extends 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 = React.createRef(); | ||||
|   anchorRef: RefObject<HTMLButtonElement> = React.createRef(); | ||||
| 
 | ||||
|   handleToggle = () => { | ||||
|     this.setState({ authMenuOpen: !this.state.authMenuOpen }); | ||||
|   } | ||||
| 
 | ||||
|   handleClose = (event) => { | ||||
|     if (this.anchorRef.current && this.anchorRef.current.contains(event.target)) { | ||||
|   handleClose = (event: React.MouseEvent<Document>) => { | ||||
|     if (this.anchorRef.current && this.anchorRef.current.contains(event.currentTarget)) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.setState({ authMenuOpen: false }); | ||||
|   } | ||||
| 
 | ||||
| @@ -105,13 +103,13 @@ class MenuAppBar extends React.Component { | ||||
|   }; | ||||
| 
 | ||||
|   render() { | ||||
|     const { classes, theme, children, sectionTitle, authenticationContext } = this.props; | ||||
|     const { classes, theme, children, sectionTitle, authenticatedContext } = this.props; | ||||
|     const { mobileOpen, authMenuOpen } = this.state; | ||||
|     const path = this.props.match.url; | ||||
|     const drawer = ( | ||||
|       <div> | ||||
|         <Toolbar> | ||||
|           <Typography variant="h6" color="primary"> | ||||
|           <Typography variant="h6" color="textPrimary"> | ||||
|             {PROJECT_NAME} | ||||
|           </Typography> | ||||
|           <Divider absolute /> | ||||
| @@ -138,7 +136,7 @@ class MenuAppBar extends React.Component { | ||||
|             </ListItemIcon> | ||||
|             <ListItemText primary="Network Time" /> | ||||
|           </ListItem> | ||||
|           <ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticationContext.isAdmin()}> | ||||
|           <ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}> | ||||
|             <ListItemIcon> | ||||
|               <LockIcon /> | ||||
|             </ListItemIcon> | ||||
| @@ -156,7 +154,7 @@ class MenuAppBar extends React.Component { | ||||
| 
 | ||||
|     return ( | ||||
|       <div className={classes.root}> | ||||
|         <AppBar position="fixed" className={classes.appBar}> | ||||
|         <AppBar position="fixed" className={classes.appBar} elevation={0}> | ||||
|           <Toolbar> | ||||
|             <IconButton | ||||
|               color="inherit" | ||||
| @@ -191,13 +189,13 @@ class MenuAppBar extends React.Component { | ||||
|                               <AccountCircleIcon /> | ||||
|                             </Avatar> | ||||
|                           </ListItemAvatar> | ||||
|                           <ListItemText primary={"Signed in as: " + authenticationContext.user.username} secondary={authenticationContext.isAdmin() ? "Admin User" : undefined} /> | ||||
|                           <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" color="primary" onClick={authenticationContext.signOut}>Sign Out</Button> | ||||
|                       <Button variant="contained" fullWidth color="primary" onClick={authenticatedContext.signOut}>Sign Out</Button> | ||||
|                     </CardActions> | ||||
|                   </Card> | ||||
|                 </ClickAwayListener> | ||||
| @@ -243,14 +241,10 @@ class MenuAppBar extends React.Component { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| MenuAppBar.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   theme: PropTypes.object.isRequired, | ||||
|   sectionTitle: PropTypes.string.isRequired, | ||||
| }; | ||||
| 
 | ||||
| export default withAuthenticationContext( | ||||
|   withRouter( | ||||
|     withStyles(styles, { withTheme: true })(MenuAppBar) | ||||
| export default withRouter( | ||||
|   withTheme( | ||||
|     withAuthenticatedContext( | ||||
|       withStyles(styles)(MenuAppBar) | ||||
|     ) | ||||
|   ) | ||||
| ); | ||||
| @@ -1,21 +1,25 @@ | ||||
| import React from 'react'; | ||||
| import { TextValidator } from 'react-material-ui-form-validator'; | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import { InputAdornment } from '@material-ui/core'; | ||||
| import Visibility from '@material-ui/icons/Visibility'; | ||||
| import VisibilityOff from '@material-ui/icons/VisibilityOff'; | ||||
| import IconButton from '@material-ui/core/IconButton'; | ||||
| import { TextValidator, ValidatorComponentProps } from 'react-material-ui-form-validator'; | ||||
| 
 | ||||
| const styles = theme => ( | ||||
|   { | ||||
| 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" | ||||
|     } | ||||
|   } | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| class PasswordValidator extends React.Component { | ||||
| type PasswordValidatorProps = WithStyles<typeof styles> & Exclude<ValidatorComponentProps, "type" | "InputProps">; | ||||
| 
 | ||||
| interface PasswordValidatorState { | ||||
|   showPassword: boolean; | ||||
| } | ||||
| 
 | ||||
| class PasswordValidator extends React.Component<PasswordValidatorProps, PasswordValidatorState> { | ||||
| 
 | ||||
|   state = { | ||||
|     showPassword: false | ||||
| @@ -1,125 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import { withSnackbar } from 'notistack'; | ||||
| import { redirectingAuthorizedFetch } from '../authentication/Authentication'; | ||||
|  | ||||
| /* | ||||
| * It is unlikely this application will grow complex enough to require redux. | ||||
| * | ||||
| * This HOC acts as an interface to a REST service, providing data and change | ||||
| * event callbacks to the wrapped components along with a function to persist the | ||||
| * changes. | ||||
| */ | ||||
| export const restComponent = (endpointUrl, FormComponent) => { | ||||
|  | ||||
|   return withSnackbar( | ||||
|     class extends React.Component { | ||||
|  | ||||
|       constructor(props) { | ||||
|         super(props); | ||||
|  | ||||
|         this.state = { | ||||
|           data: null, | ||||
|           fetched: false, | ||||
|           errorMessage: null | ||||
|         }; | ||||
|  | ||||
|         this.setState = this.setState.bind(this); | ||||
|         this.loadData = this.loadData.bind(this); | ||||
|         this.saveData = this.saveData.bind(this); | ||||
|         this.setData = this.setData.bind(this); | ||||
|       } | ||||
|  | ||||
|       setData(data) { | ||||
|         this.setState({ | ||||
|           data: data, | ||||
|           fetched: true, | ||||
|           errorMessage: null | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       loadData() { | ||||
|         this.setState({ | ||||
|           data: null, | ||||
|           fetched: false, | ||||
|           errorMessage: null | ||||
|         }); | ||||
|         redirectingAuthorizedFetch(endpointUrl) | ||||
|           .then(response => { | ||||
|             if (response.status === 200) { | ||||
|               return response.json(); | ||||
|             } | ||||
|             throw Error("Invalid status code: " + response.status); | ||||
|           }) | ||||
|           .then(json => { this.setState({ data: json, fetched: true }) }) | ||||
|           .catch(error => { | ||||
|             const errorMessage = error.message || "Unknown error"; | ||||
|             this.props.enqueueSnackbar("Problem fetching: " + errorMessage, { | ||||
|               variant: 'error', | ||||
|             }); | ||||
|             this.setState({ data: null, fetched: true, errorMessage  }); | ||||
|           }); | ||||
|       } | ||||
|  | ||||
|       saveData(e) { | ||||
|         this.setState({ fetched: false }); | ||||
|         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("Changes successfully applied.", { | ||||
|               variant: 'success', | ||||
|             }); | ||||
|             this.setState({ data: json, fetched: true }); | ||||
|           }).catch(error => { | ||||
|             const errorMessage = error.message || "Unknown error"; | ||||
|             this.props.enqueueSnackbar("Problem saving: " + errorMessage, { | ||||
|               variant: 'error', | ||||
|             }); | ||||
|             this.setState({ data: null, fetched: true, errorMessage  }); | ||||
|           }); | ||||
|       } | ||||
|  | ||||
|       handleValueChange = name => (event) => { | ||||
|         const { data } = this.state; | ||||
|         data[name] = event.target.value; | ||||
|         this.setState({ data }); | ||||
|       }; | ||||
|  | ||||
|       handleSliderChange = name => (event, newValue) => { | ||||
|         const { data } = this.state; | ||||
|         data[name] = newValue; | ||||
|         this.setState({ data }); | ||||
|       }; | ||||
|  | ||||
|       handleCheckboxChange = name => event => { | ||||
|         const { data } = this.state; | ||||
|         data[name] = event.target.checked; | ||||
|         this.setState({ data }); | ||||
|       } | ||||
|  | ||||
|       render() { | ||||
|         return <FormComponent | ||||
|           handleValueChange={this.handleValueChange} | ||||
|           handleCheckboxChange={this.handleCheckboxChange} | ||||
|           handleSliderChange={this.handleSliderChange} | ||||
|           setData={this.setData} | ||||
|           saveData={this.saveData} | ||||
|           loadData={this.loadData} | ||||
|           {...this.state} | ||||
|           {...this.props} | ||||
|         />; | ||||
|       } | ||||
|  | ||||
|     } | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										116
									
								
								interface/src/components/RestController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								interface/src/components/RestController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| 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; | ||||
|   handleCheckboxChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>, checked: boolean) => void; | ||||
|   handleSliderChange: (name: keyof D) => (event: React.ChangeEvent<{}>, value: number | number[]) => void; | ||||
|  | ||||
|   setData: (data: D) => void; | ||||
|   saveData: () => void; | ||||
|   loadData: () => void; | ||||
|  | ||||
|   data?: D; | ||||
|   loading: boolean; | ||||
|   errorMessage?: string; | ||||
| } | ||||
|  | ||||
| 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) => { | ||||
|         this.setState({ | ||||
|           data, | ||||
|           loading: false, | ||||
|           errorMessage: undefined | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       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("Changes successfully applied.", { variant: 'success' }); | ||||
|           this.setState({ data: json, loading: false }); | ||||
|         }).catch(error => { | ||||
|           const errorMessage = error.message || "Unknown error"; | ||||
|           this.props.enqueueSnackbar("Problem saving: " + errorMessage, { variant: 'error' }); | ||||
|           this.setState({ data: undefined, loading: false, errorMessage }); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const data = { ...this.state.data!, [name]: event.target.value }; | ||||
|         this.setState({ data }); | ||||
|       } | ||||
|  | ||||
|       handleCheckboxChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const data = { ...this.state.data!, [name]: event.target.checked }; | ||||
|         this.setState({ data }); | ||||
|       } | ||||
|  | ||||
|       handleSliderChange = (name: keyof D) => (event: React.ChangeEvent<{}>, value: number | number[]) => { | ||||
|         const data = { ...this.state.data!, [name]: value }; | ||||
|         this.setState({ data }); | ||||
|       }; | ||||
|  | ||||
|       render() { | ||||
|         return <RestController | ||||
|           handleValueChange={this.handleValueChange} | ||||
|           handleCheckboxChange={this.handleCheckboxChange} | ||||
|           handleSliderChange={this.handleSliderChange} | ||||
|           setData={this.setData} | ||||
|           saveData={this.saveData} | ||||
|           loadData={this.loadData} | ||||
|           {...this.state} | ||||
|           {...this.props as P} | ||||
|         />; | ||||
|       } | ||||
|  | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										55
									
								
								interface/src/components/RestFormLoader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								interface/src/components/RestFormLoader.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; | ||||
| import { Button, LinearProgress, Typography } from '@material-ui/core'; | ||||
| import { RestControllerProps } from './RestController'; | ||||
|  | ||||
| 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}> | ||||
|           Reset | ||||
|         </Button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   return render({ ...rest, loadData, data }); | ||||
| } | ||||
| @@ -1,37 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import Paper from '@material-ui/core/Paper'; | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   content: { | ||||
|     padding: theme.spacing(2), | ||||
|     margin: theme.spacing(3), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| function SectionContent(props) { | ||||
|   const { children, classes, title, titleGutter } = props; | ||||
|   return ( | ||||
|       <Paper className={classes.content}> | ||||
|         <Typography variant="h6" gutterBottom={titleGutter}> | ||||
|           {title} | ||||
|         </Typography> | ||||
|         {children} | ||||
|       </Paper> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| SectionContent.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
| 	children: PropTypes.oneOfType([ | ||||
|         PropTypes.arrayOf(PropTypes.node), | ||||
|         PropTypes.node | ||||
|     ]).isRequired, | ||||
|   title: PropTypes.string.isRequired, | ||||
|   titleGutter: PropTypes.bool | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(SectionContent); | ||||
							
								
								
									
										33
									
								
								interface/src/components/SectionContent.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								interface/src/components/SectionContent.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import React 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), | ||||
|     } | ||||
|   }) | ||||
| ); | ||||
|  | ||||
| interface SectionContentProps { | ||||
|   title: string; | ||||
|   titleGutter?: boolean; | ||||
| } | ||||
|  | ||||
| const SectionContent: React.FC<SectionContentProps> = (props) => { | ||||
|   const { children, title, titleGutter } = props; | ||||
|   const classes = useStyles(); | ||||
|   return ( | ||||
|     <Paper className={classes.content}> | ||||
|       <Typography variant="h6" gutterBottom={titleGutter}> | ||||
|         {title} | ||||
|       </Typography> | ||||
|       {children} | ||||
|     </Paper> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SectionContent; | ||||
							
								
								
									
										11
									
								
								interface/src/components/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								interface/src/components/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| 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 SectionContent } from './SectionContent'; | ||||
|  | ||||
| export * from './RestFormLoader'; | ||||
| export * from './RestController'; | ||||
| @@ -1,3 +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 = process.env.REACT_APP_ENDPOINT_ROOT; | ||||
| @@ -1,4 +0,0 @@ | ||||
| export const IDLE = "idle"; | ||||
| export const SUCCESS = "success"; | ||||
| export const ERROR = "error"; | ||||
| export const WARN = "warn"; | ||||
| @@ -1,28 +0,0 @@ | ||||
| import * as Highlight from '../constants/Highlight'; | ||||
|  | ||||
| export const NTP_INACTIVE = 0; | ||||
| export const NTP_ACTIVE = 1; | ||||
|  | ||||
| export const isNtpActive = ntpStatus => ntpStatus && ntpStatus.status === NTP_ACTIVE; | ||||
|  | ||||
| export const ntpStatusHighlight = ntpStatus => { | ||||
|   switch (ntpStatus.status) { | ||||
|     case NTP_INACTIVE: | ||||
|       return Highlight.IDLE; | ||||
|     case NTP_ACTIVE: | ||||
|       return Highlight.SUCCESS; | ||||
|     default: | ||||
|       return Highlight.ERROR; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const ntpStatus = ntpStatus => { | ||||
|   switch (ntpStatus.status) { | ||||
|     case NTP_INACTIVE: | ||||
|       return "Inactive"; | ||||
|     case NTP_ACTIVE: | ||||
|       return "Active"; | ||||
|     default: | ||||
|       return "Unknown"; | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| import moment from 'moment'; | ||||
|  | ||||
| export const formatIsoDateTime = isoDateString => moment.parseZone(isoDateString).format('ll @ HH:mm:ss'); | ||||
| @@ -1,5 +0,0 @@ | ||||
| export const WIFI_AP_MODE_ALWAYS = 0; | ||||
| export const WIFI_AP_MODE_DISCONNECTED = 1; | ||||
| export const WIFI_AP_NEVER = 2; | ||||
|  | ||||
| export const isAPEnabled = apMode => apMode === WIFI_AP_MODE_ALWAYS || apMode === WIFI_AP_MODE_DISCONNECTED; | ||||
| @@ -1,38 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { AP_SETTINGS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import APSettingsForm from '../forms/APSettingsForm'; | ||||
|  | ||||
| class APSettings extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { fetched, errorMessage, data, saveData, loadData, handleValueChange } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="AP Settings"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <APSettingsForm | ||||
|               apSettings={data} | ||||
|               onSubmit={saveData} | ||||
|               onReset={loadData} | ||||
|               handleValueChange={handleValueChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restComponent(AP_SETTINGS_ENDPOINT, APSettings); | ||||
| @@ -1,121 +0,0 @@ | ||||
| import React, { Component, Fragment } from 'react'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import ListItemAvatar from '@material-ui/core/ListItemAvatar'; | ||||
| import Avatar from '@material-ui/core/Avatar'; | ||||
| import Divider from '@material-ui/core/Divider'; | ||||
| import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; | ||||
| import DeviceHubIcon from '@material-ui/icons/DeviceHub'; | ||||
| import ComputerIcon from '@material-ui/icons/Computer'; | ||||
| import RefreshIcon from '@material-ui/icons/Refresh'; | ||||
|  | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent' | ||||
|  | ||||
| import * as Highlight from '../constants/Highlight'; | ||||
| import { AP_STATUS_ENDPOINT } from '../constants/Endpoints'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   ["apStatus_" + Highlight.SUCCESS]: { | ||||
|     backgroundColor: theme.palette.highlight_success | ||||
|   }, | ||||
|   ["apStatus_" + Highlight.IDLE]: { | ||||
|     backgroundColor: theme.palette.highlight_idle | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class APStatus extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   apStatusHighlight(data) { | ||||
|     return data.active ? Highlight.SUCCESS : Highlight.IDLE; | ||||
|   } | ||||
|  | ||||
|   apStatus(data) { | ||||
|     return data.active ? "Active" : "Inactive"; | ||||
|   } | ||||
|  | ||||
|   createListItems(data, classes) { | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <ListItem> | ||||
|           <ListItemAvatar> | ||||
|             <Avatar className={classes["apStatus_" + this.apStatusHighlight(data)]}> | ||||
|               <SettingsInputAntennaIcon /> | ||||
|             </Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText primary="Status" secondary={this.apStatus(data)} /> | ||||
|         </ListItem> | ||||
|         <Divider variant="inset" component="li" /> | ||||
|         <ListItem> | ||||
|           <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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   renderAPStatus(data, classes) { | ||||
|     return ( | ||||
|       <div> | ||||
|         <List> | ||||
|           {this.createListItems(data, classes)} | ||||
|         </List> | ||||
|         <Button startIcon={<RefreshIcon />} variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}> | ||||
|           Refresh | ||||
|         </Button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { fetched, errorMessage, data, loadData, classes } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="AP Status"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={ | ||||
|             () => this.renderAPStatus(data, classes) | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default restComponent(AP_STATUS_ENDPOINT, withStyles(styles)(APStatus)); | ||||
| @@ -1,39 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { SECURITY_SETTINGS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import ManageUsersForm from '../forms/ManageUsersForm'; | ||||
|  | ||||
| class ManageUsers extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { fetched, errorMessage, data, saveData, loadData, setData, handleValueChange } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="Manage Users" titleGutter> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <ManageUsersForm | ||||
|               userData={data} | ||||
|               onSubmit={saveData} | ||||
|               onReset={loadData} | ||||
|               setData={setData} | ||||
|               handleValueChange={handleValueChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restComponent(SECURITY_SETTINGS_ENDPOINT, ManageUsers); | ||||
| @@ -1,40 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { NTP_SETTINGS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import NTPSettingsForm from '../forms/NTPSettingsForm'; | ||||
|  | ||||
| class NTPSettings extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { fetched, errorMessage, data, saveData, setData, loadData, handleValueChange, handleCheckboxChange } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="NTP Settings"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <NTPSettingsForm | ||||
|               ntpSettings={data} | ||||
|               setData={setData} | ||||
|               onSubmit={saveData} | ||||
|               onReset={loadData} | ||||
|               handleValueChange={handleValueChange} | ||||
|               handleCheckboxChange={handleCheckboxChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restComponent(NTP_SETTINGS_ENDPOINT, NTPSettings); | ||||
| @@ -1,138 +0,0 @@ | ||||
| import React, { Component, Fragment } from 'react'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemAvatar from '@material-ui/core/ListItemAvatar'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import Avatar from '@material-ui/core/Avatar'; | ||||
| import Divider from '@material-ui/core/Divider'; | ||||
|  | ||||
| import SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle'; | ||||
| import AccessTimeIcon from '@material-ui/icons/AccessTime'; | ||||
| import DNSIcon from '@material-ui/icons/Dns'; | ||||
| import UpdateIcon from '@material-ui/icons/Update'; | ||||
| import AvTimerIcon from '@material-ui/icons/AvTimer'; | ||||
| import RefreshIcon from '@material-ui/icons/Refresh'; | ||||
|  | ||||
| import { isNtpActive, ntpStatusHighlight, ntpStatus } from '../constants/NTPStatus'; | ||||
| import * as Highlight from '../constants/Highlight'; | ||||
| import { formatIsoDateTime } from '../constants/TimeFormat'; | ||||
| import { NTP_STATUS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
|  | ||||
| import moment from 'moment'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   ["ntpStatus_" + Highlight.SUCCESS]: { | ||||
|     backgroundColor: theme.palette.highlight_success | ||||
|   }, | ||||
|   ["ntpStatus_" + Highlight.ERROR]: { | ||||
|     backgroundColor: theme.palette.highlight_error | ||||
|   }, | ||||
|   ["ntpStatus_" + Highlight.WARN]: { | ||||
|     backgroundColor: theme.palette.highlight_warn | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class NTPStatus extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   createListItems(data, classes) { | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <ListItem > | ||||
|           <ListItemAvatar> | ||||
|             <Avatar className={classes["ntpStatus_" + ntpStatusHighlight(data)]}> | ||||
|               <UpdateIcon /> | ||||
|             </Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText primary="Status" secondary={ntpStatus(data)} /> | ||||
|         </ListItem> | ||||
|         <Divider variant="inset" component="li" /> | ||||
|         { | ||||
|           isNtpActive(data) && ( | ||||
|             <Fragment> | ||||
|               <ListItem> | ||||
|                 <ListItemAvatar> | ||||
|                   <Avatar> | ||||
|                     <AccessTimeIcon /> | ||||
|                   </Avatar> | ||||
|                 </ListItemAvatar> | ||||
|                 <ListItemText primary="Local Time" secondary={formatIsoDateTime(data.time_local)} /> | ||||
|               </ListItem> | ||||
|               <Divider variant="inset" component="li" /> | ||||
|               <ListItem> | ||||
|                 <ListItemAvatar> | ||||
|                   <Avatar> | ||||
|                     <AccessTimeIcon /> | ||||
|                   </Avatar> | ||||
|                 </ListItemAvatar> | ||||
|                 <ListItemText primary="UTC Time" secondary={formatIsoDateTime(data.time_utc)} /> | ||||
|               </ListItem> | ||||
|               <Divider variant="inset" component="li" /> | ||||
|             </Fragment> | ||||
|           )} | ||||
|         <ListItem> | ||||
|           <ListItemAvatar> | ||||
|             <Avatar> | ||||
|               <DNSIcon /> | ||||
|             </Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText primary="NTP Server" secondary={data.server} /> | ||||
|         </ListItem> | ||||
|         <Divider variant="inset" component="li" /> | ||||
|         <ListItem> | ||||
|           <ListItemAvatar> | ||||
|             <Avatar> | ||||
|               <AvTimerIcon /> | ||||
|             </Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText primary="Uptime" secondary={moment.duration(data.uptime, 'seconds').humanize()} /> | ||||
|         </ListItem> | ||||
|         <Divider variant="inset" component="li" /> | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   renderNTPStatus(data, classes) { | ||||
|     return ( | ||||
|       <div> | ||||
|         <List> | ||||
|           {this.createListItems(data, classes)} | ||||
|         </List> | ||||
|         <Button startIcon={<RefreshIcon />} variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}> | ||||
|           Refresh | ||||
|         </Button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, fetched, errorMessage, loadData, classes } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="NTP Status"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={ | ||||
|             () => this.renderNTPStatus(data, classes) | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default restComponent(NTP_STATUS_ENDPOINT, withStyles(styles)(NTPStatus)); | ||||
| @@ -1,39 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { OTA_SETTINGS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import OTASettingsForm from '../forms/OTASettingsForm'; | ||||
|  | ||||
| class OTASettings extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { fetched, errorMessage, data, saveData, loadData, handleValueChange, handleCheckboxChange } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="OTA Settings"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <OTASettingsForm | ||||
|               otaSettings={data} | ||||
|               onSubmit={saveData} | ||||
|               onReset={loadData} | ||||
|               handleValueChange={handleValueChange} | ||||
|               handleCheckboxChange={handleCheckboxChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restComponent(OTA_SETTINGS_ENDPOINT, OTASettings); | ||||
| @@ -1,38 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { SECURITY_SETTINGS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SecuritySettingsForm from '../forms/SecuritySettingsForm'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
|  | ||||
| class SecuritySettings extends Component { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, fetched, errorMessage, saveData, loadData, handleValueChange } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="Security Settings"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <SecuritySettingsForm | ||||
|               securitySettings={data} | ||||
|               onSubmit={saveData} | ||||
|               onReset={loadData} | ||||
|               handleValueChange={handleValueChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restComponent(SECURITY_SETTINGS_ENDPOINT, SecuritySettings); | ||||
| @@ -1,125 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { withSnackbar } from 'notistack'; | ||||
|  | ||||
| import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import WiFiNetworkSelector from '../forms/WiFiNetworkSelector'; | ||||
| import { redirectingAuthorizedFetch } from '../authentication/Authentication'; | ||||
|  | ||||
| const NUM_POLLS = 10 | ||||
| const POLLING_FREQUENCY = 500 | ||||
| const RETRY_EXCEPTION_TYPE = "retry" | ||||
|  | ||||
| class WiFiNetworkScanner extends Component { | ||||
|  | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|     this.pollCount = 0; | ||||
|     this.state = { | ||||
|       scanningForNetworks: true, | ||||
|       errorMessage: null, | ||||
|       networkList: null | ||||
|     }; | ||||
|     this.pollNetworkList = this.pollNetworkList.bind(this); | ||||
|     this.requestNetworkScan = this.requestNetworkScan.bind(this); | ||||
|   } | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.scanNetworks(); | ||||
|   } | ||||
|  | ||||
|   requestNetworkScan() { | ||||
|     const { scanningForNetworks } = this.state; | ||||
|     if (!scanningForNetworks) { | ||||
|       this.scanNetworks(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   scanNetworks() { | ||||
|     this.pollCount = 0; | ||||
|     this.setState({ scanningForNetworks: true, networkList: null, errorMessage: null }); | ||||
|     redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT).then(response => { | ||||
|       if (response.status === 202) { | ||||
|         this.schedulePollTimeout(); | ||||
|         return; | ||||
|       } | ||||
|       throw Error("Scanning for networks returned unexpected response code: " + response.status); | ||||
|     }).catch(error => { | ||||
|       this.props.enqueueSnackbar("Problem scanning: " + error.message, { | ||||
|         variant: 'error', | ||||
|       }); | ||||
|       this.setState({ scanningForNetworks: false, networkList: null, errorMessage: error.message }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   schedulePollTimeout() { | ||||
|     setTimeout(this.pollNetworkList, POLLING_FREQUENCY); | ||||
|   } | ||||
|  | ||||
|   retryError() { | ||||
|     return { | ||||
|       name: RETRY_EXCEPTION_TYPE, | ||||
|       message: "Network list not ready, will retry in " + POLLING_FREQUENCY + "ms." | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   compareNetworks(network1, network2) { | ||||
|     if (network1.rssi < network2.rssi) | ||||
|       return 1; | ||||
|     if (network1.rssi > network2.rssi) | ||||
|       return -1; | ||||
|     return 0; | ||||
|   } | ||||
|  | ||||
|   pollNetworkList() { | ||||
|     redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT) | ||||
|       .then(response => { | ||||
|         if (response.status === 200) { | ||||
|           return response.json(); | ||||
|         } | ||||
|         if (response.status === 202) { | ||||
|           if (++this.pollCount < NUM_POLLS) { | ||||
|             this.schedulePollTimeout(); | ||||
|             throw this.retryError(); | ||||
|           } else { | ||||
|             throw Error("Device did not return network list in timely manner."); | ||||
|           } | ||||
|         } | ||||
|         throw Error("Device returned unexpected response code: " + response.status); | ||||
|       }) | ||||
|       .then(json => { | ||||
|         json.networks.sort(this.compareNetworks) | ||||
|         this.setState({ scanningForNetworks: false, networkList: json, errorMessage: null }) | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         if (error.name !== RETRY_EXCEPTION_TYPE) { | ||||
|           this.props.enqueueSnackbar("Problem scanning: " + error.message, { | ||||
|             variant: 'error', | ||||
|           }); | ||||
|           this.setState({ scanningForNetworks: false, networkList: null, errorMessage: error.message }); | ||||
|         } | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { scanningForNetworks, networkList, errorMessage } = this.state; | ||||
|     return ( | ||||
|       <SectionContent title="Network Scanner"> | ||||
|         <WiFiNetworkSelector scanningForNetworks={scanningForNetworks} | ||||
|           networkList={networkList} | ||||
|           errorMessage={errorMessage} | ||||
|           requestNetworkScan={this.requestNetworkScan} | ||||
|           selectNetwork={this.props.selectNetwork} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| WiFiNetworkScanner.propTypes = { | ||||
|   selectNetwork: PropTypes.func.isRequired | ||||
| }; | ||||
|  | ||||
| export default withSnackbar(WiFiNetworkScanner); | ||||
| @@ -1,69 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import { WIFI_SETTINGS_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import WiFiSettingsForm from '../forms/WiFiSettingsForm'; | ||||
|  | ||||
| class WiFiSettings extends Component { | ||||
|  | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|  | ||||
|     this.deselectNetworkAndLoadData = this.deselectNetworkAndLoadData.bind(this); | ||||
|   } | ||||
|  | ||||
|   componentDidMount() { | ||||
|     const { selectedNetwork } = this.props; | ||||
|     if (selectedNetwork) { | ||||
|       var wifiSettings = { | ||||
|         ssid: selectedNetwork.ssid, | ||||
|         password: "", | ||||
|         hostname: "esp8266-react", | ||||
|         static_ip_config: false, | ||||
|       } | ||||
|       this.props.setData(wifiSettings); | ||||
|     } else { | ||||
|       this.props.loadData(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   deselectNetworkAndLoadData() { | ||||
|     this.props.deselectNetwork(); | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, fetched, errorMessage, saveData, loadData, handleValueChange, handleCheckboxChange, selectedNetwork, deselectNetwork } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="WiFi Settings"> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <WiFiSettingsForm | ||||
|               wifiSettings={data} | ||||
|               selectedNetwork={selectedNetwork} | ||||
|               deselectNetwork={deselectNetwork} | ||||
|               onSubmit={saveData} | ||||
|               onReset={this.deselectNetworkAndLoadData} | ||||
|               handleValueChange={handleValueChange} | ||||
|               handleCheckboxChange={handleCheckboxChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| WiFiSettings.propTypes = { | ||||
|   deselectNetwork: PropTypes.func, | ||||
|   selectedNetwork: PropTypes.object | ||||
| }; | ||||
|  | ||||
| export default restComponent(WIFI_SETTINGS_ENDPOINT, WiFiSettings); | ||||
| @@ -1,84 +0,0 @@ | ||||
| import React, { Fragment } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { isAPEnabled } from '../constants/WiFiAPModes'; | ||||
| import PasswordValidator from '../components/PasswordValidator'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import MenuItem from '@material-ui/core/MenuItem'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   textField: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   selectField: { | ||||
|     width: "100%", | ||||
|     marginTop: theme.spacing(2), | ||||
|     marginBottom: theme.spacing(0.5) | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class APSettingsForm extends React.Component { | ||||
|  | ||||
|   render() { | ||||
|     const { classes, apSettings, handleValueChange, onSubmit, onReset } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={onSubmit} ref="APSettingsForm"> | ||||
|         <SelectValidator name="provision_mode" label="Provide Access Point..." value={apSettings.provision_mode} className={classes.selectField} | ||||
|           onChange={handleValueChange('provision_mode')}> | ||||
|           <MenuItem value={0}>Always</MenuItem> | ||||
|           <MenuItem value={1}>When WiFi Disconnected</MenuItem> | ||||
|           <MenuItem value={2}>Never</MenuItem> | ||||
|         </SelectValidator> | ||||
|         { | ||||
|           isAPEnabled(apSettings.provision_mode) && | ||||
|           <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" | ||||
|               className={classes.textField} | ||||
|               value={apSettings.ssid} | ||||
|               onChange={handleValueChange('ssid')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <PasswordValidator | ||||
|               validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|               errorMessages={['Access Point Password is required', 'Access Point Password must be 64 characters or less']} | ||||
|               name="password" | ||||
|               label="Access Point Password" | ||||
|               className={classes.textField} | ||||
|               value={apSettings.password} | ||||
|               onChange={handleValueChange('password')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|           </Fragment> | ||||
|         } | ||||
|         <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit"> | ||||
|           Save | ||||
|         </Button> | ||||
|         <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|           Reset | ||||
|         </Button> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| APSettingsForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   apSettings: PropTypes.object, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(APSettingsForm); | ||||
| @@ -1,105 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import FormControlLabel from '@material-ui/core/FormControlLabel'; | ||||
| import MenuItem from '@material-ui/core/MenuItem'; | ||||
| import Switch from '@material-ui/core/Switch'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import isIP from '../validators/isIP'; | ||||
| import isHostname from '../validators/isHostname'; | ||||
| import or from '../validators/or'; | ||||
| import { timeZoneSelectItems, selectedTimeZone, TIME_ZONES } from '../constants/TZ'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   switchControl: { | ||||
|     width: "100%", | ||||
|     marginTop: theme.spacing(2), | ||||
|     marginBottom: theme.spacing(0.5) | ||||
|   }, | ||||
|   textField: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class NTPSettingsForm extends React.Component { | ||||
|  | ||||
|   componentWillMount() { | ||||
|     ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); | ||||
|   } | ||||
|  | ||||
|   changeTimeZone = (event) => { | ||||
|     const { ntpSettings, setData } = this.props; | ||||
|     setData({ | ||||
|       ...ntpSettings, | ||||
|       tz_label: event.target.value, | ||||
|       tz_format: TIME_ZONES[event.target.value] | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { classes, ntpSettings, handleValueChange, handleCheckboxChange, onSubmit, onReset } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={onSubmit}> | ||||
|         <FormControlLabel className={classes.switchControl} | ||||
|           control={ | ||||
|             <Switch | ||||
|               checked={ntpSettings.enabled} | ||||
|               onChange={handleCheckboxChange('enabled')} | ||||
|               value="enabled" | ||||
|               color="primary" | ||||
|             /> | ||||
|           } | ||||
|           label="Enable NTP?" | ||||
|         />         | ||||
|         <TextValidator | ||||
|           validators={['required', 'isIPOrHostname']} | ||||
|           errorMessages={['Server is required', "Not a valid IP address or hostname"]} | ||||
|           name="server" | ||||
|           label="Server" | ||||
|           className={classes.textField} | ||||
|           value={ntpSettings.server} | ||||
|           onChange={handleValueChange('server')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <SelectValidator | ||||
|           native | ||||
|           validators={['required']} | ||||
|           errorMessages={['Time zone is required']} | ||||
|           labelId="tz_label" | ||||
|           label="Time zone" | ||||
|           value={selectedTimeZone(ntpSettings.tz_label, ntpSettings.tz_format)} | ||||
|           onChange={this.changeTimeZone} | ||||
|           className={classes.textField} | ||||
|           margin="normal" | ||||
|         > | ||||
|           <MenuItem disabled={true}>Time zone...</MenuItem> | ||||
|           {timeZoneSelectItems()} | ||||
|         </SelectValidator> | ||||
|         <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit"> | ||||
|           Save | ||||
|         </Button> | ||||
|         <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|           Reset | ||||
|         </Button> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| NTPSettingsForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   ntpSettings: PropTypes.object, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired, | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(NTPSettingsForm); | ||||
| @@ -1,93 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import Switch from '@material-ui/core/Switch'; | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
| import FormControlLabel from '@material-ui/core/FormControlLabel'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import isIP from '../validators/isIP'; | ||||
| import isHostname from '../validators/isHostname'; | ||||
| import or from '../validators/or'; | ||||
| import PasswordValidator from '../components/PasswordValidator'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   switchControl: { | ||||
|     width: "100%", | ||||
|     marginTop: theme.spacing(2), | ||||
|     marginBottom: theme.spacing(0.5) | ||||
|   }, | ||||
|   textField: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class OTASettingsForm extends React.Component { | ||||
|  | ||||
|   componentWillMount() { | ||||
|     ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { classes, otaSettings, handleValueChange, handleCheckboxChange, onSubmit, onReset } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={onSubmit}> | ||||
|         <FormControlLabel className={classes.switchControl} | ||||
|           control={ | ||||
|             <Switch | ||||
|               checked={otaSettings.enabled} | ||||
|               onChange={handleCheckboxChange('enabled')} | ||||
|               value="enabled" | ||||
|               color="primary" | ||||
|             /> | ||||
|           } | ||||
|           label="Enable OTA Updates?" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isNumber', 'minNumber:1025', 'maxNumber:65535']} | ||||
|           errorMessages={['Port is required', "Must be a number", "Must be greater than 1024 ", "Max value is 65535"]} | ||||
|           name="port" | ||||
|           label="Port" | ||||
|           className={classes.textField} | ||||
|           value={otaSettings.port} | ||||
|           type="number" | ||||
|           onChange={handleValueChange('port')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <PasswordValidator | ||||
|           validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|           errorMessages={['OTA Password is required', 'OTA Point Password must be 64 characters or less']} | ||||
|           name="password" | ||||
|           label="Password" | ||||
|           className={classes.textField} | ||||
|           value={otaSettings.password} | ||||
|           onChange={handleValueChange('password')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit"> | ||||
|           Save | ||||
|         </Button> | ||||
|         <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|           Reset | ||||
|         </Button> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| OTASettingsForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   otaSettings: PropTypes.object, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired, | ||||
|   handleCheckboxChange: PropTypes.func.isRequired, | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(OTASettingsForm); | ||||
| @@ -1,70 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| import Box from '@material-ui/core/Box'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import PasswordValidator from '../components/PasswordValidator'; | ||||
| import { withAuthenticationContext } from '../authentication/Context'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   textField: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class SecuritySettingsForm extends React.Component { | ||||
|  | ||||
|   onSubmit = () => { | ||||
|     this.props.onSubmit(); | ||||
|     this.props.authenticationContext.refresh(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { classes, securitySettings, handleValueChange, onReset } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={this.onSubmit} ref="SecuritySettingsForm"> | ||||
|         <PasswordValidator | ||||
|           validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|           errorMessages={['JWT Secret Required', 'JWT Secret must be 64 characters or less']} | ||||
|           name="jwt_secret" | ||||
|           label="JWT Secret" | ||||
|           className={classes.textField} | ||||
|           value={securitySettings.jwt_secret} | ||||
|           onChange={handleValueChange('jwt_secret')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <Typography component="div" variant="body1"> | ||||
|           <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|             If you modify the JWT Secret, all users will be logged out. | ||||
|           </Box> | ||||
|         </Typography> | ||||
|         <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit"> | ||||
|           Save | ||||
|         </Button> | ||||
|         <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|           Reset | ||||
|       	</Button> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| SecuritySettingsForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   securitySettings: PropTypes.object, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired, | ||||
|   authenticationContext: PropTypes.object.isRequired, | ||||
| }; | ||||
|  | ||||
| export default withAuthenticationContext(withStyles(styles)(SecuritySettingsForm)); | ||||
| @@ -1,102 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
|  | ||||
| import FormControlLabel from '@material-ui/core/FormControlLabel'; | ||||
| import Switch from '@material-ui/core/Switch'; | ||||
| import FormGroup from '@material-ui/core/FormGroup'; | ||||
| import DialogTitle from '@material-ui/core/DialogTitle'; | ||||
| import Dialog from '@material-ui/core/Dialog'; | ||||
| import DialogContent from '@material-ui/core/DialogContent'; | ||||
| import DialogActions from '@material-ui/core/DialogActions'; | ||||
|  | ||||
| import PasswordValidator from '../components/PasswordValidator'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   textField: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   button: { | ||||
|     margin: theme.spacing(0.5) | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class UserForm extends React.Component { | ||||
|  | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|     this.formRef = React.createRef(); | ||||
|   } | ||||
|  | ||||
|   componentWillMount() { | ||||
|     ValidatorForm.addValidationRule('uniqueUsername', this.props.uniqueUsername); | ||||
|   } | ||||
|  | ||||
|   submit = () => { | ||||
|     this.formRef.current.submit(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { classes, user, creating, handleValueChange, handleCheckboxChange, onDoneEditing, onCancelEditing } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}> | ||||
|         <Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open={true}> | ||||
|           <DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle> | ||||
|           <DialogContent dividers={true}> | ||||
|             <TextValidator | ||||
|               validators={creating ? ['required', 'uniqueUsername', 'matchRegexp:^[a-zA-Z0-9_\\.]{1,24}$'] : []} | ||||
|               errorMessages={creating ? ['Username is required', "Username already exists", "Must be 1-24 characters: alpha numeric, '_' or '.'"] : []} | ||||
|               name="username" | ||||
|               label="Username" | ||||
|               className={classes.textField} | ||||
|               value={user.username} | ||||
|               disabled={!creating} | ||||
|               onChange={handleValueChange('username')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <PasswordValidator | ||||
|               validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|               errorMessages={['Password is required', 'Password must be 64 characters or less']} | ||||
|               name="password" | ||||
|               label="Password" | ||||
|               className={classes.textField} | ||||
|               value={user.password} | ||||
|               onChange={handleValueChange('password')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <FormGroup> | ||||
|               <FormControlLabel | ||||
|                 control={<Switch checked={user.admin} onChange={handleCheckboxChange('admin')} id="admin" />} | ||||
|                 label="Admin?" | ||||
|               /> | ||||
|             </FormGroup> | ||||
|           </DialogContent> | ||||
|           <DialogActions> | ||||
|             <Button variant="contained" color="primary" className={classes.button} type="submit" onClick={this.submit}> | ||||
|               Done | ||||
|             </Button> | ||||
|             <Button variant="contained" color="secondary" className={classes.button} type="submit" onClick={onCancelEditing}> | ||||
|               Cancel | ||||
|             </Button>             | ||||
|           </DialogActions> | ||||
|         </Dialog> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| UserForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   user: PropTypes.object.isRequired, | ||||
|   creating: PropTypes.bool.isRequired, | ||||
|   onDoneEditing: PropTypes.func.isRequired, | ||||
|   onCancelEditing: PropTypes.func.isRequired, | ||||
|   uniqueUsername: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired, | ||||
|   handleCheckboxChange: PropTypes.func.isRequired | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(UserForm); | ||||
| @@ -1,107 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import LinearProgress from '@material-ui/core/LinearProgress'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
|  | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemIcon from '@material-ui/core/ListItemIcon'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import ListItemAvatar from '@material-ui/core/ListItemAvatar'; | ||||
|  | ||||
| import Avatar from '@material-ui/core/Avatar'; | ||||
| import Badge from '@material-ui/core/Badge'; | ||||
|  | ||||
| import WifiIcon from '@material-ui/icons/Wifi'; | ||||
| import LockIcon from '@material-ui/icons/Lock'; | ||||
| import LockOpenIcon from '@material-ui/icons/LockOpen'; | ||||
| import PermScanWifiIcon from '@material-ui/icons/PermScanWifi'; | ||||
|  | ||||
| import { isNetworkOpen, networkSecurityMode } from '../constants/WiFiSecurityModes'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   scanningProgress: { | ||||
|     margin: theme.spacing(4), | ||||
|     textAlign: "center" | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class WiFiNetworkSelector extends Component { | ||||
|  | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|  | ||||
|     this.renderNetwork = this.renderNetwork.bind(this); | ||||
|   } | ||||
|  | ||||
|   renderNetwork(network) { | ||||
|     return ( | ||||
|       <ListItem key={network.bssid} button onClick={() => this.props.selectNetwork(network)}> | ||||
|         <ListItemAvatar> | ||||
|           <Avatar> | ||||
|             {isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />} | ||||
|           </Avatar> | ||||
|         </ListItemAvatar> | ||||
|         <ListItemText | ||||
|           primary={network.ssid} | ||||
|           secondary={"Security: "+ networkSecurityMode(network) + ", Ch: " + network.channel} | ||||
|         /> | ||||
|         <ListItemIcon> | ||||
|           <Badge badgeContent={network.rssi + "db"}> | ||||
|             <WifiIcon /> | ||||
|           </Badge> | ||||
|         </ListItemIcon> | ||||
|       </ListItem> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { classes, scanningForNetworks, networkList, errorMessage, requestNetworkScan } = this.props; | ||||
|     return ( | ||||
|       <div> | ||||
|         { | ||||
|           scanningForNetworks ? | ||||
|           <div> | ||||
|             <LinearProgress className={classes.scanningProgress}/> | ||||
|             <Typography variant="h6" className={classes.scanningProgress}> | ||||
|               Scanning... | ||||
|             </Typography> | ||||
|           </div> | ||||
|           : | ||||
|           networkList ? | ||||
|           <List> | ||||
|             {networkList.networks.map(this.renderNetwork)} | ||||
|           </List> | ||||
|           : | ||||
|           <div> | ||||
|             <Typography variant="h6" className={classes.scanningProgress}> | ||||
|               {errorMessage} | ||||
|             </Typography> | ||||
|           </div> | ||||
|         } | ||||
|  | ||||
|         <Button startIcon={<PermScanWifiIcon />} variant="contained" color="secondary" className={classes.button} onClick={requestNetworkScan} disabled={scanningForNetworks}> | ||||
|           Scan again... | ||||
|         </Button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| WiFiNetworkSelector.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   selectNetwork: PropTypes.func.isRequired, | ||||
|   scanningForNetworks: PropTypes.bool.isRequired, | ||||
|   errorMessage: PropTypes.string, | ||||
|   networkList: PropTypes.object, | ||||
|   requestNetworkScan: PropTypes.func.isRequired | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(WiFiNetworkSelector); | ||||
| @@ -1,201 +0,0 @@ | ||||
| import React, { Fragment } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import Checkbox from '@material-ui/core/Checkbox'; | ||||
| import FormControlLabel from '@material-ui/core/FormControlLabel'; | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import ListItemAvatar from '@material-ui/core/ListItemAvatar'; | ||||
| import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; | ||||
|  | ||||
| import Avatar from '@material-ui/core/Avatar'; | ||||
| import IconButton from '@material-ui/core/IconButton'; | ||||
| import LockIcon from '@material-ui/icons/Lock'; | ||||
| import LockOpenIcon from '@material-ui/icons/LockOpen'; | ||||
| import DeleteIcon from '@material-ui/icons/Delete'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
| import { isNetworkOpen, networkSecurityMode } from '../constants/WiFiSecurityModes'; | ||||
|  | ||||
| import isIP from '../validators/isIP'; | ||||
| import isHostname from '../validators/isHostname'; | ||||
| import optional from '../validators/optional'; | ||||
| import PasswordValidator from '../components/PasswordValidator'; | ||||
|  | ||||
| const styles = theme => ({ | ||||
|   textField: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   checkboxControl: { | ||||
|     width: "100%" | ||||
|   }, | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
|  | ||||
| class WiFiSettingsForm extends React.Component { | ||||
|  | ||||
|   componentWillMount() { | ||||
|     ValidatorForm.addValidationRule('isIP', isIP); | ||||
|     ValidatorForm.addValidationRule('isHostname', isHostname); | ||||
|     ValidatorForm.addValidationRule('isOptionalIP', optional(isIP)); | ||||
|   } | ||||
|  | ||||
|   renderSelectedNetwork() { | ||||
|     const { selectedNetwork, deselectNetwork } = this.props; | ||||
|     return ( | ||||
|       <List> | ||||
|         <ListItem> | ||||
|           <ListItemAvatar> | ||||
|             <Avatar> | ||||
|               {isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />} | ||||
|             </Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText | ||||
|             primary={selectedNetwork.ssid} | ||||
|             secondary={"Security: " + networkSecurityMode(selectedNetwork) + ", Ch: " + selectedNetwork.channel} | ||||
|           /> | ||||
|           <ListItemSecondaryAction> | ||||
|             <IconButton aria-label="Manual Config" onClick={deselectNetwork}> | ||||
|               <DeleteIcon /> | ||||
|             </IconButton> | ||||
|           </ListItemSecondaryAction> | ||||
|         </ListItem> | ||||
|       </List> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { classes, wifiSettings, selectedNetwork, handleValueChange, handleCheckboxChange, onSubmit, onReset } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={onSubmit} ref="WiFiSettingsForm"> | ||||
|         { | ||||
|           selectedNetwork ? this.renderSelectedNetwork() : | ||||
|             <TextValidator | ||||
|               validators={['matchRegexp:^.{0,32}$']} | ||||
|               errorMessages={['SSID must be 32 characters or less']} | ||||
|               name="ssid" | ||||
|               label="SSID" | ||||
|               className={classes.textField} | ||||
|               value={wifiSettings.ssid} | ||||
|               onChange={handleValueChange('ssid')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|         } | ||||
|         { | ||||
|           !isNetworkOpen(selectedNetwork) && | ||||
|           <PasswordValidator | ||||
|             validators={['matchRegexp:^.{0,64}$']} | ||||
|             errorMessages={['Password must be 64 characters or less']} | ||||
|             name="password" | ||||
|             label="Password" | ||||
|             className={classes.textField} | ||||
|             value={wifiSettings.password} | ||||
|             onChange={handleValueChange('password')} | ||||
|             margin="normal" | ||||
|           /> | ||||
|         } | ||||
|         <TextValidator | ||||
|           validators={['required', 'isHostname']} | ||||
|           errorMessages={['Hostname is required', "Not a valid hostname"]} | ||||
|           name="hostname" | ||||
|           label="Hostname" | ||||
|           className={classes.textField} | ||||
|           value={wifiSettings.hostname} | ||||
|           onChange={handleValueChange('hostname')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <FormControlLabel className={classes.checkboxControl} | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               value="static_ip_config" | ||||
|               checked={wifiSettings.static_ip_config} | ||||
|               onChange={handleCheckboxChange("static_ip_config")} | ||||
|             /> | ||||
|           } | ||||
|           label="Static IP Config?" | ||||
|         /> | ||||
|         { | ||||
|           wifiSettings.static_ip_config && | ||||
|           <Fragment> | ||||
|             <TextValidator | ||||
|               validators={['required', 'isIP']} | ||||
|               errorMessages={['Local IP is required', 'Must be an IP address']} | ||||
|               name="local_ip" | ||||
|               label="Local IP" | ||||
|               className={classes.textField} | ||||
|               value={wifiSettings.local_ip} | ||||
|               onChange={handleValueChange('local_ip')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <TextValidator | ||||
|               validators={['required', 'isIP']} | ||||
|               errorMessages={['Gateway IP is required', 'Must be an IP address']} | ||||
|               name="gateway_ip" | ||||
|               label="Gateway" | ||||
|               className={classes.textField} | ||||
|               value={wifiSettings.gateway_ip} | ||||
|               onChange={handleValueChange('gateway_ip')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <TextValidator | ||||
|               validators={['required', 'isIP']} | ||||
|               errorMessages={['Subnet mask is required', 'Must be an IP address']} | ||||
|               name="subnet_mask" | ||||
|               label="Subnet" | ||||
|               className={classes.textField} | ||||
|               value={wifiSettings.subnet_mask} | ||||
|               onChange={handleValueChange('subnet_mask')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <TextValidator | ||||
|               validators={['isOptionalIP']} | ||||
|               errorMessages={['Must be an IP address']} | ||||
|               name="dns_ip_1" | ||||
|               label="DNS IP #1" | ||||
|               className={classes.textField} | ||||
|               value={wifiSettings.dns_ip_1} | ||||
|               onChange={handleValueChange('dns_ip_1')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <TextValidator | ||||
|               validators={['isOptionalIP']} | ||||
|               errorMessages={['Must be an IP address']} | ||||
|               name="dns_ip_2" | ||||
|               label="DNS IP #2" | ||||
|               className={classes.textField} | ||||
|               value={wifiSettings.dns_ip_2} | ||||
|               onChange={handleValueChange('dns_ip_2')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|           </Fragment> | ||||
|         } | ||||
|         <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit"> | ||||
|           Save | ||||
|         </Button> | ||||
|         <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|           Reset | ||||
|         </Button> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| WiFiSettingsForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   wifiSettings: PropTypes.object, | ||||
|   deselectNetwork: PropTypes.func, | ||||
|   selectedNetwork: PropTypes.object, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired, | ||||
|   handleCheckboxChange: PropTypes.func.isRequired | ||||
| }; | ||||
|  | ||||
| export default withStyles(styles)(WiFiSettingsForm); | ||||
							
								
								
									
										30
									
								
								interface/src/ntp/NTPSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/ntp/NTPSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { NTP_SETTINGS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import NTPSettingsForm from './NTPSettingsForm'; | ||||
| import { NTPSettings } from './types'; | ||||
|  | ||||
| type NTPSettingsControllerProps = RestControllerProps<NTPSettings>; | ||||
|  | ||||
| class NTPSettingsController extends Component<NTPSettingsControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="NTP Settings" titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <NTPSettingsForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController); | ||||
							
								
								
									
										84
									
								
								interface/src/ntp/NTPSettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								interface/src/ntp/NTPSettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| import React from 'react'; | ||||
| import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Checkbox, MenuItem } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { RestFormProps, FormActions, FormButton, BlockFormControlLabel } from '../components'; | ||||
| import { isIP, isHostname, or } from '../validators'; | ||||
|  | ||||
| import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ'; | ||||
| import { NTPSettings } from './types'; | ||||
|  | ||||
| type NTPSettingsFormProps = RestFormProps<NTPSettings>; | ||||
|  | ||||
| class NTPSettingsForm extends React.Component<NTPSettingsFormProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); | ||||
|   } | ||||
|  | ||||
|   changeTimeZone = (event: React.ChangeEvent<HTMLSelectElement>) => { | ||||
|     const { data, setData } = this.props; | ||||
|     setData({ | ||||
|       ...data, | ||||
|       tz_label: event.target.value, | ||||
|       tz_format: TIME_ZONES[event.target.value] | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, handleValueChange, handleCheckboxChange, saveData, loadData } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={saveData}> | ||||
|         <BlockFormControlLabel | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               checked={data.enabled} | ||||
|               onChange={handleCheckboxChange('enabled')} | ||||
|               value="enabled" | ||||
|             /> | ||||
|           } | ||||
|           label="Enable NTP?" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isIPOrHostname']} | ||||
|           errorMessages={['Server is required', "Not a valid IP address or hostname"]} | ||||
|           name="server" | ||||
|           label="Server" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.server} | ||||
|           onChange={handleValueChange('server')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <SelectValidator | ||||
|           validators={['required']} | ||||
|           errorMessages={['Time zone is required']} | ||||
|           name="tz_label" | ||||
|           labelId="tz_label" | ||||
|           label="Time zone" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           native | ||||
|           value={selectedTimeZone(data.tz_label, data.tz_format)} | ||||
|           onChange={this.changeTimeZone} | ||||
|           margin="normal" | ||||
|         > | ||||
|           <MenuItem disabled={true}>Time zone...</MenuItem> | ||||
|           {timeZoneSelectItems()} | ||||
|         </SelectValidator> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|             Save | ||||
|           </FormButton> | ||||
|           <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|             Reset | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default NTPSettingsForm; | ||||
							
								
								
									
										29
									
								
								interface/src/ntp/NTPStatus.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								interface/src/ntp/NTPStatus.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import { Theme } from "@material-ui/core"; | ||||
| import { NTPStatus } from "./types"; | ||||
|  | ||||
| export const NTP_INACTIVE = 0; | ||||
| export const NTP_ACTIVE = 1; | ||||
|  | ||||
| export const isNtpActive = ({ status }: NTPStatus) => status === NTP_ACTIVE; | ||||
|  | ||||
| export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => { | ||||
|   switch (status) { | ||||
|     case NTP_INACTIVE: | ||||
|       return theme.palette.info.main; | ||||
|     case NTP_ACTIVE: | ||||
|       return theme.palette.success.main; | ||||
|     default: | ||||
|       return theme.palette.error.main; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const ntpStatus = ({ status }: NTPStatus) => { | ||||
|   switch (status) { | ||||
|     case NTP_INACTIVE: | ||||
|       return "Inactive"; | ||||
|     case NTP_ACTIVE: | ||||
|       return "Active"; | ||||
|     default: | ||||
|       return "Unknown"; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										30
									
								
								interface/src/ntp/NTPStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/ntp/NTPStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { NTP_STATUS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import NTPStatusForm from './NTPStatusForm'; | ||||
| import { NTPStatus } from './types'; | ||||
|  | ||||
| type NTPStatusControllerProps = RestControllerProps<NTPStatus>; | ||||
|  | ||||
| class NTPStatusController extends Component<NTPStatusControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="NTP Status"> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <NTPStatusForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(NTP_STATUS_ENDPOINT, NTPStatusController); | ||||
							
								
								
									
										89
									
								
								interface/src/ntp/NTPStatusForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								interface/src/ntp/NTPStatusForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import React, { Component, Fragment } from 'react'; | ||||
| import moment from 'moment'; | ||||
|  | ||||
| import { WithTheme, withTheme } from '@material-ui/core/styles'; | ||||
| import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; | ||||
|  | ||||
| import SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle'; | ||||
| import AccessTimeIcon from '@material-ui/icons/AccessTime'; | ||||
| import DNSIcon from '@material-ui/icons/Dns'; | ||||
| import UpdateIcon from '@material-ui/icons/Update'; | ||||
| import AvTimerIcon from '@material-ui/icons/AvTimer'; | ||||
| import RefreshIcon from '@material-ui/icons/Refresh'; | ||||
|  | ||||
| import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; | ||||
|  | ||||
| import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus'; | ||||
| import { formatIsoDateTime } from './TimeFormat'; | ||||
| import { NTPStatus } from './types'; | ||||
|  | ||||
| type NTPStatusFormProps = RestFormProps<NTPStatus> & WithTheme; | ||||
|  | ||||
| class NTPStatusForm extends Component<NTPStatusFormProps> { | ||||
|  | ||||
|   render() { | ||||
|     const { data, theme } = this.props | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <List> | ||||
|           <ListItem> | ||||
|             <ListItemAvatar> | ||||
|               <HighlightAvatar color={ntpStatusHighlight(data, theme)}> | ||||
|                 <UpdateIcon /> | ||||
|               </HighlightAvatar> | ||||
|             </ListItemAvatar> | ||||
|             <ListItemText primary="Status" secondary={ntpStatus(data)} /> | ||||
|           </ListItem> | ||||
|           <Divider variant="inset" component="li" /> | ||||
|           {isNtpActive(data) && ( | ||||
|             <Fragment> | ||||
|               <ListItem> | ||||
|                 <ListItemAvatar> | ||||
|                   <Avatar> | ||||
|                     <AccessTimeIcon /> | ||||
|                   </Avatar> | ||||
|                 </ListItemAvatar> | ||||
|                 <ListItemText primary="Local Time" secondary={formatIsoDateTime(data.time_local)} /> | ||||
|               </ListItem> | ||||
|               <Divider variant="inset" component="li" /> | ||||
|               <ListItem> | ||||
|                 <ListItemAvatar> | ||||
|                   <Avatar> | ||||
|                     <SwapVerticalCircleIcon /> | ||||
|                   </Avatar> | ||||
|                 </ListItemAvatar> | ||||
|                 <ListItemText primary="UTC Time" secondary={formatIsoDateTime(data.time_utc)} /> | ||||
|               </ListItem> | ||||
|               <Divider variant="inset" component="li" /> | ||||
|             </Fragment> | ||||
|           )} | ||||
|           <ListItem> | ||||
|             <ListItemAvatar> | ||||
|               <Avatar> | ||||
|                 <DNSIcon /> | ||||
|               </Avatar> | ||||
|             </ListItemAvatar> | ||||
|             <ListItemText primary="NTP Server" secondary={data.server} /> | ||||
|           </ListItem> | ||||
|           <Divider variant="inset" component="li" /> | ||||
|           <ListItem> | ||||
|             <ListItemAvatar> | ||||
|               <Avatar> | ||||
|                 <AvTimerIcon /> | ||||
|               </Avatar> | ||||
|             </ListItemAvatar> | ||||
|             <ListItemText primary="Uptime" secondary={moment.duration(data.uptime, 'seconds').humanize()} /> | ||||
|           </ListItem> | ||||
|           <Divider variant="inset" component="li" /> | ||||
|         </List> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> | ||||
|             Refresh | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withTheme(NTPStatusForm); | ||||
							
								
								
									
										39
									
								
								interface/src/ntp/NetworkTime.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								interface/src/ntp/NetworkTime.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' | ||||
|  | ||||
| import { Tabs, Tab } from '@material-ui/core'; | ||||
|  | ||||
| import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; | ||||
| import { MenuAppBar } from '../components'; | ||||
|  | ||||
| import NTPStatusController from './NTPStatusController'; | ||||
| import NTPSettingsController from './NTPSettingsController'; | ||||
|  | ||||
| type NetworkTimeProps = AuthenticatedContextProps & RouteComponentProps; | ||||
|  | ||||
| class NetworkTime extends Component<NetworkTimeProps> { | ||||
|  | ||||
|   handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticatedContext } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Network Time"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> | ||||
|           <Tab value="/ntp/status" label="NTP Status" /> | ||||
|           <Tab value="/ntp/settings" label="NTP Settings" disabled={!authenticatedContext.me.admin} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/ntp/status" component={NTPStatusController} /> | ||||
|           <AuthenticatedRoute exact={true} path="/ntp/settings" component={NTPSettingsController} /> | ||||
|           <Redirect to="/ntp/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withAuthenticatedContext(NetworkTime) | ||||
| @@ -1,7 +1,11 @@ | ||||
| import React from 'react'; | ||||
| import MenuItem from '@material-ui/core/MenuItem'; | ||||
| 
 | ||||
| export const TIME_ZONES = { | ||||
| type TimeZones = {  | ||||
|   [name: string]: string  | ||||
| }; | ||||
| 
 | ||||
| export const TIME_ZONES: TimeZones = { | ||||
|   "Africa/Abidjan": "GMT0", | ||||
|   "Africa/Accra": "GMT0", | ||||
|   "Africa/Addis_Ababa": "EAT-3", | ||||
| @@ -464,7 +468,7 @@ export const TIME_ZONES = { | ||||
|   "Etc/Zulu": "UTC0" | ||||
| } | ||||
| 
 | ||||
| export function selectedTimeZone(label, format){ | ||||
| export function selectedTimeZone(label: string, format: string) { | ||||
|   return TIME_ZONES[label] === format ? label : undefined; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										3
									
								
								interface/src/ntp/TimeFormat.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								interface/src/ntp/TimeFormat.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| import moment from 'moment'; | ||||
|  | ||||
| export const formatIsoDateTime = (isoDateString: string) => moment.parseZone(isoDateString).format('ll @ HH:mm:ss'); | ||||
							
								
								
									
										14
									
								
								interface/src/ntp/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								interface/src/ntp/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| export interface NTPStatus { | ||||
|   status: number; | ||||
|   time_utc: string; | ||||
|   time_local: string; | ||||
|   server: string; | ||||
|   uptime: number; | ||||
| } | ||||
|  | ||||
| export interface NTPSettings { | ||||
|   enabled: boolean; | ||||
|   server: string; | ||||
|   tz_label: string; | ||||
|   tz_format: string; | ||||
| } | ||||
| @@ -1,83 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { ENDPOINT_ROOT } from '../constants/Env'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
|  | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| import Slider from '@material-ui/core/Slider'; | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| export const DEMO_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "demoSettings"; | ||||
|  | ||||
| const valueToPercentage = (value) => `${Math.round(value / 255 * 100)}%`; | ||||
|  | ||||
| class DemoController extends Component { | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, fetched, errorMessage, saveData, loadData, handleSliderChange } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="Controller" titleGutter> | ||||
|         <LoadingNotification | ||||
|           onReset={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={() => | ||||
|             <DemoControllerForm | ||||
|               demoSettings={data} | ||||
|               onReset={loadData} | ||||
|               onSubmit={saveData} | ||||
|               handleSliderChange={handleSliderChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| const useStyles = makeStyles(theme => ({ | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   }, | ||||
|   blinkSpeedLabel: { | ||||
|     marginBottom: theme.spacing(5), | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function DemoControllerForm(props) { | ||||
|   const { demoSettings, onSubmit, onReset, handleSliderChange } = props; | ||||
|   const classes = useStyles(); | ||||
|   return ( | ||||
|     <ValidatorForm onSubmit={onSubmit}> | ||||
|       <Typography id="blink-speed-slider" className={classes.blinkSpeedLabel}> | ||||
|         Blink Speed | ||||
|       </Typography> | ||||
|       <Slider | ||||
|         value={demoSettings.blink_speed} | ||||
|         valueLabelFormat={valueToPercentage} | ||||
|         aria-labelledby="blink-speed-slider" | ||||
|         valueLabelDisplay="on" | ||||
|         min={0} | ||||
|         max={255} | ||||
|         onChange={handleSliderChange('blink_speed')} | ||||
|       /> | ||||
|       <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit"> | ||||
|         Save | ||||
|       </Button> | ||||
|       <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|         Reset | ||||
|       </Button> | ||||
|     </ValidatorForm> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default restComponent(DEMO_SETTINGS_ENDPOINT, DemoController); | ||||
							
								
								
									
										75
									
								
								interface/src/project/DemoController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								interface/src/project/DemoController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Typography, Slider, Box } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { ENDPOINT_ROOT } from '../api'; | ||||
| import { restController, RestControllerProps, RestFormLoader, RestFormProps, FormActions, FormButton, SectionContent } from '../components'; | ||||
|  | ||||
| export const DEMO_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "demoSettings"; | ||||
|  | ||||
| interface DemoSettings { | ||||
|   blink_speed: number; | ||||
| } | ||||
|  | ||||
| type DemoControllerProps = RestControllerProps<DemoSettings>; | ||||
|  | ||||
| class DemoController extends Component<DemoControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title='Demo Controller' titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={props => ( | ||||
|             <DemoControllerForm {...props} /> | ||||
|           )} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(DEMO_SETTINGS_ENDPOINT, DemoController); | ||||
|  | ||||
| const valueToPercentage = (value: number) => `${Math.round(value / 255 * 100)}%`; | ||||
|  | ||||
| type DemoControllerFormProps = RestFormProps<DemoSettings>; | ||||
|  | ||||
| function DemoControllerForm(props: DemoControllerFormProps) { | ||||
|   const { data, saveData, loadData, handleSliderChange } = props; | ||||
|   return ( | ||||
|     <ValidatorForm onSubmit={saveData}> | ||||
|       <Typography id="blink-speed-slider"> | ||||
|         Blink Speed | ||||
|       </Typography> | ||||
|       <Box pt={5}> | ||||
|         <Slider | ||||
|           value={data.blink_speed} | ||||
|           valueLabelFormat={valueToPercentage} | ||||
|           aria-labelledby="blink-speed-slider" | ||||
|           valueLabelDisplay="on" | ||||
|           min={0} | ||||
|           max={255} | ||||
|           onChange={handleSliderChange('blink_speed')} | ||||
|         /> | ||||
|       </Box> | ||||
|       <FormActions> | ||||
|         <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|           Save | ||||
|         </FormButton> | ||||
|         <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|           Reset | ||||
|         </FormButton> | ||||
|       </FormActions> | ||||
|     </ValidatorForm> | ||||
|   ); | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -1,27 +1,12 @@ | ||||
| import React, { Component } from 'react'; | ||||
| 
 | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Table from '@material-ui/core/Table'; | ||||
| import TableHead from '@material-ui/core/TableHead'; | ||||
| import TableCell from '@material-ui/core/TableCell'; | ||||
| import TableBody from '@material-ui/core/TableBody'; | ||||
| import TableRow from '@material-ui/core/TableRow'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| 
 | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| 
 | ||||
| const styles = theme => ({ | ||||
|   fileTable: { | ||||
|     marginBottom: theme.spacing(2) | ||||
|   } | ||||
| }); | ||||
| import { Typography, TableRow, TableBody, TableCell, TableHead, Table, Box } from '@material-ui/core'; | ||||
| import { SectionContent } from '../components'; | ||||
| 
 | ||||
| class DemoInformation extends Component { | ||||
| 
 | ||||
|   render() { | ||||
|     const { classes } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="Demo Project - Blink Speed Controller" titleGutter> | ||||
|       <SectionContent title='Demo Information' titleGutter> | ||||
|         <Typography variant="body1" paragraph> | ||||
|           This simple demo project allows you to control the blink speed of the built-in LED. | ||||
|           It demonstrates how the esp8266-react framework may be extended for your own IoT project. | ||||
| @@ -34,7 +19,7 @@ class DemoInformation extends Component { | ||||
|         <Typography variant="body1" paragraph> | ||||
|           The demo project interface code stored in the interface/project directory: | ||||
|         </Typography> | ||||
|         <Table className={classes.fileTable}> | ||||
|         <Table> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
| @@ -48,7 +33,7 @@ class DemoInformation extends Component { | ||||
|           <TableBody> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 ProjectMenu.js | ||||
|                 ProjectMenu.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 You can add your project's screens to the side bar here. | ||||
| @@ -56,7 +41,7 @@ class DemoInformation extends Component { | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 ProjectRouting.js | ||||
|                 ProjectRouting.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 The routing which controls the screens of your project. | ||||
| @@ -64,7 +49,7 @@ class DemoInformation extends Component { | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 DemoProject.js | ||||
|                 DemoProject.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 This screen, with tabs and tab routing. | ||||
| @@ -72,15 +57,15 @@ class DemoInformation extends Component { | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 DemoInformation.js | ||||
|                 DemoInformation.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 The demo information tab. | ||||
|                 The demo information page. | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 DemoController.js | ||||
|                 DemoController.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 The demo controller tab, to control the built-in LED. | ||||
| @@ -88,13 +73,15 @@ class DemoInformation extends Component { | ||||
|             </TableRow> | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|         <Typography variant="body1" paragraph> | ||||
|         <Box mt={2}> | ||||
|           <Typography variant="body1"> | ||||
|             See the project <a href="https://github.com/rjwats/esp8266-react/">README</a> for a full description of the demo project. | ||||
|           </Typography> | ||||
|         </Box> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default withStyles(styles)(DemoInformation); | ||||
| export default DemoInformation; | ||||
| @@ -1,27 +1,27 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router-dom' | ||||
| import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' | ||||
| 
 | ||||
| import { Tabs, Tab } from '@material-ui/core'; | ||||
| 
 | ||||
| import { PROJECT_PATH } from '../api'; | ||||
| import { MenuAppBar } from '../components'; | ||||
| import { AuthenticatedRoute } from '../authentication'; | ||||
| 
 | ||||
| import { PROJECT_PATH } from '../constants/Env'; | ||||
| import MenuAppBar from '../components/MenuAppBar'; | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import DemoInformation from './DemoInformation'; | ||||
| import DemoController from './DemoController'; | ||||
| 
 | ||||
| import Tabs from '@material-ui/core/Tabs'; | ||||
| import Tab from '@material-ui/core/Tab'; | ||||
| class DemoProject extends Component<RouteComponentProps> { | ||||
| 
 | ||||
| class DemoProject extends Component { | ||||
| 
 | ||||
|   handleTabChange = (event, path) => { | ||||
|   handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
| 
 | ||||
|   render() { | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Demo Project"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/information`} label="Information" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/controller`} label="Controller" /> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/information`} label="Demo Information" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/controller`} label="Demo Controller" /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/information`} component={DemoInformation} /> | ||||
| @@ -1,15 +1,12 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Link, withRouter } from 'react-router-dom'; | ||||
| import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; | ||||
| 
 | ||||
| import { PROJECT_PATH } from '../constants/Env'; | ||||
| 
 | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemIcon from '@material-ui/core/ListItemIcon'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import {List, ListItem, ListItemIcon, ListItemText} from '@material-ui/core'; | ||||
| import SettingsRemoteIcon from '@material-ui/icons/SettingsRemote'; | ||||
| 
 | ||||
| class ProjectMenu extends Component { | ||||
| import { PROJECT_PATH } from '../api'; | ||||
| 
 | ||||
| class ProjectMenu extends Component<RouteComponentProps> { | ||||
| 
 | ||||
|   render() { | ||||
|     const path = this.props.match.url; | ||||
| @@ -1,8 +1,9 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router'; | ||||
| 
 | ||||
| import { PROJECT_PATH } from '../constants/Env'; | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import { PROJECT_PATH } from '../api'; | ||||
| import { AuthenticatedRoute } from '../authentication'; | ||||
| 
 | ||||
| import DemoProject from './DemoProject'; | ||||
| 
 | ||||
| class ProjectRouting extends Component { | ||||
							
								
								
									
										1
									
								
								interface/src/react-app-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								interface/src/react-app-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| /// <reference types="react-scripts" /> | ||||
| @@ -1,37 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router-dom' | ||||
|  | ||||
| import Tabs from '@material-ui/core/Tabs'; | ||||
| import Tab from '@material-ui/core/Tab'; | ||||
|  | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import MenuAppBar from '../components/MenuAppBar'; | ||||
| import APSettings from '../containers/APSettings'; | ||||
| import APStatus from '../containers/APStatus'; | ||||
| import { withAuthenticationContext } from '../authentication/Context.js'; | ||||
|  | ||||
| class AccessPoint extends Component { | ||||
|  | ||||
|   handleTabChange = (event, path) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticationContext } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Access Point"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> | ||||
|           <Tab value="/ap/status" label="Access Point Status" /> | ||||
|           <Tab value="/ap/settings" label="Access Point Settings" disabled={!authenticationContext.isAdmin()} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/ap/status" component={APStatus} /> | ||||
|           <AuthenticatedRoute exact={true} path="/ap/settings" component={APSettings} /> | ||||
|           <Redirect to="/ap/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(AccessPoint); | ||||
| @@ -1,38 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router-dom' | ||||
|  | ||||
| import Tabs from '@material-ui/core/Tabs'; | ||||
| import Tab from '@material-ui/core/Tab'; | ||||
|  | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import MenuAppBar from '../components/MenuAppBar'; | ||||
| import NTPSettings from '../containers/NTPSettings'; | ||||
| import NTPStatus from '../containers/NTPStatus'; | ||||
| import { withAuthenticationContext } from '../authentication/Context.js'; | ||||
|  | ||||
| class NetworkTime extends Component { | ||||
|  | ||||
|   handleTabChange = (event, path) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticationContext } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Network Time"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> | ||||
|           <Tab value="/ntp/status" label="NTP Status" /> | ||||
|           <Tab value="/ntp/settings" label="NTP Settings" disabled={!authenticationContext.isAdmin()} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/ntp/status" component={NTPStatus} /> | ||||
|           <AuthenticatedRoute exact={true} path="/ntp/settings" component={NTPSettings} /> | ||||
|           <Redirect to="/ntp/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(NetworkTime) | ||||
| @@ -1,35 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router-dom' | ||||
|  | ||||
| import Tabs from '@material-ui/core/Tabs'; | ||||
| import Tab from '@material-ui/core/Tab'; | ||||
|  | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import MenuAppBar from '../components/MenuAppBar'; | ||||
| import ManageUsers from '../containers/ManageUsers'; | ||||
| import SecuritySettings from '../containers/SecuritySettings'; | ||||
|  | ||||
| class Security extends Component { | ||||
|  | ||||
|   handleTabChange = (event, path) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Security"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> | ||||
|           <Tab value="/security/users" label="Manage Users" /> | ||||
|           <Tab value="/security/settings" label="Security Settings" /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/security/users" component={ManageUsers} /> | ||||
|           <AuthenticatedRoute exact={true} path="/security/settings" component={SecuritySettings} /> | ||||
|           <Redirect to="/security/users" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default Security; | ||||
| @@ -1,37 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router-dom' | ||||
|  | ||||
| import Tabs from '@material-ui/core/Tabs'; | ||||
| import Tab from '@material-ui/core/Tab'; | ||||
|  | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import MenuAppBar from '../components/MenuAppBar'; | ||||
| import OTASettings from '../containers/OTASettings'; | ||||
| import SystemStatus from '../containers/SystemStatus'; | ||||
| import { withAuthenticationContext } from '../authentication/Context.js'; | ||||
|  | ||||
| class System extends Component { | ||||
|  | ||||
|   handleTabChange = (event, path) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticationContext } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="System"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> | ||||
|           <Tab value="/system/status" label="System Status" /> | ||||
|           <Tab value="/system/ota" label="OTA Settings" disabled={!authenticationContext.isAdmin()} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/system/status" component={SystemStatus} /> | ||||
|           <AuthenticatedRoute exact={true} path="/system/ota" component={OTASettings} /> | ||||
|           <Redirect to="/system/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(System); | ||||
| @@ -1,74 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch } from 'react-router-dom' | ||||
|  | ||||
| import Tabs from '@material-ui/core/Tabs'; | ||||
| import Tab from '@material-ui/core/Tab'; | ||||
|  | ||||
| import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; | ||||
| import MenuAppBar from '../components/MenuAppBar'; | ||||
| import WiFiNetworkScanner from '../containers/WiFiNetworkScanner'; | ||||
| import WiFiSettings from '../containers/WiFiSettings'; | ||||
| import WiFiStatus from '../containers/WiFiStatus'; | ||||
| import { withAuthenticationContext } from '../authentication/Context.js'; | ||||
|  | ||||
| class WiFiConnection extends Component { | ||||
|  | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|     this.state = { | ||||
|       selectedNetwork: null | ||||
|     }; | ||||
|     this.selectNetwork = this.selectNetwork.bind(this); | ||||
|     this.deselectNetwork = this.deselectNetwork.bind(this); | ||||
|   } | ||||
|  | ||||
|   selectNetwork(network) { | ||||
|     this.setState({ selectedNetwork: network }); | ||||
|     this.props.history.push('/wifi/settings'); | ||||
|   } | ||||
|  | ||||
|   deselectNetwork(network) { | ||||
|     this.setState({ selectedNetwork: null }); | ||||
|   } | ||||
|  | ||||
|   handleTabChange = (event, path) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticationContext } = this.props; | ||||
|     const ConfiguredWiFiNetworkScanner = (props) => { | ||||
|       return ( | ||||
|         <WiFiNetworkScanner | ||||
|           selectNetwork={this.selectNetwork} | ||||
|           {...props} | ||||
|         /> | ||||
|       ); | ||||
|     }; | ||||
|     const ConfiguredWiFiSettings = (props) => { | ||||
|       return ( | ||||
|         <WiFiSettings | ||||
|           deselectNetwork={this.deselectNetwork} selectedNetwork={this.state.selectedNetwork} | ||||
|           {...props} | ||||
|         /> | ||||
|       ); | ||||
|     }; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="WiFi Connection"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> | ||||
|           <Tab value="/wifi/status" label="WiFi Status" /> | ||||
|           <Tab value="/wifi/scan" label="Scan Networks" disabled={!authenticationContext.isAdmin()} /> | ||||
|           <Tab value="/wifi/settings" label="WiFi Settings" disabled={!authenticationContext.isAdmin()} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/wifi/status" component={WiFiStatus} /> | ||||
|           <AuthenticatedRoute exact={true} path="/wifi/scan" component={ConfiguredWiFiNetworkScanner} /> | ||||
|           <AuthenticatedRoute exact={true} path="/wifi/settings" component={ConfiguredWiFiSettings} /> | ||||
|           <Redirect to="/wifi/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticationContext(WiFiConnection); | ||||
							
								
								
									
										30
									
								
								interface/src/security/ManageUsersController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/security/ManageUsersController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { SECURITY_SETTINGS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import ManageUsersForm from './ManageUsersForm'; | ||||
| import { SecuritySettings } from './types'; | ||||
|  | ||||
| type ManageUsersControllerProps = RestControllerProps<SecuritySettings>; | ||||
|  | ||||
| class ManageUsersController extends Component<ManageUsersControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="Manage Users" titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <ManageUsersForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(SECURITY_SETTINGS_ENDPOINT, ManageUsersController); | ||||
| @@ -1,18 +1,9 @@ | ||||
| import React, { Fragment } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
| 
 | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import Typography from '@material-ui/core/Typography'; | ||||
| import Table from '@material-ui/core/Table'; | ||||
| import TableBody from '@material-ui/core/TableBody'; | ||||
| import TableCell from '@material-ui/core/TableCell'; | ||||
| import TableFooter from '@material-ui/core/TableFooter'; | ||||
| import TableHead from '@material-ui/core/TableHead'; | ||||
| import TableRow from '@material-ui/core/TableRow'; | ||||
| import Box from '@material-ui/core/Box'; | ||||
| import { Table, TableBody, TableCell, TableHead, TableFooter, TableRow } from '@material-ui/core'; | ||||
| import { Box, Button, Typography, } from '@material-ui/core'; | ||||
| 
 | ||||
| import EditIcon from '@material-ui/icons/Edit'; | ||||
| import DeleteIcon from '@material-ui/icons/Delete'; | ||||
| import CloseIcon from '@material-ui/icons/Close'; | ||||
| @@ -21,20 +12,13 @@ import IconButton from '@material-ui/core/IconButton'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
| import PersonAddIcon from '@material-ui/icons/PersonAdd'; | ||||
| 
 | ||||
| import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; | ||||
| import { RestFormProps, FormActions, FormButton } from '../components'; | ||||
| 
 | ||||
| import UserForm from './UserForm'; | ||||
| import { withAuthenticationContext } from '../authentication/Context'; | ||||
| import { SecuritySettings, User } from './types'; | ||||
| 
 | ||||
| const styles = theme => ({ | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   }, | ||||
|   table: { | ||||
|     '& td, & th': { padding: theme.spacing(0.5) } | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| function compareUsers(a, b) { | ||||
| function compareUsers(a: User, b: User) { | ||||
|   if (a.username < b.username) { | ||||
|     return -1; | ||||
|   } | ||||
| @@ -44,12 +28,18 @@ function compareUsers(a, b) { | ||||
|   return 0; | ||||
| } | ||||
| 
 | ||||
| class ManageUsersForm extends React.Component { | ||||
| type ManageUsersFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps; | ||||
| 
 | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|     this.state = {}; | ||||
|   } | ||||
| type ManageUsersFormState = { | ||||
|   creating: boolean; | ||||
|   user?: User; | ||||
| } | ||||
| 
 | ||||
| class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersFormState> { | ||||
| 
 | ||||
|   state: ManageUsersFormState = { | ||||
|     creating: false | ||||
|   }; | ||||
| 
 | ||||
|   createUser = () => { | ||||
|     this.setState({ | ||||
| @@ -62,21 +52,21 @@ class ManageUsersForm extends React.Component { | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   uniqueUsername = username => { | ||||
|     return !this.props.userData.users.find(u => u.username === username); | ||||
|   uniqueUsername = (username: string) => { | ||||
|     return !this.props.data.users.find(u => u.username === username); | ||||
|   } | ||||
| 
 | ||||
|   noAdminConfigured = () => { | ||||
|     return !this.props.userData.users.find(u => u.admin); | ||||
|     return !this.props.data.users.find(u => u.admin); | ||||
|   } | ||||
| 
 | ||||
|   removeUser = user => { | ||||
|     const { userData } = this.props; | ||||
|     const users = userData.users.filter(u => u.username !== user.username); | ||||
|     this.props.setData({ ...userData, users }); | ||||
|   removeUser = (user: User) => { | ||||
|     const { data } = this.props; | ||||
|     const users = data.users.filter(u => u.username !== user.username); | ||||
|     this.props.setData({ ...data, users }); | ||||
|   } | ||||
| 
 | ||||
|   startEditingUser = user => { | ||||
|   startEditingUser = (user: User) => { | ||||
|     this.setState({ | ||||
|       creating: false, | ||||
|       user | ||||
| @@ -91,45 +81,37 @@ class ManageUsersForm extends React.Component { | ||||
| 
 | ||||
|   doneEditingUser = () => { | ||||
|     const { user } = this.state; | ||||
|     const { userData } = this.props; | ||||
|     const users = userData.users.filter(u => u.username !== user.username); | ||||
|     if (user) { | ||||
|       const { data } = this.props; | ||||
|       const users = data.users.filter(u => u.username !== user.username); | ||||
|       users.push(user); | ||||
|     this.props.setData({ ...userData, users }); | ||||
|       this.props.setData({ ...data, users }); | ||||
|       this.setState({ | ||||
|         user: undefined | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   handleUserValueChange = name => event => { | ||||
|     const { user } = this.state; | ||||
|     this.setState({ | ||||
|       user: { | ||||
|         ...user, [name]: event.target.value | ||||
|       } | ||||
|     }); | ||||
|   handleUserValueChange = (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     this.setState({ user: { ...this.state.user!, [name]: event.target.value } }); | ||||
|   }; | ||||
| 
 | ||||
|   handleUserCheckboxChange = name => event => { | ||||
|     const { user } = this.state; | ||||
|     this.setState({ | ||||
|       user: { | ||||
|         ...user, [name]: event.target.checked | ||||
|       } | ||||
|     }); | ||||
|   handleUserCheckboxChange = (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     this.setState({ user: { ...this.state.user!, [name]: event.target.checked } }); | ||||
|   } | ||||
| 
 | ||||
|   onSubmit = () => { | ||||
|     this.props.onSubmit(); | ||||
|     this.props.authenticationContext.refresh(); | ||||
|     this.props.saveData(); | ||||
|     this.props.authenticatedContext.refresh(); | ||||
|   } | ||||
| 
 | ||||
|   render() { | ||||
|     const { classes, userData, onReset } = this.props; | ||||
|     const { data, loadData } = this.props; | ||||
|     const { user, creating } = this.state; | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <ValidatorForm onSubmit={this.onSubmit}> | ||||
|           <Table className={classes.table}> | ||||
|           <Table size="small"> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Username</TableCell> | ||||
| @@ -138,7 +120,7 @@ class ManageUsersForm extends React.Component { | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {userData.users.sort(compareUsers).map(user => ( | ||||
|               {data.users.sort(compareUsers).map(user => ( | ||||
|                 <TableRow key={user.username}> | ||||
|                   <TableCell component="th" scope="row"> | ||||
|                     {user.username} | ||||
| @@ -178,12 +160,14 @@ class ManageUsersForm extends React.Component { | ||||
|               </Box> | ||||
|             </Typography> | ||||
|           } | ||||
|           <Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit" disabled={this.noAdminConfigured()}> | ||||
|           <FormActions> | ||||
|             <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit" disabled={this.noAdminConfigured()}> | ||||
|               Save | ||||
|           </Button> | ||||
|           <Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> | ||||
|             </FormButton> | ||||
|             <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|               Reset | ||||
|       		</Button> | ||||
|             </FormButton> | ||||
|           </FormActions> | ||||
|         </ValidatorForm> | ||||
|         { | ||||
|           user && | ||||
| @@ -203,14 +187,4 @@ class ManageUsersForm extends React.Component { | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| ManageUsersForm.propTypes = { | ||||
|   classes: PropTypes.object.isRequired, | ||||
|   userData: PropTypes.object, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onReset: PropTypes.func.isRequired, | ||||
|   setData: PropTypes.func.isRequired, | ||||
|   handleValueChange: PropTypes.func.isRequired, | ||||
|   authenticationContext: PropTypes.object.isRequired, | ||||
| }; | ||||
| 
 | ||||
| export default withAuthenticationContext(withStyles(styles)(ManageUsersForm)); | ||||
| export default withAuthenticatedContext(ManageUsersForm); | ||||
							
								
								
									
										37
									
								
								interface/src/security/Security.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								interface/src/security/Security.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' | ||||
|  | ||||
| import { Tabs, Tab } from '@material-ui/core'; | ||||
|  | ||||
| import { AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; | ||||
| import { MenuAppBar } from '../components'; | ||||
|  | ||||
| import ManageUsersController from './ManageUsersController'; | ||||
| import SecuritySettingsController from './SecuritySettingsController'; | ||||
|  | ||||
| type SecurityProps = AuthenticatedContextProps & RouteComponentProps; | ||||
|  | ||||
| class Security extends Component<SecurityProps> { | ||||
|  | ||||
|   handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Security"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> | ||||
|           <Tab value="/security/users" label="Manage Users" /> | ||||
|           <Tab value="/security/settings" label="Security Settings" /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/security/users" component={ManageUsersController} /> | ||||
|           <AuthenticatedRoute exact={true} path="/security/settings" component={SecuritySettingsController} /> | ||||
|           <Redirect to="/security/users" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default Security; | ||||
							
								
								
									
										30
									
								
								interface/src/security/SecuritySettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/security/SecuritySettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { SECURITY_SETTINGS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import SecuritySettingsForm from './SecuritySettingsForm'; | ||||
| import { SecuritySettings } from './types'; | ||||
|  | ||||
| type SecuritySettingsControllerProps = RestControllerProps<SecuritySettings>; | ||||
|  | ||||
| class SecuritySettingsController extends Component<SecuritySettingsControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="Security Settings" titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <SecuritySettingsForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(SECURITY_SETTINGS_ENDPOINT, SecuritySettingsController); | ||||
							
								
								
									
										55
									
								
								interface/src/security/SecuritySettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								interface/src/security/SecuritySettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| import React from 'react'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Box, Typography } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; | ||||
| import { RestFormProps, PasswordValidator, FormActions, FormButton } from '../components'; | ||||
|  | ||||
| import { SecuritySettings } from './types'; | ||||
|  | ||||
| type SecuritySettingsFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps; | ||||
|  | ||||
| class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> { | ||||
|  | ||||
|   onSubmit = () => { | ||||
|     this.props.saveData(); | ||||
|     this.props.authenticatedContext.refresh(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, handleValueChange, loadData } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={this.onSubmit}> | ||||
|         <PasswordValidator | ||||
|           validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|           errorMessages={['JWT Secret Required', 'JWT Secret must be 64 characters or less']} | ||||
|           name="jwt_secret" | ||||
|           label="JWT Secret" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.jwt_secret} | ||||
|           onChange={handleValueChange('jwt_secret')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <Typography component="div" variant="body1"> | ||||
|           <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|             If you modify the JWT Secret, all users will be logged out. | ||||
|           </Box> | ||||
|         </Typography> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|             Save | ||||
|           </FormButton> | ||||
|           <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|             Reset | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withAuthenticatedContext(SecuritySettingsForm); | ||||
							
								
								
									
										87
									
								
								interface/src/security/UserForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								interface/src/security/UserForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| import React, { RefObject } from 'react'; | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Dialog, DialogTitle, DialogContent, DialogActions, Checkbox } from '@material-ui/core'; | ||||
|  | ||||
| import { PasswordValidator, BlockFormControlLabel, FormButton } from '../components'; | ||||
|  | ||||
| import { User } from './types'; | ||||
|  | ||||
| interface UserFormProps { | ||||
|   creating: boolean; | ||||
|   user: User; | ||||
|   uniqueUsername: (value: any) => boolean; | ||||
|   handleValueChange: (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => void; | ||||
|   handleCheckboxChange: (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>, checked: boolean) => void; | ||||
|   onDoneEditing: () => void; | ||||
|   onCancelEditing: () => void; | ||||
| } | ||||
|  | ||||
| class UserForm extends React.Component<UserFormProps> { | ||||
|  | ||||
|   formRef: RefObject<any> = React.createRef(); | ||||
|  | ||||
|   componentDidMount() { | ||||
|     ValidatorForm.addValidationRule('uniqueUsername', this.props.uniqueUsername); | ||||
|   } | ||||
|  | ||||
|   submit = () => { | ||||
|     this.formRef.current.submit(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { user, creating, handleValueChange, handleCheckboxChange, onDoneEditing, onCancelEditing } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}> | ||||
|         <Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open={true}> | ||||
|           <DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle> | ||||
|           <DialogContent dividers={true}> | ||||
|             <TextValidator | ||||
|               validators={creating ? ['required', 'uniqueUsername', 'matchRegexp:^[a-zA-Z0-9_\\.]{1,24}$'] : []} | ||||
|               errorMessages={creating ? ['Username is required', "Username already exists", "Must be 1-24 characters: alpha numeric, '_' or '.'"] : []} | ||||
|               name="username" | ||||
|               label="Username" | ||||
|               fullWidth | ||||
|               variant="outlined" | ||||
|               value={user.username} | ||||
|               disabled={!creating} | ||||
|               onChange={handleValueChange('username')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <PasswordValidator | ||||
|               validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|               errorMessages={['Password is required', 'Password must be 64 characters or less']} | ||||
|               name="password" | ||||
|               label="Password" | ||||
|               fullWidth | ||||
|               variant="outlined" | ||||
|               value={user.password} | ||||
|               onChange={handleValueChange('password')} | ||||
|               margin="normal" | ||||
|             /> | ||||
|             <BlockFormControlLabel | ||||
|               control={ | ||||
|                 <Checkbox | ||||
|                   value="admin" | ||||
|                   checked={user.admin} | ||||
|                   onChange={handleCheckboxChange('admin')} | ||||
|                 /> | ||||
|               } | ||||
|               label="Admin?" | ||||
|             /> | ||||
|           </DialogContent> | ||||
|           <DialogActions> | ||||
|             <FormButton variant="contained" color="primary" type="submit" onClick={this.submit}> | ||||
|               Done | ||||
|             </FormButton> | ||||
|             <FormButton variant="contained" color="secondary" onClick={onCancelEditing}> | ||||
|               Cancel | ||||
|             </FormButton> | ||||
|           </DialogActions> | ||||
|         </Dialog> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default UserForm; | ||||
							
								
								
									
										11
									
								
								interface/src/security/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								interface/src/security/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export interface User { | ||||
|   username: string; | ||||
|   password: string; | ||||
|   admin: boolean; | ||||
| } | ||||
|  | ||||
| export interface SecuritySettings { | ||||
|   users: User[]; | ||||
|   jwt_secret: string; | ||||
| } | ||||
|  | ||||
							
								
								
									
										145
									
								
								interface/src/serviceWorker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								interface/src/serviceWorker.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | ||||
| // This optional code is used to register a service worker. | ||||
| // register() is not called by default. | ||||
|  | ||||
| // This lets the app load faster on subsequent visits in production, and gives | ||||
| // it offline capabilities. However, it also means that developers (and users) | ||||
| // will only see deployed updates on subsequent visits to a page, after all the | ||||
| // existing tabs open on the page have been closed, since previously cached | ||||
| // resources are updated in the background. | ||||
|  | ||||
| // To learn more about the benefits of this model and instructions on how to | ||||
| // opt-in, read https://bit.ly/CRA-PWA | ||||
|  | ||||
| const isLocalhost = Boolean( | ||||
|   window.location.hostname === 'localhost' || | ||||
|     // [::1] is the IPv6 localhost address. | ||||
|     window.location.hostname === '[::1]' || | ||||
|     // 127.0.0.0/8 are considered localhost for IPv4. | ||||
|     window.location.hostname.match( | ||||
|       /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ | ||||
|     ) | ||||
| ); | ||||
|  | ||||
| type Config = { | ||||
|   onSuccess?: (registration: ServiceWorkerRegistration) => void; | ||||
|   onUpdate?: (registration: ServiceWorkerRegistration) => void; | ||||
| }; | ||||
|  | ||||
| export function register(config?: Config) { | ||||
|   if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { | ||||
|     // The URL constructor is available in all browsers that support SW. | ||||
|     const publicUrl = new URL( | ||||
|       process.env.PUBLIC_URL, | ||||
|       window.location.href | ||||
|     ); | ||||
|     if (publicUrl.origin !== window.location.origin) { | ||||
|       // Our service worker won't work if PUBLIC_URL is on a different origin | ||||
|       // from what our page is served on. This might happen if a CDN is used to | ||||
|       // serve assets; see https://github.com/facebook/create-react-app/issues/2374 | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     window.addEventListener('load', () => { | ||||
|       const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; | ||||
|  | ||||
|       if (isLocalhost) { | ||||
|         // This is running on localhost. Let's check if a service worker still exists or not. | ||||
|         checkValidServiceWorker(swUrl, config); | ||||
|  | ||||
|         // Add some additional logging to localhost, pointing developers to the | ||||
|         // service worker/PWA documentation. | ||||
|         navigator.serviceWorker.ready.then(() => { | ||||
|           console.log( | ||||
|             'This web app is being served cache-first by a service ' + | ||||
|               'worker. To learn more, visit https://bit.ly/CRA-PWA' | ||||
|           ); | ||||
|         }); | ||||
|       } else { | ||||
|         // Is not localhost. Just register service worker | ||||
|         registerValidSW(swUrl, config); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function registerValidSW(swUrl: string, config?: Config) { | ||||
|   navigator.serviceWorker | ||||
|     .register(swUrl) | ||||
|     .then(registration => { | ||||
|       registration.onupdatefound = () => { | ||||
|         const installingWorker = registration.installing; | ||||
|         if (installingWorker == null) { | ||||
|           return; | ||||
|         } | ||||
|         installingWorker.onstatechange = () => { | ||||
|           if (installingWorker.state === 'installed') { | ||||
|             if (navigator.serviceWorker.controller) { | ||||
|               // At this point, the updated precached content has been fetched, | ||||
|               // but the previous service worker will still serve the older | ||||
|               // content until all client tabs are closed. | ||||
|               console.log( | ||||
|                 'New content is available and will be used when all ' + | ||||
|                   'tabs for this page are closed. See https://bit.ly/CRA-PWA.' | ||||
|               ); | ||||
|  | ||||
|               // Execute callback | ||||
|               if (config && config.onUpdate) { | ||||
|                 config.onUpdate(registration); | ||||
|               } | ||||
|             } else { | ||||
|               // At this point, everything has been precached. | ||||
|               // It's the perfect time to display a | ||||
|               // "Content is cached for offline use." message. | ||||
|               console.log('Content is cached for offline use.'); | ||||
|  | ||||
|               // Execute callback | ||||
|               if (config && config.onSuccess) { | ||||
|                 config.onSuccess(registration); | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }; | ||||
|       }; | ||||
|     }) | ||||
|     .catch(error => { | ||||
|       console.error('Error during service worker registration:', error); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| function checkValidServiceWorker(swUrl: string, config?: Config) { | ||||
|   // Check if the service worker can be found. If it can't reload the page. | ||||
|   fetch(swUrl, { | ||||
|     headers: { 'Service-Worker': 'script' } | ||||
|   }) | ||||
|     .then(response => { | ||||
|       // Ensure service worker exists, and that we really are getting a JS file. | ||||
|       const contentType = response.headers.get('content-type'); | ||||
|       if ( | ||||
|         response.status === 404 || | ||||
|         (contentType != null && contentType.indexOf('javascript') === -1) | ||||
|       ) { | ||||
|         // No service worker found. Probably a different app. Reload the page. | ||||
|         navigator.serviceWorker.ready.then(registration => { | ||||
|           registration.unregister().then(() => { | ||||
|             window.location.reload(); | ||||
|           }); | ||||
|         }); | ||||
|       } else { | ||||
|         // Service worker found. Proceed as normal. | ||||
|         registerValidSW(swUrl, config); | ||||
|       } | ||||
|     }) | ||||
|     .catch(() => { | ||||
|       console.log( | ||||
|         'No internet connection found. App is running in offline mode.' | ||||
|       ); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export function unregister() { | ||||
|   if ('serviceWorker' in navigator) { | ||||
|     navigator.serviceWorker.ready.then(registration => { | ||||
|       registration.unregister(); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										30
									
								
								interface/src/system/OTASettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/system/OTASettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { OTA_SETTINGS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import OTASettingsForm from './OTASettingsForm'; | ||||
| import { OTASettings } from './types'; | ||||
|  | ||||
| type OTASettingsControllerProps = RestControllerProps<OTASettings>; | ||||
|  | ||||
| class OTASettingsController extends Component<OTASettingsControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="OTA Settings" titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <OTASettingsForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(OTA_SETTINGS_ENDPOINT, OTASettingsController); | ||||
							
								
								
									
										69
									
								
								interface/src/system/OTASettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								interface/src/system/OTASettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import React from 'react'; | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Checkbox } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { RestFormProps, BlockFormControlLabel, PasswordValidator, FormButton, FormActions } from '../components'; | ||||
| import {isIP,isHostname,or}  from '../validators'; | ||||
|  | ||||
| import { OTASettings } from './types'; | ||||
|  | ||||
| type OTASettingsFormProps = RestFormProps<OTASettings>; | ||||
|  | ||||
| class OTASettingsForm extends React.Component<OTASettingsFormProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, handleValueChange, handleCheckboxChange, saveData, loadData } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={saveData}> | ||||
|         <BlockFormControlLabel | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               checked={data.enabled} | ||||
|               onChange={handleCheckboxChange("enabled")} | ||||
|             /> | ||||
|           } | ||||
|           label="Enable OTA Updates?" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isNumber', 'minNumber:1025', 'maxNumber:65535']} | ||||
|           errorMessages={['Port is required', "Must be a number", "Must be greater than 1024 ", "Max value is 65535"]} | ||||
|           name="port" | ||||
|           label="Port" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.port} | ||||
|           type="number" | ||||
|           onChange={handleValueChange('port')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <PasswordValidator | ||||
|           validators={['required', 'matchRegexp:^.{1,64}$']} | ||||
|           errorMessages={['OTA Password is required', 'OTA Point Password must be 64 characters or less']} | ||||
|           name="password" | ||||
|           label="Password" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.password} | ||||
|           onChange={handleValueChange('password')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|             Save | ||||
|           </FormButton> | ||||
|           <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|             Reset | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default OTASettingsForm; | ||||
							
								
								
									
										38
									
								
								interface/src/system/System.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								interface/src/system/System.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' | ||||
|  | ||||
| import { Tabs, Tab } from '@material-ui/core'; | ||||
|  | ||||
| import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; | ||||
| import { MenuAppBar } from '../components'; | ||||
|  | ||||
| import SystemStatusController from './SystemStatusController'; | ||||
| import OTASettingsController from './OTASettingsController'; | ||||
|  | ||||
| type SystemProps = AuthenticatedContextProps & RouteComponentProps; | ||||
|  | ||||
| class System extends Component<SystemProps> { | ||||
|  | ||||
|   handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticatedContext } = 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} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact={true} path="/system/status" component={SystemStatusController} /> | ||||
|           <AuthenticatedRoute exact={true} path="/system/ota" component={OTASettingsController} /> | ||||
|           <Redirect to="/system/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticatedContext(System); | ||||
							
								
								
									
										30
									
								
								interface/src/system/SystemStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/system/SystemStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { SYSTEM_STATUS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import SystemStatusForm from './SystemStatusForm'; | ||||
| import { SystemStatus } from './types'; | ||||
|  | ||||
| type SystemStatusControllerProps = RestControllerProps<SystemStatus>; | ||||
|  | ||||
| class SystemStatusController extends Component<SystemStatusControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="System Status"> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <SystemStatusForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(SYSTEM_STATUS_ENDPOINT, SystemStatusController); | ||||
| @@ -1,18 +1,7 @@ | ||||
| import React, { Component, Fragment } from 'react'; | ||||
| import { withSnackbar } from 'notistack'; | ||||
| 
 | ||||
| import { withStyles } from '@material-ui/core/styles'; | ||||
| import Button from '@material-ui/core/Button'; | ||||
| import List from '@material-ui/core/List'; | ||||
| import ListItem from '@material-ui/core/ListItem'; | ||||
| import ListItemAvatar from '@material-ui/core/ListItemAvatar'; | ||||
| import ListItemText from '@material-ui/core/ListItemText'; | ||||
| import Avatar from '@material-ui/core/Avatar'; | ||||
| import Divider from '@material-ui/core/Divider'; | ||||
| import Dialog from '@material-ui/core/Dialog'; | ||||
| import DialogActions from '@material-ui/core/DialogActions'; | ||||
| import DialogTitle from '@material-ui/core/DialogTitle'; | ||||
| import DialogContent from '@material-ui/core/DialogContent'; | ||||
| import { Avatar, Button, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core'; | ||||
| import { List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; | ||||
| 
 | ||||
| import DevicesIcon from '@material-ui/icons/Devices'; | ||||
| import MemoryIcon from '@material-ui/icons/Memory'; | ||||
| @@ -22,36 +11,28 @@ import DataUsageIcon from '@material-ui/icons/DataUsage'; | ||||
| import AutorenewIcon from '@material-ui/icons/Autorenew'; | ||||
| import RefreshIcon from '@material-ui/icons/Refresh'; | ||||
| 
 | ||||
| import { SYSTEM_STATUS_ENDPOINT, RESTART_ENDPOINT } from '../constants/Endpoints'; | ||||
| import { restComponent } from '../components/RestComponent'; | ||||
| import LoadingNotification from '../components/LoadingNotification'; | ||||
| import SectionContent from '../components/SectionContent'; | ||||
| import { redirectingAuthorizedFetch } from '../authentication/Authentication'; | ||||
| import { redirectingAuthorizedFetch } from '../authentication'; | ||||
| import { RestFormProps, FormButton, FormActions } from '../components'; | ||||
| import { RESTART_ENDPOINT } from '../api'; | ||||
| 
 | ||||
| const styles = theme => ({ | ||||
|   button: { | ||||
|     marginRight: theme.spacing(2), | ||||
|     marginTop: theme.spacing(2), | ||||
|   } | ||||
| }); | ||||
| import { SystemStatus } from './types'; | ||||
| 
 | ||||
| class SystemStatus extends Component { | ||||
| interface SystemStatusFormState { | ||||
|   confirmRestart: boolean; | ||||
|   processing: boolean; | ||||
| } | ||||
| 
 | ||||
| type SystemStatusFormProps = RestFormProps<SystemStatus>; | ||||
| 
 | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
| class SystemStatusForm extends Component<SystemStatusFormProps, SystemStatusFormState> { | ||||
| 
 | ||||
|     this.state = { | ||||
|   state: SystemStatusFormState = { | ||||
|     confirmRestart: false, | ||||
|     processing: false | ||||
|   } | ||||
|   } | ||||
| 
 | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
| 
 | ||||
|   createListItems(data, classes) { | ||||
|   createListItems() { | ||||
|     const { data } = this.props | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <ListItem > | ||||
| @@ -103,20 +84,26 @@ class SystemStatus extends Component { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   renderSystemStatus(data, classes) { | ||||
|   renderRestartDialog() { | ||||
|     return ( | ||||
|       <div> | ||||
|         <List> | ||||
|           {this.createListItems(data, classes)} | ||||
|         </List> | ||||
|         <Button startIcon={<RefreshIcon />} variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}> | ||||
|           Refresh | ||||
|         </Button> | ||||
|         <Button startIcon={<AutorenewIcon />} variant="contained" color="secondary" className={classes.button} onClick={this.onRestart}> | ||||
|       <Dialog | ||||
|         open={this.state.confirmRestart} | ||||
|         onClose={this.onRestartRejected} | ||||
|       > | ||||
|         <DialogTitle>Confirm Restart</DialogTitle> | ||||
|         <DialogContent dividers={true}> | ||||
|           Are you sure you want to restart the device? | ||||
|         </DialogContent> | ||||
|         <DialogActions> | ||||
|           <Button startIcon={<AutorenewIcon />} variant="contained" onClick={this.onRestartConfirmed} disabled={this.state.processing} color="primary" autoFocus> | ||||
|             Restart | ||||
|           </Button> | ||||
|       </div> | ||||
|     ); | ||||
|           <Button variant="contained" onClick={this.onRestartRejected} color="secondary"> | ||||
|             Cancel | ||||
|           </Button> | ||||
|         </DialogActions> | ||||
|       </Dialog> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   onRestart = () => { | ||||
| @@ -144,45 +131,25 @@ class SystemStatus extends Component { | ||||
|       }); | ||||
|   } | ||||
| 
 | ||||
|   renderRestartDialog() { | ||||
|     return ( | ||||
|       <Dialog | ||||
|         open={this.state.confirmRestart} | ||||
|         onClose={this.onRestartRejected} | ||||
|       > | ||||
|         <DialogTitle>Confirm Restart</DialogTitle> | ||||
|         <DialogContent dividers={true}> | ||||
|           Are you sure you want to restart the device? | ||||
|         </DialogContent> | ||||
|         <DialogActions> | ||||
|           <Button startIcon={<AutorenewIcon />} variant="contained" onClick={this.onRestartConfirmed} disabled={this.state.processing} color="primary" autoFocus> | ||||
|             Restart | ||||
|           </Button> | ||||
|           <Button variant="contained" onClick={this.onRestartRejected} color="secondary"> | ||||
|             Cancel | ||||
|           </Button> | ||||
|         </DialogActions> | ||||
|       </Dialog> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   render() { | ||||
|     const { data, fetched, errorMessage, loadData, classes } = this.props; | ||||
|     return ( | ||||
|       <SectionContent title="System Status"> | ||||
|         <LoadingNotification | ||||
|           onRestart={loadData} | ||||
|           fetched={fetched} | ||||
|           errorMessage={errorMessage} | ||||
|           render={ | ||||
|             () => this.renderSystemStatus(data, classes) | ||||
|           } | ||||
|         /> | ||||
|       <Fragment> | ||||
|         <List> | ||||
|           {this.createListItems()} | ||||
|         </List> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> | ||||
|             Refresh | ||||
|           </FormButton> | ||||
|           <FormButton startIcon={<AutorenewIcon />} variant="contained" color="primary" onClick={this.onRestart}> | ||||
|             Restart | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|         {this.renderRestartDialog()} | ||||
|       </SectionContent> | ||||
|     ) | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default withSnackbar(restComponent(SYSTEM_STATUS_ENDPOINT, withStyles(styles)(SystemStatus))); | ||||
| export default SystemStatusForm; | ||||
							
								
								
									
										14
									
								
								interface/src/system/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								interface/src/system/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| export interface SystemStatus { | ||||
|   esp_platform: string; | ||||
|   cpu_freq_mhz: number; | ||||
|   free_heap: number; | ||||
|   sketch_size: number; | ||||
|   free_sketch_space: number; | ||||
|   flash_chip_size: number; | ||||
| } | ||||
|  | ||||
| export interface OTASettings { | ||||
|   enabled: boolean; | ||||
|   port: number; | ||||
|   password: string; | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user