diff --git a/ui/src/Layout.js b/ui/src/Layout.js index 69f5e6f..77497b6 100644 --- a/ui/src/Layout.js +++ b/ui/src/Layout.js @@ -8,15 +8,15 @@ import Login from './pages/Login'; import axios from 'axios'; import {createMuiTheme, MuiThemeProvider, withStyles} from 'material-ui/styles'; import config from 'react-global-configuration'; -import CurrentUserStore from './stores/CurrentUserStore'; +import GlobalStore from './stores/GlobalStore'; import {HashRouter, Redirect, Route, Switch} from 'react-router-dom'; -import {getToken} from './actions/defaultAxios'; import Applications from './pages/Applications'; import Clients from './pages/Clients'; import Users from './pages/Users'; import PropTypes from 'prop-types'; import SettingsDialog from './component/SettingsDialog'; import SnackBarHandler from './component/SnackBarHandler'; +import LoadingSpinner from './component/LoadingSpinner'; const lightTheme = createMuiTheme({ palette: { @@ -49,9 +49,10 @@ class Layout extends Component { darkTheme: true, redirect: false, showSettings: false, - loggedIn: CurrentUserStore.isLoggedIn(), - admin: CurrentUserStore.isAdmin(), - name: CurrentUserStore.getName(), + loggedIn: GlobalStore.isLoggedIn(), + admin: GlobalStore.isAdmin(), + name: GlobalStore.getName(), + authenticating: GlobalStore.authenticating(), version: Layout.defaultVersion, }; @@ -64,11 +65,11 @@ class Layout extends Component { } componentWillMount() { - CurrentUserStore.on('change', this.updateUser); + GlobalStore.on('change', this.updateUser); } componentWillUnmount() { - CurrentUserStore.removeListener('change', this.updateUser); + GlobalStore.removeListener('change', this.updateUser); } toggleTheme = () => this.setState({...this.state, darkTheme: !this.state.darkTheme}); @@ -76,9 +77,10 @@ class Layout extends Component { updateUser = () => { this.setState({ ...this.state, - loggedIn: CurrentUserStore.isLoggedIn(), - admin: CurrentUserStore.isAdmin(), - name: CurrentUserStore.getName(), + loggedIn: GlobalStore.isLoggedIn(), + admin: GlobalStore.isAdmin(), + name: GlobalStore.getName(), + authenticating: GlobalStore.authenticating(), }); }; @@ -86,15 +88,13 @@ class Layout extends Component { showSettings = () => this.setState({...this.state, showSettings: true}); render() { - const {name, admin, version, loggedIn, showSettings} = this.state; + const {name, admin, version, loggedIn, showSettings, authenticating} = this.state; const {classes} = this.props; const theme = this.state.darkTheme ? darkTheme : lightTheme; return ( -
-
@@ -102,9 +102,10 @@ class Layout extends Component {
+ {authenticating ? : null} (loggedIn ? () : ())}/> - {(loggedIn || getToken() != null) ? null : } + {loggedIn ? null : } @@ -117,7 +118,6 @@ class Layout extends Component {
-
); } diff --git a/ui/src/actions/GlobalAction.js b/ui/src/actions/GlobalAction.js index 644b441..a0c04f5 100644 --- a/ui/src/actions/GlobalAction.js +++ b/ui/src/actions/GlobalAction.js @@ -4,14 +4,14 @@ import * as MessageAction from './MessageAction'; import * as ClientAction from './ClientAction'; import dispatcher from '../stores/dispatcher'; -/** Calls all actions to initialize the state. */ -export function initialLoad() { +export function initialLoad(resp) { AppAction.fetchApps(); - UserAction.fetchCurrentUser(); MessageAction.fetchMessages(); MessageAction.listenToWebSocket(); ClientAction.fetchClients(); - UserAction.fetchUsers(); + if (resp.data.admin) { + UserAction.fetchUsers(); + } } export function snack(message) { diff --git a/ui/src/actions/MessageAction.js b/ui/src/actions/MessageAction.js index 2b04666..aad97ed 100644 --- a/ui/src/actions/MessageAction.js +++ b/ui/src/actions/MessageAction.js @@ -3,6 +3,7 @@ import config from 'react-global-configuration'; import axios from 'axios'; import {getToken} from './defaultAxios'; import {snack} from './GlobalAction'; +import * as UserAction from './UserAction'; /** Fetches all messages from the current user. */ export function fetchMessages() { @@ -51,5 +52,5 @@ export function listenToWebSocket() { ws.onmessage = (data) => dispatcher.dispatch({type: 'ONE_MESSAGE', payload: JSON.parse(data.data)}); - ws.onclose = (data) => console.log('WebSocket closed, this normally means the client was deleted.', data); + ws.onclose = () => UserAction.tryAuthenticate().then(listenToWebSocket); } diff --git a/ui/src/actions/UserAction.js b/ui/src/actions/UserAction.js index 46874c5..1ce4b28 100644 --- a/ui/src/actions/UserAction.js +++ b/ui/src/actions/UserAction.js @@ -15,6 +15,7 @@ import {snack} from './GlobalAction'; export function login(username, password) { const browser = detect(); const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser'; + authenticating(); axios.create().request(config.get('url') + 'client', { method: 'POST', data: {name: name}, @@ -22,8 +23,12 @@ export function login(username, password) { }).then(function(resp) { snack(`A client named '${name}' was created for your session.`); setAuthorizationToken(resp.data.token); - GlobalAction.initialLoad(); - }).catch(() => snack('Login failed')); + tryAuthenticate().then(GlobalAction.initialLoad) + .catch(() => console.log('create client succeeded, but authenticated with given token failed')); + }).catch(() => { + snack('Login failed'); + noAuthentication(); + }); } /** Log the user out. */ @@ -31,18 +36,43 @@ export function logout() { if (getToken() !== null) { axios.delete(config.get('url') + 'client/' + ClientStore.getIdByToken(getToken())).then(() => { setAuthorizationToken(null); - dispatcher.dispatch({type: 'REMOVE_CURRENT_USER'}); + noAuthentication(); }); } } -/** Fetches the current user. */ -export function fetchCurrentUser() { - axios.get(config.get('url') + 'current/user').then(function(resp) { - dispatcher.dispatch({type: 'SET_CURRENT_USER', payload: resp.data}); +export function tryAuthenticate() { + return axios.create().get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': getToken()}}).then((resp) => { + dispatcher.dispatch({type: 'AUTHENTICATED', payload: resp.data}); + return resp; + }).catch((resp) => { + if (getToken()) { + setAuthorizationToken(null); + snack('Authentication failed, try to re-login. (client or user was deleted)'); + } + noAuthentication(); + return Promise.reject(resp); }); } +export function checkIfAlreadyLoggedIn() { + const token = getToken(); + if (token) { + setAuthorizationToken(token); + tryAuthenticate().then(GlobalAction.initialLoad); + } else { + noAuthentication(); + } +} + +function noAuthentication() { + dispatcher.dispatch({type: 'NO_AUTHENTICATION'}); +} + +function authenticating() { + dispatcher.dispatch({type: 'AUTHENTICATING'}); +} + /** * Changes the current user. * @param {string} pass @@ -86,7 +116,7 @@ export function createUser(name, pass, admin) { export function updateUser(id, name, pass, admin) { axios.post(config.get('url') + 'user/' + id, {name, pass, admin}).then(function() { fetchUsers(); - fetchCurrentUser(); // just in case update current user + tryAuthenticate(); // try authenticate updates the current user snack('User updated'); }); } diff --git a/ui/src/actions/defaultAxios.js b/ui/src/actions/defaultAxios.js index 132e819..04deaae 100644 --- a/ui/src/actions/defaultAxios.js +++ b/ui/src/actions/defaultAxios.js @@ -1,17 +1,14 @@ import axios from 'axios'; -import dispatcher from '../stores/dispatcher'; -import * as GlobalAction from './GlobalAction'; import {snack} from './GlobalAction'; +import {tryAuthenticate} from './UserAction'; -let currentToken = null; const tokenKey = 'gotify-login-key'; /** * Set the authorization token for the next requests. - * @param {string} token the gotify application token + * @param {string|null} token the gotify application token */ export function setAuthorizationToken(token) { - currentToken = token; if (token) { localStorage.setItem(tokenKey, token); axios.defaults.headers.common['X-Gotify-Key'] = token; @@ -28,9 +25,7 @@ axios.interceptors.response.use(undefined, (error) => { } if (error.response.status === 401) { - snack('Authentication failed'); - setAuthorizationToken(null); - dispatcher.dispatch({type: 'REMOVE_CURRENT_USER'}); + tryAuthenticate().then(() => snack('Could not complete request.')); } return Promise.reject(error); @@ -40,14 +35,5 @@ axios.interceptors.response.use(undefined, (error) => { * @return {string} the application token */ export function getToken() { - return currentToken; -} - -/** Checks if the current user is logged, if so update the state. */ -export function checkIfAlreadyLoggedIn() { - const key = localStorage.getItem(tokenKey); - if (key) { - setAuthorizationToken(key); - GlobalAction.initialLoad(); - } + return localStorage.getItem(tokenKey); } diff --git a/ui/src/component/LoadingSpinner.js b/ui/src/component/LoadingSpinner.js new file mode 100644 index 0000000..30e4848 --- /dev/null +++ b/ui/src/component/LoadingSpinner.js @@ -0,0 +1,18 @@ +import React, {Component} from 'react'; +import {CircularProgress} from 'material-ui/Progress'; +import DefaultPage from './DefaultPage'; +import Grid from 'material-ui/Grid'; + +class LoadingSpinner extends Component { + render() { + return ( + + + + + + ); + } +} + +export default LoadingSpinner; diff --git a/ui/src/index.js b/ui/src/index.js index 2e09a4a..4bee0b8 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -2,11 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import Layout from './Layout'; import registerServiceWorker from './registerServiceWorker'; -import {checkIfAlreadyLoggedIn} from './actions/defaultAxios'; import config from 'react-global-configuration'; import * as Notifications from './stores/Notifications'; import 'typeface-roboto'; import 'typeface-roboto-mono'; +import * as UserAction from './actions/UserAction'; const defaultDevConfig = { url: 'http://localhost:80/', @@ -29,7 +29,7 @@ const defaultProdConfig = { } else { config.set(window.config || defaultDevConfig); } - checkIfAlreadyLoggedIn(); + UserAction.checkIfAlreadyLoggedIn(); ReactDOM.render(, document.getElementById('root')); registerServiceWorker(); }()); diff --git a/ui/src/stores/CurrentUserStore.js b/ui/src/stores/GlobalStore.js similarity index 60% rename from ui/src/stores/CurrentUserStore.js rename to ui/src/stores/GlobalStore.js index 5164458..95e0f4d 100644 --- a/ui/src/stores/CurrentUserStore.js +++ b/ui/src/stores/GlobalStore.js @@ -1,10 +1,15 @@ import {EventEmitter} from 'events'; import dispatcher from './dispatcher'; -class CurrentUserStore extends EventEmitter { +class GlobalStore extends EventEmitter { constructor() { super(); this.currentUser = null; + this.isAuthenticating = true; + } + + authenticating() { + return this.isAuthenticating; } get() { @@ -24,19 +29,23 @@ class CurrentUserStore extends EventEmitter { } set(user) { + this.isAuthenticating = false; this.currentUser = user; this.emit('change'); } handle(data) { - if (data.type === 'REMOVE_CURRENT_USER') { + if (data.type === 'NO_AUTHENTICATION') { this.set(null); - } else if (data.type === 'SET_CURRENT_USER') { + } else if (data.type === 'AUTHENTICATED') { this.set(data.payload); + } else if (data.type === 'AUTHENTICATING') { + this.isAuthenticating = true; + this.emit('change'); } } } -const store = new CurrentUserStore(); +const store = new GlobalStore(); dispatcher.register(store.handle.bind(store)); export default store;