diff --git a/UserStories.md b/UserStories.md index 4f0133e..d2be3b3 100644 --- a/UserStories.md +++ b/UserStories.md @@ -1,21 +1,21 @@ # User Stories for the app -1. [ ] Replace current sticky note system +1. [x] Replace current sticky note system 2. [x] Add a public facing page with basic contact info -3. [ ] Add an employee login to the notes app +3. [x] Add an employee login to the notes app 4. [x] Provide a welcome page after login -5. [ ] Provide easy navigation -6. [ ] Display current user and assigned role -7. [ ] Provide a logout option -8. [ ] Require users to login at least once per week -9. [ ] Provide a way to remove employee access asap if needed +5. [x] Provide easy navigation +6. [x] Display current user and assigned role +7. [x] Provide a logout option +8. [x] Require users to login at least once per week +9. [x] Provide a way to remove employee access asap if needed 10. [x] Notes are assigned to specific employees -11. [ ] Notes have a ticket #, title, note body, created & updated dates +11. [x] Notes have a ticket #, title, note body, created & updated dates 12. [x] Notes are either OPEN or COMPLETED -13. [ ] Users can be Employees, Managers, or Admins -14. [ ] Notes can only be deleted by Managers or Admins -15. [ ] Anyone can create a note (when customer checks-in) -16. [ ] Employees can only view and edit their assigned notes -17. [ ] Managers and Admins can view, edit, and delete all notes -18. [ ] Only Managers and Admins can access User Settings -19. [ ] Only Managers and Admins can create new users \ No newline at end of file +13. [x] Users can be Employees, Managers, or Admins +14. [x] Notes can only be deleted by Managers or Admins +15. [x] Anyone can create a note (when customer checks-in) +16. [x] Employees can only view and edit their assigned notes +17. [x] Managers and Admins can view, edit, and delete all notes +18. [x] Only Managers and Admins can access User Settings +19. [x] Only Managers and Admins can create new users diff --git a/client/src/App.js b/client/src/App.js index 402b0ef..ca6b204 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -11,33 +11,44 @@ import NewUserForm from "./features/users/NewUserForm"; import EditNote from "./features/notes/EditNote"; import NewNote from "./features/notes/NewNote"; import Prefetch from "./features/auth/Prefetch"; +import PersistLogin from "./features/auth/PersistLogin"; +import RequireAuth from "./features/auth/RequireAuth"; +import { ROLES } from "./config/roles"; function App() { return ( }> + {/* Public Routes */} } /> } /> - }> - }> - - } /> - - - } /> - } /> - } /> + {/* Protected Routes */} + }> + }> + }> + }> + + } /> + + }> + + } /> + } /> + } /> + + + + + } /> + } /> + } /> + + + {/* End Dash */} - - - } /> - } /> - } /> - - - {/* End Dash */} - + + {/* End Protected Routes */} diff --git a/client/src/app/api/apiSlice.js b/client/src/app/api/apiSlice.js index e0d1978..f91843c 100644 --- a/client/src/app/api/apiSlice.js +++ b/client/src/app/api/apiSlice.js @@ -1,11 +1,55 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { setCredentials } from '../../features/auth/authSlice'; + +const baseQuery = fetchBaseQuery({ + baseUrl: 'http://localhost:3500', + credentials: 'include', + prepareHeaders: (headers, { getState }) => { + const token = getState().auth.token + + if (token) { + headers.set("authorization", `Bearer ${token}`) + } + return headers + } +}) + +const baseQueryWithReauth = async (args, api, extraOptions) => { + // console.log(args) // request url, method, body + // console.log(api) // signal, dispatch, getState() + // console.log(extraOptions) //custom like {shout: true} + + let result = await baseQuery(args, api, extraOptions); + + // If you want, handle other status codes, too + if (result?.error?.status === 403) { + console.log('sending refresh token') + + // send refresh token to get new access token + const refreshResult = await baseQuery('/auth/refresh', api, extraOptions); + + if (refreshResult?.data) { + + // store the new token + api.dispatch(setCredentials({ ...refreshResult.data })) + + // retry original query with new access token + result = await baseQuery(args, api, extraOptions) + } else { + + if (refreshResult?.error?.status === 403) { + refreshResult.error.data.message = "Your login has expired. " + } + return refreshResult + } + }; + + return result; +}; // Create an API slice using createApi export const apiSlice = createApi({ - // Specify the baseQuery configuration with the base URL - baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3500' }), - // Define tagTypes for caching and invalidation + baseQuery: baseQueryWithReauth, tagTypes: ['Note', 'User'], - // Define endpoints using the builder function endpoints: builder => ({}) }); diff --git a/client/src/app/store.js b/client/src/app/store.js index 259b222..7059f1a 100644 --- a/client/src/app/store.js +++ b/client/src/app/store.js @@ -1,10 +1,12 @@ import { configureStore } from "@reduxjs/toolkit"; import { apiSlice } from './api/apiSlice'; import { setupListeners } from "@reduxjs/toolkit/query"; +import authReducer from "../features/auth/authSlice"; export const store = configureStore({ reducer: { [apiSlice.reducerPath]: apiSlice.reducer, + auth: authReducer, }, middleware: getDefaultMiddleware => getDefaultMiddleware().concat(apiSlice.middleware), diff --git a/client/src/components/DashFooter.js b/client/src/components/DashFooter.js index 2e9cfdd..abaefea 100644 --- a/client/src/components/DashFooter.js +++ b/client/src/components/DashFooter.js @@ -1,16 +1,19 @@ -// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -// import { faHouse } from "@fortawesome/free-solid-svg-icons"; -// import { useNavigate, useLocation } from "react-router-dom"; +// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +// import { faHouse } from "@fortawesome/free-solid-svg-icons" +// import { useNavigate, useLocation } from 'react-router-dom' +import useAuth from "../hooks/useAuth" const DashFooter = () => { - // const navigate = useNavigate(); - // const { pathname } = useLocation(); + const { username, status } = useAuth() - // const onGoHomeClicked = () => navigate("/dash"); + // const navigate = useNavigate() + // const { pathname } = useLocation() - // let goHomeButton = null; - // if (pathname !== "/dash") { + // const onGoHomeClicked = () => navigate('/dash') + + // let goHomeButton = null + // if (pathname !== '/dash') { // goHomeButton = ( // + ) + } + + let newUserButton = null + if (USERS_REGEX.test(pathname)) { + newUserButton = ( + + ) + } + + let userButton = null + if (isManager || isAdmin) { + if (!USERS_REGEX.test(pathname) && pathname.includes('/dash')) { + userButton = ( + + ) + } + } + + let notesButton = null + if (!NOTES_REGEX.test(pathname) && pathname.includes('/dash')) { + notesButton = ( + + ) + } + + const logoutButton = ( + + ) + + const errClass = isError ? "errmsg" : "offscreen" + + let buttonContent + if (isLoading) { + buttonContent =

Logging Out...

+ } else { + buttonContent = ( + <> + {newNoteButton} + {newUserButton} + {notesButton} + {userButton} + {logoutButton} + + ) + } const content = ( -
-
- -

fixScribe

- - -
-
+ <> +

{error?.data?.message}

+ +
+
+ +

techNotes

+ + +
+
+ ) return content } - export default DashHeader \ No newline at end of file diff --git a/client/src/features/auth/Login.js b/client/src/features/auth/Login.js index afcbcce..05528d6 100644 --- a/client/src/features/auth/Login.js +++ b/client/src/features/auth/Login.js @@ -1,9 +1,126 @@ -import React from 'react' +import { useRef, useState, useEffect } from 'react'; // Importing React hooks +import { useNavigate, Link } from 'react-router-dom'; // Importing navigation and link components from React Router + +import { useDispatch } from 'react-redux'; // Importing dispatch hook from Redux +import { setCredentials } from './authSlice'; // Importing setCredentials action from authSlice +import { useLoginMutation } from './authApiSlice'; // Importing custom hook for login mutation + +import usePersist from '../../hooks/usePersist'; // Importing custom hook for persisting state const Login = () => { - return ( -

Login

- ) -} + const userRef = useRef(); // Creating a ref for the username input field + const errRef = useRef(); // Creating a ref for the error message + const [username, setUsername] = useState(''); // State for storing the username + const [password, setPassword] = useState(''); // State for storing the password + const [errMsg, setErrMsg] = useState(''); // State for storing the error message + const [persist, setPersist] = usePersist(); // Using custom hook for persisting state + + const navigate = useNavigate(); // Hook for navigation + const dispatch = useDispatch(); // Hook for dispatching actions + + const [login, { isLoading }] = useLoginMutation(); // Using custom hook for login mutation + + // Effect hook to focus on the username input field when the component mounts + useEffect(() => { + userRef.current.focus(); + }, []); + + // Effect hook to clear the error message when username or password changes + useEffect(() => { + setErrMsg(''); + }, [username, password]); + + // Handler for form submission + const handleSubmit = async (e) => { + e.preventDefault(); // Preventing default form submission + try { + const { accessToken } = await login({ username, password }).unwrap(); // Attempting login and getting access token + dispatch(setCredentials({ accessToken })); // Dispatching setCredentials action with access token + setUsername(''); // Clearing username state + setPassword(''); // Clearing password state + navigate('/dash'); // Navigating to dashboard + } catch (err) { + // Handling errors and setting appropriate error messages + if (!err.status) { + setErrMsg('No Server Response'); + } else if (err.status === 400) { + setErrMsg('Missing Username or Password'); + } else if (err.status === 401) { + setErrMsg('Unauthorized'); + } else { + setErrMsg(err.data?.message); + } + errRef.current.focus(); // Focusing on the error message + } + }; + + // Handler for username input change + const handleUserInput = (e) => setUsername(e.target.value); + + // Handler for password input change + const handlePwdInput = (e) => setPassword(e.target.value); + + // Handler for toggling persist state + const handleToggle = () => setPersist(prev => !prev); + + // Determining error message class + const errClass = errMsg ? "errmsg" : "offscreen"; + + // Returning loading message if the login mutation is in progress + if (isLoading) return

Loading...

; + + // JSX content for the component + const content = ( +
+
+

Employee Login

+
+
+

{errMsg}

+ +
+ + + + + + + + +
+
+ +
+ ); + + return content; +}; -export default Login \ No newline at end of file +export default Login; diff --git a/client/src/features/auth/PersistLogin.js b/client/src/features/auth/PersistLogin.js new file mode 100644 index 0000000..1e58941 --- /dev/null +++ b/client/src/features/auth/PersistLogin.js @@ -0,0 +1,70 @@ +import { Outlet, Link } from "react-router-dom"; // Importing Outlet and Link components from React Router +import { useEffect, useRef, useState } from 'react'; // Importing React hooks +import { useRefreshMutation } from "./authApiSlice"; // Importing custom hook for refresh mutation +import usePersist from "../../hooks/usePersist"; // Importing custom hook for persisting state +import { useSelector } from 'react-redux'; // Importing selector hook from Redux +import { selectCurrentToken } from "./authSlice"; // Importing selector for current token from authSlice + +const PersistLogin = () => { + const [persist] = usePersist(); // Using custom hook for persisting state + const token = useSelector(selectCurrentToken); // Getting current token from Redux store + const effectRan = useRef(false); // Creating a ref to track if the effect has run + + const [trueSuccess, setTrueSuccess] = useState(false); // State to track successful token refresh + + const [refresh, { + isUninitialized, + isLoading, + isSuccess, + isError, + error + }] = useRefreshMutation(); // Using custom hook for refresh mutation + + useEffect(() => { + if (effectRan.current === true || process.env.NODE_ENV !== 'development') { // React 18 Strict Mode + const verifyRefreshToken = async () => { + console.log('verifying refresh token'); + try { + await refresh(); // Refreshing the token + setTrueSuccess(true); // Setting success state + } catch (err) { + console.error(err); // Logging error + } + }; + + if (!token && persist) verifyRefreshToken(); // If no token and persist is true, verify refresh token + } + + return () => effectRan.current = true; // Setting effectRan to true when component unmounts + + // eslint-disable-next-line + }, []); // Empty dependency array to run effect only once + + let content; + if (!persist) { // persist: no + console.log('no persist'); + content = ; + } else if (isLoading) { // persist: yes, token: no + console.log('loading'); + content =

Loading...

; + } else if (isError) { // persist: yes, token: no + console.log('error'); + content = ( +

+ {`${error?.data?.message} - `} + Please login again. +

+ ); + } else if (isSuccess && trueSuccess) { // persist: yes, token: yes + console.log('success'); + content = ; + } else if (token && isUninitialized) { // persist: yes, token: yes + console.log('token and uninit'); + console.log(isUninitialized); + content = ; + } + + return content; // Returning the appropriate content +}; + +export default PersistLogin; diff --git a/client/src/features/auth/RequireAuth.js b/client/src/features/auth/RequireAuth.js new file mode 100644 index 0000000..9796cdd --- /dev/null +++ b/client/src/features/auth/RequireAuth.js @@ -0,0 +1,17 @@ +import { useLocation, Navigate, Outlet } from "react-router-dom"; +import useAuth from "../../hooks/useAuth"; + +const RequireAuth = ({ allowedRoles }) => { + const location = useLocation(); + const { roles } = useAuth(); + + const content = ( + roles.some(role => allowedRoles.includes(role)) + ? + : + ); + + return content; +}; + +export default RequireAuth; \ No newline at end of file diff --git a/client/src/features/auth/Welcome.js b/client/src/features/auth/Welcome.js index aec9d5b..1442a78 100644 --- a/client/src/features/auth/Welcome.js +++ b/client/src/features/auth/Welcome.js @@ -1,7 +1,10 @@ import { Link } from "react-router-dom"; +import useAuth from '../../hooks/useAuth' const Welcome = () => { + const { username, isManager, isAdmin } = useAuth(); + const date = new Date(); const today = new Intl.DateTimeFormat('en-CA', { dateStyle: 'full', timeStyle: 'long' }).format(date); @@ -10,20 +13,20 @@ const Welcome = () => {

{today}

-

Welcome!

+

Welcome {username}!

View Notes

Add New Note

-

View User Settings

+ {(isManager || isAdmin) &&

View User Settings

} -

Add New User

+ {(isManager || isAdmin) &&

Add New User

} ); - return content + return content; }; export default Welcome \ No newline at end of file diff --git a/client/src/features/auth/authApiSlice.js b/client/src/features/auth/authApiSlice.js new file mode 100644 index 0000000..09932ee --- /dev/null +++ b/client/src/features/auth/authApiSlice.js @@ -0,0 +1,57 @@ +import { apiSlice } from "../../app/api/apiSlice"; // Importing apiSlice from the application API +import { logOut, setCredentials } from "./authSlice"; // Importing actions from authSlice + +export const authApiSlice = apiSlice.injectEndpoints({ + endpoints: builder => ({ + // Mutation for login + login: builder.mutation({ + query: credentials => ({ + url: '/auth', // API endpoint for login + method: 'POST', // HTTP method + body: { ...credentials } // Request body containing credentials + }) + }), + // Mutation for logout + sendLogout: builder.mutation({ + query: () => ({ + url: '/auth/logout', // API endpoint for logout + method: 'POST', // HTTP method + }), + async onQueryStarted(arg, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; // Awaiting the response + console.log(data); + dispatch(logOut()); // Dispatching logOut action + setTimeout(() => { + dispatch(apiSlice.util.resetApiState()); // Resetting API state after a timeout + }, 1000); + } catch (err) { + console.log(err); // Logging error + } + } + }), + // Mutation for refreshing token + refresh: builder.mutation({ + query: () => ({ + url: '/auth/refresh', // API endpoint for token refresh + method: 'GET', // HTTP method + }), + async onQueryStarted(arg, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; // Awaiting the response + console.log(data); + const { accessToken } = data; // Extracting access token from response + dispatch(setCredentials({ accessToken })); // Dispatching setCredentials action + } catch (err) { + console.log(err); // Logging error + } + } + }), + }) +}); + +export const { + useLoginMutation, // Exporting hook for login mutation + useSendLogoutMutation, // Exporting hook for logout mutation + useRefreshMutation, // Exporting hook for refresh mutation +} = authApiSlice; // Exporting authApiSlice diff --git a/client/src/features/auth/authSlice.js b/client/src/features/auth/authSlice.js new file mode 100644 index 0000000..abb8614 --- /dev/null +++ b/client/src/features/auth/authSlice.js @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const authSlice = createSlice({ + name: 'auth', + initialState: { token: null }, + reducers: { + setCredentials: (state, action) => { + const { accessToken } = action.payload + state.token = accessToken + }, + logOut: (state, action) => { + state.token = null + }, + } +}); + +export const { setCredentials, logOut } = authSlice.actions; + +export default authSlice.reducer; + +export const selectCurrentToken = (state) => state.auth.token; \ No newline at end of file diff --git a/client/src/features/notes/EditNoteForm.js b/client/src/features/notes/EditNoteForm.js index fb1082e..d9a24ee 100644 --- a/client/src/features/notes/EditNoteForm.js +++ b/client/src/features/notes/EditNoteForm.js @@ -3,11 +3,22 @@ import { useUpdateNoteMutation, useDeleteNoteMutation } from "./notesApiSlice"; import { useNavigate } from "react-router-dom"; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSave, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import useAuth from "../../hooks/useAuth"; const EditNoteForm = ({ note, users }) => { - // Destructuring the result of the custom hooks into specific variables - const [updateNote, { isLoading, isSuccess, isError, error }] = useUpdateNoteMutation(); - const [deleteNote, { isSuccess: isDelSuccess, isError: isDelError, error: delerror }] = useDeleteNoteMutation(); + const { isManager, isAdmin } = useAuth() + + const [updateNote, { + isLoading, + isSuccess, + isError, + error + }] = useUpdateNoteMutation(); + const [deleteNote, { + isSuccess: isDelSuccess, + isError: isDelError, + error: delerror + }] = useDeleteNoteMutation(); // Hook for navigation const navigate = useNavigate(); @@ -68,6 +79,19 @@ const EditNoteForm = ({ note, users }) => { // Error content to display const errContent = (error?.data?.message || delerror?.data?.message) ?? ''; + let deleteButton = null; + if (isManager || isAdmin) { + deleteButton = ( + + ) + }; + // JSX content for the form const content = ( <> @@ -84,13 +108,7 @@ const EditNoteForm = ({ note, users }) => { > - + {deleteButton}