diff --git a/.eslintrc.js b/.eslintrc.js index 64dee9b..a8e8325 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { extends: ['airbnb-typescript-prettier'], + ignorePatterns: ['.eslintrc.js'], // Rules can be here to override the preset of eslint from airbnb, if they are too strict. rules: { '@typescript-eslint/ban-ts-comment': 'off', @@ -22,6 +23,8 @@ module.exports = { 'no-console': 'off', 'react/destructuring-assignment': 0, + 'react/jsx-props-no-spreading': 'off', + 'import/prefer-default-export': 0, // 'react/prop-types': 0, -> this is an example }, overrides: [ diff --git a/package-lock.json b/package-lock.json index fe60949..5f4b849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1273,6 +1273,11 @@ "@hapi/hoek": "^8.3.0" } }, + "@hookform/resolvers": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-1.3.4.tgz", + "integrity": "sha512-K56VLSInXNIT/r14pkzRn1FJclqzGOWqpe3Bf0kz2Hf98ZOmRRFh4fhB7F3ofqCQ03CEQQkV44CTg7ql6nEvEg==" + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2026,6 +2031,11 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=" }, + "@types/lodash": { + "version": "4.14.168", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz", + "integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==" + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -2872,6 +2882,14 @@ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.1.tgz", "integrity": "sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==" }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -10031,6 +10049,11 @@ "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" }, + "nanoclone": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", + "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" + }, "nanoid": { "version": "3.1.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", @@ -12289,6 +12312,11 @@ "react-is": "^16.8.1" } }, + "property-expr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.4.tgz", + "integrity": "sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg==" + }, "proxy-addr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", @@ -12623,6 +12651,11 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, + "react-hook-form": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.15.1.tgz", + "integrity": "sha512-bL0LQuQ3OlM3JYfbacKtBPLOHhmgYz8Lj6ivMrvu2M6e1wnt4sbGRtPEPYCc/8z3WDbjrMwfAfLX92OsB65pFA==" + }, "react-icons": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.2.0.tgz", @@ -15145,6 +15178,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" + }, "tough-cookie": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", @@ -17175,6 +17213,20 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "yup": { + "version": "0.32.8", + "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.8.tgz", + "integrity": "sha512-SZulv5FIZ9d5H99EN5tRCRPXL0eyoYxWIP1AacCrjC9d4DfP13J1dROdKGfpfRHT3eQB6/ikBl5jG21smAfCkA==", + "requires": { + "@babel/runtime": "^7.10.5", + "@types/lodash": "^4.14.165", + "lodash": "^4.17.20", + "lodash-es": "^4.17.11", + "nanoclone": "^0.2.1", + "property-expr": "^2.0.4", + "toposort": "^2.0.2" + } } } } diff --git a/package.json b/package.json index 4180365..d0bd862 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,26 @@ "version": "0.1.0", "private": true, "dependencies": { + "@hookform/resolvers": "^1.3.4", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "axios": "^0.21.1", "brace": "^0.11.1", "jsoneditor": "^9.1.9", "jsoneditor-react": "^3.1.0", "node-sass": "^4.14.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-hook-form": "^6.15.1", "react-icons": "^4.2.0", "react-router-dom": "^5.2.0", "react-scripts": "4.0.1", "semantic-ui-css": "^2.4.1", "semantic-ui-react": "^2.0.3", "typescript": "^4.1.3", - "web-vitals": "^0.2.4" + "web-vitals": "^0.2.4", + "yup": "^0.32.8" }, "scripts": { "start": "react-scripts start", diff --git a/src/App.tsx b/src/App.tsx index ed84c33..89ad187 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,43 @@ -import React from 'react'; -import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; +import React, { useState } from 'react'; +import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import './App.scss'; import BaseLayout from './components/layouts/BaseLayout'; -import IpfsList from './pages/ipfs/IpfsList'; +import { storageNames } from './config'; +import UserContext from './hooks/UserContext'; import Ipfs from './pages/ipfs/Ipfs'; - -import './App.scss'; +import IpfsList from './pages/ipfs/IpfsList'; +import Login from './pages/Login/Login'; +import { ProtectedRoute } from './shared/ProtectedRoute'; function App(): JSX.Element { + // Handle user state + const accessToken = localStorage.getItem(storageNames.user) || ''; + const [isLoggedIn, setLoggedIn] = useState(!!accessToken); + + // Handle user login by setting the storage and state + const login = (token: string): void => { + setLoggedIn(true); + localStorage.setItem(storageNames.user, token); + }; + + // Handle user logout + const logout = (): void => { + setLoggedIn(false); + localStorage.removeItem(storageNames.user); + }; + return ( - - - - - - - - + + + + + + + + + + + ); } diff --git a/src/components/layouts/BaseLayout/BaseLayout.tsx b/src/components/layouts/BaseLayout/BaseLayout.tsx index 9d43d7c..2cdf024 100644 --- a/src/components/layouts/BaseLayout/BaseLayout.tsx +++ b/src/components/layouts/BaseLayout/BaseLayout.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Container } from 'semantic-ui-react'; -import NavigationBar from '../NavigationBar'; +import { NavigationBar } from '../NavigationBar'; interface BaseLayoutProps { children: any; diff --git a/src/components/layouts/NavigationBar/NavigationBar.tsx b/src/components/layouts/NavigationBar/NavigationBar.tsx index 8083009..daf082b 100644 --- a/src/components/layouts/NavigationBar/NavigationBar.tsx +++ b/src/components/layouts/NavigationBar/NavigationBar.tsx @@ -1,10 +1,16 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { FaUserCircle } from 'react-icons/fa'; import { Link } from 'react-router-dom'; - +import UserContext from '../../../hooks/UserContext'; import './NavigationBar.scss'; const NavigationBar = (): JSX.Element => { + const userContext = useContext(UserContext); + + const logout = (): void => { + userContext.logout(); + }; + return (
-
- {/* TODO: Need to be a user page */} - - - -
+ {userContext.isLoggedIn ? ( +
+ {/* TODO: Need to have this as a small menu showing up */} + + + +
+ ) : ( + '' + )} ); diff --git a/src/components/layouts/NavigationBar/index.ts b/src/components/layouts/NavigationBar/index.ts new file mode 100644 index 0000000..d4fd1e8 --- /dev/null +++ b/src/components/layouts/NavigationBar/index.ts @@ -0,0 +1 @@ +export { default as NavigationBar } from './NavigationBar'; diff --git a/src/components/layouts/NavigationBar/index.tsx b/src/components/layouts/NavigationBar/index.tsx deleted file mode 100644 index 513a881..0000000 --- a/src/components/layouts/NavigationBar/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import NavigationBar from './NavigationBar'; - -export default NavigationBar; diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 0000000..9df0189 --- /dev/null +++ b/src/config/api.ts @@ -0,0 +1,5 @@ +export const apis = { + // TODO: Replace this by the BE api urls we would be using. Probably storing this in a config file + url: 'https://jsonplaceholder.typicode.com', + timeout: 2 * 60 * 1000, // 2 minutes timeout +}; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..f8cb76b --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export { apis } from './api'; +export { storageNames } from './storage-names'; diff --git a/src/config/storage-names.ts b/src/config/storage-names.ts new file mode 100644 index 0000000..b36ebf7 --- /dev/null +++ b/src/config/storage-names.ts @@ -0,0 +1,3 @@ +export const storageNames = { + user: 'pl11', +}; diff --git a/src/hooks/UserContext.ts b/src/hooks/UserContext.ts new file mode 100644 index 0000000..f33236e --- /dev/null +++ b/src/hooks/UserContext.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +interface UserContextState { + isLoggedIn: boolean; + login: (token: string) => void; + logout: () => void; +} + +const UserContext = createContext({} as UserContextState); + +export default UserContext; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..4780e53 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export { default as UserContext } from './UserContext'; diff --git a/src/pages/Login/Login.scss b/src/pages/Login/Login.scss new file mode 100644 index 0000000..d12c6ab --- /dev/null +++ b/src/pages/Login/Login.scss @@ -0,0 +1,83 @@ +$elevation-card: 0 0 1px 0 rgba(8, 11, 14, 0.6), 0 16px 16px -1px rgba(8, 11, 14, 0.1); +$color-secondary: #e1e4e8; +$error-color: red; + +.login { + &__card { + max-width: 400px; + margin: auto; + box-shadow: $elevation-card; + padding: 30px; + background: white; + } + + &__title { + font-size: 22px; + line-height: 32px; + font-weight: 500; + text-align: center; + margin: 8px 0 20px; + font-weight: bold; + } + + &__input-container { + position: relative; + margin-top: 20px; + } + + &__input { + border-radius: 100px; + border: 1px solid $color-secondary; + font-size: 14px; + line-height: 24px; + font-weight: 500; + height: 40px; + width: 100%; + outline: none; + padding: 0 15px; + + &--error { + border-color: $error-color; + } + } + + &__input-error-msg { + font-size: 12px; + margin-left: 15px; + color: $error-color; + } + + &__input-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + outline: none; + display: inline-block; + padding: 10px; + color: $color-secondary; + } + + &__password-icon { + color: black; + cursor: pointer; + } + + &__button { + border-radius: 100px; + font-size: 14px; + line-height: 24px; + background: black; + color: white; + padding: 0 10px; + height: 40px; + width: 100%; + border: none; + margin-top: 40px; + cursor: pointer; + padding: 0 20px; + outline: none; + } +} diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx new file mode 100644 index 0000000..0136004 --- /dev/null +++ b/src/pages/Login/Login.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import React, { useContext } from 'react'; +import { useForm } from 'react-hook-form'; +import { FaEnvelope, FaEye, FaEyeSlash } from 'react-icons/fa'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; +import * as yup from 'yup'; +import UserContext from '../../hooks/UserContext'; +import paralinkApi from '../../services/interceptor'; +import './Login.scss'; + +interface LoginProps { + from: string; +} + +type LoginFormData = { + email: string; + password: string; +}; + +const loginSchema = yup.object().shape({ + email: yup.string().email().required(), + password: yup.string().required(), // We could define rules here for password if there are +}); + +const Login = (props: RouteComponentProps<{}, {}, LoginProps>): JSX.Element => { + const userContext = useContext(UserContext); + const { register, handleSubmit, errors } = useForm({ resolver: yupResolver(loginSchema) }); + + const [state, setState] = React.useState({ + // email: '', + // password: '', + seePassword: false, + }); + + const { from } = props.location.state || { from: { pathname: '/' } }; + + // const handleChange = (e: React.ChangeEvent): void => { + // const { id, value } = e.target; + // setState((prevState) => ({ + // ...prevState, + // [id]: value, + // })); + // console.log('errors', errors); + // }; + + const togglePassword = (): void => { + setState((prevState) => ({ + ...prevState, + seePassword: !prevState.seePassword, + })); + }; + + const login = (data: LoginFormData): void => { + // Avoid the page to refresh from submitting the form + // event.preventDefault(); + // TODO: put like a loading icon on the login button + + // TODO: do like a safe check on the email & password here + + console.log('data', data); + // Just to check + paralinkApi + // For now this is going to be the placeholder + .get('https://jsonplaceholder.typicode.com/todos') + .then(() => { + // We should get the login token here from the BE + const token = 'faketokenhere'; + userContext.login(token); + }) + .catch((err: any) => { + // TODO: Need to display the error in the UI ( user not found, not correct password, not correct email) + console.error(err); + }) + .finally(() => { + // TODO: login loading should be set to false here + }); + }; + + if (userContext.isLoggedIn) { + return ; + } + + // TODO: handle pressing enter to submit ( maybe having it as a form instead and using submit ) + return ( +
+
Login into your account
+
+
+ +
+ +
+
+ {errors.email?.message ? {errors.email?.message} : ''} + +
+ + + +
+ {errors.password?.message ? {errors.password?.message} : ''} + +
+
+ ); +}; + +export default Login; diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx new file mode 100644 index 0000000..bf79bae --- /dev/null +++ b/src/pages/Login/index.tsx @@ -0,0 +1 @@ +export { default as Login } from './Login'; diff --git a/src/services/interceptor/auth.ts b/src/services/interceptor/auth.ts new file mode 100644 index 0000000..0cc1482 --- /dev/null +++ b/src/services/interceptor/auth.ts @@ -0,0 +1,32 @@ +import axios from 'axios'; +import { apis, storageNames } from '../../config'; + +const paralinkApi = axios.create({ + baseURL: apis.url, + timeout: apis.timeout, +}); + +paralinkApi.interceptors.request.use( + (config: any) => { + // Check for user token, we can't use hooks from here + const accessToken = localStorage.getItem(storageNames.user); + + // If user not logged we just pass it through, the backend should not accept it + // This lets us do api requests like for logging in for example. + // Depending on the API we could potentially reject here before doing the call and add an exception for login path + if (!accessToken) { + return config; + } + + // Otherwise set the token in the request + const request = config; + request.headers.authorization = `Bearer ${accessToken}`; + return request; + }, + (error) => { + // User might have been rejected + return Promise.reject(error); + }, +); + +export default paralinkApi; diff --git a/src/services/interceptor/index.ts b/src/services/interceptor/index.ts new file mode 100644 index 0000000..46571d5 --- /dev/null +++ b/src/services/interceptor/index.ts @@ -0,0 +1,3 @@ +import paralinkApi from './auth'; + +export default paralinkApi; diff --git a/src/shared/ProtectedRoute/ProtectedRoute.tsx b/src/shared/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..b7d01f5 --- /dev/null +++ b/src/shared/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,28 @@ +import React, { useContext } from 'react'; +import { Redirect, Route } from 'react-router-dom'; +import UserContext from '../../hooks/UserContext'; + +// Disable the eslint for next any +// eslint-disable-next-line +const ProtectedRoute = ({ component: Component, ...rest }: any): JSX.Element => { + const userContext = useContext(UserContext); + /** Verify that user is logged in otherwise redirect to login page */ + return ( + + userContext.isLoggedIn ? ( + + ) : ( + + ) + } + /> + ); +}; +export default ProtectedRoute; diff --git a/src/shared/ProtectedRoute/index.tsx b/src/shared/ProtectedRoute/index.tsx new file mode 100644 index 0000000..4de35d0 --- /dev/null +++ b/src/shared/ProtectedRoute/index.tsx @@ -0,0 +1 @@ +export { default as ProtectedRoute } from './ProtectedRoute';