Browse Source

Added login page and lots of related improvments

update-deps
Alex Mikhalev 7 years ago
parent
commit
2baca5fdd0
  1. 50
      app/components/App.tsx
  2. 8
      app/components/DeviceView.tsx
  3. 10
      app/components/MessageTest.tsx
  4. 6
      app/components/MessagesView.tsx
  5. 3
      app/components/NavBar.tsx
  6. 13
      app/index.tsx
  7. 75
      app/pages/LoginPage.tsx
  8. 18
      app/pages/index.tsx
  9. 34
      app/sprinklersRpc/websocketClient.ts
  10. 16
      app/state/AppState.ts
  11. 1
      app/state/HttpApi.ts
  12. 2
      app/state/Token.ts
  13. 7
      app/state/TokenStore.ts
  14. 8
      app/state/index.ts
  15. 23
      app/state/reactContext.tsx
  16. 100
      app/styles/app.scss
  17. 2
      common/http.ts
  18. 0
      server/express/expressLogger.ts
  19. 16
      server/sprinklersRpc/websocketServer.ts

50
app/components/App.tsx

@ -1,47 +1,37 @@ @@ -1,47 +1,37 @@
import { observer } from "mobx-react";
// import DevTools from "mobx-react-devtools";
import * as React from "react";
import { Redirect, Route, RouteComponentProps, Switch } from "react-router";
import { BrowserRouter as Router } from "react-router-dom";
import { Redirect, Route, Switch } from "react-router";
import { Container } from "semantic-ui-react";
import { DevicesView, MessagesView, MessageTest, NavBar } from "@app/components";
import { MessagesView, NavBar } from "@app/components";
import * as p from "@app/pages";
// tslint:disable:ordered-imports
import "font-awesome/css/font-awesome.css";
import "semantic-ui-css/semantic.css";
import "@app/styles/app.scss";
function DevicePage({match}: RouteComponentProps<{deviceId: string}>) {
function NavContainer() {
return (
<DevicesView deviceId={match.params.deviceId}/>
<Container className="app">
<NavBar/>
<Switch>
<Route path="/devices/:deviceId" component={p.DevicePage}/>
<Route path="/messagesTest" component={p.MessagesTestPage}/>
<Redirect to="/"/>
</Switch>
<MessagesView/>
</Container>
);
}
function MessagesTestPage() {
export default function App() {
return (
<MessageTest/>
<Switch>
<Route path="/login" component={p.LoginPage}/>
<NavContainer/>
</Switch>
);
}
class App extends React.Component {
render() {
return (
<Router>
<Container className="app">
<NavBar/>
<Switch>
<Route path="/devices/:deviceId" component={DevicePage}/>
<Route path="/messagesTest" component={MessagesTestPage}/>
<Redirect to="/"/>
</Switch>
<MessagesView/>
</Container>
</Router>
);
}
}
export default observer(App);

8
app/components/DeviceView.tsx

@ -3,7 +3,7 @@ import { observer } from "mobx-react"; @@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Grid, Header, Icon, Item, SemanticICONS } from "semantic-ui-react";
import { injectState, StateBase } from "@app/state";
import { AppState, injectState } from "@app/state";
import { ConnectionState as ConState } from "@common/sprinklersRpc";
import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from ".";
import "./DeviceView.scss";
@ -45,13 +45,13 @@ const ConnectionState = observer(({ connectionState, className }: @@ -45,13 +45,13 @@ const ConnectionState = observer(({ connectionState, className }:
interface DeviceViewProps {
deviceId: string;
state: StateBase;
appState: AppState;
}
class DeviceView extends React.Component<DeviceViewProps> {
render() {
const { uiStore, sprinklersApi } = this.props.state;
const device = sprinklersApi.getDevice(this.props.deviceId);
const { uiStore, sprinklersRpc } = this.props.appState;
const device = sprinklersRpc.getDevice(this.props.deviceId);
const { id, connectionState, sections, programs, sectionRunner } = device;
const deviceBody = connectionState.isAvailable && (
<React.Fragment>

10
app/components/MessageTest.tsx

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
import * as React from "react";
import { Button, Segment } from "semantic-ui-react";
import { injectState, StateBase } from "@app/state";
import { AppState, injectState } from "@app/state";
import { getRandomId } from "@common/utils";
class MessageTest extends React.Component<{ state: StateBase }> {
class MessageTest extends React.Component<{ appState: AppState }> {
render() {
return (
<Segment>
@ -17,20 +17,20 @@ class MessageTest extends React.Component<{ state: StateBase }> { @@ -17,20 +17,20 @@ class MessageTest extends React.Component<{ state: StateBase }> {
}
private test1 = () => {
this.props.state.uiStore.addMessage({
this.props.appState.uiStore.addMessage({
info: true, content: "Test Message! " + getRandomId(), header: "Header to test message",
});
}
private test2 = () => {
this.props.state.uiStore.addMessage({
this.props.appState.uiStore.addMessage({
warning: true, content: "Im gonna dissapear in 5 seconds " + getRandomId(),
header: "Header to test message", timeout: 5000,
});
}
private test3 = () => {
this.props.state.uiStore.addMessage({
this.props.appState.uiStore.addMessage({
color: "brown", content: <div className="ui segment">I Have crazy content!</div>,
header: "Header to test message", timeout: 5000,
});

6
app/components/MessagesView.tsx

@ -3,7 +3,7 @@ import { observer } from "mobx-react"; @@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Message, MessageProps, TransitionGroup } from "semantic-ui-react";
import { injectState, StateBase, UiMessage, UiStore } from "@app/state/";
import { AppState, injectState, UiMessage, UiStore } from "@app/state/";
@observer
class MessageView extends React.Component<{
@ -33,9 +33,9 @@ class MessageView extends React.Component<{ @@ -33,9 +33,9 @@ class MessageView extends React.Component<{
}
}
class MessagesView extends React.Component<{ state: StateBase }> {
class MessagesView extends React.Component<{ appState: AppState }> {
render() {
const { uiStore } = this.props.state;
const { uiStore } = this.props.appState;
const messages = uiStore.messages.map((message) => (
<MessageView key={message.id} uiStore={uiStore} message={message} />
));

3
app/components/NavBar.tsx

@ -18,6 +18,9 @@ function NavBar({ location }: { location: Location }) { @@ -18,6 +18,9 @@ function NavBar({ location }: { location: Location }) {
<Menu>
<NavItem to="/devices/grinklers">Device grinklers</NavItem>
<NavItem to="/messagesTest">Messages test</NavItem>
<Menu.Menu position="right">
<NavItem to="/login">Login</NavItem>
</Menu.Menu>
</Menu>
);
}

13
app/index.tsx

@ -1,15 +1,16 @@ @@ -1,15 +1,16 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import { AppContainer } from "react-hot-loader";
import { Router } from "react-router-dom";
import App from "@app/components/App";
import { ProvideState, StateBase, WebApiState as StateClass } from "@app/state";
import { AppState, ProvideState } from "@app/state";
import logger from "@common/logger";
const state: StateBase = new StateClass();
const state = new AppState();
state.start()
.catch((err) => {
logger.error({err}, "error starting state");
.catch((err: any) => {
logger.error({ err }, "error starting state");
});
const rootElem = document.getElementById("app");
@ -18,7 +19,9 @@ const doRender = (Component: React.ComponentType) => { @@ -18,7 +19,9 @@ const doRender = (Component: React.ComponentType) => {
ReactDOM.render((
<AppContainer>
<ProvideState state={state}>
<Component />
<Router history={state.history}>
<Component/>
</Router>
</ProvideState>
</AppContainer>
), rootElem);

75
app/pages/LoginPage.tsx

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
import { AppState, injectState } from "@app/state";
import log from "@common/logger";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { Container, Dimmer, Form, Header, InputOnChangeData, Loader, Segment } from "semantic-ui-react";
class LoginPageState {
@observable username = "";
@observable password = "";
@observable loading: boolean = false;
@computed get canLogin() {
return this.username.length > 0 && this.password.length > 0;
}
onUsernameChange = (e: any, data: InputOnChangeData) => {
this.username = data.value;
}
onPasswordChange = (e: any, data: InputOnChangeData) => {
this.password = data.value;
}
login(appState: AppState) {
this.loading = true;
appState.httpApi.tokenStore.grantPassword(this.username, this.password)
.then(() => {
this.loading = false;
log.info("logged in");
appState.history.push("/");
})
.catch((err) => {
this.loading = false;
log.error({ err }, "login error");
});
}
}
class LoginPage extends React.Component<{ appState: AppState }> {
pageState = new LoginPageState();
render() {
const { username, password, canLogin, loading } = this.pageState;
return (
<Container className="loginPage">
<Segment>
<Dimmer inverted active={loading}>
<Loader/>
</Dimmer>
<Header as="h1">Login</Header>
<Form>
<Form.Input label="Username" value={username} onChange={this.pageState.onUsernameChange}/>
<Form.Input
label="Password"
value={password}
type="password"
onChange={this.pageState.onPasswordChange}
/>
<Form.Button disabled={!canLogin} onClick={this.login}>Login</Form.Button>
</Form>
</Segment>
</Container>
);
}
login = () => {
this.pageState.login(this.props.appState);
}
}
const DecoratedLoginPage = injectState(observer(LoginPage));
export { DecoratedLoginPage as LoginPage };

18
app/pages/index.tsx

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
import * as React from "react";
import { RouteComponentProps } from "react-router";
import { DevicesView, MessageTest} from "@app/components";
export { LoginPage } from "./LoginPage";
export function DevicePage({ match }: RouteComponentProps<{ deviceId: string }>) {
return (
<DevicesView deviceId={match.params.deviceId}/>
);
}
export function MessagesTestPage() {
return (
<MessageTest/>
);
}

34
app/sprinklersRpc/websocketClient.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { action, observable, when } from "mobx";
import { update } from "serializr";
import { TokenStore } from "@app/state/TokenStore";
import * as rpc from "@common/jsonRpc";
import logger from "@common/logger";
import * as deviceRequests from "@common/sprinklersRpc/deviceRequests";
@ -18,17 +19,15 @@ const RECONNECT_TIMEOUT_MS = 5000; @@ -18,17 +19,15 @@ const RECONNECT_TIMEOUT_MS = 5000;
// tslint:disable:member-ordering
export class WSSprinklersDevice extends s.SprinklersDevice {
readonly api: WebSocketApiClient;
readonly api: WebSocketRpcClient;
private _id: string;
constructor(api: WebSocketApiClient, id: string) {
constructor(api: WebSocketRpcClient, id: string) {
super();
this.api = api;
this._id = id;
when(() => api.connectionState.isConnected || false, () => {
this.subscribe();
});
this.waitSubscribe();
}
get id() {
@ -36,9 +35,6 @@ export class WSSprinklersDevice extends s.SprinklersDevice { @@ -36,9 +35,6 @@ export class WSSprinklersDevice extends s.SprinklersDevice {
}
async subscribe() {
if (this.api.accessToken) {
await this.api.authenticate(this.api.accessToken);
}
const subscribeRequest: ws.IDeviceSubscribeRequest = {
deviceId: this.id,
};
@ -58,26 +54,35 @@ export class WSSprinklersDevice extends s.SprinklersDevice { @@ -58,26 +54,35 @@ export class WSSprinklersDevice extends s.SprinklersDevice {
makeRequest(request: deviceRequests.Request): Promise<deviceRequests.Response> {
return this.api.makeDeviceCall(this.id, request);
}
waitSubscribe = () => {
when(() => this.api.connected, () => {
this.subscribe();
when(() => !this.api.connected, this.waitSubscribe);
});
}
}
export class WebSocketApiClient implements s.SprinklersRPC {
export class WebSocketRpcClient implements s.SprinklersRPC {
readonly webSocketUrl: string;
devices: Map<string, WSSprinklersDevice> = new Map();
@observable connectionState: s.ConnectionState = new s.ConnectionState();
socket: WebSocket | null = null;
tokenStore: TokenStore;
private nextRequestId = Math.round(Math.random() * 1000000);
private responseCallbacks: ws.ServerResponseHandlers = {};
private reconnectTimer: number | null = null;
accessToken: string | undefined;
get connected(): boolean {
return this.connectionState.isConnected || false;
}
constructor(webSocketUrl: string) {
constructor(webSocketUrl: string, tokenStore: TokenStore) {
this.webSocketUrl = webSocketUrl;
this.tokenStore = tokenStore;
this.connectionState.clientToServer = false;
this.connectionState.serverToBroker = false;
}
@ -115,6 +120,12 @@ export class WebSocketApiClient implements s.SprinklersRPC { @@ -115,6 +120,12 @@ export class WebSocketApiClient implements s.SprinklersRPC {
return this.makeRequest("authenticate", { accessToken });
}
async tryAuthenticate() {
when(() => this.tokenStore.accessToken.isValid, () => {
return this.authenticate(this.tokenStore.accessToken.token!);
});
}
// args must all be JSON serializable
async makeDeviceCall(deviceId: string, request: deviceRequests.Request): Promise<deviceRequests.Response> {
if (this.socket == null) {
@ -194,6 +205,7 @@ export class WebSocketApiClient implements s.SprinklersRPC { @@ -194,6 +205,7 @@ export class WebSocketApiClient implements s.SprinklersRPC {
private onOpen() {
log.info("established websocket connection");
this.connectionState.clientToServer = true;
this.tryAuthenticate();
}
/* tslint:disable-next-line:member-ordering */

16
app/state/ClientState.ts → app/state/AppState.ts

@ -1,26 +1,28 @@ @@ -1,26 +1,28 @@
import { WebSocketApiClient } from "@app/sprinklersRpc/websocketClient";
import { WebSocketRpcClient } from "@app/sprinklersRpc/websocketClient";
import HttpApi from "@app/state/HttpApi";
import { UiStore } from "@app/state/UiStore";
import { createBrowserHistory, History } from "history";
const isDev = process.env.NODE_ENV === "development";
const websocketPort = isDev ? 8080 : location.port;
export default class ClientState {
sprinklersApi = new WebSocketApiClient(`ws://${location.hostname}:${websocketPort}`);
export default class AppState {
history: History = createBrowserHistory();
uiStore = new UiStore();
httpApi = new HttpApi();
tokenStore = this.httpApi.tokenStore;
sprinklersRpc = new WebSocketRpcClient(`ws://${location.hostname}:${websocketPort}`,
this.tokenStore);
async start() {
if (!this.httpApi.tokenStore.accessToken.isValid) {
if (this.httpApi.tokenStore.refreshToken.isValid) {
await this.httpApi.tokenStore.grantRefresh();
} else {
await this.httpApi.tokenStore.grantPassword("alex", "kakashka");
this.history.push("/login");
}
}
this.sprinklersApi.accessToken = this.httpApi.tokenStore.accessToken.token!;
this.sprinklersApi.start();
this.sprinklersRpc.start();
}
}

1
app/state/HttpApi.ts

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import { Token } from "@app/state/Token";
import { TokenStore } from "@app/state/TokenStore";
export class HttpApiError extends Error {

2
app/state/Token.ts

@ -62,4 +62,4 @@ export class Token { @@ -62,4 +62,4 @@ export class Token {
@computed get isValid() {
return this.token != null && !this.isExpired;
}
}
}

7
app/state/TokenStore.ts

@ -5,6 +5,8 @@ import { Token } from "@app/state/Token"; @@ -5,6 +5,8 @@ import { Token } from "@app/state/Token";
import { TokenGrantPasswordRequest, TokenGrantRefreshRequest, TokenGrantResponse } from "@common/http";
import logger from "@common/logger";
const log = logger.child({ source: "TokenStore"});
export class TokenStore {
@observable accessToken: Token = new Token();
@observable refreshToken: Token = new Token();
@ -24,7 +26,7 @@ export class TokenStore { @@ -24,7 +26,7 @@ export class TokenStore {
}, request);
this.accessToken.token = response.access_token;
this.refreshToken.token = response.refresh_token;
logger.debug({ aud: this.accessToken.claims!.aud }, "got password grant tokens");
log.debug({ aud: this.accessToken.claims!.aud }, "got password grant tokens");
}
async grantRefresh() {
@ -39,7 +41,6 @@ export class TokenStore { @@ -39,7 +41,6 @@ export class TokenStore {
}, request);
this.accessToken.token = response.access_token;
this.refreshToken.token = response.refresh_token;
logger.debug({ aud: this.accessToken.claims!.aud }, "got refresh grant tokens");
log.debug({ aud: this.accessToken.claims!.aud }, "got refresh grant tokens");
}
}

8
app/state/index.ts

@ -1,9 +1,3 @@ @@ -1,9 +1,3 @@
export { UiMessage, UiStore } from "./UiStore";
export * from "./reactContext";
export { ClientState as StateBase } from "./ClientState";
import ClientState from "./ClientState";
export class WebApiState extends ClientState {
}
export { default as AppState } from "./AppState";

23
app/state/reactContext.tsx

@ -1,15 +1,15 @@ @@ -1,15 +1,15 @@
import * as React from "react";
import { StateBase } from "@app/state";
import { AppState } from "@app/state";
const StateContext = React.createContext<StateBase | null>(null);
const StateContext = React.createContext<AppState | null>(null);
export interface ProvideStateProps {
state: StateBase;
state: AppState;
children: React.ReactNode;
}
export function ProvideState({state, children}: ProvideStateProps) {
export function ProvideState({ state, children }: ProvideStateProps) {
return (
<StateContext.Provider value={state}>
{children}
@ -18,11 +18,11 @@ export function ProvideState({state, children}: ProvideStateProps) { @@ -18,11 +18,11 @@ export function ProvideState({state, children}: ProvideStateProps) {
}
export interface ConsumeStateProps {
children: (state: StateBase) => React.ReactNode;
children: (state: AppState) => React.ReactNode;
}
export function ConsumeState({children}: ConsumeStateProps) {
const consumeState = (state: StateBase | null) => {
export function ConsumeState({ children }: ConsumeStateProps) {
const consumeState = (state: AppState | null) => {
if (state == null) {
throw new Error("Component with ConsumeState must be mounted inside ProvideState");
}
@ -35,14 +35,15 @@ type Diff<T extends string | number | symbol, U extends string | number | symbol @@ -35,14 +35,15 @@ type Diff<T extends string | number | symbol, U extends string | number | symbol
({[P in T]: P } & {[P in U]: never } & { [x: string]: never })[T];
type Omit<T, K extends keyof T> = {[P in Diff<keyof T, K>]: T[P]};
export function injectState<P extends { state: StateBase }>(Component: React.ComponentType<P>) {
return class extends React.Component<Omit<P, "state">> {
export function injectState<P extends { appState: AppState }>(Component: React.ComponentType<P>):
React.ComponentClass<Omit<P, "appState">> {
return class extends React.Component<Omit<P, "appState">> {
render() {
const consumeState = (state: StateBase | null) => {
const consumeState = (state: AppState | null) => {
if (state == null) {
throw new Error("Component with injectState must be mounted inside ProvideState");
}
return <Component {...this.props} state={state}/>;
return <Component {...this.props} appState={state}/>;
};
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
}

100
app/styles/app.scss

@ -1,45 +1,46 @@ @@ -1,45 +1,46 @@
.app {
margin-top: 1em;
margin-top: 1em;
}
.sectionRunner--pausedState {
padding-left: .75em;
font-size: .75em;
font-weight: lighter;
padding-left: .75em;
font-size: .75em;
font-weight: lighter;
}
.sectionRunner--pausedState > .fa {
padding-right: .2em;
padding-right: .2em;
}
.sectionRunner--pausedState-unpaused {
}
.flex-horizontal-space-between {
display: flex;
align-items: baseline;
justify-content: space-between;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.sectionRun .progress {
margin: 1em 0 0 !important;
margin: 1em 0 0 !important;
}
.sectionRun .ui.progress .bar {
-webkit-transition: none;
transition: none;
min-width: 0 !important;
-webkit-transition: none;
transition: none;
min-width: 0 !important;
}
.section--number,
.program--number {
width: 2em
width: 2em
}
.section--name /*,
.program--name*/ {
width: 10em;
white-space: nowrap;
.program--name*/
{
width: 10em;
white-space: nowrap;
}
.section--state {
@ -47,17 +48,16 @@ @@ -47,17 +48,16 @@
}
.ui.table {
tr > td.program--running {
display: flex !important;
@media only screen and (min-width: 768px) {
//line-height: 36px;
}
tr > td.program--running {
display: flex !important;
@media only screen and (min-width: 768px) {
//line-height: 36px;
}
}
}
.section--state-true {
color: green;
color: green;
}
.section--state-false {
@ -66,25 +66,55 @@ @@ -66,25 +66,55 @@
.durationInput--minutes,
.durationInput--seconds {
min-width: 6em !important;
min-width: 6em !important;
}
.durationInput .ui.labeled.input > .label {
width: 3em;
width: 3em;
}
.messages {
position: fixed;
/* top: 12px; */
bottom: 1em;
left: 1em;
right: 1em;
padding-left: 0;
z-index: 1000;
display: flex;
flex-direction: column;
position: fixed;
/* top: 12px; */
bottom: 1em;
left: 1em;
right: 1em;
padding-left: 0;
z-index: 1000;
display: flex;
flex-direction: column;
}
.flex-spacer {
flex: 1;
flex: 1;
}
.ui.container.loginPage {
margin-top: 1em;
.ui.header {
text-align: center;
}
/* Mobile */
@media only screen and (max-width: 767px) {
width: auto !important;
margin-left: 1em !important;
margin-right: 1em !important;
}
/* Tablet */
@media only screen and (min-width: 768px) and (max-width: 991px) {
width: 600px;
}
/* Small Monitor */
@media only screen and (min-width: 992px) and (max-width: 1199px) {
width: 600px;
}
/* Large Monitor */
@media only screen and (min-width: 1200px) {
width: 800px;
}
}

2
common/http.ts

@ -14,4 +14,4 @@ export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshReq @@ -14,4 +14,4 @@ export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshReq
export interface TokenGrantResponse {
access_token: string;
refresh_token: string;
}
}

0
server/express/logger.ts → server/express/expressLogger.ts

16
server/sprinklersRpc/websocketServer.ts

@ -33,12 +33,6 @@ export class WebSocketClient { @@ -33,12 +33,6 @@ export class WebSocketClient {
}
start() {
this.disposers.push(autorun(() => {
const updateData: ws.IBrokerConnectionUpdate = {
brokerConnected: this.state.mqttClient.connected,
};
this.sendNotification("brokerConnectionUpdate", updateData);
}));
this.socket.on("message", this.handleSocketMessage);
this.socket.on("close", this.stop);
}
@ -48,6 +42,15 @@ export class WebSocketClient { @@ -48,6 +42,15 @@ export class WebSocketClient {
this.api.removeClient(this);
}
private subscribeBrokerConnection() {
this.disposers.push(autorun(() => {
const updateData: ws.IBrokerConnectionUpdate = {
brokerConnected: this.state.mqttClient.connected,
};
this.sendNotification("brokerConnectionUpdate", updateData);
}));
}
private checkAuthorization() {
if (!this.userId) {
throw new ws.RpcError("this WebSocket session has not been authenticated",
@ -68,6 +71,7 @@ export class WebSocketClient { @@ -68,6 +71,7 @@ export class WebSocketClient {
}
this.userId = decoded.aud;
log.info({ userId: decoded.aud, name: decoded.name }, "authenticated websocket client");
this.subscribeBrokerConnection();
return {
result: "success",
data: { authenticated: true, message: "authenticated" },

Loading…
Cancel
Save