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:
rjwats
2020-02-09 10:21:13 +00:00
committed by GitHub
parent ea6aa78d60
commit 260e9a18d0
121 changed files with 7450 additions and 5963 deletions

View File

@ -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));

View 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));

View File

@ -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);
}

View 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>
);
}
};
}

View File

@ -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))

View File

@ -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>
);
};
}

View File

@ -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);

View 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);

View 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';