diff --git a/react-ui/src/components/view/login/login.tsx b/react-ui/src/components/view/login/login.view.tsx similarity index 98% rename from react-ui/src/components/view/login/login.tsx rename to react-ui/src/components/view/login/login.view.tsx index fe3b6369cb1d242834265f9b76b48f0f523f19c8..ba8da826ec61d73e9d28a85253784f38624580e6 100644 --- a/react-ui/src/components/view/login/login.tsx +++ b/react-ui/src/components/view/login/login.view.tsx @@ -6,11 +6,10 @@ import logo from '@assets/logo.svg' import useLoginViewModel from '@viewmodel/login.viewmodel' import React, { useRef } from 'react' -const LoginPage = () => { +const LoginPage = ({ children }) => { const { t } = useTranslation('common') const { login, handleErrorMessageRendering, displayFormFieldChecks, loginLoading } = useLoginViewModel(); - const usernameRef = useRef<HTMLInputElement>(null) const passwordRef = useRef<HTMLInputElement>(null) diff --git a/react-ui/src/components/view/splash/splash.view.ts b/react-ui/src/components/view/splash/splash.view.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/react-ui/src/components/view_model/login.viewmodel.ts b/react-ui/src/components/view_model/login.viewmodel.ts index 110507a653dcc5da702f00fe10a9f8e28ae95d6f..ed666d401b6059c0f74161dde459e5dd9813aec8 100644 --- a/react-ui/src/components/view_model/login.viewmodel.ts +++ b/react-ui/src/components/view_model/login.viewmodel.ts @@ -1,8 +1,7 @@ -import { AuthServiceLoginApiArg, AuthServiceLoginApiResponse, useAuthServiceLoginMutation } from "@api/api"; -import { setToken } from "@reducer/user.reducer"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useDispatch } from "react-redux"; -import { AppDispatch } from "src/stores"; +import { useAppSelector } from "@hooks"; +import { useAuth } from "@provider/auth.provider"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; export interface PageLoginState { submitted: boolean, @@ -10,24 +9,15 @@ export interface PageLoginState { } export default function useLoginViewModel() { - const dispatch = useDispatch<AppDispatch>(); - - const [ - sendLogin, - { data: loginResponse, error: loginError, isLoading: loginLoading, reset: resetLogin } - ] = useAuthServiceLoginMutation() - + const {login, loginProperties} = useAuth(); + const {isLoading: loginLoading, error: loginError, reset: resetLogin} = loginProperties!; + const [localFormState, updateLocalFormState] = useState({ submitted: false, valid: false, }); - - useEffect(() => { - console.log('loginREsponse:' + loginResponse); - }, [loginResponse]) - const handleErrorMessageRendering = (formInvalidError: JSX.Element, backendResponseError: JSX.Element): JSX.Element | null => { // backend response check console.log('loginError:' + loginError); @@ -43,21 +33,6 @@ export default function useLoginViewModel() { return null; } - /** - * Returns the /login payload - */ - const getAuthPayload = (username: string, password: string): AuthServiceLoginApiArg => { - const payload: AuthServiceLoginApiArg = { - rbacLoginRequest: { - username, - pwd: password, - timestamp: new Date().getTime().toString(), - }, - } - - return payload; - } - const isFormValid = (username: string | undefined, password: string | undefined): boolean => { return !!username && !!password; } @@ -71,29 +46,15 @@ export default function useLoginViewModel() { const loginHandler = (username: string | undefined, password: string | undefined) => { resetLogin(); const valid = isFormValid(username, password); - + updateLocalFormState({ ...localFormState, valid, submitted: true }) - + if (valid) { - executeLogin(username!, password!); + //executeLogin(username!, password!); + login(username!, password!); } } - const executeLogin = (username: string, password: string) => { - const authPayload = getAuthPayload(username, password); - - sendLogin(authPayload).unwrap().then((response: AuthServiceLoginApiResponse) => { - if (!response.token) { - // reset the action by calling the reset hook - throw Error("Response is successful but no token was provided. Expected response {token: '<jwt-token>'}"); - } - - dispatch(setToken(response.token)); - }).catch((error) => { - // determine whether 500 or 401 err - }); - } - const displayFormFieldChecks = (): boolean => { return localFormState.submitted && !loginError; } diff --git a/react-ui/src/index.tsx b/react-ui/src/index.tsx index 8441405d05e410c0c6d4d5a74794a54d027ff27a..42d968e8c5ecd55f4d66b2c9e36a9eab7a0963fc 100644 --- a/react-ui/src/index.tsx +++ b/react-ui/src/index.tsx @@ -1,24 +1,25 @@ -import './index.scss' import React from 'react' import ReactDOM, { Container } from 'react-dom/client' import { - Outlet, Route, RouterProvider, createBrowserRouter, - createRoutesFromElements, + createRoutesFromElements } from 'react-router-dom' import Landingpage from './components/view/landingpage/landingpage' -import LoginPage from './components/view/login/login' +import './index.scss' -import './i18n/config' -import { I18nextProvider } from 'react-i18next' import i18next from 'i18next' +import { I18nextProvider } from 'react-i18next' import { Provider } from 'react-redux' -import { persistor, store } from './stores' import { ToastContainer } from 'react-toastify' import { PersistGate } from 'redux-persist/integration/react' -import './utils/icons/icons'; +import './i18n/config' +import { persistor, store } from './stores' +import './utils/icons/icons' +import { AuthLayout } from '@layout/auth.layout' +import { ProtectedLayout } from '@layout/protected.layout' +import { LoginLayout } from '@layout/login.layout' const root = ReactDOM.createRoot(document.getElementById('root') as Container) @@ -26,12 +27,12 @@ const root = ReactDOM.createRoot(document.getElementById('root') as Container) // create a proper routing const router = createBrowserRouter( createRoutesFromElements( - <> - <Route element={<Outlet />}> + <Route element={<AuthLayout />}> + <Route path="/login" element={<LoginLayout />} /> + <Route element={<ProtectedLayout/>}> <Route path="/" element={<Landingpage />} /> - <Route path="/login" element={<LoginPage />} /> </Route> - </> + </Route> ) ) diff --git a/react-ui/src/stores/api.store.ts b/react-ui/src/stores/api.store.ts index a63a9d7725ab2418bff7d6860d33d9749d1977b1..9f9df0e52a30c939536c0ddabee594a26ce39448 100644 --- a/react-ui/src/stores/api.store.ts +++ b/react-ui/src/stores/api.store.ts @@ -5,7 +5,7 @@ import { RootState } from '.' export const emptySplitApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: '/api', prepareHeaders: (headers, { getState }) => { - const token = (getState() as RootState).userReducer.token + const token = (getState() as RootState).user.token if (token) { headers.set('authorization', `Bearer ${token}`) diff --git a/react-ui/src/stores/persist.store.ts b/react-ui/src/stores/persist.store.ts index 53e17bffdeebfd7cc12e78a0380eaf2fe00cf0c6..39f70ea2f53558765d4f0cd701e76d9b6670e343 100644 --- a/react-ui/src/stores/persist.store.ts +++ b/react-ui/src/stores/persist.store.ts @@ -1,4 +1,3 @@ -import loginReducer from "@reducer/login.reducer"; import userReducer from "@reducer/user.reducer"; import { combineReducers } from "redux"; import { persistReducer } from "redux-persist"; @@ -15,8 +14,7 @@ const rootPersistConfig = { const rootReducer = combineReducers({ - userReducer: userReducer, - loginPageReducer: loginReducer, + user: userReducer, [emptySplitApi.reducerPath]: emptySplitApi.reducer, }) diff --git a/react-ui/src/stores/reducer/login.reducer.ts b/react-ui/src/stores/reducer/login.reducer.ts deleted file mode 100644 index 7016aa72335a6a2eda986706818438114687cee6..0000000000000000000000000000000000000000 --- a/react-ui/src/stores/reducer/login.reducer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { startListening } from '../middleware/listener.middleware'; -import { AuthServiceLoginApiArg, RbacUser, api, useAuthServiceLoginMutation } from '@api/api'; - -const initialState = { - backendResponse: { - valid: false, - send: false - }, - form: { - valid: false, - fields: { - username: '', - password: '' - } - } -} - -const loginSlice = createSlice({ - name: 'page_login', - initialState, - reducers: { - setLoginBackendCheck: (state, action: PayloadAction<boolean>) => { state.backendResponse.send = action.payload }, - - loginSuccess: (state, action: PayloadAction<string>) => { - state.backendResponse.valid = true; - } - }, -}) - -export const { setLoginBackendCheck } = loginSlice.actions - -export default loginSlice.reducer - - -// startListening({ -// actionCreator: setLoginFormFields, - -// effect: async (action, listenerApi) => { -// const payload: AuthServiceLoginApiArg = { -// rbacLoginRequest: { -// username: action.payload.username, -// pwd: action.payload.password, -// timestamp: new Date().getTime().toString(), -// }, -// } -// listenerApi.dispatch(api.endpoints.authServiceLogin.initiate(payload)); -// }, -// }) \ No newline at end of file diff --git a/react-ui/src/stores/reducer/user.reducer.ts b/react-ui/src/stores/reducer/user.reducer.ts index d1a3c99cad3f033f1496ac6a8119f26fdcb52ced..478acb1c37deb32f149addff1b93c158324a1839 100644 --- a/react-ui/src/stores/reducer/user.reducer.ts +++ b/react-ui/src/stores/reducer/user.reducer.ts @@ -1,5 +1,6 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { RbacUser } from '../../utils/api/api'; +import { startListening } from '../middleware/listener.middleware'; export interface UserSliceState { token: string, @@ -24,4 +25,23 @@ const userSlice = createSlice({ export const { setToken } = userSlice.actions export default userSlice.reducer -export const userReducerPath = userSlice.reducerPath; \ No newline at end of file +export const userReducerPath = userSlice.reducerPath; + + + +startListening({ + actionCreator: setToken, + + effect: async (action, listenerApi) => { + + + // const payload: AuthServiceLoginApiArg = { + // rbacLoginRequest: { + // username: action.payload.username, + // pwd: action.payload.password, + // timestamp: new Date().getTime().toString(), + // }, + // } + // listenerApi.dispatch(api.endpoints.authServiceLogin.initiate(payload)); + }, +}) \ No newline at end of file diff --git a/react-ui/src/utils/layouts/auth.layout.tsx b/react-ui/src/utils/layouts/auth.layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..84c717264775266409cb4beabe5ba05518ef3536 --- /dev/null +++ b/react-ui/src/utils/layouts/auth.layout.tsx @@ -0,0 +1,10 @@ +import { AuthProvider } from "@provider/auth.provider"; +import { useOutlet } from "react-router-dom" + +export const AuthLayout = () => { + const outlet = useOutlet(); + + return ( + <AuthProvider>{outlet}</AuthProvider> + ) +} \ No newline at end of file diff --git a/react-ui/src/utils/layouts/login.layout.tsx b/react-ui/src/utils/layouts/login.layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2c9b4a12ce42a1d386f14271be23ec269146d53 --- /dev/null +++ b/react-ui/src/utils/layouts/login.layout.tsx @@ -0,0 +1,23 @@ +import { useAppSelector } from "@hooks"; +import LoginPage from "@view/login/login.view"; +import { useEffect } from "react"; +import { useNavigate, useOutlet } from "react-router-dom"; + + +// if user is already logged in then redirect to home page +export const LoginLayout = ({ children }) => { + const outlet = useOutlet(); + const { token } = useAppSelector(state => state.user); + const navigate = useNavigate(); + + useEffect(() => { + if (!!token) { + navigate('/'); + return; + } + }); + + return ( + <LoginPage>{outlet}</LoginPage> + ) +} \ No newline at end of file diff --git a/react-ui/src/utils/layouts/protected.layout.tsx b/react-ui/src/utils/layouts/protected.layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e938c2f19d05e516544de11b041523a1ab880a3a --- /dev/null +++ b/react-ui/src/utils/layouts/protected.layout.tsx @@ -0,0 +1,20 @@ +import { Link, Navigate, Outlet } from "react-router-dom"; +import { useAppSelector } from '../../hooks'; + +export const ProtectedLayout = () => { + const { token } = useAppSelector(state => state.user); + + if (!!!token) { + return <Navigate to="/login" />; + } + + return ( + <div> + <nav> + <Link to="/settings">Settings</Link> + <Link to="/profile">Profile</Link> + </nav> + <Outlet /> + </div> + ) +}; \ No newline at end of file diff --git a/react-ui/src/utils/provider/auth.provider.tsx b/react-ui/src/utils/provider/auth.provider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9e1e09c5cae8c1be5ae03a8e1ec15be415aaf1fd --- /dev/null +++ b/react-ui/src/utils/provider/auth.provider.tsx @@ -0,0 +1,100 @@ +import { AuthServiceLoginApiArg, AuthServiceLoginApiResponse, useAuthServiceLoginMutation } from "@api/api"; +import { setToken } from "@reducer/user.reducer"; +import { createContext, useContext, useMemo } from "react"; +import { useDispatch } from "react-redux"; +import { redirect, useNavigate } from "react-router-dom"; +import { AppDispatch } from "src/stores"; + +interface AuthProviderType { + login: (username: string, password: string) => void, + logout: () => void, + // todo figure out the type of loginProperties + loginProperties: { + isLoading: boolean, + isSuccess: boolean, + isError: boolean, + error: object, + data: object, + reset: () => void + } | undefined +} + + +const AuthContext = createContext<AuthProviderType>({ + login: () => { throw new Error("login function not implemented"); }, + logout: () => { throw new Error("logout function not implemented"); }, + loginProperties: undefined, +}); + +export const AuthProvider = ({ children }) => { + + const dispatch = useDispatch<AppDispatch>(); + const navigate = useNavigate(); + + const [ + sendLogin, + loginProperties, + ] = useAuthServiceLoginMutation() + + + /** + * Returns the /login payload + */ + const getAuthPayload = (username: string, password: string): AuthServiceLoginApiArg => { + const payload: AuthServiceLoginApiArg = { + rbacLoginRequest: { + username, + pwd: password, + timestamp: new Date().getTime().toString(), + }, + } + + return payload; + } + + const executeLogin = (username: string, password: string) => { + const authPayload = getAuthPayload(username, password); + + sendLogin(authPayload).unwrap().then((response: AuthServiceLoginApiResponse) => { + if (!response.token) { + // reset the action by calling the reset hook + throw Error("Response is successful but no token was provided. Expected response {token: '<jwt-token>'}"); + } + + dispatch(setToken(response.token)); + + + navigate('/'); + }).catch((error) => { + // determine whether 500 or 401 err + }); + } + + const login = (username: string, password: string) => { + executeLogin(username, password); + } + + const logout = () => { + // TODO + } + + const value = useMemo( + () => ({ + login, + logout, + loginProperties + }), + [] + ); + + return ( + <AuthContext.Provider value={value}> + {children} + </AuthContext.Provider> + ) +} + + +export const useAuth = () => { + return useContext(AuthContext); +} diff --git a/react-ui/tsconfig.json b/react-ui/tsconfig.json index 761bbe68320f8da3a7c3c84d4702286606ca92c5..b8c8c062584c4b502c414e4036d18e579d53e35f 100644 --- a/react-ui/tsconfig.json +++ b/react-ui/tsconfig.json @@ -27,6 +27,9 @@ "@viewmodel/*": ["src/components/view_model/*"], "@view/*": ["src/components/view/*"], "@reducer/*": ["src/stores/reducer/*"], + "@provider/*": ["src/utils/provider/*"], + "@layout/*": ["src/utils/layouts/*"], + "@hooks": ["src/hooks"], } }, "include": [ diff --git a/react-ui/vite.config.mjs b/react-ui/vite.config.mjs index d5f7d6a210042f88c9946c6913a876c98bd97392..c52ee47caa32007655b72c71a31b4bfca45c7f48 100644 --- a/react-ui/vite.config.mjs +++ b/react-ui/vite.config.mjs @@ -33,6 +33,9 @@ export default defineConfig({ "@viewmodel": "/src/components/view_model", "@view": "/src/components/view", "@reducer": "/src/stores/reducer", + "@provider": "/src/utils/provider", + "@layout": "/src/utils/layouts", + "@hooks": "/src/hooks.ts", }, },