Skip to content

Commit

Permalink
Merge pull request #433 from smacker/add_router
Browse files Browse the repository at this point in the history
add client-side router
  • Loading branch information
smacker authored Dec 28, 2018
2 parents 2da3a3a + 9200cf9 commit d91461d
Show file tree
Hide file tree
Showing 12 changed files with 323 additions and 114 deletions.
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"@types/react-dom": "^16.0.11",
"react": "^16.6.3",
"react-dom": "^16.6.3",
"react-router-dom": "^4.3.1",
"react-scripts": "2.1.1",
"typescript": "^3.2.2"
},
Expand All @@ -28,6 +29,7 @@
"not op_mini all"
],
"devDependencies": {
"@types/react-router-dom": "^4.3.1",
"jest-fetch-mock": "^2.1.0"
}
}
5 changes: 4 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);

// Unmount raises error about update of unmounted component
// due to using promises for checking authorization
setTimeout(() => ReactDOM.unmountComponentAtNode(div), 0);
});
163 changes: 86 additions & 77 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,107 +1,116 @@
import React, { Component, ReactElement } from 'react';
import Token from './services/token';
import * as api from './api';
import React, { Component } from 'react';
import {
BrowserRouter as Router,
Route,
Redirect,
Link,
RouteProps,
RouteComponentProps
} from 'react-router-dom';
import { User } from './services/auth';
import Auth from './services/auth';
import Loader from './components/Loader';
import Callback from './Callback';
import './App.css';

function Loader() {
return <div>loading...</div>;
function Login() {
return (
<header className="App-header">
<a className="App-link" href={Auth.loginUrl}>
Login using Github
</a>
</header>
);
}

interface ErrorProps {
errors: string[];
function Logout() {
Auth.logout();

return <Redirect to="/" />;
}

function Errors({ errors }: ErrorProps) {
return <div>{errors.join(',')}</div>;
interface IndexProps {
user: User;
}

function Login() {
function Index({ user }: IndexProps) {
return (
<header className="App-header">
<a className="App-link" href={api.loginUrl}>
Login using Github
</a>
Hello {user.name}! <Link to="/logout">Logout</Link>
</header>
);
}

interface HelloProps {
name: string;
interface PrivateRouteState {
isAuthenticated: boolean | undefined;
}

function Hello({ name }: HelloProps) {
return <header className="App-header">Hello {name}!</header>;
interface PrivateRouteComponentProps<P> extends RouteComponentProps {
user: User | null;
}

interface AppState {
// we need undefined state for initial render
loggedIn: boolean | undefined;
name: string;
errors: string[];
interface PrivateRouteProps extends RouteProps {
component:
| React.ComponentType<PrivateRouteComponentProps<any>>
| React.ComponentType<any>;
}

class App extends Component<{}, AppState> {
constructor(props: {}) {
super(props);

this.fetchState = this.fetchState.bind(this);
function PrivateRoute({ component, ...rest }: PrivateRouteProps) {
class CheckAuthComponent extends Component<
RouteComponentProps,
PrivateRouteState
> {
constructor(props: RouteComponentProps) {
super(props);

this.state = {
loggedIn: undefined,
name: '',
errors: []
};
}

componentDidMount() {
// TODO: add router and use it instead of this "if"
if (window.location.pathname === '/callback') {
api
.callback(window.location.search)
.then(resp => {
Token.set(resp.token);
window.history.replaceState({}, '', '/');
})
.then(this.fetchState)
.catch(errors => this.setState({ errors }));
return;
this.state = { isAuthenticated: undefined };
}

if (!Token.exists()) {
this.setState({ loggedIn: false });
return;
componentDidMount() {
Auth.isAuthenticated
.then(ok => this.setState({ isAuthenticated: ok }))
.catch(() => this.setState({ isAuthenticated: false }));
}

// ignore error here, just ask user to re-login
// it would cover all cases like expired token, changes on backend and so on
this.fetchState().catch(err => console.error(err));
}

fetchState() {
return api
.me()
.then(resp => this.setState({ loggedIn: true, name: resp.name }))
.catch(err => {
this.setState({ loggedIn: false });

throw err;
});
render() {
if (!component) {
return null;
}

if (this.state.isAuthenticated === true) {
const Component = component;
return <Component {...this.props} user={Auth.user} />;
}

if (this.state.isAuthenticated === false) {
return (
<Redirect
to={{
pathname: '/login',
state: { from: this.props.location }
}}
/>
);
}

return <Loader />;
}
}

render() {
const { loggedIn, name, errors } = this.state;

let content: ReactElement<any>;
if (errors.length) {
content = <Errors errors={errors} />;
} else if (typeof loggedIn === 'undefined') {
content = <Loader />;
} else {
content = loggedIn ? <Hello name={name} /> : <Login />;
}
return <Route {...rest} component={CheckAuthComponent} />;
}

return <div className="App">{content}</div>;
}
function AppRouter() {
return (
<Router>
<div className="App">
<PrivateRoute path="/" exact component={Index} />
<Route path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<Route path="/callback" component={Callback} />
</div>
</Router>
);
}

export default App;
export default AppRouter;
50 changes: 50 additions & 0 deletions frontend/src/Callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { Component } from 'react';
import { Redirect } from 'react-router-dom';
import * as H from 'history';
import Auth from './services/auth';
import * as api from './api';
import Loader from './components/Loader';
import Errors from './components/Errors';

interface CallbackProps {
location: H.Location;
}

interface CallbackState {
success: boolean;
errors: string[];
}

class Callback extends Component<CallbackProps, CallbackState> {
constructor(props: CallbackProps) {
super(props);

this.state = {
success: false,
errors: []
};
}

componentDidMount() {
Auth.callback(this.props.location.search)
.then(() => this.setState({ success: true }))
.catch(errors => this.setState({ errors }));
}

render() {
const { errors, success } = this.state;

if (errors.length) {
return <Errors errors={errors} />;
}

if (success) {
const { from } = this.props.location.state || { from: { pathname: '/' } };
return <Redirect to={from} />;
}

return <Loader />;
}
}

export default Callback;
8 changes: 4 additions & 4 deletions frontend/src/api.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { GlobalWithFetchMock } from 'jest-fetch-mock';
import Token from './services/token';
import Auth from './services/auth';
import { apiCall } from './api';

// can be moved to setupFiles later if needed
Expand All @@ -13,7 +13,7 @@ describe('api', () => {
});

it('apiCall ok', () => {
Token.set('token');
window.localStorage.setItem('token', 'token');
fetchMock.mockResponseOnce(JSON.stringify({ data: 'result' }));

return apiCall('/test').then(resp => {
Expand Down Expand Up @@ -56,12 +56,12 @@ describe('api', () => {
});

it('apiCall removes token on unauthorized response', () => {
Token.set('token');
window.localStorage.setItem('token', 'token');
fetchMock.mockResponseOnce('', { status: 401 });

return apiCall('/test').catch(err => {
expect(err).toEqual(['Unauthorized']);
expect(Token.get()).toBe(null);
expect(Auth.token).toBe(null);
});
});
});
7 changes: 3 additions & 4 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import lookoutOptions from './services/options';
import TokenService from './services/token';
import Auth from './services/auth';

export const serverUrl = lookoutOptions.SERVER_URL || 'http://127.0.0.1:8080';

Expand All @@ -19,11 +19,10 @@ export function apiCall<T>(
url: string,
options: ApiCallOptions = {}
): Promise<T> {
const token = TokenService.get();
const fetchOptions: RequestInit = {
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${Auth.token}`,
'Content-Type': 'application/json'
},
body: null
Expand All @@ -37,7 +36,7 @@ export function apiCall<T>(
if (!response.ok) {
// when server return Unauthorized we need to remove token
if (response.status === 401) {
TokenService.remove();
Auth.logout();
}

return response
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/Errors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';

interface ErrorProps {
errors: string[];
}

function Errors({ errors }: ErrorProps) {
return <div>{errors.join(',')}</div>;
}

export default Errors;
7 changes: 7 additions & 0 deletions frontend/src/components/Loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

function Loader() {
return <div>loading...</div>;
}

export default Loader;
Loading

0 comments on commit d91461d

Please sign in to comment.