Revize 8370b6c1
Přidáno uživatelem Václav Honzík před asi 3 roky(ů)
frontend/package.json | ||
---|---|---|
24 | 24 |
"swagger-typescript-api": "^9.3.1", |
25 | 25 |
"ts-node": "^10.7.0", |
26 | 26 |
"typescript": "^4.4.2", |
27 |
"web-vitals": "^2.1.0" |
|
27 |
"web-vitals": "^2.1.0", |
|
28 |
"yup": "^0.32.11" |
|
28 | 29 |
}, |
29 | 30 |
"scripts": { |
30 | 31 |
"start": "react-scripts start", |
... | ... | |
60 | 61 |
"@types/react": "^17.0.20", |
61 | 62 |
"@types/react-dom": "^17.0.9", |
62 | 63 |
"@types/react-redux": "^7.1.23", |
63 |
"@types/redux-persist": "^4.3.1" |
|
64 |
"@types/redux-persist": "^4.3.1", |
|
65 |
"@types/yup": "^0.29.13" |
|
64 | 66 |
} |
65 | 67 |
} |
frontend/src/App.tsx | ||
---|---|---|
1 |
import React, { CSSProperties } from 'react' |
|
2 | 1 |
import './App.css' |
3 | 2 |
import { Routes, Route, Link } from 'react-router-dom' |
4 | 3 |
import Home from './features/Home/Home' |
5 | 4 |
import Catalog from './features/Catalog/Catalog' |
6 | 5 |
import NotFound from './features/NotFound/NotFound' |
7 |
import { Paper, Theme } from '@mui/material' |
|
6 |
import { Container, Paper, Theme } from '@mui/material'
|
|
8 | 7 |
import { ThemeProvider } from '@emotion/react' |
9 | 8 |
import { useSelector } from 'react-redux' |
10 | 9 |
import { RootState } from './features/redux/store' |
11 |
import { createTheme } from '@mui/material/styles'
|
|
10 |
import Login from './features/Auth/Login'
|
|
12 | 11 |
|
13 | 12 |
const App = () => { |
14 | 13 |
const theme: Theme = useSelector((state: RootState) => state.theme.theme) |
15 | 14 |
|
15 |
// TODO remove this its only for debug |
|
16 |
const user = useSelector((state: RootState) => state.user) |
|
17 |
|
|
16 | 18 |
return ( |
17 | 19 |
<ThemeProvider theme={theme}> |
20 |
<p>{JSON.stringify(user)}</p> |
|
18 | 21 |
<Paper style={{ minHeight: '100vh', borderRadius: 0 }}> |
19 | 22 |
<nav> |
20 |
<Link to="/">Home</Link> |
|
21 |
<Link to="/catalog">Catalog</Link> |
|
23 |
<Link to="/">Home </Link> |
|
24 |
<Link to="/catalog">Catalog </Link> |
|
25 |
<Link to="/login">Login </Link> |
|
22 | 26 |
</nav> |
23 |
<Routes> |
|
24 |
<Route path="/" element={<Home />} /> |
|
25 |
<Route path="/catalog" element={<Catalog />} /> |
|
26 |
<Route path="*" element={<NotFound />} /> |
|
27 |
</Routes> |
|
27 |
<Container> |
|
28 |
<Routes> |
|
29 |
<Route path="/" element={<Home />} /> |
|
30 |
<Route path="/catalog" element={<Catalog />} /> |
|
31 |
<Route path="/login" element={<Login />} /> |
|
32 |
<Route path="*" element={<NotFound />} /> |
|
33 |
</Routes> |
|
34 |
</Container> |
|
28 | 35 |
</Paper> |
29 | 36 |
</ThemeProvider> |
30 | 37 |
) |
frontend/src/api/api.ts | ||
---|---|---|
3 | 3 |
import config from '../config/conf' |
4 | 4 |
import { AppStore } from '../features/redux/store' |
5 | 5 |
|
6 |
let store: AppStore // this will get injected later
|
|
6 |
let store: AppStore // this will get injected in index.tsx
|
|
7 | 7 |
|
8 | 8 |
export const injectStore = (_store: Store) => { |
9 | 9 |
store = _store |
... | ... | |
23 | 23 |
(config) => { |
24 | 24 |
const accessToken = store.getState().user.accessToken // get the access token from the store |
25 | 25 |
if (accessToken) { |
26 |
// @ts-ignore |
|
27 |
// this will always be defined, axios developers are just lazy to provide better Typescript types |
|
26 |
// @ts-ignore (axios typescript types are a crime and this will always be defined) |
|
28 | 27 |
config.headers['Authorization'] = accessToken as string |
29 | 28 |
} |
30 | 29 |
|
... | ... | |
38 | 37 |
axiosInstance.interceptors.response.use( |
39 | 38 |
(res) => res, |
40 | 39 |
async (err) => { |
41 |
|
|
42 | 40 |
const originalConfig = err.config |
43 | 41 |
|
44 | 42 |
// Original URL might be login in which case we don't want to refresh the access token |
... | ... | |
53 | 51 |
// If there is no refresh token we simply log the user out |
54 | 52 |
if (!oldRefreshToken) { |
55 | 53 |
store.dispatch({ type: 'user/logout' }) |
56 |
return Promise.reject(err) |
|
57 | 54 |
} |
58 | 55 |
|
59 |
// Set this to retry the request that failed |
|
60 |
originalConfig.retry = true |
|
61 |
|
|
62 | 56 |
// Try refreshing the JWT |
63 | 57 |
try { |
64 |
const refreshConfig = createBaseInstance()
|
|
58 |
const refreshInstance = createBaseInstance()
|
|
65 | 59 |
// @ts-ignore |
66 |
refreshConfig.headers['Authorization'] = oldRefreshToken as string
|
|
60 |
refreshInstance.headers['Authorization'] = oldRefreshToken as string
|
|
67 | 61 |
|
68 |
// Send the request
|
|
69 |
const { data } = await axiosInstance.get('/refreshToken')
|
|
62 |
// Set this to retry the request that failed
|
|
63 |
originalConfig.retry = true
|
|
70 | 64 |
|
65 |
// Send the request |
|
66 |
const { data } = await axiosInstance.get('/users/refreshToken') |
|
71 | 67 |
const { accessToken, refreshToken } = data |
72 | 68 |
|
73 | 69 |
// Set the new tokens |
... | ... | |
76 | 72 |
payload: { accessToken, refreshToken }, |
77 | 73 |
}) |
78 | 74 |
|
75 |
// Return the failed instance so it can retry |
|
79 | 76 |
return axiosInstance(originalConfig) |
80 | 77 |
} catch (err: any) { |
81 | 78 |
// If the refresh token fails we log the user out |
82 | 79 |
store.dispatch({ type: 'user/logout' }) |
83 |
return Promise.reject(err) |
|
80 |
originalConfig.retry = false // do not retry we are logged out |
|
81 |
return originalConfig |
|
84 | 82 |
} |
85 | 83 |
} |
86 | 84 |
) |
frontend/src/features/Auth/Login.tsx | ||
---|---|---|
1 |
import { Button, TextField, Typography } from '@mui/material' |
|
2 |
import { useFormik } from 'formik' |
|
3 |
import { Fragment, useEffect } from 'react' |
|
4 |
import { useDispatch, useSelector } from 'react-redux' |
|
5 |
import { useNavigate } from 'react-router-dom' |
|
6 |
import * as yup from 'yup' |
|
7 |
import { SchemaOf } from 'yup' |
|
8 |
import { PasswordDto, UserDto } from '../../swagger/data-contracts' |
|
9 |
import { RootState } from '../redux/store' |
|
10 |
import { logIn } from './userThunks' |
|
11 |
|
|
12 |
interface LoginFields { |
|
13 |
username: string |
|
14 |
password: string |
|
15 |
} |
|
1 | 16 |
|
2 | 17 |
const Login = () => { |
18 |
const validationSchema: SchemaOf<LoginFields> = yup.object().shape({ |
|
19 |
username: yup.string().required('Username is required'), |
|
20 |
password: yup.string().required('Password is required'), |
|
21 |
}) |
|
22 |
|
|
23 |
const dispatch = useDispatch() |
|
24 |
const formik = useFormik({ |
|
25 |
initialValues: { |
|
26 |
username: '', |
|
27 |
password: '', |
|
28 |
}, |
|
29 |
validationSchema, |
|
30 |
onSubmit: () => { |
|
31 |
dispatch( |
|
32 |
logIn({ |
|
33 |
name: formik.values.username, |
|
34 |
passwords: { |
|
35 |
password: formik.values.password, |
|
36 |
confirmationPassword: '', |
|
37 |
} as PasswordDto, |
|
38 |
} as UserDto) |
|
39 |
) |
|
40 |
}, |
|
41 |
}) |
|
42 |
|
|
43 |
// Redirect to home if the user is logged in |
|
44 |
const userLoggedIn = useSelector( |
|
45 |
(state: RootState) => state.user.isLoggedIn |
|
46 |
) |
|
47 |
const navigate = useNavigate() |
|
48 |
useEffect(() => { |
|
49 |
if (userLoggedIn) { |
|
50 |
navigate('/') |
|
51 |
} |
|
52 |
}, [userLoggedIn, navigate]) |
|
3 | 53 |
|
4 | 54 |
return ( |
5 |
<> |
|
55 |
<Fragment> |
|
56 |
<Typography variant="h3">Login</Typography> |
|
57 |
<p>Credentials = admin:password</p> |
|
6 | 58 |
|
7 |
</> |
|
59 |
<form onSubmit={formik.handleSubmit}> |
|
60 |
<TextField |
|
61 |
label="Username" |
|
62 |
name="username" |
|
63 |
fullWidth |
|
64 |
sx={{ mb: 2 }} |
|
65 |
value={formik.values.username} |
|
66 |
onChange={formik.handleChange} |
|
67 |
error={ |
|
68 |
Boolean(formik.errors.username) && |
|
69 |
formik.touched.username |
|
70 |
} |
|
71 |
helperText={ |
|
72 |
formik.errors.username && |
|
73 |
formik.touched.username && |
|
74 |
formik.errors.username |
|
75 |
} |
|
76 |
/> |
|
77 |
<TextField |
|
78 |
type="password" |
|
79 |
label="Password" |
|
80 |
name="password" |
|
81 |
fullWidth |
|
82 |
value={formik.values.password} |
|
83 |
onChange={formik.handleChange} |
|
84 |
error={ |
|
85 |
Boolean(formik.errors.password) && |
|
86 |
formik.touched.password |
|
87 |
} |
|
88 |
helperText={ |
|
89 |
formik.errors.password && |
|
90 |
formik.touched.password && |
|
91 |
formik.errors.password |
|
92 |
} |
|
93 |
sx={{ mb: 2 }} |
|
94 |
/> |
|
95 |
<Button |
|
96 |
size="large" |
|
97 |
variant="contained" |
|
98 |
color="primary" |
|
99 |
type="submit" |
|
100 |
fullWidth |
|
101 |
> |
|
102 |
Login |
|
103 |
</Button> |
|
104 |
</form> |
|
105 |
</Fragment> |
|
8 | 106 |
) |
9 | 107 |
} |
10 | 108 |
|
11 |
export default Login |
|
109 |
export default Login |
frontend/src/features/Auth/userReducer.ts | ||
---|---|---|
1 |
import { AnyAction } from 'redux' |
|
2 |
import persistReducer from 'redux-persist/es/persistReducer' |
|
3 |
import storage from 'redux-persist/lib/storage' |
|
4 |
|
|
5 |
export interface UserState { |
|
6 |
accessToken?: string |
|
7 |
refreshToken?: string |
|
8 |
username: string |
|
9 |
roles: string[] |
|
10 |
isLoggedIn: boolean |
|
11 |
} |
|
12 |
|
|
13 |
// All possible actions |
|
14 |
export enum AuthStateActions { |
|
15 |
LOG_IN = 'LOG_IN', |
|
16 |
LOG_OUT = 'LOG_OUT', |
|
17 |
UPDATE_ACCESS_TOKEN = 'REFRESH_ACCESS_TOKEN', |
|
18 |
UPDATE_REFRESH_TOKEN = 'UPDATE_REFRESH_TOKEN', |
|
19 |
UPDATE_TOKENS = 'UPDATE_TOKENS', |
|
20 |
} |
|
21 |
|
|
22 |
// Only needed if the state is to be persisted |
|
23 |
const persistConfig = { |
|
24 |
key: 'auth', |
|
25 |
storage |
|
26 |
} |
|
27 |
|
|
28 |
const initialState: UserState = { |
|
29 |
roles: [], |
|
30 |
isLoggedIn: false, |
|
31 |
username: '', |
|
32 |
} |
|
33 |
|
|
34 |
|
|
35 |
const _authReducer = ( |
|
36 |
state: UserState = initialState, |
|
37 |
action: AnyAction |
|
38 |
): UserState => { |
|
39 |
switch (action.type) { |
|
40 |
case AuthStateActions.LOG_IN: |
|
41 |
return { |
|
42 |
...action.payload, |
|
43 |
isAuthenticated: true, |
|
44 |
} |
|
45 |
case AuthStateActions.LOG_OUT: |
|
46 |
return initialState |
|
47 |
|
|
48 |
case AuthStateActions.UPDATE_ACCESS_TOKEN: |
|
49 |
return { |
|
50 |
...state, |
|
51 |
accessToken: action.payload, |
|
52 |
} |
|
53 |
|
|
54 |
case AuthStateActions.UPDATE_REFRESH_TOKEN: |
|
55 |
return { |
|
56 |
...state, |
|
57 |
refreshToken: action.payload, |
|
58 |
} |
|
59 |
|
|
60 |
case AuthStateActions.UPDATE_TOKENS: |
|
61 |
return { ...state, ...action.payload } |
|
62 |
|
|
63 |
default: |
|
64 |
return state |
|
65 |
} |
|
66 |
} |
|
67 |
|
|
68 |
const authReducer = persistReducer(persistConfig, _authReducer) |
|
69 |
|
|
70 |
export default authReducer |
frontend/src/features/Auth/userSlice.ts | ||
---|---|---|
1 | 1 |
import { createSlice, PayloadAction } from '@reduxjs/toolkit' |
2 | 2 |
import { persistReducer } from 'redux-persist' |
3 | 3 |
import storage from 'redux-persist/lib/storage' |
4 |
import { logIn } from './userThunks' |
|
4 | 5 |
|
5 | 6 |
export interface UserState { |
6 | 7 |
accessToken?: string |
... | ... | |
8 | 9 |
username: string |
9 | 10 |
roles: string[] |
10 | 11 |
isLoggedIn: boolean |
12 |
lastErr?: string // consumable for errors during thunks |
|
11 | 13 |
} |
12 | 14 |
|
13 | 15 |
const persistConfig = { |
... | ... | |
29 | 31 |
|
30 | 32 |
// Reducers that update the state |
31 | 33 |
reducers: { |
32 |
logout: () => {
|
|
33 |
return initialState // Reset to the inital state
|
|
34 |
},
|
|
35 |
refreshTokens: (state, action) => {
|
|
36 |
return {
|
|
37 |
...state,
|
|
38 |
accessToken: action.payload.accessToken,
|
|
39 |
refreshToken: action.payload.refreshToken,
|
|
40 |
}
|
|
41 |
}, |
|
34 |
logout: () => initialState, // Reset to the inital state
|
|
35 |
refreshTokens: (state, action) => ({
|
|
36 |
...state,
|
|
37 |
accessToken: action.payload.accessToken,
|
|
38 |
refreshToken: action.payload.refreshToken,
|
|
39 |
}),
|
|
40 |
setErr: (state, action) => ({
|
|
41 |
...state,
|
|
42 |
lastErr: action.payload,
|
|
43 |
}),
|
|
42 | 44 |
}, |
43 | 45 |
|
44 |
// For thunks (async operations) |
|
45 |
extraReducers: {}, |
|
46 |
// Thunks |
|
47 |
extraReducers: (builder) => { |
|
48 |
builder.addCase(logIn.fulfilled, () => { |
|
49 |
console.log('Action performed') // TODO remove |
|
50 |
}) // TODO funny |
|
51 |
}, |
|
46 | 52 |
}) |
47 | 53 |
|
48 | 54 |
const userReducer = persistReducer(persistConfig, userSlice.reducer) |
55 |
|
|
49 | 56 |
export default userReducer |
frontend/src/features/Auth/userThunks.ts | ||
---|---|---|
2 | 2 |
import axiosInstance from '../../api/api' |
3 | 3 |
import { UserDto } from '../../swagger/data-contracts' |
4 | 4 |
|
5 |
export interface RegisterUser { |
|
6 |
username: string |
|
7 |
email: string |
|
8 |
password: string |
|
9 |
passwordRepeat: string |
|
10 |
} |
|
11 |
export const registerUser = createAsyncThunk( |
|
12 |
'users/register', |
|
13 |
async (registerUser: UserDto) => { |
|
5 |
const loginError = |
|
6 |
'Server error occurred while logging in. Please contact help service to resolve this issue or try again later.' |
|
7 |
|
|
8 |
|
|
9 |
export const logIn = createAsyncThunk( |
|
10 |
'user/login', |
|
11 |
async (userDto: UserDto, { dispatch, getState }) => { |
|
14 | 12 |
try { |
15 |
const { data } = await axiosInstance.post( |
|
16 |
'/users/register', |
|
17 |
registerUser |
|
18 |
) |
|
19 |
} catch (error: any) { |
|
13 |
// @ts-ignore |
|
14 |
// TODO fix |
|
15 |
if (getState().user.isLoggedIn) { |
|
16 |
return |
|
17 |
} |
|
18 |
console.log('Dispatching login thunk') // TODO remove |
|
19 |
const { data, status } = await axiosInstance.post('/login', userDto) |
|
20 |
const { accessToken, refreshToken } = data |
|
21 |
console.log(data) // TODO remove |
|
22 |
if (status !== 200) { |
|
23 |
// TODO read API err |
|
24 |
dispatch({ type: 'user/setErr', payload: loginError }) |
|
25 |
return |
|
26 |
} |
|
20 | 27 |
|
28 |
dispatch({ type: 'user/refreshTokens', payload: { accessToken, refreshToken } }) |
|
29 |
} catch (err: any) { |
|
30 |
dispatch({ type: 'user/setErr', payload: loginError }) |
|
21 | 31 |
} |
22 | 32 |
} |
23 | 33 |
) |
frontend/src/features/redux/store.ts | ||
---|---|---|
2 | 2 |
import { applyMiddleware, combineReducers, createStore } from 'redux' |
3 | 3 |
import { persistStore } from 'redux-persist' |
4 | 4 |
import thunk from 'redux-thunk' |
5 |
import userReducer from '../Auth/userReducer'
|
|
5 |
import userReducer from '../Auth/userSlice'
|
|
6 | 6 |
import themeReducer from '../Theme/themeReducer' |
7 | 7 |
|
8 | 8 |
// Store holds shared state in the application |
Také k dispozici: Unified diff
login simple form impl + slice for user state