From b03234567aa2069f777a7a5d38a2ae0bc0d7ebc5 Mon Sep 17 00:00:00 2001 From: Jon Bergli Heier Date: Mon, 16 Nov 2020 20:32:50 +0100 Subject: Add frontend Authentication is currently working but no other functionality is yet implemented. Authentication is done using OAuth 2 via the backend, currently we assume jab is being used. --- frontend/src/app.js | 20 ++++++ frontend/src/auth.js | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/flash.js | 49 +++++++++++++ frontend/src/index.js | 66 ++++++++++++++++++ frontend/src/theme.js | 55 +++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 frontend/src/app.js create mode 100644 frontend/src/auth.js create mode 100644 frontend/src/flash.js create mode 100644 frontend/src/index.js create mode 100644 frontend/src/theme.js (limited to 'frontend/src') diff --git a/frontend/src/app.js b/frontend/src/app.js new file mode 100644 index 0000000..d445faa --- /dev/null +++ b/frontend/src/app.js @@ -0,0 +1,20 @@ +import React, { useContext } from 'react'; +import { Jumbotron } from 'react-bootstrap'; +import { UserContext } from './auth.js'; + +function UserHome() { + return 'user logged in'; +} + +function Main() { + const user = useContext(UserContext); + if (user.is_logged_in) { + return ; + } + return +

Inventory

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla pharetra fringilla dolor, sed tempor est. Maecenas malesuada feugiat nisl ut hendrerit. Donec faucibus augue quis mi fermentum, ac tempus neque dignissim. Nullam egestas bibendum enim, at feugiat lorem consectetur eu. Ut faucibus, dolor gravida ultricies malesuada, leo ex volutpat turpis, at placerat lectus mauris ut tortor. Phasellus sit amet lorem laoreet nunc sollicitudin pharetra. Pellentesque laoreet est lacinia velit porta aliquam ut quis sem. Sed vel convallis purus. Nunc elementum fermentum leo sit amet elementum.

+
+} + +export default Main; diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..0ed1e1a --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,185 @@ +import React from 'react'; +import { Redirect, generatePath } from 'react-router-dom'; +import { isExpired } from 'react-jwt'; + +const UserContext = React.createContext(); + +class Login extends React.Component { + constructor(props) { + super(props); + this.state = {}; + } + async componentDidMount() { + let url = await this.get_oauth_url(); + this.setState({url: url}); + window.location = url; + } + render() { + if (this.state.url) { + return
Redirecting to {this.state.url}
; + } + return
Logging in…
; + } + async get_oauth_url() { + let redirect_uri = window.location.origin + generatePath('/oauth-callback'); + let response = await fetch('/api/auth/request', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'redirect_uri=' + encodeURIComponent(redirect_uri), + }); + let data = await response.json(); + if (response.ok && data.url) { + return data.url; + } + } +} + +class Logout extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + this.state = {done: false}; + } + async componentDidMount() { + await this.context.logout(); + this.setState({done: true}); + } + render() { + if (this.state.done) { + return ; + } + return
Logging out…
; + } +} + +class OauthCallback extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + this.state = { + error: null, + done: false, + }; + } + async componentDidMount() { + this.setState({error: null}); + let params = new URLSearchParams(this.props.location.search); + let error = params.get('error'); + if (error) { + this.setState({error: params.get('error_description')}); + return; + } + let redirect_uri = window.location.origin + generatePath('/oauth-callback'); + let response = await fetch('/api/auth/response', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'code=' + encodeURIComponent(params.get('code')) + '&state=' + encodeURIComponent(params.get('state')) + + '&redirect_uri=' + encodeURIComponent(redirect_uri), + }); + let data = await response.json(); + if (!response.ok) { + this.setState({error: data.message}); + return; + } + const user = await this.context.get_user(); + if (user) { + this.setState({error: null, done: true}); + window.flash({header: 'Logged in', text: 'You are now logged in as ' + user.username + '.'}); + } else { + window.flash({header: 'Login failed', text: 'Something failed during authentication.'}); + } + } + render() { + if (this.state.error) { + return this.state.error; + } + if (this.state.done) { + return ; + } + return 'Logging in…'; + } +} + +class AuthenticationProvider extends React.Component { + constructor(props) { + super(props); + this.state = { + username: null, + is_logged_in: false, + access_token: null, + }; + this.logout = this.logout.bind(this); + this.get_user = this.get_user.bind(this); + this.get_token = this.get_token.bind(this); + } + async componentDidMount() { + await this.get_user(); + } + render() { + return + {this.props.children} + ; + } + async logout() { + const was_logged_in = this.state.is_logged_in; + await fetch('/api/auth/logout', { + method: 'POST', + }); + this.setState({username: null, is_logged_in: false, access_token: null}); + if (was_logged_in) { + window.flash({header: 'Logged out', text: 'You are now logged out.'}); + } + } + async get_user() { + let token = await this.get_token(); + if (!token) { + return; + } + let response = await fetch('/api/user',{ + headers: { + Authorization: 'Bearer ' + token, + } + }); + if (response.ok) { + let data = await response.json(); + this.setState({username: data.username, is_logged_in: true}); + return data; + } else { + await this.logout(); + } + } + async refresh() { + let response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: '', + }); + if (response.ok) { + let data = await response.json(); + return data.access_token; + } + } + async get_token() { + let access_token = this.state.access_token; + let is_expired = !access_token || isExpired(access_token); + if (is_expired) { + access_token = await this.refresh(); + this.setState({access_token: access_token}); + } + return access_token; + } +} + +export { UserContext, Login, Logout, OauthCallback, AuthenticationProvider }; diff --git a/frontend/src/flash.js b/frontend/src/flash.js new file mode 100644 index 0000000..44b9479 --- /dev/null +++ b/frontend/src/flash.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { Container } from 'react-bootstrap'; +import Toast from 'react-bootstrap/Toast'; + +class Flash extends React.Component { + constructor(props) { + super(props); + this.state = { + messages: [], + } + this.seq = 0; + } + componentDidMount() { + window.flash = this.flash.bind(this); + } + componentWillUnmount() { + window.flash = null; + } + render() { + if (this.state.messages.length === 0) { + return ''; + } + let that = this; + const messages = this.state.messages.map(function(message, index) { + return that.remove(message.seq)} autohide={true} delay={(index+1)*3000}> + {message.header || 'Inventory'} + {message.text} + ; + }); + return + {messages} + ; + } + remove(seq) { + this.setState(function(state, props) { + const messages = state.messages.filter((message) => message.seq !== seq); + return { messages }; + }); + } + flash(message) { + message.seq = ++this.seq; + this.setState(function(state, props) { + const messages = [...state.messages, message]; + return { messages }; + }); + } +} + +export { Flash }; diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..c2a2b74 --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,66 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter, Link, Switch, Route } from 'react-router-dom'; +import { Navbar, Nav, Container } from 'react-bootstrap'; +import { UserContext, Login, Logout, OauthCallback, AuthenticationProvider } from './auth.js'; +import { Flash } from './flash'; +import { ThemeContext, ThemeProvider, ThemeSwitcher } from './theme.js'; +import Main from './app'; + +function LoginNavigation(props) { + if (!props.value.is_logged_in) { + return Login + } + return <> + Logout [{props.value.username}] + ; +} + +function Navigation() { + return + {theme => ( + + Inventory + + + + + + )} + ; +} + +function Router() { + return + + + + + + + + + + + ; +} + +function Root() { + return + + + + ; +} + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..649e4d6 --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { Button } from 'react-bootstrap'; + +const ThemeContext = React.createContext(); + +const themes = { + 'flatly': { + css: '/bootstrap.flatly.min.css', + bg: 'light', + }, + 'darkly': { + css: '/bootstrap.darkly.min.css', + bg: 'dark', + }, +}; + +class ThemeProvider extends React.Component { + constructor(props) { + super(props); + this.state = { + theme: localStorage.getItem('theme') || 'flatly', + }; + this.switch_theme = this.switch_theme.bind(this); + } + componentDidMount() { + this.set_theme(); + } + componentDidUpdate() { + this.set_theme(); + } + render() { + return + {this.props.children} + ; + } + set_theme() { + let e = document.getElementById('theme-stylesheet'); + const theme = themes[this.state.theme]; + e.href = theme.css; + localStorage.setItem('theme', this.state.theme); + } + switch_theme() { + const new_theme = (this.state.theme === 'flatly') ? 'darkly' : 'flatly'; + this.setState({theme: new_theme}); + } +} + +class ThemeSwitcher extends React.Component { + static contextType = ThemeContext; + render() { + return ; + } +} + +export { ThemeContext, ThemeProvider, ThemeSwitcher }; -- cgit v1.2.3