commit 8370b6c177dcc21d6d951ee3cab6dca99459cfad
Author: Vaclav Honzik <vaclavhonzik98@gmail.com>
Date:   Thu Mar 31 23:53:59 2022 +0200

    login simple form impl + slice for user state

diff --git a/frontend/package.json b/frontend/package.json
index 4f4e989..86b6706 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,7 +24,8 @@
     "swagger-typescript-api": "^9.3.1",
     "ts-node": "^10.7.0",
     "typescript": "^4.4.2",
-    "web-vitals": "^2.1.0"
+    "web-vitals": "^2.1.0",
+    "yup": "^0.32.11"
   },
   "scripts": {
     "start": "react-scripts start",
@@ -60,6 +61,7 @@
     "@types/react": "^17.0.20",
     "@types/react-dom": "^17.0.9",
     "@types/react-redux": "^7.1.23",
-    "@types/redux-persist": "^4.3.1"
+    "@types/redux-persist": "^4.3.1",
+    "@types/yup": "^0.29.13"
   }
 }
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a3942f..2bcd3ad 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,30 +1,37 @@
-import React, { CSSProperties } from 'react'
 import './App.css'
 import { Routes, Route, Link } from 'react-router-dom'
 import Home from './features/Home/Home'
 import Catalog from './features/Catalog/Catalog'
 import NotFound from './features/NotFound/NotFound'
-import { Paper, Theme } from '@mui/material'
+import { Container, Paper, Theme } from '@mui/material'
 import { ThemeProvider } from '@emotion/react'
 import { useSelector } from 'react-redux'
 import { RootState } from './features/redux/store'
-import { createTheme } from '@mui/material/styles'
+import Login from './features/Auth/Login'
 
 const App = () => {
     const theme: Theme = useSelector((state: RootState) => state.theme.theme)
 
+    // TODO remove this its only for debug
+    const user = useSelector((state: RootState) => state.user)
+
     return (
         <ThemeProvider theme={theme}>
+            <p>{JSON.stringify(user)}</p>
             <Paper style={{ minHeight: '100vh', borderRadius: 0 }}>
                 <nav>
-                    <Link to="/">Home</Link>
-                    <Link to="/catalog">Catalog</Link>
+                    <Link to="/">Home </Link>
+                    <Link to="/catalog">Catalog </Link>
+                    <Link to="/login">Login </Link>
                 </nav>
-                <Routes>
-                    <Route path="/" element={<Home />} />
-                    <Route path="/catalog" element={<Catalog />} />
-                    <Route path="*" element={<NotFound />} />
-                </Routes>
+                <Container>
+                    <Routes>
+                        <Route path="/" element={<Home />} />
+                        <Route path="/catalog" element={<Catalog />} />
+                        <Route path="/login" element={<Login />} />
+                        <Route path="*" element={<NotFound />} />
+                    </Routes>
+                </Container>
             </Paper>
         </ThemeProvider>
     )
diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts
index 8aaaebc..843a52d 100644
--- a/frontend/src/api/api.ts
+++ b/frontend/src/api/api.ts
@@ -3,7 +3,7 @@ import { Store } from 'redux'
 import config from '../config/conf'
 import { AppStore } from '../features/redux/store'
 
-let store: AppStore // this will get injected later
+let store: AppStore // this will get injected in index.tsx
 
 export const injectStore = (_store: Store) => {
     store = _store
@@ -23,8 +23,7 @@ axiosInstance.interceptors.request.use(
     (config) => {
         const accessToken = store.getState().user.accessToken // get the access token from the store
         if (accessToken) {
-            // @ts-ignore
-            // this will always be defined, axios developers are just lazy to provide better Typescript types
+            // @ts-ignore (axios typescript types are a crime and this will always be defined)
             config.headers['Authorization'] = accessToken as string
         }
 
@@ -38,7 +37,6 @@ axiosInstance.interceptors.request.use(
 axiosInstance.interceptors.response.use(
     (res) => res,
     async (err) => {
-
         const originalConfig = err.config
 
         // Original URL might be login in which case we don't want to refresh the access token
@@ -53,21 +51,19 @@ axiosInstance.interceptors.response.use(
         // If there is no refresh token we simply log the user out
         if (!oldRefreshToken) {
             store.dispatch({ type: 'user/logout' })
-            return Promise.reject(err)
         }
 
-        // Set this to retry the request that failed
-        originalConfig.retry = true
-
         // Try refreshing the JWT
         try {
-            const refreshConfig = createBaseInstance()
+            const refreshInstance = createBaseInstance()
             // @ts-ignore
-            refreshConfig.headers['Authorization'] = oldRefreshToken as string
+            refreshInstance.headers['Authorization'] = oldRefreshToken as string
 
-            // Send the request
-            const { data } = await axiosInstance.get('/refreshToken')
+            // Set this to retry the request that failed
+            originalConfig.retry = true
 
+            // Send the request
+            const { data } = await axiosInstance.get('/users/refreshToken')
             const { accessToken, refreshToken } = data
 
             // Set the new tokens
@@ -76,11 +72,13 @@ axiosInstance.interceptors.response.use(
                 payload: { accessToken, refreshToken },
             })
 
+            // Return the failed instance so it can retry
             return axiosInstance(originalConfig)
         } catch (err: any) {
             // If the refresh token fails we log the user out
             store.dispatch({ type: 'user/logout' })
-            return Promise.reject(err)
+            originalConfig.retry = false // do not retry we are logged out
+            return originalConfig
         }
     }
 )
diff --git a/frontend/src/features/Auth/Login.tsx b/frontend/src/features/Auth/Login.tsx
index 7550e8f..25c63b4 100644
--- a/frontend/src/features/Auth/Login.tsx
+++ b/frontend/src/features/Auth/Login.tsx
@@ -1,11 +1,109 @@
+import { Button, TextField, Typography } from '@mui/material'
+import { useFormik } from 'formik'
+import { Fragment, useEffect } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { useNavigate } from 'react-router-dom'
+import * as yup from 'yup'
+import { SchemaOf } from 'yup'
+import { PasswordDto, UserDto } from '../../swagger/data-contracts'
+import { RootState } from '../redux/store'
+import { logIn } from './userThunks'
+
+interface LoginFields {
+    username: string
+    password: string
+}
 
 const Login = () => {
+    const validationSchema: SchemaOf<LoginFields> = yup.object().shape({
+        username: yup.string().required('Username is required'),
+        password: yup.string().required('Password is required'),
+    })
+
+    const dispatch = useDispatch()
+    const formik = useFormik({
+        initialValues: {
+            username: '',
+            password: '',
+        },
+        validationSchema,
+        onSubmit: () => {
+            dispatch(
+                logIn({
+                    name: formik.values.username,
+                    passwords: {
+                        password: formik.values.password,
+                        confirmationPassword: '',
+                    } as PasswordDto,
+                } as UserDto)
+            )
+        },
+    })
+
+    // Redirect to home if the user is logged in
+    const userLoggedIn = useSelector(
+        (state: RootState) => state.user.isLoggedIn
+    )
+    const navigate = useNavigate()
+    useEffect(() => {
+        if (userLoggedIn) {
+            navigate('/')
+        }
+    }, [userLoggedIn, navigate])
 
     return (
-        <>
+        <Fragment>
+            <Typography variant="h3">Login</Typography>
+            <p>Credentials = admin:password</p>
 
-        </>
+            <form onSubmit={formik.handleSubmit}>
+                <TextField
+                    label="Username"
+                    name="username"
+                    fullWidth
+                    sx={{ mb: 2 }}
+                    value={formik.values.username}
+                    onChange={formik.handleChange}
+                    error={
+                        Boolean(formik.errors.username) &&
+                        formik.touched.username
+                    }
+                    helperText={
+                        formik.errors.username &&
+                        formik.touched.username &&
+                        formik.errors.username
+                    }
+                />
+                <TextField
+                    type="password"
+                    label="Password"
+                    name="password"
+                    fullWidth
+                    value={formik.values.password}
+                    onChange={formik.handleChange}
+                    error={
+                        Boolean(formik.errors.password) &&
+                        formik.touched.password
+                    }
+                    helperText={
+                        formik.errors.password &&
+                        formik.touched.password &&
+                        formik.errors.password
+                    }
+                    sx={{ mb: 2 }}
+                />
+                <Button
+                    size="large"
+                    variant="contained"
+                    color="primary"
+                    type="submit"
+                    fullWidth
+                >
+                    Login
+                </Button>
+            </form>
+        </Fragment>
     )
 }
 
-export default Login
\ No newline at end of file
+export default Login
diff --git a/frontend/src/features/Auth/userReducer.ts b/frontend/src/features/Auth/userReducer.ts
deleted file mode 100644
index e82478e..0000000
--- a/frontend/src/features/Auth/userReducer.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { AnyAction } from 'redux'
-import persistReducer from 'redux-persist/es/persistReducer'
-import storage from 'redux-persist/lib/storage'
-
-export interface UserState {
-    accessToken?: string
-    refreshToken?: string
-    username: string
-    roles: string[]
-    isLoggedIn: boolean
-}
-
-// All possible actions
-export enum AuthStateActions {
-    LOG_IN = 'LOG_IN',
-    LOG_OUT = 'LOG_OUT',
-    UPDATE_ACCESS_TOKEN = 'REFRESH_ACCESS_TOKEN',
-    UPDATE_REFRESH_TOKEN = 'UPDATE_REFRESH_TOKEN',
-    UPDATE_TOKENS = 'UPDATE_TOKENS',
-}
-
-// Only needed if the state is to be persisted
-const persistConfig = {
-    key: 'auth',
-    storage
-}
-
-const initialState: UserState = {
-    roles: [],
-    isLoggedIn: false,
-    username: '',
-}
-
-
-const _authReducer = (
-    state: UserState = initialState,
-    action: AnyAction
-): UserState => {
-    switch (action.type) {
-        case AuthStateActions.LOG_IN:
-            return {
-                ...action.payload,
-                isAuthenticated: true,
-            }
-        case AuthStateActions.LOG_OUT:
-            return initialState
-
-        case AuthStateActions.UPDATE_ACCESS_TOKEN:
-            return {
-                ...state,
-                accessToken: action.payload,
-            }
-
-        case AuthStateActions.UPDATE_REFRESH_TOKEN:
-            return {
-                ...state,
-                refreshToken: action.payload,
-            }
-
-        case AuthStateActions.UPDATE_TOKENS:
-            return { ...state, ...action.payload }
-
-        default:
-            return state
-    }
-}
-
-const authReducer = persistReducer(persistConfig, _authReducer)
-
-export default authReducer
diff --git a/frontend/src/features/Auth/userSlice.ts b/frontend/src/features/Auth/userSlice.ts
index f10b798..4b9bb5d 100644
--- a/frontend/src/features/Auth/userSlice.ts
+++ b/frontend/src/features/Auth/userSlice.ts
@@ -1,6 +1,7 @@
 import { createSlice, PayloadAction } from '@reduxjs/toolkit'
 import { persistReducer } from 'redux-persist'
 import storage from 'redux-persist/lib/storage'
+import { logIn } from './userThunks'
 
 export interface UserState {
     accessToken?: string
@@ -8,6 +9,7 @@ export interface UserState {
     username: string
     roles: string[]
     isLoggedIn: boolean
+    lastErr?: string // consumable for errors during thunks
 }
 
 const persistConfig = {
@@ -29,21 +31,26 @@ export const userSlice = createSlice({
 
     // Reducers that update the state
     reducers: {
-        logout: () => {
-            return initialState // Reset to the inital state
-        },
-        refreshTokens: (state, action) => {
-            return {
-                ...state,
-                accessToken: action.payload.accessToken,
-                refreshToken: action.payload.refreshToken,
-            }
-        },
+        logout: () => initialState, // Reset to the inital state
+        refreshTokens: (state, action) => ({
+            ...state,
+            accessToken: action.payload.accessToken,
+            refreshToken: action.payload.refreshToken,
+        }),
+        setErr: (state, action) => ({
+            ...state,
+            lastErr: action.payload,
+        }),
     },
 
-    // For thunks (async operations)
-    extraReducers: {},
+    // Thunks
+    extraReducers: (builder) => {
+        builder.addCase(logIn.fulfilled, () => {
+            console.log('Action performed') // TODO remove
+        }) // TODO funny
+    },
 })
 
 const userReducer = persistReducer(persistConfig, userSlice.reducer)
+
 export default userReducer
diff --git a/frontend/src/features/Auth/userThunks.ts b/frontend/src/features/Auth/userThunks.ts
index 7fe5451..afe5191 100644
--- a/frontend/src/features/Auth/userThunks.ts
+++ b/frontend/src/features/Auth/userThunks.ts
@@ -2,22 +2,32 @@ import { createAsyncThunk } from '@reduxjs/toolkit'
 import axiosInstance from '../../api/api'
 import { UserDto } from '../../swagger/data-contracts'
 
-export interface RegisterUser {
-    username: string
-    email: string
-    password: string
-    passwordRepeat: string
-}
-export const registerUser = createAsyncThunk(
-    'users/register',
-    async (registerUser: UserDto) => {
+const loginError =
+    'Server error occurred while logging in. Please contact help service to resolve this issue or try again later.'
+
+
+export const logIn = createAsyncThunk(
+    'user/login',
+    async (userDto: UserDto, { dispatch, getState }) => {
         try {
-            const { data } = await axiosInstance.post(
-                '/users/register',
-                registerUser
-            )
-        } catch (error: any) {
+            // @ts-ignore
+            // TODO fix
+            if (getState().user.isLoggedIn) {
+                return
+            }
+            console.log('Dispatching login thunk') // TODO remove
+            const { data, status } = await axiosInstance.post('/login', userDto)
+            const { accessToken, refreshToken } = data
+            console.log(data) // TODO remove
+            if (status !== 200) {
+                // TODO read API err
+                dispatch({ type: 'user/setErr', payload: loginError })
+                return
+            }
 
+            dispatch({ type: 'user/refreshTokens', payload: { accessToken, refreshToken } })
+        } catch (err: any) {
+            dispatch({ type: 'user/setErr', payload: loginError })
         }
     }
 )
diff --git a/frontend/src/features/redux/store.ts b/frontend/src/features/redux/store.ts
index 9065061..42c15e7 100644
--- a/frontend/src/features/redux/store.ts
+++ b/frontend/src/features/redux/store.ts
@@ -2,7 +2,7 @@
 import { applyMiddleware, combineReducers, createStore } from 'redux'
 import { persistStore } from 'redux-persist'
 import thunk from 'redux-thunk'
-import userReducer from '../Auth/userReducer'
+import userReducer from '../Auth/userSlice'
 import themeReducer from '../Theme/themeReducer'
 
 // Store holds shared state in the application
