diff --git a/client/package.json b/client/package.json index 1ab2a74517def44f50cc688355c9dfa64cd7ccfe..0a4ff64eed3b791fc2787cc1298eba4fb6c896fc 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 ae5d6a5b986b51d1e01158071631b75e51e80764..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..d96373339b0407d006e0ba37dc0e4f4cff63e6dd --- /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 c5c18b169fcc2ade194ac898739505c92a1fdff9..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..655b475eaed9c3493c7d661b9bcfe417abda9b1d --- /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 0000000000000000000000000000000000000000..0d8b6462fb5368ab59e52b420e325ea00ca3b5f4 --- /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 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /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 3019b52336377bbcda85cb7e071534e50c8ca300..47f8be758d41d889732782c6573180609ea96128 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 fae296c8396711b7bac8729b13949f45751ece87..136b7e6b1e9c5730bd34a545450aa10480042845 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 e1fcb5e12159b69850a3accb94306f71abd6ee66..07514fb2ca897b88efed5906e2ca06485c0ca786 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 f4fddeb15a496e3e50a00cd8b5197ebe44121d57..2a75edab28643e8e9290ad7b5b9b4e8d70b6b7cd 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 af6519929dc7379094fa87c421361ffbe51f7053..cfc0351fbcefc3ac74df06b63d5f3ea2a0ff9d57 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 772b9c27aaa97bd58018eb353fa422ccd145c8c5..d14feaa3c4eeee4ae46f9d9e4c623617a36dd70c 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 0000000000000000000000000000000000000000..cd15358f0b3a31f36a7a5d1dc4d395a6af9aec53 --- /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 08781570f5653144d676dc76b89da477c085fddf..6dc9aaa4deddac5c078d23f5d96f8d8438f2b453 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 b714b8bc0432513cdff132e11f800e9170518688..2596bac005e729afb695c692b400c91e50812ea3 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 f85d7d33f624100f060ec29e76a647e5f27a4cd1..288d64d8d6572266cdd78a3c23b411594d5b5f04 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==