pull/498/head
Rick Carlino 2017-10-12 15:28:19 -05:00
parent 1f8f686cf5
commit deb0e13593
16 changed files with 94 additions and 47 deletions

View File

@ -8,8 +8,7 @@ conf.output = {
// must match config.webpack.output_dir
path: path.join(__dirname, '..', 'public', 'webpack'),
publicPath: '//localhost:' + devServerPort + '/webpack/',
filename: '[name].js',
watch: true
filename: '[name].js'
};
conf.devServer = {

View File

@ -13,7 +13,7 @@
"start": "echo '===We use `rails api:start` now.==='",
"heroku-postbuild": "webpack --config=./config/webpack.prod.js",
"dev": "echo '===We use `rails api:start` now.==='",
"webpack": "./node_modules/.bin/webpack-dev-server --config config/webpack.config.js --host 0.0.0.0",
"webpack": "./node_modules/.bin/webpack-dev-server --config config/webpack.dev.js --host 0.0.0.0",
"test": "jest --coverage --no-cache",
"typecheck": "tsc --noEmit --jsx preserve"
},

View File

@ -24,7 +24,7 @@ import { AuthState } from "../auth/interfaces";
import { maybeRefreshToken } from "../refresh_token";
import { API } from "../api/index";
API.setBaseUrl("http://whateer.party");
API.setBaseUrl("http://whatever.party");
describe("maybeRefreshToken()", () => {
it("gives you back your token when things fail", (done) => {

View File

@ -21,7 +21,7 @@ import { connectDevice } from "../connectivity/connect_device";
export function didLogin(authState: AuthState, dispatch: Function) {
API.setBaseUrl(authState.token.unencoded.iss);
dispatch(fetchReleases(authState.token.unencoded.os_update_server));
dispatch(loginOk(authState));
dispatch(setToken(authState));
Sync.fetchSyncData(dispatch);
dispatch(connectDevice(authState));
}
@ -30,7 +30,7 @@ export function didLogin(authState: AuthState, dispatch: Function) {
function onLogin(dispatch: Function) {
return (response: HttpData<AuthState>) => {
const { data } = response;
Session.replace(data);
Session.replaceToken(data);
didLogin(data, dispatch);
push("/app/controls");
};
@ -53,12 +53,12 @@ function loginErr() {
/** Very important. Once called, all outbound HTTP requests will
* have a JSON Web Token attached to their "Authorization" header,
* thereby granting access to the API. */
export function loginOk(auth: AuthState): ReduxAction<AuthState> {
export function setToken(auth: AuthState): ReduxAction<AuthState> {
axios.interceptors.response.use(responseFulfilled, responseRejected);
axios.interceptors.request.use(requestFulfilled(auth));
return {
type: Actions.LOGIN_OK,
type: Actions.REPLACE_TOKEN,
payload: auth
};
}
@ -120,7 +120,10 @@ export function logout() {
// In those cases, seeing a logout message may confuse the user.
// To circumvent this, we must check if the user had a token.
// If there was infact a token, we can safely show the message.
if (Session.getAll()) { success(t("You have been logged out.")); }
if (Session.fetchStoredToken()) {
success(t("You have been logged out."));
}
Session.clear();
// Technically this is unreachable code:
return {

View File

@ -3,6 +3,6 @@ import { generateReducer } from "../redux/generate_reducer";
import { Actions } from "../constants";
export let authReducer = generateReducer<AuthState | undefined>(undefined)
.add<AuthState>(Actions.LOGIN_OK, (s, { payload }) => {
.add<AuthState>(Actions.REPLACE_TOKEN, (s, { payload }) => {
return payload;
});

View File

@ -1,13 +1,32 @@
import { didLogin } from "../auth/actions";
import { didLogin, setToken } from "../auth/actions";
import { Thunk } from "../redux/interfaces";
import { Session } from "../session";
import { maybeRefreshToken } from "../refresh_token";
import { goHome, withTimeout } from "../util";
import { AuthState } from "../auth/interfaces";
/** Lets Redux know that the app is ready to bootstrap. */
export const storeToken =
(auth: AuthState, dispatch: Function) => () => {
dispatch(setToken(auth));
didLogin(auth, dispatch);
};
/** Amount of time were willing to wait before concluding that the token is bad
* or the API is down. */
const MAX_TOKEN_WAIT_TIME = 10000;
/** (IMPORTANT) One of the highest level callbacks in the app.
* called once DOM is ready and React is attached to the DOM.
* => Lets Redux know that the app is ready to bootstrap.
* => Checks for token updates since last log in. */
export function ready(): Thunk {
return (dispatch, getState) => {
const state = Session.getAll() || getState().auth;
if (state) {
didLogin(state, dispatch);
const auth = Session.fetchStoredToken() || getState().auth;
if (auth) {
withTimeout(MAX_TOKEN_WAIT_TIME, maybeRefreshToken(auth))
.then(storeToken(auth, dispatch), goHome);
} else {
goHome();
}
};
}

View File

@ -379,7 +379,7 @@ export enum Actions {
REFRESH_RESOURCE_NO = "REFRESH_RESOURCE_NO",
// Auth
LOGIN_OK = "LOGIN_OK",
REPLACE_TOKEN = "LOGIN_OK",
// Config
CHANGE_API_PORT = "CHANGE_API_PORT",

View File

@ -2,7 +2,6 @@ import { Farmbot } from "farmbot";
import { bail } from "./util";
import { set } from "lodash";
import { AuthState } from "./auth/interfaces";
import { maybeRefreshToken } from "./refresh_token";
let device: Farmbot;
@ -10,15 +9,11 @@ const secure = location.protocol === "https:"; // :(
export const getDevice = (): Farmbot => (device || bail("NO DEVICE SET"));
export function fetchNewDevice(oldToken: AuthState): Promise<Farmbot> {
return maybeRefreshToken(oldToken)
.then(({ token }) => {
device = new Farmbot({ token: token.encoded, secure });
set(window, "current_bot", device);
return device
.connect()
.then(() => {
return device;
}, () => bail("NO CONNECT"));
});
export function fetchNewDevice(auth: AuthState): Promise<Farmbot> {
device = new Farmbot({ token: auth.token.encoded, secure });
set(window, "current_bot", device);
return device
.connect()
.then(() => device, () => bail("NO CONNECT"));
}

View File

@ -33,7 +33,7 @@ export class FrontPage extends React.Component<{}, Partial<FrontPageState>> {
}
componentDidMount() {
if (Session.getAll()) { window.location.href = "/app/controls"; }
if (Session.fetchStoredToken()) { window.location.href = "/app/controls"; }
logInit();
API.setBaseUrl(API.fetchBrowserLocation());
this.setState({
@ -66,7 +66,7 @@ export class FrontPage extends React.Component<{}, Partial<FrontPageState>> {
API.setBaseUrl(url);
axios.post(API.current.tokensPath, payload)
.then((resp: HttpData<AuthState>) => {
Session.replace(resp.data);
Session.replaceToken(resp.data);
window.location.href = "/app/controls";
}).catch((error: Error) => {
switch (_.get(error, "response.status")) {
@ -308,5 +308,5 @@ export class FrontPage extends React.Component<{}, Partial<FrontPageState>> {
</div>;
}
render() { return Session.getAll() ? <div /> : this.defaultContent(); }
render() { return Session.fetchStoredToken() ? <div /> : this.defaultContent(); }
}

View File

@ -2,7 +2,7 @@ import * as React from "react";
import axios from "axios";
import { t } from "i18next";
import { error as log, init as logInit } from "farmbot-toastr";
import { prettyPrintApiErrors } from "../util";
import { prettyPrintApiErrors, goHome } from "../util";
import { API } from "../api";
import { State, Props } from "./interfaces";
import { Widget, WidgetHeader, WidgetBody, Row, Col } from "../ui/index";
@ -42,7 +42,7 @@ export class PasswordReset extends React.Component<Props, State> {
password,
password_confirmation: passwordConfirmation,
}).then(() => {
window.location.href = "/";
goHome();
}).catch((error: string) => {
log(prettyPrintApiErrors(error as {}));
});

View File

@ -2,9 +2,19 @@ import axios from "axios";
import { API } from "./api/index";
import { AuthState } from "./auth/interfaces";
import { HttpData } from "./util";
import { setToken } from "./auth/actions";
/** Grab a new token from the API (won't extend token's exp. date).
* Redirect to home page on failure. */
export let maybeRefreshToken = (old: AuthState): Promise<AuthState> => {
return axios
.get(API.current.tokensPath)
.then((x: HttpData<AuthState>) => x.data, () => (old));
API.setBaseUrl(old.token.unencoded.iss);
setToken(old); // The Axios interceptors might not be set yet.
type Resp = HttpData<AuthState>;
return axios.get(API.current.tokensPath).then((x: Resp) => {
setToken(old);
return x.data;
}, (x) => {
return Promise.reject("X");
});
};

View File

@ -9,7 +9,7 @@ import { history } from "./history";
import { Store } from "./redux/interfaces";
import { ready } from "./config/actions";
import { Session } from "./session";
import { isMobile, attachToRoot } from "./util";
import { isMobile, attachToRoot, goHome } from "./util";
import { Callback } from "i18next";
interface RootComponentProps {
@ -77,7 +77,7 @@ export class RootComponent extends React.Component<RootComponentProps, {}> {
requireAuth(_discard: RouterState, replace: RedirectFunction) {
const { store } = this.props;
if (Session.getAll()) { // has a previous session in cache
if (Session.fetchStoredToken()) { // has a previous session in cache
if (store.getState().auth) { // Has session, logged in.
return;
} else { // Has session but not logged in (returning visitor).
@ -289,10 +289,10 @@ export class RootComponent extends React.Component<RootComponentProps, {}> {
render() {
// ==== TEMPORARY HACK. TODO: Add a before hook, if such a thing exists in
// React Router. Or switch routing libs.
const notLoggedIn = !Session.getAll();
const notLoggedIn = !Session.fetchStoredToken();
const restrictedArea = window.location.pathname.includes("/app");
if (notLoggedIn && restrictedArea) {
window.location.href = "/";
goHome();
}
// ==== END HACK ====
return <Provider store={_store}>

View File

@ -17,12 +17,12 @@ export namespace Session {
const KEY = "session";
/** Replace the contents of session storage. */
export function replace(nextState: AuthState) {
export function replaceToken(nextState: AuthState) {
localStorage[KEY] = JSON.stringify(nextState);
}
/** Fetch the previous session. */
export function getAll(): AuthState | undefined {
export function fetchStoredToken(): AuthState | undefined {
try {
const v: AuthState = JSON.parse(localStorage[KEY]);
if (box(v).kind === "object") {
@ -74,9 +74,7 @@ export namespace Session {
const isBooleanSetting =
// tslint:disable-next-line:no-any
(x: any): x is BooleanSetting => {
return !!BooleanSetting[x];
};
(x: any): x is BooleanSetting => !!BooleanSetting[x];
export function safeBooleanSettting(name: string): BooleanSetting {
if (isBooleanSetting(name)) {

View File

@ -52,7 +52,7 @@ export class Wow extends React.Component<Props, Partial<State>> {
axios
.post(API.current.tokensPath, payload)
.then((resp: HttpData<AuthState>) => {
Session.replace(resp.data);
Session.replaceToken(resp.data);
window.location.href = "/app/controls";
})
.catch(error => {

View File

@ -9,6 +9,7 @@ import { box } from "boxed_value";
import { TaggedResource } from "./resources/tagged_resources";
import { AxiosResponse } from "axios";
import { history } from "./history";
import { Session } from "./session";
// http://stackoverflow.com/a/901144/1064917
// Grab a query string param by name, because react-router-redux doesn't
@ -447,3 +448,25 @@ export function bitArray(...values: boolean[]) {
export function bail(message: string): never {
throw new Error(message);
}
export const goHome = (): never => {
Session.clear();
window.location.href = "/";
return bail("Going to home page.");
};
// Thanks,
// https://italonascimento.github.io
// /applying-a-timeout-to-your-promises/#implementing-the-timeout
export function withTimeout<T>(ms: number, promise: Promise<T>) {
// Create a promise that rejects in <ms> milliseconds
const timeout = new Promise((resolve, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(`Timed out in ${ms} ms.`);
}, ms);
});
// Returns a race between our timeout and the passed in promise
return Promise.race([promise, timeout]);
}

View File

@ -13,7 +13,7 @@ export async function verify() {
const url = API.fetchBrowserLocation();
try {
let r: HttpData<AuthState> = await axios.put(url + "/api/users/verify/" + token);
Session.replace(r.data);
Session.replaceToken(r.data);
window.location.href = window.location.origin + "/app/controls";
} catch (e) {
document.write(`