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 = ( // { // } const content = ( - - - - + {deleteButton} diff --git a/client/src/features/notes/NewNote.js b/client/src/features/notes/NewNote.js index 3252576..2fd867d 100644 --- a/client/src/features/notes/NewNote.js +++ b/client/src/features/notes/NewNote.js @@ -1,12 +1,14 @@ -import { useSelector } from 'react-redux' -import { selectAllUsers } from '../users/usersApiSlice' -import NewNoteForm from './NewNoteForm' +import { useSelector } from 'react-redux'; +import { selectAllUsers } from '../users/usersApiSlice'; +import NewNoteForm from './NewNoteForm'; const NewNote = () => { - const users = useSelector(selectAllUsers) + const users = useSelector(selectAllUsers); - const content = users ? : Loading... + if (!users?.length) return Not Currently Available; - return content + const content = ; + + return content; } export default NewNote \ No newline at end of file diff --git a/client/src/features/notes/NewNoteForm.js b/client/src/features/notes/NewNoteForm.js index 3d99940..577c829 100644 --- a/client/src/features/notes/NewNoteForm.js +++ b/client/src/features/notes/NewNoteForm.js @@ -15,6 +15,7 @@ const NewNoteForm = ({ users }) => { const [title, setTitle] = useState(''); const [text, setText] = useState(''); const [userId, setUserId] = useState(users[0].id); // Defaulting to the first user's ID + const [errMsg, setErrMsg] = useState(''); // State for error messages // Effect hook to reset form and navigate when note is successfully added useEffect(() => { @@ -26,10 +27,15 @@ const NewNoteForm = ({ users }) => { } }, [isSuccess, navigate]); + // Effect hook to clear error message when inputs change + useEffect(() => { + setErrMsg(''); + }, [title, text, userId]); + // Handlers for input changes - const onTitleChanged = e => setTitle(e.target.value); - const onTextChanged = e => setText(e.target.value); - const onUserIdChanged = e => setUserId(e.target.value); + const onTitleChanged = (e) => setTitle(e.target.value); + const onTextChanged = (e) => setText(e.target.value); + const onUserIdChanged = (e) => setUserId(e.target.value); // Checking if all fields are filled and no loading is in progress const canSave = [title, text, userId].every(Boolean) && !isLoading; @@ -37,27 +43,39 @@ const NewNoteForm = ({ users }) => { // Handler for form submission const onSaveNoteClicked = async (e) => { e.preventDefault(); - if (canSave) { - await addNewNote({ user: userId, title, text }); + try { + if (canSave) { + await addNewNote({ user: userId, title, text }).unwrap(); + } + } catch (err) { + if (!err.status) { + setErrMsg('No Server Response'); + } else if (err.status === 400) { + setErrMsg('Missing Title or Text'); + } else if (err.status === 401) { + setErrMsg('Unauthorized'); + } else { + setErrMsg(err.data?.message || 'Failed to save the note'); + } } }; // Creating options for the user select dropdown - const options = users.map(user => ( + const options = users.map((user) => ( {user.username} )); // Classes for error message and incomplete input validation - const errClass = isError ? "errmsg" : "offscreen"; + const errClass = errMsg ? "errmsg" : "offscreen"; const validTitleClass = !title ? "form__input--incomplete" : ''; const validTextClass = !text ? "form__input--incomplete" : ''; // JSX content for the form const content = ( <> - {error?.data?.message} + {errMsg} New Note @@ -93,7 +111,7 @@ const NewNoteForm = ({ users }) => { value={text} onChange={onTextChanged} /> - + ASSIGNED TO: { return content; }; -export default NewNoteForm; \ No newline at end of file +export default NewNoteForm; diff --git a/client/src/features/notes/NotesList.js b/client/src/features/notes/NotesList.js index 77426f3..ac4bd44 100644 --- a/client/src/features/notes/NotesList.js +++ b/client/src/features/notes/NotesList.js @@ -2,6 +2,9 @@ import { useGetNotesQuery } from "./notesApiSlice" import Note from "./Note" const NotesList = () => { + + const { username, isManager, isAdmin } = useAuth() + // Fetch notes data using the custom hook const { data: notes, // Destructure notes data @@ -9,7 +12,7 @@ const NotesList = () => { isSuccess, // Success state isError, // Error state error // Error object - } = useGetNotesQuery(undefined, { + } = useGetNotesQuery('notesList', { pollingInterval: 15000, refetchOnFocus: true, refetchOnMountOrArgChange: true @@ -27,12 +30,17 @@ const NotesList = () => { // If data fetching is successful, render the table if (isSuccess) { - const { ids } = notes // Destructure ids from notes data + const { ids, entities } = notes // Destructure ids from notes data + + let filteredIds + if (isManager || isAdmin) { + filteredIds = [...ids] + } else { + filteredIds = ids.filter(noteId => entities[noteId].username === username) + } // Map note IDs to Note components - const tableContent = ids?.length - ? ids.map(noteId => ) - : null + const tableContent = ids?.length && filteredIds.map(noteId => ); // Render the table with note data content = ( diff --git a/client/src/features/users/UsersList.js b/client/src/features/users/UsersList.js index a7b20ae..fb89402 100644 --- a/client/src/features/users/UsersList.js +++ b/client/src/features/users/UsersList.js @@ -9,7 +9,7 @@ const UsersList = () => { isSuccess, isError, error - } = useGetUsersQuery(undefined, { + } = useGetUsersQuery('usersList', { pollingInterval: 60000, refetchOnFocus: true, refetchOnMountOrArgChange: true @@ -27,9 +27,7 @@ const UsersList = () => { const { ids } = users; - const tableContent = ids?.length - ? ids.map(userId => ) - : null; + const tableContent = ids?.length && ids.map(userId => ); content = ( diff --git a/client/src/hooks/useAuth.js b/client/src/hooks/useAuth.js new file mode 100644 index 0000000..db5b2e8 --- /dev/null +++ b/client/src/hooks/useAuth.js @@ -0,0 +1,26 @@ +import { useSelector } from 'react-redux'; +import { selectCurrentToken } from "../features/auth/authSlice"; +import { jwtDecode } from 'jwt-decode'; + +const useAuth = () => { + const token = useSelector(selectCurrentToken); + let isManager = false + let isAdmin = false + let status = "Employee" + + if (token) { + const decoded = jwtDecode(token); + const { username, roles } = decoded.UserInfo; + + isManager = roles.includes('Manager'); + isAdmin = roles.includes('Admin'); + + if (isManager) status = "Manager"; + if (isAdmin) status = "Admin"; + + return { username, roles, status, isManager, isAdmin }; + }; + + return { username: '', roles: [], isManager, isAdmin, status }; +}; +export default useAuth; \ No newline at end of file diff --git a/client/src/hooks/usePersist.js b/client/src/hooks/usePersist.js new file mode 100644 index 0000000..484272d --- /dev/null +++ b/client/src/hooks/usePersist.js @@ -0,0 +1,12 @@ +import { useState, useEffect } from "react"; + +const usePersist = () => { + const [persist, setPersist] = useState(JSON.parse(localStorage.getItem("persist")) || false); + + useEffect(() => { + localStorage.setItem("persist", JSON.stringify(persist)) + }, [persist]) + + return [persist, setPersist]; +} +export default usePersist; \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css index a8f7d89..aa6e29d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -37,6 +37,11 @@ border-radius: 5px; } +.errmsg a:any-link { + color: var(--ERROR); + text-decoration: underline; +} + .nowrap { white-space: nowrap; } @@ -187,6 +192,7 @@ a:focus-visible { border-top: 1.5px solid #000; padding-top: 40px; text-align: left; /* Left-align text */ + margin-top: auto; } .login_button { @@ -219,8 +225,8 @@ a:focus-visible { padding: 20px; background-color: #fff; display: flex; - align-items: center; - justify-content: space-between; + align-items: start; + justify-content: left; box-sizing: border-box; } diff --git a/package-lock.json b/package-lock.json index 109aaa8..a89e2e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,19 @@ "name": "RepairTracker", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "jwt-decode": "^4.0.0" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + } + } } diff --git a/package.json b/package.json index 0967ef4..61f38f6 100644 --- a/package.json +++ b/package.json @@ -1 +1,5 @@ -{} +{ + "dependencies": { + "jwt-decode": "^4.0.0" + } +} diff --git a/server/controllers/notesController.js b/server/controllers/notesController.js index a496025..b47b7c5 100644 --- a/server/controllers/notesController.js +++ b/server/controllers/notesController.js @@ -107,16 +107,16 @@ const deleteNote = asyncHandler(async (req, res) => { return res.status(400).json({ message: 'Note not found' }); }; - const deletedNote = await note.findByIdAndDelete(id).exec(); + const deletedNote = await Note.findByIdAndDelete(id).exec(); const reply = `Note '${deletedNote.title}' with ID ${deletedNote._id} deleted`; res.json(reply); }); -export { +export { getAllNotes, createNewNote, updateNote, deleteNote - }; \ No newline at end of file +}; \ No newline at end of file diff --git a/server/middleware/logger.js b/server/middleware/logger.js index 80394a4..5ccf9e6 100644 --- a/server/middleware/logger.js +++ b/server/middleware/logger.js @@ -2,7 +2,7 @@ import { format } from 'date-fns'; // Date formatting library import { v4 as uuid } from 'uuid'; // UUID generation library import fs from 'fs'; // File system module for synchronous operations -import fsPromises from 'fs/promises'; // File system module for asynchronous operations +import { promises as fsPromises } from 'fs'; // File system module for asynchronous operations import { fileURLToPath } from "url"; import { dirname } from "path"; @@ -18,14 +18,14 @@ const logEvents = async (message, logFileName) => { try { // Check if logs directory exists, create if it doesn't if (!fs.existsSync(`${__dirname}/../logs`)) { - await fssPromises.mkdir(`${__dirname}/../logs`); + await fsPromises.mkdir(`${__dirname}/../logs`); } // Append log entry to specified log file await fsPromises.appendFile(`${__dirname}/../logs/${logFileName}`, logItem); } catch (err) { console.log(err); // Log any errors that occur during logging } - + }; // Middleware function to log requests
Loading...
Not Currently Available
{error?.data?.message}
{errMsg}