summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorJon Bergli Heier <snakebite@jvnv.net>2020-11-16 20:32:50 +0100
committerJon Bergli Heier <snakebite@jvnv.net>2020-11-16 20:32:50 +0100
commitb03234567aa2069f777a7a5d38a2ae0bc0d7ebc5 (patch)
treec59c86f45eb63c1d8b6cb3fcdad4ce16fc483887 /frontend/src
parentf23c03a3375cce6c16ba9255abaddf03efca01eb (diff)
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.
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/app.js20
-rw-r--r--frontend/src/auth.js185
-rw-r--r--frontend/src/flash.js49
-rw-r--r--frontend/src/index.js66
-rw-r--r--frontend/src/theme.js55
5 files changed, 375 insertions, 0 deletions
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 <UserHome />;
+ }
+ return <Jumbotron>
+ <h1>Inventory</h1>
+ <p>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.</p>
+ </Jumbotron>
+}
+
+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 <div>Redirecting to <a href={this.state.url}>{this.state.url}</a></div>;
+ }
+ return <div>Logging in…</div>;
+ }
+ 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 <Redirect to='/' />;
+ }
+ return <div>Logging out…</div>;
+ }
+}
+
+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 <Redirect to='/' />;
+ }
+ 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 <UserContext.Provider value={{
+ username: this.state.username,
+ is_logged_in: this.state.is_logged_in,
+ get_user: this.get_user,
+ logout: this.logout,
+ get_token: this.get_token}}>
+ {this.props.children}
+ </UserContext.Provider>;
+ }
+ 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 <Toast key={message.seq} onClose={() => that.remove(message.seq)} autohide={true} delay={(index+1)*3000}>
+ <Toast.Header><strong className="mr-auto">{message.header || 'Inventory'}</strong></Toast.Header>
+ <Toast.Body>{message.text}</Toast.Body>
+ </Toast>;
+ });
+ return <Container style={{position: 'absolute', top: 0, right: 0}}>
+ {messages}
+ </Container>;
+ }
+ 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 <Nav.Link as={Link} to="/login">Login</Nav.Link>
+ }
+ return <>
+ <Nav.Link as={Link} to="/logout">Logout [{props.value.username}]</Nav.Link>
+ </>;
+}
+
+function Navigation() {
+ return <ThemeContext.Consumer>
+ {theme => (
+ <Navbar bg={theme.bg} variant={theme.bg} expand="lg">
+ <Navbar.Brand as={Link} to="/">Inventory</Navbar.Brand>
+ <Navbar.Toggle aria-controls="navbar-content" />
+ <Navbar.Collapse id="navbar-content">
+ <Nav className="mr-auto">
+ <Nav.Link as={Link} to="/">Home</Nav.Link>
+ <UserContext.Consumer>
+ {value => <LoginNavigation value={value} />}
+ </UserContext.Consumer>
+ </Nav>
+ <ThemeSwitcher />
+ </Navbar.Collapse>
+ </Navbar>)}
+ </ThemeContext.Consumer>;
+}
+
+function Router() {
+ return <BrowserRouter>
+ <Navigation />
+ <Flash />
+ <Container fluid>
+ <Switch>
+ <Route path="/login" component={Login} />
+ <Route path="/logout" component={Logout} />
+ <Route path="/oauth-callback" component={OauthCallback} />
+ <Route path="/" component={Main} />
+ </Switch>
+ </Container>
+ </BrowserRouter>;
+}
+
+function Root() {
+ return <AuthenticationProvider>
+ <ThemeProvider>
+ <Router />
+ </ThemeProvider>
+ </AuthenticationProvider>;
+}
+
+ReactDOM.render(
+ <React.StrictMode>
+ <Root />
+ </React.StrictMode>,
+ 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 <ThemeContext.Provider value={{...themes[this.state.theme], switch_theme: this.switch_theme}}>
+ {this.props.children}
+ </ThemeContext.Provider>;
+ }
+ 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 <Button size='sm' variant={this.context.bg} onClick={this.context.switch_theme}>Switch theme</Button>;
+ }
+}
+
+export { ThemeContext, ThemeProvider, ThemeSwitcher };