Skip to content

Commit 2da3a3a

Browse files
authored
Merge pull request #425 from smacker/auth_frontend
Auth frontend
2 parents 6329144 + 676db5d commit 2da3a3a

File tree

11 files changed

+410
-42
lines changed

11 files changed

+410
-42
lines changed

frontend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@
2626
"not dead",
2727
"not ie <= 11",
2828
"not op_mini all"
29-
]
29+
],
30+
"devDependencies": {
31+
"jest-fetch-mock": "^2.1.0"
32+
}
3033
}

frontend/public/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
You need to enable JavaScript to run this app.
2727
</noscript>
2828
<div id="root"></div>
29+
<script>window.lookout = window.REPLACE_BY_SERVER || undefined;</script>
2930
<!--
3031
This HTML file is a template.
3132
If you open it directly in the browser, you will see an empty page.
@@ -36,5 +37,7 @@
3637
To begin the development, run `npm start` or `yarn start`.
3738
To create a production bundle, use `npm run build` or `yarn build`.
3839
-->
40+
<!-- create react app removes comments during build that's why we need div -->
41+
<div class="invisible-footer"></div>
3942
</body>
4043
</html>

frontend/src/App.tsx

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,106 @@
1-
import React, { Component } from 'react';
1+
import React, { Component, ReactElement } from 'react';
2+
import Token from './services/token';
3+
import * as api from './api';
24
import './App.css';
35

4-
class App extends Component {
6+
function Loader() {
7+
return <div>loading...</div>;
8+
}
9+
10+
interface ErrorProps {
11+
errors: string[];
12+
}
13+
14+
function Errors({ errors }: ErrorProps) {
15+
return <div>{errors.join(',')}</div>;
16+
}
17+
18+
function Login() {
19+
return (
20+
<header className="App-header">
21+
<a className="App-link" href={api.loginUrl}>
22+
Login using Github
23+
</a>
24+
</header>
25+
);
26+
}
27+
28+
interface HelloProps {
29+
name: string;
30+
}
31+
32+
function Hello({ name }: HelloProps) {
33+
return <header className="App-header">Hello {name}!</header>;
34+
}
35+
36+
interface AppState {
37+
// we need undefined state for initial render
38+
loggedIn: boolean | undefined;
39+
name: string;
40+
errors: string[];
41+
}
42+
43+
class App extends Component<{}, AppState> {
44+
constructor(props: {}) {
45+
super(props);
46+
47+
this.fetchState = this.fetchState.bind(this);
48+
49+
this.state = {
50+
loggedIn: undefined,
51+
name: '',
52+
errors: []
53+
};
54+
}
55+
56+
componentDidMount() {
57+
// TODO: add router and use it instead of this "if"
58+
if (window.location.pathname === '/callback') {
59+
api
60+
.callback(window.location.search)
61+
.then(resp => {
62+
Token.set(resp.token);
63+
window.history.replaceState({}, '', '/');
64+
})
65+
.then(this.fetchState)
66+
.catch(errors => this.setState({ errors }));
67+
return;
68+
}
69+
70+
if (!Token.exists()) {
71+
this.setState({ loggedIn: false });
72+
return;
73+
}
74+
75+
// ignore error here, just ask user to re-login
76+
// it would cover all cases like expired token, changes on backend and so on
77+
this.fetchState().catch(err => console.error(err));
78+
}
79+
80+
fetchState() {
81+
return api
82+
.me()
83+
.then(resp => this.setState({ loggedIn: true, name: resp.name }))
84+
.catch(err => {
85+
this.setState({ loggedIn: false });
86+
87+
throw err;
88+
});
89+
}
90+
591
render() {
6-
return (
7-
<div className="App">
8-
<header className="App-header">
9-
<a className="App-link" href="http://127.0.0.1:8080/login">
10-
Login using Github
11-
</a>
12-
</header>
13-
</div>
14-
);
92+
const { loggedIn, name, errors } = this.state;
93+
94+
let content: ReactElement<any>;
95+
if (errors.length) {
96+
content = <Errors errors={errors} />;
97+
} else if (typeof loggedIn === 'undefined') {
98+
content = <Loader />;
99+
} else {
100+
content = loggedIn ? <Hello name={name} /> : <Login />;
101+
}
102+
103+
return <div className="App">{content}</div>;
15104
}
16105
}
17106

frontend/src/api.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { GlobalWithFetchMock } from 'jest-fetch-mock';
2+
import Token from './services/token';
3+
import { apiCall } from './api';
4+
5+
// can be moved to setupFiles later if needed
6+
const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
7+
customGlobal.fetch = require('jest-fetch-mock');
8+
customGlobal.fetchMock = customGlobal.fetch;
9+
10+
describe('api', () => {
11+
beforeEach(() => {
12+
fetchMock.resetMocks();
13+
});
14+
15+
it('apiCall ok', () => {
16+
Token.set('token');
17+
fetchMock.mockResponseOnce(JSON.stringify({ data: 'result' }));
18+
19+
return apiCall('/test').then(resp => {
20+
expect(resp).toEqual('result');
21+
22+
const call = fetchMock.mock.calls[0];
23+
const [url, opts] = call;
24+
expect(url).toEqual('http://127.0.0.1:8080/test');
25+
expect(opts.headers.Authorization).toEqual('Bearer token');
26+
});
27+
});
28+
29+
it('apiCall http error', () => {
30+
fetchMock.mockResponseOnce('', { status: 500 });
31+
32+
return apiCall('/test').catch(err => {
33+
expect(err).toEqual(['Internal Server Error']);
34+
});
35+
});
36+
37+
it('apiCall http error with custom text', () => {
38+
fetchMock.mockResponseOnce('', { status: 404, statusText: 'Custom text' });
39+
40+
return apiCall('/test').catch(err => {
41+
expect(err).toEqual(['Custom text']);
42+
});
43+
});
44+
45+
it('apiCall http error with json response', () => {
46+
fetchMock.mockResponseOnce(
47+
JSON.stringify({ errors: [{ title: 'err1' }, { title: 'err2' }] }),
48+
{
49+
status: 500
50+
}
51+
);
52+
53+
return apiCall('/test').catch(err => {
54+
expect(err).toEqual(['err1', 'err2']);
55+
});
56+
});
57+
58+
it('apiCall removes token on unauthorized response', () => {
59+
Token.set('token');
60+
fetchMock.mockResponseOnce('', { status: 401 });
61+
62+
return apiCall('/test').catch(err => {
63+
expect(err).toEqual(['Unauthorized']);
64+
expect(Token.get()).toBe(null);
65+
});
66+
});
67+
});

frontend/src/api.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import lookoutOptions from './services/options';
2+
import TokenService from './services/token';
3+
4+
export const serverUrl = lookoutOptions.SERVER_URL || 'http://127.0.0.1:8080';
5+
6+
const apiUrl = (url: string) => `${serverUrl}${url}`;
7+
8+
interface ApiCallOptions {
9+
method?: string;
10+
body?: object;
11+
}
12+
13+
interface ServerError {
14+
title: string;
15+
description: string;
16+
}
17+
18+
export function apiCall<T>(
19+
url: string,
20+
options: ApiCallOptions = {}
21+
): Promise<T> {
22+
const token = TokenService.get();
23+
const fetchOptions: RequestInit = {
24+
credentials: 'include',
25+
headers: {
26+
Authorization: `Bearer ${token}`,
27+
'Content-Type': 'application/json'
28+
},
29+
body: null
30+
};
31+
32+
if (options.body) {
33+
fetchOptions.body = JSON.stringify(options.body);
34+
}
35+
36+
return fetch(apiUrl(url), fetchOptions).then(response => {
37+
if (!response.ok) {
38+
// when server return Unauthorized we need to remove token
39+
if (response.status === 401) {
40+
TokenService.remove();
41+
}
42+
43+
return response
44+
.json()
45+
.catch(() => {
46+
throw [response.statusText];
47+
})
48+
.then(json => {
49+
let errors: string[];
50+
51+
try {
52+
errors = (json as { errors: ServerError[] }).errors.map(
53+
e => e.title
54+
);
55+
} catch (e) {
56+
errors = [e.toString()];
57+
}
58+
59+
throw errors;
60+
});
61+
}
62+
63+
return response.json().then(json => (json as { data: T }).data);
64+
});
65+
}
66+
67+
export const loginUrl = apiUrl('/login');
68+
69+
interface AuthResponse {
70+
token: string;
71+
}
72+
73+
export function callback(queryString: string): Promise<AuthResponse> {
74+
return apiCall<AuthResponse>(`/api/callback${queryString}`);
75+
}
76+
77+
interface MeResponse {
78+
name: string;
79+
}
80+
81+
export function me(): Promise<MeResponse> {
82+
return apiCall<MeResponse>('/api/me');
83+
}

frontend/src/services/options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
interface LookoutApiOptions {
2+
SERVER_URL?: string;
3+
}
4+
5+
declare global {
6+
interface Window {
7+
lookout: LookoutApiOptions;
8+
}
9+
}
10+
11+
export default window.lookout || {};

frontend/src/services/token.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const localStorageKey = 'token';
2+
3+
class TokenService {
4+
get() {
5+
return window.localStorage.getItem(localStorageKey);
6+
}
7+
8+
set(token: string) {
9+
return window.localStorage.setItem(localStorageKey, token);
10+
}
11+
12+
remove() {
13+
return window.localStorage.removeItem(localStorageKey);
14+
}
15+
16+
exists() {
17+
return !!window.localStorage.getItem(localStorageKey);
18+
}
19+
}
20+
21+
export default new TokenService();

frontend/yarn.lock

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2438,6 +2438,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
24382438
safe-buffer "^5.0.1"
24392439
sha.js "^2.4.8"
24402440

2441+
cross-fetch@^2.2.2:
2442+
version "2.2.3"
2443+
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-2.2.3.tgz#e8a0b3c54598136e037f8650f8e823ccdfac198e"
2444+
integrity sha512-PrWWNH3yL2NYIb/7WF/5vFG3DCQiXDOVf8k3ijatbrtnwNuhMWLC7YF7uqf53tbTFDzHIUD8oITw4Bxt8ST3Nw==
2445+
dependencies:
2446+
node-fetch "2.1.2"
2447+
whatwg-fetch "2.0.4"
2448+
24412449
[email protected], cross-spawn@^6.0.0, cross-spawn@^6.0.5:
24422450
version "6.0.5"
24432451
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@@ -5159,6 +5167,14 @@ jest-environment-node@^23.4.0:
51595167
jest-mock "^23.2.0"
51605168
jest-util "^23.4.0"
51615169

5170+
jest-fetch-mock@^2.1.0:
5171+
version "2.1.0"
5172+
resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-2.1.0.tgz#49c16451b82f311158ec897e467d704e0cb118f9"
5173+
integrity sha512-jrTNlxDsZZCq6tMhdyH7gIbt4iDUHRr6C4Jp+kXItLaaaladOm9/wJjIwU3tCAEohbuW/7/naOSfg2A8H6/35g==
5174+
dependencies:
5175+
cross-fetch "^2.2.2"
5176+
promise-polyfill "^7.1.1"
5177+
51625178
jest-get-type@^22.1.0:
51635179
version "22.4.3"
51645180
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4"
@@ -6196,6 +6212,11 @@ no-case@^2.2.0:
61966212
dependencies:
61976213
lower-case "^1.1.1"
61986214

6215+
6216+
version "2.1.2"
6217+
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5"
6218+
integrity sha1-q4hOjn5X44qUR1POxwb3iNF2i7U=
6219+
61996220
62006221
version "0.7.5"
62016222
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df"
@@ -7520,6 +7541,11 @@ promise-inflight@^1.0.1:
75207541
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
75217542
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
75227543

7544+
promise-polyfill@^7.1.1:
7545+
version "7.1.2"
7546+
resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-7.1.2.tgz#ab05301d8c28536301622d69227632269a70ca3b"
7547+
integrity sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ==
7548+
75237549
75247550
version "8.0.2"
75257551
resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.2.tgz#9dcd0672192c589477d56891271bdc27547ae9f0"
@@ -9520,6 +9546,11 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5:
95209546
dependencies:
95219547
iconv-lite "0.4.24"
95229548

9549+
9550+
version "2.0.4"
9551+
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f"
9552+
integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==
9553+
95239554
95249555
version "3.0.0"
95259556
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"

0 commit comments

Comments
 (0)