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:
rjwats 2020-02-09 10:21:13 +00:00 committed by GitHub
parent ea6aa78d60
commit 260e9a18d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
121 changed files with 7450 additions and 5963 deletions

View File

@ -213,21 +213,36 @@ The framework, and MaterialUI allows for a reasonable degree of customization wi
### Theming the app ### 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 ```js
const theme = createMuiTheme({ const theme = createMuiTheme({
palette: { palette: {
primary: red, type:"dark",
secondary: deepOrange, primary: {
highlight_idle: blueGrey[900], main: '#222',
highlight_warn: orange[500], },
highlight_error: red[500], secondary: {
highlight_success: green[500], main: '#666',
}, },
info: {
main: blueGrey[900]
},
warning: {
main: orange[500]
},
error: {
main: red[500]
},
success: {
main: green[500]
}
}
}); });
``` ```
![Dark Theme](/media/dark.png?raw=true "Dark Theme")
### Changing the app icon ### 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. 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: Configure the SSID and password:
```cpp ```cpp
WiFiSettings wifiSettings = esp8266React->getWiFiSettingsService()->fetch(); WiFiSettings wifiSettings = esp8266React.getWiFiSettingsService()->fetch();
wifiSettings.ssid = "MyNetworkSSID"; wifiSettings.ssid = "MyNetworkSSID";
wifiSettings.password = "MySuperSecretPassword"; wifiSettings.password = "MySuperSecretPassword";
esp8266React.getWiFiSettingsService()->update(wifiSettings); esp8266React.getWiFiSettingsService()->update(wifiSettings);

View File

@ -1,3 +1,3 @@
# Change the IP address to that of your ESP device to enable local development of the UI. # 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. # 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/

File diff suppressed because it is too large Load Diff

View File

@ -3,37 +3,51 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@material-ui/core": "^4.7.0", "@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.5.1", "@material-ui/icons": "^4.9.1",
"compression-webpack-plugin": "^2.0.0", "@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", "jwt-decode": "^2.2.0",
"mime-types": "^2.1.25", "mime-types": "^2.1.25",
"moment": "^2.24.0", "moment": "^2.24.0",
"notistack": "^0.9.6", "notistack": "^0.9.7",
"prop-types": "^15.7.2", "react": "^16.12.0",
"react": "^16.10.1", "react-dom": "^16.12.0",
"react-dom": "^16.10.1",
"react-form-validator-core": "^0.6.4", "react-form-validator-core": "^0.6.4",
"react-jss": "^10.0.0", "react-material-ui-form-validator": "^2.0.10",
"react-material-ui-form-validator": "^2.0.9", "react-router": "^5.1.2",
"react-router": "^5.1.1", "react-router-dom": "^5.1.2",
"react-router-dom": "^5.1.1", "react-scripts": "3.3.1",
"react-scripts": "3.0.1", "typescript": "^3.7.5",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"scripts": { "scripts": {
"start": "react-app-rewired start", "start": "react-app-rewired start",
"build": "react-app-rewired build", "build": "react-app-rewired build",
"test": "react-app-rewired test --env=jsdom",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"devDependencies": { "eslintConfig": {
"react-app-rewired": "^2.1.3" "extends": "react-app"
}, },
"browserslist": [ "browserslist": {
">0.2%", "production": [
"not dead", ">0.2%",
"not ie <= 11", "not dead",
"not op_mini all" "not op_mini all"
] ],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-app-rewired": "^2.1.5"
}
} }

View File

@ -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
View 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

View File

@ -1,23 +1,24 @@
import React, { Component } from 'react'; 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 * as Authentication from './authentication/Authentication';
import AuthenticationWrapper from './authentication/AuthenticationWrapper'; import AuthenticationWrapper from './authentication/AuthenticationWrapper';
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute'; import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
import SignInPage from './containers/SignInPage'; import AuthenticatedRoute from './authentication/AuthenticatedRoute';
import WiFiConnection from './sections/WiFiConnection';
import AccessPoint from './sections/AccessPoint'; import SignIn from './SignIn';
import NetworkTime from './sections/NetworkTime';
import Security from './sections/Security';
import System from './sections/System';
import ProjectRouting from './project/ProjectRouting'; 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 { class AppRouting extends Component {
componentWillMount() { componentDidMount() {
Authentication.clearLoginRedirect(); Authentication.clearLoginRedirect();
} }
@ -25,13 +26,13 @@ class AppRouting extends Component {
return ( return (
<AuthenticationWrapper> <AuthenticationWrapper>
<Switch> <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="/wifi/*" component={WiFiConnection} />
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
<AuthenticatedRoute exact path="/security/*" component={Security} /> <AuthenticatedRoute exact path="/security/*" component={Security} />
<AuthenticatedRoute exact path="/system/*" component={System} /> <AuthenticatedRoute exact path="/system/*" component={System} />
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} />
<Redirect to="/" /> <Redirect to="/" />
</Switch> </Switch>
</AuthenticationWrapper> </AuthenticationWrapper>

View 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>
);
}
}

View File

@ -1,54 +1,55 @@
import React, { Component } from 'react'; 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 { 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 => { import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles';
return { import { Paper, Typography, Fab } from '@material-ui/core';
loginPage: { import ForwardIcon from '@material-ui/icons/Forward';
display: "flex",
height: "100vh", import { withAuthenticationContext, AuthenticationContextProps } from './authentication/AuthenticationContext';
margin: "auto", import {PasswordValidator} from './components';
padding: theme.spacing(2), import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
justifyContent: "center",
flexDirection: "column", const styles = (theme: Theme) => createStyles({
maxWidth: theme.breakpoints.values.sm loginPage: {
}, display: "flex",
loginPanel: { height: "100vh",
textAlign: "center", margin: "auto",
padding: theme.spacing(2), padding: theme.spacing(2),
paddingTop: "200px", justifyContent: "center",
backgroundImage: 'url("/app/icon.png")', flexDirection: "column",
backgroundRepeat: "no-repeat", maxWidth: theme.breakpoints.values.sm
backgroundPosition: "50% " + theme.spacing(2) + "px", },
backgroundSize: "auto 150px", loginPanel: {
width: "100%" textAlign: "center",
}, padding: theme.spacing(2),
extendedIcon: { paddingTop: "200px",
marginRight: theme.spacing(0.5), backgroundImage: 'url("/app/icon.png")',
}, backgroundRepeat: "no-repeat",
textField: { backgroundPosition: "50% " + theme.spacing(2) + "px",
width: "100%" backgroundSize: "auto 150px",
}, width: "100%"
button: { },
marginRight: theme.spacing(2), extendedIcon: {
marginTop: theme.spacing(2), marginRight: theme.spacing(0.5),
} },
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: SignInPageProps) {
constructor(props) {
super(props); super(props);
this.state = { this.state = {
username: '', username: '',
@ -57,8 +58,12 @@ class SignInPage extends Component {
}; };
} }
handleValueChange = name => event => { updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ [name]: event.target.value }); const { name, value } = event.currentTarget;
this.setState(prevState => ({
...prevState,
[name]: value,
}))
}; };
onSubmit = () => { onSubmit = () => {
@ -105,9 +110,10 @@ class SignInPage extends Component {
errorMessages={['Username is required']} errorMessages={['Username is required']}
name="username" name="username"
label="Username" label="Username"
className={classes.textField} fullWidth
variant="outlined"
value={username} value={username}
onChange={this.handleValueChange('username')} onChange={this.updateInputElement}
margin="normal" margin="normal"
/> />
<PasswordValidator <PasswordValidator
@ -116,9 +122,10 @@ class SignInPage extends Component {
errorMessages={['Password is required']} errorMessages={['Password is required']}
name="password" name="password"
label="Password" label="Password"
className={classes.textField} fullWidth
variant="outlined"
value={password} value={password}
onChange={this.handleValueChange('password')} onChange={this.updateInputElement}
margin="normal" margin="normal"
/> />
<Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}> <Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}>
@ -133,6 +140,4 @@ class SignInPage extends Component {
} }
export default withAuthenticationContext( export default withAuthenticationContext(withSnackbar(withStyles(styles)(SignInPage)));
withSnackbar(withStyles(styles)(SignInPage))
);

View 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;

View 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);

View 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;

View 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";
};

View 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);

View 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);

View 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
View 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;
}

View File

@ -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_STATUS_ENDPOINT = ENDPOINT_ROOT + "ntpStatus";
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "ntpSettings"; export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "ntpSettings";

3
interface/src/api/Env.ts Normal file
View 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!;

View File

@ -0,0 +1,2 @@
export * from './Env'
export * from './Endpoints'

View File

@ -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));

View 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));

View File

@ -1,11 +1,13 @@
import * as H from 'history';
import history 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 ACCESS_TOKEN = 'access_token';
export const LOGIN_PATHNAME = 'loginPathname'; export const LOGIN_PATHNAME = 'loginPathname';
export const LOGIN_SEARCH = 'loginSearch'; export const LOGIN_SEARCH = 'loginSearch';
export function storeLoginRedirect(location) { export function storeLoginRedirect(location?: H.Location) {
if (location) { if (location) {
localStorage.setItem(LOGIN_PATHNAME, location.pathname); localStorage.setItem(LOGIN_PATHNAME, location.pathname);
localStorage.setItem(LOGIN_SEARCH, location.search); localStorage.setItem(LOGIN_SEARCH, location.search);
@ -17,7 +19,7 @@ export function clearLoginRedirect() {
localStorage.removeItem(LOGIN_SEARCH); localStorage.removeItem(LOGIN_SEARCH);
} }
export function fetchLoginRedirect() { export function fetchLoginRedirect(): H.LocationDescriptorObject {
const loginPathname = localStorage.getItem(LOGIN_PATHNAME); const loginPathname = localStorage.getItem(LOGIN_PATHNAME);
const loginSearch = localStorage.getItem(LOGIN_SEARCH); const loginSearch = localStorage.getItem(LOGIN_SEARCH);
clearLoginRedirect(); clearLoginRedirect();
@ -30,13 +32,15 @@ export function fetchLoginRedirect() {
/** /**
* Wraps the normal fetch routene with one with provides the access token if present. * 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); const accessToken = localStorage.getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
params = params || {}; params = params || {};
params.credentials = 'include'; params.credentials = 'include';
params.headers = params.headers || {}; params.headers = {
params.headers.Authorization = 'Bearer ' + accessToken; ...params.headers,
"Authorization": 'Bearer ' + accessToken
};
} }
return fetch(url, params); return fetch(url, params);
} }
@ -44,8 +48,8 @@ export function authorizedFetch(url, params) {
/** /**
* Wraps the normal fetch routene which redirects on 401 response. * Wraps the normal fetch routene which redirects on 401 response.
*/ */
export function redirectingAuthorizedFetch(url, params) { export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> {
return new Promise(function (resolve, reject) { return new Promise<Response>((resolve, reject) => {
authorizedFetch(url, params).then(response => { authorizedFetch(url, params).then(response => {
if (response.status === 401) { if (response.status === 401) {
history.push("/unauthorized"); history.push("/unauthorized");

View 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>
);
}
};
}

View File

@ -1,15 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import history from '../history' import { withSnackbar, WithSnackbarProps } from 'notistack';
import { withSnackbar } from 'notistack';
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../constants/Endpoints';
import { ACCESS_TOKEN, authorizedFetch } from './Authentication';
import { AuthenticationContext } from './Context';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
import CircularProgress from '@material-ui/core/CircularProgress'; import CircularProgress from '@material-ui/core/CircularProgress';
import Typography from '@material-ui/core/Typography'; 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: { loadingPanel: {
padding: theme.spacing(2), padding: theme.spacing(2),
display: "flex", 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); super(props);
this.state = { this.state = {
context: { context: {
refresh: this.refresh, refresh: this.refresh,
signIn: this.signIn, signIn: this.signIn,
signOut: this.signOut, signOut: this.signOut,
isAuthenticated: this.isAuthenticated,
isAdmin: this.isAdmin
}, },
initialized: false initialized: false
}; };
@ -72,33 +81,31 @@ class AuthenticationWrapper extends React.Component {
} }
refresh = () => { refresh = () => {
var accessToken = localStorage.getItem(ACCESS_TOKEN); const accessToken = localStorage.getItem(ACCESS_TOKEN)
if (accessToken) { if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT) authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
.then(response => { .then(response => {
const user = response.status === 200 ? jwtDecode(accessToken) : undefined; const me = response.status === 200 ? decodeMeJWT(accessToken) : undefined;
this.setState({ initialized: true, context: { ...this.state.context, user } }); this.setState({ initialized: true, context: { ...this.state.context, me } });
}).catch(error => { }).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, { this.props.enqueueSnackbar("Error verifying authorization: " + error.message, {
variant: 'error', variant: 'error',
}); });
}); });
} else { } 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 { try {
localStorage.setItem(ACCESS_TOKEN, accessToken); localStorage.setItem(ACCESS_TOKEN, accessToken);
const user = jwtDecode(accessToken); const me: Me = decodeMeJWT(accessToken);
this.setState({ context: { ...this.state.context, user } }); this.setState({ context: { ...this.state.context, me } });
this.props.enqueueSnackbar(`Logged in as ${user.username}`, { this.props.enqueueSnackbar(`Logged in as ${me.username}`, { variant: 'success' });
variant: 'success',
});
} catch (err) { } 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); throw new Error("Failed to parse JWT " + err.message);
} }
} }
@ -108,24 +115,13 @@ class AuthenticationWrapper extends React.Component {
this.setState({ this.setState({
context: { context: {
...this.state.context, ...this.state.context,
user: undefined me: undefined
} }
}); });
this.props.enqueueSnackbar("You have signed out.", { this.props.enqueueSnackbar("You have signed out.", { variant: 'success', });
variant: 'success',
});
history.push('/'); 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)) export default withStyles(styles)(withSnackbar(AuthenticationWrapper))

View File

@ -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>
);
};
}

View File

@ -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);

View 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);

View 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';

View 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;

View File

@ -0,0 +1,7 @@
import { styled, Box } from "@material-ui/core";
const FormActions = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(1)
}));
export default FormActions;

View 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;

View 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;

View File

@ -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
};

View File

@ -1,42 +1,28 @@
import React from 'react'; import React, { RefObject } from 'react';
import PropTypes from 'prop-types'; import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { Link, withRouter } 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 WifiIcon from '@material-ui/icons/Wifi';
import SettingsIcon from '@material-ui/icons/Settings'; import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime'; import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle'; import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import LockIcon from '@material-ui/icons/Lock'; import LockIcon from '@material-ui/icons/Lock';
import ClickAwayListener from '@material-ui/core/ClickAwayListener'; import MenuIcon from '@material-ui/icons/Menu';
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 ProjectMenu from '../project/ProjectMenu'; import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../constants/Env'; import { PROJECT_NAME } from '../api';
import { withAuthenticationContext } from '../authentication/Context.js'; import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
const drawerWidth = 290; const drawerWidth = 290;
const styles = theme => ({ const styles = (theme: Theme) => createStyles({
root: { root: {
display: 'flex', display: 'flex',
}, },
@ -77,26 +63,38 @@ const styles = theme => ({
"& > * + *": { "& > * + *": {
marginLeft: theme.spacing(2), marginLeft: theme.spacing(2),
} }
}, }
}); });
class MenuAppBar extends React.Component { interface MenuAppBarState {
state = { mobileOpen: boolean;
mobileOpen: false, authMenuOpen: boolean;
authMenuOpen: false }
};
anchorRef = React.createRef(); 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: RefObject<HTMLButtonElement> = React.createRef();
handleToggle = () => { handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen }); this.setState({ authMenuOpen: !this.state.authMenuOpen });
} }
handleClose = (event) => { handleClose = (event: React.MouseEvent<Document>) => {
if (this.anchorRef.current && this.anchorRef.current.contains(event.target)) { if (this.anchorRef.current && this.anchorRef.current.contains(event.currentTarget)) {
return; return;
} }
this.setState({ authMenuOpen: false }); this.setState({ authMenuOpen: false });
} }
@ -105,13 +103,13 @@ class MenuAppBar extends React.Component {
}; };
render() { render() {
const { classes, theme, children, sectionTitle, authenticationContext } = this.props; const { classes, theme, children, sectionTitle, authenticatedContext } = this.props;
const { mobileOpen, authMenuOpen } = this.state; const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url; const path = this.props.match.url;
const drawer = ( const drawer = (
<div> <div>
<Toolbar> <Toolbar>
<Typography variant="h6" color="primary"> <Typography variant="h6" color="textPrimary">
{PROJECT_NAME} {PROJECT_NAME}
</Typography> </Typography>
<Divider absolute /> <Divider absolute />
@ -138,7 +136,7 @@ class MenuAppBar extends React.Component {
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Network Time" /> <ListItemText primary="Network Time" />
</ListItem> </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> <ListItemIcon>
<LockIcon /> <LockIcon />
</ListItemIcon> </ListItemIcon>
@ -156,7 +154,7 @@ class MenuAppBar extends React.Component {
return ( return (
<div className={classes.root}> <div className={classes.root}>
<AppBar position="fixed" className={classes.appBar}> <AppBar position="fixed" className={classes.appBar} elevation={0}>
<Toolbar> <Toolbar>
<IconButton <IconButton
color="inherit" color="inherit"
@ -191,13 +189,13 @@ class MenuAppBar extends React.Component {
<AccountCircleIcon /> <AccountCircleIcon />
</Avatar> </Avatar>
</ListItemAvatar> </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> </ListItem>
</List> </List>
</CardContent> </CardContent>
<Divider /> <Divider />
<CardActions className={classes.authMenuActions}> <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> </CardActions>
</Card> </Card>
</ClickAwayListener> </ClickAwayListener>
@ -243,14 +241,10 @@ class MenuAppBar extends React.Component {
} }
} }
MenuAppBar.propTypes = { export default withRouter(
classes: PropTypes.object.isRequired, withTheme(
theme: PropTypes.object.isRequired, withAuthenticatedContext(
sectionTitle: PropTypes.string.isRequired, withStyles(styles)(MenuAppBar)
}; )
export default withAuthenticationContext(
withRouter(
withStyles(styles, { withTheme: true })(MenuAppBar)
) )
); );

View File

@ -1,21 +1,25 @@
import React from 'react'; import React from 'react';
import { TextValidator } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorComponentProps } 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';
const styles = theme => ( import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
{ import { InputAdornment, IconButton } from '@material-ui/core';
input: { import {Visibility,VisibilityOff } from '@material-ui/icons';
"&::-ms-reveal": {
display: "none" 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 = { state = {
showPassword: false showPassword: false

View File

@ -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}
/>;
}
}
);
}

View 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}
/>;
}
});
}

View 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 });
}

View File

@ -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);

View 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;

View 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';

View File

@ -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;

View File

@ -1,4 +0,0 @@
export const IDLE = "idle";
export const SUCCESS = "success";
export const ERROR = "error";
export const WARN = "warn";

View File

@ -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";
}
}

View File

@ -1,3 +0,0 @@
import moment from 'moment';
export const formatIsoDateTime = isoDateString => moment.parseZone(isoDateString).format('ll @ HH:mm:ss');

View File

@ -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;

View File

@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -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);

View 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);

View 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;

View 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";
}
}

View 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);

View 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);

View 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)

View File

@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import MenuItem from '@material-ui/core/MenuItem'; 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/Abidjan": "GMT0",
"Africa/Accra": "GMT0", "Africa/Accra": "GMT0",
"Africa/Addis_Ababa": "EAT-3", "Africa/Addis_Ababa": "EAT-3",
@ -464,7 +468,7 @@ export const TIME_ZONES = {
"Etc/Zulu": "UTC0" "Etc/Zulu": "UTC0"
} }
export function selectedTimeZone(label, format){ export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }

View File

@ -0,0 +1,3 @@
import moment from 'moment';
export const formatIsoDateTime = (isoDateString: string) => moment.parseZone(isoDateString).format('ll @ HH:mm:ss');

View 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;
}

View File

@ -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);

View 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>
);
}

View File

@ -1,27 +1,12 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Typography, TableRow, TableBody, TableCell, TableHead, Table, Box } from '@material-ui/core';
import { withStyles } from '@material-ui/core/styles'; import { SectionContent } from '../components';
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)
}
});
class DemoInformation extends Component { class DemoInformation extends Component {
render() { render() {
const { classes } = this.props;
return ( return (
<SectionContent title="Demo Project - Blink Speed Controller" titleGutter> <SectionContent title='Demo Information' titleGutter>
<Typography variant="body1" paragraph> <Typography variant="body1" paragraph>
This simple demo project allows you to control the blink speed of the built-in LED. 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. 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> <Typography variant="body1" paragraph>
The demo project interface code stored in the interface/project directory: The demo project interface code stored in the interface/project directory:
</Typography> </Typography>
<Table className={classes.fileTable}> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell> <TableCell>
@ -48,7 +33,7 @@ class DemoInformation extends Component {
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell> <TableCell>
ProjectMenu.js ProjectMenu.tsx
</TableCell> </TableCell>
<TableCell> <TableCell>
You can add your project's screens to the side bar here. You can add your project's screens to the side bar here.
@ -56,7 +41,7 @@ class DemoInformation extends Component {
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
ProjectRouting.js ProjectRouting.tsx
</TableCell> </TableCell>
<TableCell> <TableCell>
The routing which controls the screens of your project. The routing which controls the screens of your project.
@ -64,7 +49,7 @@ class DemoInformation extends Component {
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
DemoProject.js DemoProject.tsx
</TableCell> </TableCell>
<TableCell> <TableCell>
This screen, with tabs and tab routing. This screen, with tabs and tab routing.
@ -72,15 +57,15 @@ class DemoInformation extends Component {
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
DemoInformation.js DemoInformation.tsx
</TableCell> </TableCell>
<TableCell> <TableCell>
The demo information tab. The demo information page.
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
DemoController.js DemoController.tsx
</TableCell> </TableCell>
<TableCell> <TableCell>
The demo controller tab, to control the built-in LED. The demo controller tab, to control the built-in LED.
@ -88,13 +73,15 @@ class DemoInformation extends Component {
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<Typography variant="body1" paragraph> <Box mt={2}>
See the project <a href="https://github.com/rjwats/esp8266-react/">README</a> for a full description of the demo project. <Typography variant="body1">
</Typography> See the project <a href="https://github.com/rjwats/esp8266-react/">README</a> for a full description of the demo project.
</Typography>
</Box>
</SectionContent> </SectionContent>
) )
} }
} }
export default withStyles(styles)(DemoInformation); export default DemoInformation;

View File

@ -1,32 +1,32 @@
import React, { Component } from 'react'; 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 DemoInformation from './DemoInformation';
import DemoController from './DemoController'; import DemoController from './DemoController';
import Tabs from '@material-ui/core/Tabs'; class DemoProject extends Component<RouteComponentProps> {
import Tab from '@material-ui/core/Tab';
class DemoProject extends Component { handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
handleTabChange = (event, path) => {
this.props.history.push(path); this.props.history.push(path);
}; };
render() { render() {
return ( return (
<MenuAppBar sectionTitle="Demo Project"> <MenuAppBar sectionTitle="Demo Project">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" variant="fullWidth"> <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
<Tab value={`/${PROJECT_PATH}/demo/information`} label="Information" /> <Tab value={`/${PROJECT_PATH}/demo/information`} label="Demo Information" />
<Tab value={`/${PROJECT_PATH}/demo/controller`} label="Controller" /> <Tab value={`/${PROJECT_PATH}/demo/controller`} label="Demo Controller" />
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/information`} component={DemoInformation} /> <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/information`} component={DemoInformation} />
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/controller`} component={DemoController} /> <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/controller`} component={DemoController} />
<Redirect to={`/${PROJECT_PATH}/demo/information`} /> <Redirect to={`/${PROJECT_PATH}/demo/information`} />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
) )

View File

@ -1,15 +1,12 @@
import React, { Component } from 'react'; 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, ListItem, ListItemIcon, ListItemText} from '@material-ui/core';
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 SettingsRemoteIcon from '@material-ui/icons/SettingsRemote'; import SettingsRemoteIcon from '@material-ui/icons/SettingsRemote';
class ProjectMenu extends Component { import { PROJECT_PATH } from '../api';
class ProjectMenu extends Component<RouteComponentProps> {
render() { render() {
const path = this.props.match.url; const path = this.props.match.url;

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Redirect, Switch } from 'react-router'; import { Redirect, Switch } from 'react-router';
import { PROJECT_PATH } from '../constants/Env'; import { PROJECT_PATH } from '../api';
import AuthenticatedRoute from '../authentication/AuthenticatedRoute'; import { AuthenticatedRoute } from '../authentication';
import DemoProject from './DemoProject'; import DemoProject from './DemoProject';
class ProjectRouting extends Component { class ProjectRouting extends Component {

1
interface/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -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);

View File

@ -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)

View File

@ -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;

View File

@ -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);

View File

@ -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);

View 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);

View File

@ -1,18 +1,9 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { ValidatorForm } from 'react-material-ui-form-validator'; import { ValidatorForm } from 'react-material-ui-form-validator';
import { withStyles } from '@material-ui/core/styles'; import { Table, TableBody, TableCell, TableHead, TableFooter, TableRow } from '@material-ui/core';
import Button from '@material-ui/core/Button'; import { Box, Button, Typography, } from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableFooter from '@material-ui/core/TableFooter';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Box from '@material-ui/core/Box';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import CloseIcon from '@material-ui/icons/Close'; 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 SaveIcon from '@material-ui/icons/Save';
import PersonAddIcon from '@material-ui/icons/PersonAdd'; import PersonAddIcon from '@material-ui/icons/PersonAdd';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
import { RestFormProps, FormActions, FormButton } from '../components';
import UserForm from './UserForm'; import UserForm from './UserForm';
import { withAuthenticationContext } from '../authentication/Context'; import { SecuritySettings, User } from './types';
const styles = theme => ({ function compareUsers(a: User, b: User) {
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
},
table: {
'& td, & th': { padding: theme.spacing(0.5) }
}
});
function compareUsers(a, b) {
if (a.username < b.username) { if (a.username < b.username) {
return -1; return -1;
} }
@ -44,12 +28,18 @@ function compareUsers(a, b) {
return 0; return 0;
} }
class ManageUsersForm extends React.Component { type ManageUsersFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps;
constructor(props) { type ManageUsersFormState = {
super(props); creating: boolean;
this.state = {}; user?: User;
} }
class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersFormState> {
state: ManageUsersFormState = {
creating: false
};
createUser = () => { createUser = () => {
this.setState({ this.setState({
@ -62,21 +52,21 @@ class ManageUsersForm extends React.Component {
}); });
}; };
uniqueUsername = username => { uniqueUsername = (username: string) => {
return !this.props.userData.users.find(u => u.username === username); return !this.props.data.users.find(u => u.username === username);
} }
noAdminConfigured = () => { noAdminConfigured = () => {
return !this.props.userData.users.find(u => u.admin); return !this.props.data.users.find(u => u.admin);
} }
removeUser = user => { removeUser = (user: User) => {
const { userData } = this.props; const { data } = this.props;
const users = userData.users.filter(u => u.username !== user.username); const users = data.users.filter(u => u.username !== user.username);
this.props.setData({ ...userData, users }); this.props.setData({ ...data, users });
} }
startEditingUser = user => { startEditingUser = (user: User) => {
this.setState({ this.setState({
creating: false, creating: false,
user user
@ -91,45 +81,37 @@ class ManageUsersForm extends React.Component {
doneEditingUser = () => { doneEditingUser = () => {
const { user } = this.state; const { user } = this.state;
const { userData } = this.props; if (user) {
const users = userData.users.filter(u => u.username !== user.username); const { data } = this.props;
users.push(user); const users = data.users.filter(u => u.username !== user.username);
this.props.setData({ ...userData, users }); users.push(user);
this.setState({ this.props.setData({ ...data, users });
user: undefined this.setState({
}); user: undefined
});
}
}; };
handleUserValueChange = name => event => { handleUserValueChange = (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => {
const { user } = this.state; this.setState({ user: { ...this.state.user!, [name]: event.target.value } });
this.setState({
user: {
...user, [name]: event.target.value
}
});
}; };
handleUserCheckboxChange = name => event => { handleUserCheckboxChange = (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => {
const { user } = this.state; this.setState({ user: { ...this.state.user!, [name]: event.target.checked } });
this.setState({
user: {
...user, [name]: event.target.checked
}
});
} }
onSubmit = () => { onSubmit = () => {
this.props.onSubmit(); this.props.saveData();
this.props.authenticationContext.refresh(); this.props.authenticatedContext.refresh();
} }
render() { render() {
const { classes, userData, onReset } = this.props; const { data, loadData } = this.props;
const { user, creating } = this.state; const { user, creating } = this.state;
return ( return (
<Fragment> <Fragment>
<ValidatorForm onSubmit={this.onSubmit}> <ValidatorForm onSubmit={this.onSubmit}>
<Table className={classes.table}> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Username</TableCell> <TableCell>Username</TableCell>
@ -138,7 +120,7 @@ class ManageUsersForm extends React.Component {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{userData.users.sort(compareUsers).map(user => ( {data.users.sort(compareUsers).map(user => (
<TableRow key={user.username}> <TableRow key={user.username}>
<TableCell component="th" scope="row"> <TableCell component="th" scope="row">
{user.username} {user.username}
@ -178,12 +160,14 @@ class ManageUsersForm extends React.Component {
</Box> </Box>
</Typography> </Typography>
} }
<Button startIcon={<SaveIcon />} variant="contained" color="primary" className={classes.button} type="submit" disabled={this.noAdminConfigured()}> <FormActions>
Save <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit" disabled={this.noAdminConfigured()}>
</Button> Save
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}> </FormButton>
Reset <FormButton variant="contained" color="secondary" onClick={loadData}>
</Button> Reset
</FormButton>
</FormActions>
</ValidatorForm> </ValidatorForm>
{ {
user && user &&
@ -203,14 +187,4 @@ class ManageUsersForm extends React.Component {
} }
ManageUsersForm.propTypes = { export default withAuthenticatedContext(ManageUsersForm);
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));

View 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;

View 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);

View 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);

View 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;

View File

@ -0,0 +1,11 @@
export interface User {
username: string;
password: string;
admin: boolean;
}
export interface SecuritySettings {
users: User[];
jwt_secret: string;
}

View 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();
});
}
}

View 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);

View 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;

View 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);

View 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);

View File

@ -1,18 +1,7 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { withSnackbar } from 'notistack';
import { withStyles } from '@material-ui/core/styles'; import { Avatar, Button, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core';
import Button from '@material-ui/core/Button'; import { List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core';
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 DevicesIcon from '@material-ui/icons/Devices'; import DevicesIcon from '@material-ui/icons/Devices';
import MemoryIcon from '@material-ui/icons/Memory'; 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 AutorenewIcon from '@material-ui/icons/Autorenew';
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from '@material-ui/icons/Refresh';
import { SYSTEM_STATUS_ENDPOINT, RESTART_ENDPOINT } from '../constants/Endpoints'; import { redirectingAuthorizedFetch } from '../authentication';
import { restComponent } from '../components/RestComponent'; import { RestFormProps, FormButton, FormActions } from '../components';
import LoadingNotification from '../components/LoadingNotification'; import { RESTART_ENDPOINT } from '../api';
import SectionContent from '../components/SectionContent';
import { redirectingAuthorizedFetch } from '../authentication/Authentication';
const styles = theme => ({ import { SystemStatus } from './types';
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
class SystemStatus extends Component { interface SystemStatusFormState {
confirmRestart: boolean;
processing: boolean;
}
type SystemStatusFormProps = RestFormProps<SystemStatus>;
constructor(props) { class SystemStatusForm extends Component<SystemStatusFormProps, SystemStatusFormState> {
super(props);
this.state = { state: SystemStatusFormState = {
confirmRestart: false, confirmRestart: false,
processing: false processing: false
}
} }
componentDidMount() { createListItems() {
this.props.loadData(); const { data } = this.props
}
createListItems(data, classes) {
return ( return (
<Fragment> <Fragment>
<ListItem > <ListItem >
@ -103,20 +84,26 @@ class SystemStatus extends Component {
); );
} }
renderSystemStatus(data, classes) { renderRestartDialog() {
return ( return (
<div> <Dialog
<List> open={this.state.confirmRestart}
{this.createListItems(data, classes)} onClose={this.onRestartRejected}
</List> >
<Button startIcon={<RefreshIcon />} variant="contained" color="secondary" className={classes.button} onClick={this.props.loadData}> <DialogTitle>Confirm Restart</DialogTitle>
Refresh <DialogContent dividers={true}>
</Button> Are you sure you want to restart the device?
<Button startIcon={<AutorenewIcon />} variant="contained" color="secondary" className={classes.button} onClick={this.onRestart}> </DialogContent>
Restart <DialogActions>
</Button> <Button startIcon={<AutorenewIcon />} variant="contained" onClick={this.onRestartConfirmed} disabled={this.state.processing} color="primary" autoFocus>
</div> Restart
); </Button>
<Button variant="contained" onClick={this.onRestartRejected} color="secondary">
Cancel
</Button>
</DialogActions>
</Dialog>
)
} }
onRestart = () => { 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() { render() {
const { data, fetched, errorMessage, loadData, classes } = this.props;
return ( return (
<SectionContent title="System Status"> <Fragment>
<LoadingNotification <List>
onRestart={loadData} {this.createListItems()}
fetched={fetched} </List>
errorMessage={errorMessage} <FormActions>
render={ <FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}>
() => this.renderSystemStatus(data, classes) Refresh
} </FormButton>
/> <FormButton startIcon={<AutorenewIcon />} variant="contained" color="primary" onClick={this.onRestart}>
Restart
</FormButton>
</FormActions>
{this.renderRestartDialog()} {this.renderRestartDialog()}
</SectionContent> </Fragment>
) );
} }
} }
export default withSnackbar(restComponent(SYSTEM_STATUS_ENDPOINT, withStyles(styles)(SystemStatus))); export default SystemStatusForm;

View 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