From 2baca5fdd08b690535f5b7468981ce306f31d9f7 Mon Sep 17 00:00:00 2001 From: Alex Mikhalev Date: Mon, 2 Jul 2018 15:22:59 -0600 Subject: [PATCH] Added login page and lots of related improvments --- app/components/App.tsx | 50 ++++----- app/components/DeviceView.tsx | 8 +- app/components/MessageTest.tsx | 10 +- app/components/MessagesView.tsx | 6 +- app/components/NavBar.tsx | 3 + app/index.tsx | 13 ++- app/pages/LoginPage.tsx | 75 +++++++++++++ app/pages/index.tsx | 18 ++++ app/sprinklersRpc/websocketClient.ts | 34 ++++-- app/state/{ClientState.ts => AppState.ts} | 16 +-- app/state/HttpApi.ts | 1 - app/state/Token.ts | 2 +- app/state/TokenStore.ts | 7 +- app/state/index.ts | 8 +- app/state/reactContext.tsx | 23 ++-- app/styles/app.scss | 100 ++++++++++++------ common/http.ts | 2 +- .../express/{logger.ts => expressLogger.ts} | 0 server/sprinklersRpc/websocketServer.ts | 16 +-- 19 files changed, 262 insertions(+), 130 deletions(-) create mode 100644 app/pages/LoginPage.tsx create mode 100644 app/pages/index.tsx rename app/state/{ClientState.ts => AppState.ts} (54%) rename server/express/{logger.ts => expressLogger.ts} (100%) diff --git a/app/components/App.tsx b/app/components/App.tsx index 33aa862..b31f946 100644 --- a/app/components/App.tsx +++ b/app/components/App.tsx @@ -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 ( - + + + + + + + + + + + ); } -function MessagesTestPage() { +export default function App() { return ( - + + + + ); } - -class App extends React.Component { - render() { - return ( - - - - - - - - - - - - - - ); - } -} - -export default observer(App); diff --git a/app/components/DeviceView.tsx b/app/components/DeviceView.tsx index 9641f28..0e4658f 100644 --- a/app/components/DeviceView.tsx +++ b/app/components/DeviceView.tsx @@ -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 }: interface DeviceViewProps { deviceId: string; - state: StateBase; + appState: AppState; } class DeviceView extends React.Component { 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 && ( diff --git a/app/components/MessageTest.tsx b/app/components/MessageTest.tsx index d782956..a800d4c 100644 --- a/app/components/MessageTest.tsx +++ b/app/components/MessageTest.tsx @@ -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 ( @@ -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:
I Have crazy content!
, header: "Header to test message", timeout: 5000, }); diff --git a/app/components/MessagesView.tsx b/app/components/MessagesView.tsx index c8282bf..cea1122 100644 --- a/app/components/MessagesView.tsx +++ b/app/components/MessagesView.tsx @@ -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<{ } } -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) => ( )); diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index 3f5c76b..b439b06 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -18,6 +18,9 @@ function NavBar({ location }: { location: Location }) { Device grinklers Messages test + + Login + ); } diff --git a/app/index.tsx b/app/index.tsx index 06770de..5d2c341 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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) => { ReactDOM.render(( - + + + ), rootElem); diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx new file mode 100644 index 0000000..ba04295 --- /dev/null +++ b/app/pages/LoginPage.tsx @@ -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 ( + + + + + + +
Login
+
+ + + Login + +
+
+ ); + } + + login = () => { + this.pageState.login(this.props.appState); + } +} + +const DecoratedLoginPage = injectState(observer(LoginPage)); +export { DecoratedLoginPage as LoginPage }; diff --git a/app/pages/index.tsx b/app/pages/index.tsx new file mode 100644 index 0000000..fd3d5ac --- /dev/null +++ b/app/pages/index.tsx @@ -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 ( + + ); +} + +export function MessagesTestPage() { + return ( + + ); +} diff --git a/app/sprinklersRpc/websocketClient.ts b/app/sprinklersRpc/websocketClient.ts index 82cedcc..a37269f 100644 --- a/app/sprinklersRpc/websocketClient.ts +++ b/app/sprinklersRpc/websocketClient.ts @@ -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; // 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 { } 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 { makeRequest(request: deviceRequests.Request): Promise { 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 = 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 { 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 { if (this.socket == null) { @@ -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 */ diff --git a/app/state/ClientState.ts b/app/state/AppState.ts similarity index 54% rename from app/state/ClientState.ts rename to app/state/AppState.ts index 3100788..655c9c1 100644 --- a/app/state/ClientState.ts +++ b/app/state/AppState.ts @@ -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(); } } diff --git a/app/state/HttpApi.ts b/app/state/HttpApi.ts index 260e173..3774656 100644 --- a/app/state/HttpApi.ts +++ b/app/state/HttpApi.ts @@ -1,4 +1,3 @@ -import { Token } from "@app/state/Token"; import { TokenStore } from "@app/state/TokenStore"; export class HttpApiError extends Error { diff --git a/app/state/Token.ts b/app/state/Token.ts index e98d75d..322c96d 100644 --- a/app/state/Token.ts +++ b/app/state/Token.ts @@ -62,4 +62,4 @@ export class Token { @computed get isValid() { return this.token != null && !this.isExpired; } -} \ No newline at end of file +} diff --git a/app/state/TokenStore.ts b/app/state/TokenStore.ts index 71db5eb..452bcd3 100644 --- a/app/state/TokenStore.ts +++ b/app/state/TokenStore.ts @@ -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 { }, 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 { }, 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"); } } - diff --git a/app/state/index.ts b/app/state/index.ts index 0c806fc..7f4b1ea 100644 --- a/app/state/index.ts +++ b/app/state/index.ts @@ -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"; diff --git a/app/state/reactContext.tsx b/app/state/reactContext.tsx index 387d7e7..be81a20 100644 --- a/app/state/reactContext.tsx +++ b/app/state/reactContext.tsx @@ -1,15 +1,15 @@ import * as React from "react"; -import { StateBase } from "@app/state"; +import { AppState } from "@app/state"; -const StateContext = React.createContext(null); +const StateContext = React.createContext(null); export interface ProvideStateProps { - state: StateBase; + state: AppState; children: React.ReactNode; } -export function ProvideState({state, children}: ProvideStateProps) { +export function ProvideState({ state, children }: ProvideStateProps) { return ( {children} @@ -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 = {[P in Diff]: T[P]}; -export function injectState

(Component: React.ComponentType

) { - return class extends React.Component> { +export function injectState

(Component: React.ComponentType

): + React.ComponentClass> { + return class extends React.Component> { 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 ; + return ; }; return {consumeState}; } diff --git a/app/styles/app.scss b/app/styles/app.scss index d4ea1b4..39fd218 100644 --- a/app/styles/app.scss +++ b/app/styles/app.scss @@ -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 @@ } .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 @@ .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; + } } \ No newline at end of file diff --git a/common/http.ts b/common/http.ts index 899eccf..f5cc09f 100644 --- a/common/http.ts +++ b/common/http.ts @@ -14,4 +14,4 @@ export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshReq export interface TokenGrantResponse { access_token: string; refresh_token: string; -} \ No newline at end of file +} diff --git a/server/express/logger.ts b/server/express/expressLogger.ts similarity index 100% rename from server/express/logger.ts rename to server/express/expressLogger.ts diff --git a/server/sprinklersRpc/websocketServer.ts b/server/sprinklersRpc/websocketServer.ts index 7eaba5a..dac7ee9 100644 --- a/server/sprinklersRpc/websocketServer.ts +++ b/server/sprinklersRpc/websocketServer.ts @@ -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 { 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 { } 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" },