Re-engineer UI in TypeScript (#89)
* Re-engineer UI in TypeScript * Switch to named imports where possible * Restructure file system layout * Update depencencies * Update README.md * Change explicit colors for better support for dark theme
This commit is contained in:
@ -1,36 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Redirect, Route
|
||||
} from "react-router-dom";
|
||||
|
||||
import { withAuthenticationContext } from './Context.js';
|
||||
import * as Authentication from './Authentication';
|
||||
import { withSnackbar } from 'notistack';
|
||||
|
||||
export class AuthenticatedRoute extends React.Component {
|
||||
|
||||
render() {
|
||||
const { enqueueSnackbar, authenticationContext, component: Component, ...rest } = this.props;
|
||||
const { location } = this.props;
|
||||
const renderComponent = (props) => {
|
||||
if (authenticationContext.isAuthenticated()) {
|
||||
return (
|
||||
<Component {...props} />
|
||||
);
|
||||
}
|
||||
Authentication.storeLoginRedirect(location);
|
||||
enqueueSnackbar("Please log in to continue.", {
|
||||
variant: 'info',
|
||||
});
|
||||
return (
|
||||
<Redirect to='/' />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Route {...rest} render={renderComponent} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withSnackbar(withAuthenticationContext(AuthenticatedRoute));
|
42
interface/src/authentication/AuthenticatedRoute.tsx
Normal file
42
interface/src/authentication/AuthenticatedRoute.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-dom";
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
|
||||
import * as Authentication from './Authentication';
|
||||
import { withAuthenticationContext, AuthenticationContextProps, AuthenticatedContext } from './AuthenticationContext';
|
||||
|
||||
type ChildComponent = React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
|
||||
|
||||
interface AuthenticatedRouteProps extends RouteProps, WithSnackbarProps, AuthenticationContextProps {
|
||||
component: ChildComponent;
|
||||
}
|
||||
|
||||
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
|
||||
|
||||
export class AuthenticatedRoute extends React.Component<AuthenticatedRouteProps> {
|
||||
|
||||
render() {
|
||||
const { enqueueSnackbar, authenticationContext, component: Component, ...rest } = this.props;
|
||||
const { location } = this.props;
|
||||
const renderComponent: RenderComponent = (props) => {
|
||||
if (authenticationContext.me) {
|
||||
return (
|
||||
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContext}>
|
||||
<Component {...props} />
|
||||
</AuthenticatedContext.Provider>
|
||||
);
|
||||
}
|
||||
Authentication.storeLoginRedirect(location);
|
||||
enqueueSnackbar("Please log in to continue.", { variant: 'info' });
|
||||
return (
|
||||
<Redirect to='/' />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Route {...rest} render={renderComponent} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withSnackbar(withAuthenticationContext(AuthenticatedRoute));
|
@ -1,11 +1,13 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import history from '../history';
|
||||
import { PROJECT_PATH } from '../constants/Env';
|
||||
import { PROJECT_PATH } from '../api';
|
||||
|
||||
export const ACCESS_TOKEN = 'access_token';
|
||||
export const LOGIN_PATHNAME = 'loginPathname';
|
||||
export const LOGIN_SEARCH = 'loginSearch';
|
||||
|
||||
export function storeLoginRedirect(location) {
|
||||
export function storeLoginRedirect(location?: H.Location) {
|
||||
if (location) {
|
||||
localStorage.setItem(LOGIN_PATHNAME, location.pathname);
|
||||
localStorage.setItem(LOGIN_SEARCH, location.search);
|
||||
@ -17,7 +19,7 @@ export function clearLoginRedirect() {
|
||||
localStorage.removeItem(LOGIN_SEARCH);
|
||||
}
|
||||
|
||||
export function fetchLoginRedirect() {
|
||||
export function fetchLoginRedirect(): H.LocationDescriptorObject {
|
||||
const loginPathname = localStorage.getItem(LOGIN_PATHNAME);
|
||||
const loginSearch = localStorage.getItem(LOGIN_SEARCH);
|
||||
clearLoginRedirect();
|
||||
@ -30,13 +32,15 @@ export function fetchLoginRedirect() {
|
||||
/**
|
||||
* Wraps the normal fetch routene with one with provides the access token if present.
|
||||
*/
|
||||
export function authorizedFetch(url, params) {
|
||||
export function authorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> {
|
||||
const accessToken = localStorage.getItem(ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
params = params || {};
|
||||
params.credentials = 'include';
|
||||
params.headers = params.headers || {};
|
||||
params.headers.Authorization = 'Bearer ' + accessToken;
|
||||
params.headers = {
|
||||
...params.headers,
|
||||
"Authorization": 'Bearer ' + accessToken
|
||||
};
|
||||
}
|
||||
return fetch(url, params);
|
||||
}
|
||||
@ -44,11 +48,11 @@ export function authorizedFetch(url, params) {
|
||||
/**
|
||||
* Wraps the normal fetch routene which redirects on 401 response.
|
||||
*/
|
||||
export function redirectingAuthorizedFetch(url, params) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
authorizedFetch(url, params).then(response => {
|
||||
if (response.status === 401) {
|
||||
history.push("/unauthorized");
|
||||
history.push("/unauthorized");
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
59
interface/src/authentication/AuthenticationContext.tsx
Normal file
59
interface/src/authentication/AuthenticationContext.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface Me {
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticationContext {
|
||||
refresh: () => void;
|
||||
signIn: (accessToken: string) => void;
|
||||
signOut: () => void;
|
||||
me?: Me;
|
||||
}
|
||||
|
||||
const AuthenticationContextDefaultValue = {} as AuthenticationContext
|
||||
export const AuthenticationContext = React.createContext(
|
||||
AuthenticationContextDefaultValue
|
||||
);
|
||||
|
||||
export interface AuthenticationContextProps {
|
||||
authenticationContext: AuthenticationContext;
|
||||
}
|
||||
|
||||
export function withAuthenticationContext<T extends AuthenticationContextProps>(Component: React.ComponentType<T>) {
|
||||
return class extends React.Component<Omit<T, keyof AuthenticationContextProps>> {
|
||||
render() {
|
||||
return (
|
||||
<AuthenticationContext.Consumer>
|
||||
{authenticationContext => <Component {...this.props as T} authenticationContext={authenticationContext} />}
|
||||
</AuthenticationContext.Consumer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthenticatedContext extends AuthenticationContext {
|
||||
me: Me;
|
||||
}
|
||||
|
||||
const AuthenticatedContextDefaultValue = {} as AuthenticatedContext
|
||||
export const AuthenticatedContext = React.createContext(
|
||||
AuthenticatedContextDefaultValue
|
||||
);
|
||||
|
||||
export interface AuthenticatedContextProps {
|
||||
authenticatedContext: AuthenticatedContext;
|
||||
}
|
||||
|
||||
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(Component: React.ComponentType<T>) {
|
||||
return class extends React.Component<Omit<T, keyof AuthenticatedContextProps>> {
|
||||
render() {
|
||||
return (
|
||||
<AuthenticatedContext.Consumer>
|
||||
{authenticatedContext => <Component {...this.props as T} authenticatedContext={authenticatedContext} />}
|
||||
</AuthenticatedContext.Consumer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,15 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import history from '../history'
|
||||
import { withSnackbar } from 'notistack';
|
||||
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../constants/Endpoints';
|
||||
import { ACCESS_TOKEN, authorizedFetch } from './Authentication';
|
||||
import { AuthenticationContext } from './Context';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
import { withStyles, Theme, createStyles, WithStyles } from '@material-ui/core/styles';
|
||||
|
||||
const styles = theme => ({
|
||||
import history from '../history'
|
||||
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
|
||||
import { ACCESS_TOKEN, authorizedFetch } from './Authentication';
|
||||
import { AuthenticationContext, Me } from './AuthenticationContext';
|
||||
|
||||
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken);
|
||||
|
||||
const styles = (theme: Theme) => createStyles({
|
||||
loadingPanel: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
@ -23,17 +27,22 @@ const styles = theme => ({
|
||||
}
|
||||
});
|
||||
|
||||
class AuthenticationWrapper extends React.Component {
|
||||
interface AuthenticationWrapperState {
|
||||
context: AuthenticationContext;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
type AuthenticationWrapperProps = WithSnackbarProps & WithStyles<typeof styles>;
|
||||
|
||||
class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> {
|
||||
|
||||
constructor(props: AuthenticationWrapperProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
context: {
|
||||
refresh: this.refresh,
|
||||
signIn: this.signIn,
|
||||
signOut: this.signOut,
|
||||
isAuthenticated: this.isAuthenticated,
|
||||
isAdmin: this.isAdmin
|
||||
},
|
||||
initialized: false
|
||||
};
|
||||
@ -72,33 +81,31 @@ class AuthenticationWrapper extends React.Component {
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
var accessToken = localStorage.getItem(ACCESS_TOKEN);
|
||||
const accessToken = localStorage.getItem(ACCESS_TOKEN)
|
||||
if (accessToken) {
|
||||
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
|
||||
.then(response => {
|
||||
const user = response.status === 200 ? jwtDecode(accessToken) : undefined;
|
||||
this.setState({ initialized: true, context: { ...this.state.context, user } });
|
||||
const me = response.status === 200 ? decodeMeJWT(accessToken) : undefined;
|
||||
this.setState({ initialized: true, context: { ...this.state.context, me } });
|
||||
}).catch(error => {
|
||||
this.setState({ initialized: true, context: { ...this.state.context, user: undefined } });
|
||||
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } });
|
||||
this.props.enqueueSnackbar("Error verifying authorization: " + error.message, {
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.setState({ initialized: true, context: { ...this.state.context, user: undefined } });
|
||||
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } });
|
||||
}
|
||||
}
|
||||
|
||||
signIn = (accessToken) => {
|
||||
signIn = (accessToken: string) => {
|
||||
try {
|
||||
localStorage.setItem(ACCESS_TOKEN, accessToken);
|
||||
const user = jwtDecode(accessToken);
|
||||
this.setState({ context: { ...this.state.context, user } });
|
||||
this.props.enqueueSnackbar(`Logged in as ${user.username}`, {
|
||||
variant: 'success',
|
||||
});
|
||||
const me: Me = decodeMeJWT(accessToken);
|
||||
this.setState({ context: { ...this.state.context, me } });
|
||||
this.props.enqueueSnackbar(`Logged in as ${me.username}`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
this.setState({ initialized: true, context: { ...this.state.context, user: undefined } });
|
||||
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } });
|
||||
throw new Error("Failed to parse JWT " + err.message);
|
||||
}
|
||||
}
|
||||
@ -108,24 +115,13 @@ class AuthenticationWrapper extends React.Component {
|
||||
this.setState({
|
||||
context: {
|
||||
...this.state.context,
|
||||
user: undefined
|
||||
me: undefined
|
||||
}
|
||||
});
|
||||
this.props.enqueueSnackbar("You have signed out.", {
|
||||
variant: 'success',
|
||||
});
|
||||
this.props.enqueueSnackbar("You have signed out.", { variant: 'success', });
|
||||
history.push('/');
|
||||
}
|
||||
|
||||
isAuthenticated = () => {
|
||||
return this.state.context.user;
|
||||
}
|
||||
|
||||
isAdmin = () => {
|
||||
const { context } = this.state;
|
||||
return context.user && context.user.admin;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withStyles(styles)(withSnackbar(AuthenticationWrapper))
|
@ -1,15 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const AuthenticationContext = React.createContext(
|
||||
{}
|
||||
);
|
||||
|
||||
export function withAuthenticationContext(Component) {
|
||||
return function AuthenticationContextComponent(props) {
|
||||
return (
|
||||
<AuthenticationContext.Consumer>
|
||||
{authenticationContext => <Component {...props} authenticationContext={authenticationContext} />}
|
||||
</AuthenticationContext.Consumer>
|
||||
);
|
||||
};
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Redirect, Route
|
||||
} from "react-router-dom";
|
||||
|
||||
import { withAuthenticationContext } from './Context.js';
|
||||
import * as Authentication from './Authentication';
|
||||
|
||||
class UnauthenticatedRoute extends React.Component {
|
||||
render() {
|
||||
const { authenticationContext, component:Component, ...rest } = this.props;
|
||||
const renderComponent = (props) => {
|
||||
if (authenticationContext.isAuthenticated()) {
|
||||
return (<Redirect to={Authentication.fetchLoginRedirect()} />);
|
||||
}
|
||||
return (<Component {...props} />);
|
||||
}
|
||||
return (
|
||||
<Route {...rest} render={renderComponent} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuthenticationContext(UnauthenticatedRoute);
|
28
interface/src/authentication/UnauthenticatedRoute.tsx
Normal file
28
interface/src/authentication/UnauthenticatedRoute.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-dom";
|
||||
|
||||
import { withAuthenticationContext, AuthenticationContextProps } from './AuthenticationContext';
|
||||
import * as Authentication from './Authentication';
|
||||
|
||||
interface UnauthenticatedRouteProps extends RouteProps {
|
||||
component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
|
||||
|
||||
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps & AuthenticationContextProps> {
|
||||
public render() {
|
||||
const { authenticationContext, component:Component, ...rest } = this.props;
|
||||
const renderComponent: RenderComponent = (props) => {
|
||||
if (authenticationContext.me) {
|
||||
return (<Redirect to={Authentication.fetchLoginRedirect()} />);
|
||||
}
|
||||
return (<Component {...props} />);
|
||||
}
|
||||
return (
|
||||
<Route {...rest} render={renderComponent} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuthenticationContext(UnauthenticatedRoute);
|
6
interface/src/authentication/index.ts
Normal file
6
interface/src/authentication/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { default as AuthenticatedRoute } from './AuthenticatedRoute';
|
||||
export { default as AuthenticationWrapper } from './AuthenticationWrapper';
|
||||
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
|
||||
|
||||
export * from './Authentication';
|
||||
export * from './AuthenticationContext';
|
Reference in New Issue
Block a user