From d6e6a4c34651293cae0da08fcfcbb055926e4dcd Mon Sep 17 00:00:00 2001
From: Roland Bernard <rolbernard@unibz.it>
Date: Sat, 17 Apr 2021 17:05:52 +0200
Subject: [PATCH] Significant refactoring and register now works

---
 client/package.json                           |  1 -
 client/src/adapters/api.ts                    | 43 ---------
 client/src/adapters/auth.ts                   | 89 +++++++++++++++++++
 client/src/adapters/constants.ts              |  3 -
 client/src/adapters/user.ts                   | 13 +++
 .../components/forms/RegisterForm/index.tsx   | 66 ++++++++++++++
 .../forms/RegisterForm/register-form.scss     |  1 +
 client/src/components/helpers/LoginRoute.tsx  |  2 +-
 .../src/components/helpers/ProtectedRoute.tsx |  2 +-
 client/src/components/ui/Page/index.tsx       |  5 +-
 client/src/components/ui/Page/page.scss       |  2 +
 client/src/components/ui/TextInput/index.tsx  | 35 +++-----
 .../components/ui/TextInput/text-input.scss   |  9 +-
 client/src/config.ts                          |  3 +
 client/src/pages/Register/index.tsx           | 62 ++++---------
 client/src/service-worker.ts                  | 15 ++--
 client/yarn.lock                              |  9 +-
 17 files changed, 220 insertions(+), 140 deletions(-)
 delete mode 100644 client/src/adapters/api.ts
 create mode 100644 client/src/adapters/auth.ts
 delete mode 100644 client/src/adapters/constants.ts
 create mode 100644 client/src/adapters/user.ts
 create mode 100644 client/src/components/forms/RegisterForm/index.tsx
 create mode 100644 client/src/components/forms/RegisterForm/register-form.scss
 create mode 100644 client/src/config.ts

diff --git a/client/package.json b/client/package.json
index 1ab2a74..0a4ff64 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,7 +15,6 @@
         "@types/react": "^16.9.53",
         "@types/react-dom": "^16.9.8",
         "@types/react-router-dom": "^5.1.7",
-        "axios": "^0.21.1",
         "react": "^17.0.2",
         "react-dom": "^17.0.2",
         "react-router-dom": "^5.2.0",
diff --git a/client/src/adapters/api.ts b/client/src/adapters/api.ts
deleted file mode 100644
index ae5d6a5..0000000
--- a/client/src/adapters/api.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import axios from 'axios';
-
-const baseUrl = 'http://localhost:8000/v1';
-
-export const authAxios = axios.create({
-    baseURL: baseUrl,
-    headers: {
-        Authorization: `Bearer ${getAccessToken}`
-    }
-});
-
-export function isLoggedIn() {
-    return getAccessToken();
-}
-
-export function getAccessToken(): string | null {
-    return localStorage.getItem('access-token');
-}
-
-export function setAccesstoken(token: string): void {
-    localStorage.setItem('access-token', token);
-}
-
-export function registerUser(username: string, password: string) {
-    return new Promise(function (resolve, reject) {
-        axios.post(`${baseUrl}/auth/register`, {
-            username: username,
-            password: password
-        }).then(() => {
-            axios.post(`${baseUrl}/auth/token`, {
-                username: username,
-                password: password
-            }).then(({ data }) => {
-                setAccesstoken(data.token);
-                resolve(true);
-            }).catch((e) => {
-                reject(e);
-            });
-        }).catch((e) => {
-            reject(e);
-        });
-    })
-}
diff --git a/client/src/adapters/auth.ts b/client/src/adapters/auth.ts
new file mode 100644
index 0000000..d963733
--- /dev/null
+++ b/client/src/adapters/auth.ts
@@ -0,0 +1,89 @@
+
+import { apiRoot } from 'config';
+
+export function getAuthHeader(): HeadersInit {
+    if (isLoggedIn()) {
+        return {
+            'Authorization': `Brearer ${getToken()}`,
+        };
+    } else {
+        return {};
+    }
+}
+
+export function isLoggedIn(): boolean {
+    return typeof getToken() === 'string';
+}
+
+export function getToken(): string | null {
+    return localStorage.getItem('access-token');
+}
+
+export function setToken(token: string) {
+    localStorage.setItem('access-token', token);
+}
+
+export function clearToken() {
+    localStorage.removeItem('access-token');
+}
+
+setInterval(extendAccessToken, 1000 * 60 * 30);
+
+async function extendAccessToken() {
+    if (isLoggedIn()) {
+        const response = await fetch(`${apiRoot}/auth/extend`, { headers: getAuthHeader() });
+        if (response.ok) {
+            const json = await response.json();
+            setToken(json.token);
+        } else if (response.status === 403) {
+            clearToken();
+        }
+    }
+}
+
+export async function register(username: string, password: string): Promise<boolean> {
+    try {
+        const response = await fetch(`${apiRoot}/auth/register`, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json'
+            },
+            body: JSON.stringify({
+                username: username,
+                password: password,
+            }),
+        });
+        if (response.ok) {
+            const json = await response.json();
+            setToken(json.token);
+        }
+        return response.ok;
+    } catch (e) {
+        // Probably a network error
+        throw e;
+    }
+}
+
+export async function login(username: string, password: string): Promise<boolean> {
+    try {
+        const response = await fetch(`${apiRoot}/auth/token`, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json'
+            },
+            body: JSON.stringify({
+                username: username,
+                password: password
+            }),
+        });
+        if (response.ok) {
+            const json = await response.json();
+            setToken(json.token);
+        }
+        return response.ok;
+    } catch (e) {
+        // Probably a network error
+        throw e;
+    }
+}
+
diff --git a/client/src/adapters/constants.ts b/client/src/adapters/constants.ts
deleted file mode 100644
index c5c18b1..0000000
--- a/client/src/adapters/constants.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-
-export const DEMO_CONSTANT = 42;
-
diff --git a/client/src/adapters/user.ts b/client/src/adapters/user.ts
new file mode 100644
index 0000000..655b475
--- /dev/null
+++ b/client/src/adapters/user.ts
@@ -0,0 +1,13 @@
+
+import { apiRoot } from 'config';
+
+export async function exists(username: string) {
+    try {
+        const response = await fetch(`${apiRoot}/user/name/${username}`);
+        return response.ok;
+    } catch (e) {
+        // Probably a network error
+        return false;
+    }
+}
+
diff --git a/client/src/components/forms/RegisterForm/index.tsx b/client/src/components/forms/RegisterForm/index.tsx
new file mode 100644
index 0000000..0d8b646
--- /dev/null
+++ b/client/src/components/forms/RegisterForm/index.tsx
@@ -0,0 +1,66 @@
+
+import { FormEvent, useCallback, useState } from 'react';
+
+import TextInput from 'components/ui/TextInput';
+import Button from 'components/ui/Button';
+import { exists } from 'adapters/user';
+
+import './register-form.scss';
+
+async function validateUsername(username: string) {
+    if (username?.length < 3) {
+        return 'Username has to be at least 4 characters long.';
+    } else if (await exists(username)) {
+        return 'Username is already taken by someone else.';
+    } else {
+        return null;
+    }
+}
+
+function validatePassword(password: string) {
+    if (password?.length < 3) {
+        return 'Password has to be at least 6 characters long';
+    } else {
+        return null;
+    }
+}
+
+interface Props {
+    onSubmit?: (username: string, password: string) => void
+}
+
+export default function RegisterForm({ onSubmit }: Props) {
+    const [username, setUsername] = useState<string>('');
+    const [password, setPassword] = useState<string>('');
+
+    const handleSubmit = useCallback(async (e: FormEvent) => {
+        e.preventDefault();
+        if (await validateUsername(username) === null && validatePassword(password) === null) {
+            onSubmit?.(username, password);
+        }
+    }, [ onSubmit, password, username ]);
+
+    return (
+        <form onSubmit={handleSubmit}>
+            <TextInput
+                label="Username"
+                name="username"
+                color="dark"
+                onChange={setUsername}
+                validation={validateUsername}
+            />
+            <TextInput
+                label="Password"
+                name="password"
+                color="dark"
+                type="password"
+                onChange={setPassword}
+                validation={validatePassword}
+            />
+            <Button type="submit">
+                Register now
+            </Button>
+        </form>
+    );
+}
+
diff --git a/client/src/components/forms/RegisterForm/register-form.scss b/client/src/components/forms/RegisterForm/register-form.scss
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/client/src/components/forms/RegisterForm/register-form.scss
@@ -0,0 +1 @@
+
diff --git a/client/src/components/helpers/LoginRoute.tsx b/client/src/components/helpers/LoginRoute.tsx
index 3019b52..47f8be7 100644
--- a/client/src/components/helpers/LoginRoute.tsx
+++ b/client/src/components/helpers/LoginRoute.tsx
@@ -1,6 +1,6 @@
 
 import { Route, RouteProps, useHistory } from 'react-router-dom';
-import { isLoggedIn } from 'adapters/api';
+import { isLoggedIn } from 'adapters/auth';
 import { useEffect } from 'react';
 
 export default function LoginRoute(props: RouteProps) {
diff --git a/client/src/components/helpers/ProtectedRoute.tsx b/client/src/components/helpers/ProtectedRoute.tsx
index fae296c..136b7e6 100644
--- a/client/src/components/helpers/ProtectedRoute.tsx
+++ b/client/src/components/helpers/ProtectedRoute.tsx
@@ -1,6 +1,6 @@
 
 import { Route, RouteProps, useHistory } from 'react-router-dom';
-import { isLoggedIn } from 'adapters/api';
+import { isLoggedIn } from 'adapters/auth';
 import { useEffect } from 'react';
 
 export default function ProtectedRoute(props: RouteProps) {
diff --git a/client/src/components/ui/Page/index.tsx b/client/src/components/ui/Page/index.tsx
index e1fcb5e..07514fb 100644
--- a/client/src/components/ui/Page/index.tsx
+++ b/client/src/components/ui/Page/index.tsx
@@ -1,4 +1,6 @@
+
 import { ReactNode } from 'react';
+
 import Header from 'components/ui/Header';
 import './page.scss';
 
@@ -18,4 +20,5 @@ export default function Page({ children, className, header }: Props) {
             </main>
         </>
     );
-}
\ No newline at end of file
+}
+
diff --git a/client/src/components/ui/Page/page.scss b/client/src/components/ui/Page/page.scss
index f4fddeb..2a75eda 100644
--- a/client/src/components/ui/Page/page.scss
+++ b/client/src/components/ui/Page/page.scss
@@ -1,3 +1,4 @@
+
 .page-container {
     max-width: 1440px;
     min-height: 100vh;
@@ -5,3 +6,4 @@
     background: rgba(255, 255, 255, 0.4);
     backdrop-filter: blur(30px);
 }
+
diff --git a/client/src/components/ui/TextInput/index.tsx b/client/src/components/ui/TextInput/index.tsx
index af65199..cfc0351 100644
--- a/client/src/components/ui/TextInput/index.tsx
+++ b/client/src/components/ui/TextInput/index.tsx
@@ -1,4 +1,5 @@
-import React, { ChangeEvent, Dispatch, FocusEvent, useState } from "react";
+
+import { ChangeEvent, Dispatch, FocusEvent, useCallback, useState } from "react";
 
 import './text-input.scss';
 
@@ -8,30 +9,20 @@ interface Props {
     color?: 'dark'
     type?: 'password' | 'textarea' | 'text',
     onChange: Dispatch<string>,
-    validateFn: (arg0: string) => string | null;
+    validation: (text: string) => Promise<string | null> | string | null;
 }
 
-export default function TextInput({ label, name, type, color, onChange, validateFn }: Props) {
+export default function TextInput({ label, name, type, color, onChange, validation }: Props) {
     const [error, setError] = useState('');
 
-    type = type ?? 'text';
-
-    const setValue = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>): void => {
+    const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
         onChange(e.target.value);
-    }
-
-    const validateField = (e: FocusEvent<HTMLTextAreaElement | HTMLInputElement>): void => {
-        if (validateFn) {
-            let error = validateFn(e.target.value);
-            
-            if (error)
-                setError(error);
-            else
-                setError('');
-        }
-    }
+    }, [ onChange ]);
 
-    const errorTag = error ? (<div className="error">{error}</div>) : null;
+    const handleBlur = useCallback(async (e: FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => {
+        let error = await validation?.(e.target.value);
+        setError(error ?? '');
+    }, [ validation ]);
 
     return (
         <div className={'input-element' + (type === 'textarea' ? ' textarea' : '')}>
@@ -39,11 +30,11 @@ export default function TextInput({ label, name, type, color, onChange, validate
                 <label htmlFor={name}>{label}</label>
                 {
                     type === 'textarea' ?
-                        (<textarea onChange={setValue} name={name} id={name} onBlur={validateField}></textarea>)
-                        : (<input onChange={setValue} type={type} name={name} id={name} onBlur={validateField} />)
+                        (<textarea onChange={handleChange} name={name} id={name} onBlur={handleBlur} />)
+                        : (<input onChange={handleChange} type={type} name={name} id={name} onBlur={handleBlur} autoComplete="off"/>)
                 }
             </div >
-            {errorTag}
+            <div className="error">{error}</div>
         </div>
     );
 }
diff --git a/client/src/components/ui/TextInput/text-input.scss b/client/src/components/ui/TextInput/text-input.scss
index 772b9c2..d14feaa 100644
--- a/client/src/components/ui/TextInput/text-input.scss
+++ b/client/src/components/ui/TextInput/text-input.scss
@@ -1,3 +1,4 @@
+
 @use 'styles/settings.scss'as s;
 @use 'styles/mixins.scss'as mx;
 
@@ -8,6 +9,7 @@
         color: s.$error-color;
         margin-top: 10px;
         padding-left: 15px;
+        height: 1rem;
     }
 
     .input-field {
@@ -16,7 +18,6 @@
         width: 100%;
 
         &.dark {
-
             textarea,
             input {
                 color: s.$body-color;
@@ -24,10 +25,8 @@
             }
 
             &:before {
-
                 background: rgba(0, 0, 0, 0.05);
             }
-
         }
 
         &:before {
@@ -49,7 +48,6 @@
             font-weight: s.$weight-bold;
             z-index: 20;
             transform: translateY(-50%);
-
         }
 
         textarea {
@@ -74,4 +72,5 @@
             background: rgba(255, 255, 255, 0.1);
         }
     }
-}
\ No newline at end of file
+}
+
diff --git a/client/src/config.ts b/client/src/config.ts
new file mode 100644
index 0000000..cd15358
--- /dev/null
+++ b/client/src/config.ts
@@ -0,0 +1,3 @@
+
+export const apiRoot = 'http://localhost:8000/v1';
+
diff --git a/client/src/pages/Register/index.tsx b/client/src/pages/Register/index.tsx
index 0878157..6dc9aaa 100644
--- a/client/src/pages/Register/index.tsx
+++ b/client/src/pages/Register/index.tsx
@@ -1,64 +1,32 @@
-import Page from 'components/ui/Page';
-import TextInput from 'components/ui/TextInput';
-import Button from 'components/ui/Button';
-import { FormEvent, useState } from 'react';
+
+import { useCallback } from 'react';
 import { Link, useHistory } from 'react-router-dom';
-import { registerUser } from 'adapters/api';
-import './register.scss';
 
-function usernameIsValid(username: string) {
-    return (username && username.length > 3) ? null : 'Username has to be at least 4 characters long.';
-}
+import Page from 'components/ui/Page';
+import RegisterForm from 'components/forms/RegisterForm';
+import { register } from 'adapters/auth';
 
-function passwordIsValid(password: string) {
-    return (password && password.length > 5) ? null : 'Password has to be at least 6 characters long';
-}
+import './register.scss';
 
 export default function Register() {
-    const [username, setUsername] = useState<string>('');
-    const [password, setPassword] = useState<string>('');
-
     const history = useHistory();
 
-
-    const register = async (e: FormEvent) => {
-        e.preventDefault();
-
-        if (usernameIsValid(username) && passwordIsValid(password)) {
-            await registerUser(username, password).then((data) => {
+    const handleSubmit = useCallback(async (username: string, password: string) => {
+        try {
+            if (await register(username, password)) {
                 history.push('/tasks');
-            }).catch(() => {
-            });
-
-        }
-    }
+            }
+        } catch (e) { }
+    }, [ history ]);
 
     return (
         <Page header={false}>
             <div className="content-container">
                 <h1>Register</h1>
-                <form onSubmit={register}>
-                    <TextInput
-                        label="Username"
-                        name="username"
-                        color="dark"
-                        onChange={setUsername}
-                        validateFn={usernameIsValid}
-                    />
-                    <TextInput
-                        label="Password"
-                        name="password"
-                        color="dark"
-                        type="password"
-                        onChange={setPassword}
-                        validateFn={passwordIsValid}
-                    />
-                    <Button type="submit">
-                        Register now
-                    </Button>
-                </form>
+                <RegisterForm onSubmit={handleSubmit} />
                 <Link to="/login">You already have an account?</Link>
             </div>
         </Page>
     );
-}
\ No newline at end of file
+}
+
diff --git a/client/src/service-worker.ts b/client/src/service-worker.ts
index b714b8b..2596bac 100644
--- a/client/src/service-worker.ts
+++ b/client/src/service-worker.ts
@@ -1,11 +1,11 @@
 /// <reference lib="webworker" />
 /* eslint-disable no-restricted-globals */
 
-import {clientsClaim} from 'workbox-core';
-import {ExpirationPlugin} from 'workbox-expiration';
-import {precacheAndRoute, createHandlerBoundToURL} from 'workbox-precaching';
-import {registerRoute} from 'workbox-routing';
-import {StaleWhileRevalidate} from 'workbox-strategies';
+import { clientsClaim } from 'workbox-core';
+import { ExpirationPlugin } from 'workbox-expiration';
+import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
+import { registerRoute } from 'workbox-routing';
+import { StaleWhileRevalidate } from 'workbox-strategies';
 
 declare const self: ServiceWorkerGlobalScope;
 
@@ -45,11 +45,10 @@ registerRoute(
     createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
 );
 
-// An example runtime caching route for requests that aren't handled by the
-// precache, in this case same-origin .png requests like those from in public/
+// Runtime caching route for images requests that aren't handled by the precache.
 registerRoute(
     // Add in any other file extensions or routing criteria as needed.
-    ({url}) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
+    ({url}) => url.origin === self.location.origin && url.pathname.match(/.(png|jpg|jpeg|gif)$/),
     // Customize this strategy as needed, e.g., by changing to CacheFirst.
     new StaleWhileRevalidate({
         cacheName: 'images',
diff --git a/client/yarn.lock b/client/yarn.lock
index f85d7d3..288d64d 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -2580,13 +2580,6 @@ axe-core@^4.0.2:
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.4.tgz#f19cd99a84ee32a318b9c5b5bb8ed373ad94f143"
   integrity sha512-Pdgfv6iP0gNx9ejRGa3zE7Xgkj/iclXqLfe7BnatdZz0QnLZ3jrRHUVH8wNSdN68w05Sk3ShGTb3ydktMTooig==
 
-axios@^0.21.1:
-  version "0.21.1"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
-  integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
-  dependencies:
-    follow-redirects "^1.10.0"
-
 axobject-query@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -5094,7 +5087,7 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.3"
     readable-stream "^2.3.6"
 
-follow-redirects@^1.0.0, follow-redirects@^1.10.0:
+follow-redirects@^1.0.0:
   version "1.13.3"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"
   integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==
-- 
GitLab