-
Notifications
You must be signed in to change notification settings - Fork 1
Home
Welcome to the job-tracker wiki!
Documenting my steps while making job-tracker
Just going to make a working list of my the tools, environment, etc.
- Visual Studio Code
- VS Code's ES7+ React/Redux/React-Native snippets install @ extensions tab
- vscode-styled-components
- Windows Terminal
- Node.js (along with
npm) - Dotenv Official +Vault
- MongoDB
One of the first things I do when creating a project is create a README.md a file in markdown that describes key things one needs to know about the project.
Key things:
- Description
- How to run the project
- A live demo link if possible
- Technologies used
- Prerequisites (JDK, Java, MySQL, etc...)
- Specifications or Features of the project
- ...
Nice to have:
- Testing
- Sample Screenshots
- Sample Data
So in the project folder (named job-tracker here) make that README.md file.
Within the project folder, create a client directory within it. So in the terminal (whether it is Windows Terminal opened at the project direcotry or Visual Studio Code's integrated terminal - use the shortcut [Ctrl + `]) then make directory.
mkdir clientNext change directory into client, and bootstrap the app with React
cd client
npx create-react-app .npx create-react-app - does all the necessary setup and configuration for you to immediately start working on your project.
Now run the app in development mode:
npm run startI digress here to explain a bit about the folder structure of the project, to skip go to Step 4
job-tracker
|- client
|- node_modules
|- public
|- src
-
node_modules- contains all the react.js dependencies -
.gitignore- used by source control tool to identify which files and folders should be included or ignored during code commit -
package.json- This file contains dependencies and scripts required for the project. -
src- one of the main folders in the react project. -
Index.js- the file that will be called once we will run the project. -
App.js- is a component that will get loaded under index.js file. Any changes here will reflect in localhost://3000
Now the src folder is usually where the source code or raw code before minification, compilation and/or concatenation.
- It is where one goes to read and edit code
- Usually the folder where anything that is used when the app is compiled
- This also means this is where we store images, usually under an
assetsfolder (for best practice)
- This also means this is where we store images, usually under an
Key benefits of putting code in src folder:
- Scripts and stylesheets get minified and bundled together to avoid extra network requests.
- Missing files cause compilation errors instead of 404 errors for your users.
- Result filenames include content hashes so you don't need to worry about browsers caching their old versions.
Also when using webpack's asset bundling, files in src will be rebuilt. Source
The other end of src is dist folder which means distribution, or the code that is compiled, minimifed, etc...
The public folder contains anything that is not used by your app when it compiles.
- e.g., an image outside the app (like a favicon)
If you put a file into the public folder, it will not be processed by webpack. Instead it will be copied into the build folder untouched. To reference assets in the public folder, you need to use an environment variable called PUBLIC_URL.
Inside
index.html, you can use it like this:
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />Project directory so far:
job-tracker
|- client
|- node_modules
|- public
|- src
| - assets
I'll store images under assets. A running list of what images are needed:
- favicon.ico
- logo
- main image ( for the front page)
- 404
Delete some files and code that won't be needed for our application
Go to the src folder and delete these files:
- App.css
- App.test.js
- index.css
- logo.svg
- reportWebVitals.js
- setupTests.js
Nearly all the files in src except for App.js and index.js.
Then update App.js and index.js accordingly
function App() {
return (
<div className="App">
<h1>Job Tracker</h1>
</div>
);
}
export default App;import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);Let's look into public > index.html and replace contents of <title> with our project title.
For the favicon, this line already handles the src attribute's value
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />Check out this link: favicon.io.
| Options | What I chose |
|---|---|
| Text | J |
| Background | Rounded |
| Font Family | Roboto |
| Font Variant | Regular 400 Normal |
| Font Size | 120 |
| Font Color | #FFFFFF |
| Background Color | #2cb1bc |
So just place our own favicon.ico into the public folder, and replace the placeholder react favicon.
It should now re-render that image.
Side-note: Now I was deciding whether to just discard the step CSS normalize step altogether, since if you are familiar with CSS at a decent level to understand box-sizing (and spot box-sizing related bugs), margins, padding, and specific browser behaviors (default body margin, needing 100% height), etc... then just write your own CSS for the ground up. I would've omitted this step but I want to skip some of the CSS to go straight to the app development. Go to Step 7 if you want to omit this step.
- Going to speed up the development process by using normalize.css which makes browsers render all elements more consistently. It creates a set of baseline styles for HTML elements for browsers across the board.
You can either download the file from the link or run the command in the terminal:
npm install normalize.cssFirst stop the server if you have it running with Ctrl + C, then run the above command within the client folder.
Now let's use the file, so in index.js before the line import './index.css'; put import 'normalize.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import 'normalize.css';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);Do not run npm audit fix --force if you do not know what you are doing. Just ignore the npm audit report till a fix is found, because it ended up installing and breaking the current code. Alternatively, run npm audit fix without the --force parameter (dangerous option sincce it upgrades the unsafe dependencies regardless of any rules).
Thankfully, version control system git to the rescue! I was able to save my package-lock.json and package.json by using git revert before the change happened. Then I ran npm install to restore the modules.
Create a pages directory within src. Then create a Landing.js component.
Tip: In VS Code, Ctrl + b to toggle side menu or Ctrl + Shift + e to open project explorer. Click on src folder within that side menu, then click the new file icon (page + icon) and type out pages/Landing.js which will create both a directory and file at the same time.
job-tracker
|- client
|- node_modules
|- public
|- src
| - assets
| - pages
Head over to favicon.ico and click the Logo generator. Here are my settings:
| Options | What I chose |
|---|---|
| Text | Job Tracker |
| Font | Roboto |
| color | none |
| Font Color | #2cb1bc |
| Text | J |
| Font Color | white |
| Shape | Rounded |
| Color | #2cb1bc |
| #209CEE |
Issue: SyntaxError: unknown: Namespace tags are not supported by default. React's JSX doesn't support namespace tags.
In the SVG file, I changed
xmlns:xlink TO xmlnsXlink
xmlns:svgjs TO xmlnsXsvgjs
The reason being is that we are working with JSX, and these must be in camelCase property. Here is the Stack Overflow Response.
Description on the left, Login/Register on the right
Styling components for React. Here are the docs.
styled-components utilises tagged template literals to style your components.
It removes the mapping between components and styles. This means that when you're defining your styles, you're actually creating a normal React component, that has your styles attached to it.
npm install styled-componentsAlso download the extension called vscode-styled-components (to have syntax highlighting of CSS within the Template literals)
Landing.js
import React from 'react'
import logo from '../assets/images/logo.svg'
import main from '../assets/images/main.svg'
import styled from 'styled-components'
function Landing() {
return (
<main>
<nav>
<img src={logo} alt="job tracker logo" className="logo" />
</nav>
<div className="container page">
<div className="info">
<h1>Job <span>Tracking</span> App</h1>
<h4>Track and manage all your job applications in one place.</h4>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit.
Fuga odit pariatur voluptatum quam quia facere delectus, ipsam deleniti
officiis culpa. Lorem, ipsum dolor sit amet consectetur adipisicing elit.
Veritatis optio provident iusto tempore nam natus odio sit, ipsum dolorem pariatur!
</p>
<button className='btn btn-hero'>Login/Register</button>
</div>
<img src={main} alt="job hunt" className='img main-img'></img>
</div>
</main>
);
}
const Wrapper = styled.main`
`
export default Landing import styled-components module, then create a styled component (called Wrapper) right before the export.
import React from 'react'
import logo from '../assets/images/logo.svg'
import main from '../assets/images/main.svg'
import styled from 'styled-components'
function Landing() {
return (
<main>
{ /* ... logic here */ }
</main>
);
}
const Wrapper = styled.main`
`
export default Landing Why styled.main ? Since we wrapped the Landing component in a <main> tag.
Now we can replace the <main> tag with <Wrapper>:
import React from 'react'
import logo from '../assets/images/logo.svg'
import main from '../assets/images/main.svg'
import styled from 'styled-components'
function Landing() {
return (
<Wrapper>
{ /* ... logic here */ }
</Wrapper>
);
}
const Wrapper = styled.main`
nav {
width: var(--fluid-width);
}
`
export default Landing Now we can select all the elements and all the classes, inside the React component.
And just like with *SASS we can do nesting of css, so lets say we target only the <span> inside <h1>, so we can do something like this:
h1 {
font-weight: 700;
span{
color: red;
}
}- No class name bugs - styled components generates unique class names for your styles. No duplication, overlap or misspellings
- Easier deltion of CSS
- Maintenance
- Automatic critical CSS
- Simple dynamic styling
Check out the Motivation behind styled components.
Let's start targeting the HTML elements:
const Wrapper = styled.main`
nav {
width: var(--fluid-width);
max-width: var(--max-width);
margin: 0 auto;
height: var(--nav-height);
display: flex;
align-items: center;
}
.page {
min-height: calc(100vh - var(--nav-height));
display: grid;
align-items: center;
margin-top: -3rem;
}
h1 {
font-weight: 700;
span {
color: var(--primary-500);
}
}
p {
color: var(--grey-600);
}
.main-img {
display: none;
}
@media (min-width: 992px) {
.page {
grid-template-columns: 1fr 1fr;
column-gap: 3rem;
}
.main-img {
display: block;
}
}
`The styled component Wrapper will be moved into a file also named Landing.js but under the wrappers folder where we'll import into our pages > Landing.js.
On Chrome Developer Tools, this issue with pop up.
Issue solved: duplicated module in node_modules.
Ran this command in the terminal:
npm dedupeProject Directory:
job-tracker
|- client
|- node_modules
|- public
|- src
| - assets
| - css
| - images
| - wrappers
| - components
| - pages
Now create a Logo.js within components, and move out the following lines out of Landing.js into logo component:
import logo from '../assets/images/logo.svg'
<img src={logo} alt="job tracker logo" className="logo" />And import it in Landing.js
import Logo from '../components/Logo';
function Landing() {
return (
<Wrapper>
<nav>
<Logo />
</nav>
{/* ... */}
</Wrapper>
);
}When the project scales, the components folder will be large and in turn the number of imports will increase. e.g., for Landing Page, let says it is not just the logo but many things like Header,
Footer, NavBar, SideBar, Ads components then the imports would take up a lot of space in Landing component:
Landing.js
import Logo from '../components/Logo';
import Header from '../components/Header';
import NavBar from '../components/NavBar';
import SideBar from '../components/SideBar';
import Ads from '../components/Ads';
import Footer from '../components/Footer';
function Landing() {
return (
<Wrapper>
<Logo />
<Header />
<NavBar />
<SideBar />
<Ads />
<Footer />
{/* ... */}
</Wrapper>
);
}
export default Landing This is fine as the app still works. Its all about preference, but the other and cleaner/concise way to do this is to set up an index.js within the components folder (or any folder with different files such as pages). In index.js import the Logo component and export everything.
So instead of import one component file at a time like above, we can just look into index.js and specify what components are needed. So here is what the import in Landing looks like:
import { Logo } from '../components'
function Landing() {
return (
<Wrapper>
<Logo />
</Wrapper>
);
}
export default Landing The index.js in the components folder:
import Logo from "./Logo";
export { Logo };Now we can easily scale our imports/exports like so:
components > index.js
import Ads from './Ads'
import Footer from './Footer'
import Header from './Header'
import Loading from './Loading'
import Logo from './Logo'
import Navbar from './Navbar'
import SideBar from './SideBar'
export {
Ads,
Footer,
Header,
Loading,
Logo,
Navbar,
SideBar,
}Landing.js
import { Ads, Footer, Header, Loading, Logo, NavBar, SideBar } from '../components'
function Landing() {
return (
<Wrapper>
<Logo />
<Header />
<NavBar />
<SideBar />
<Ads />
<Footer />
{/* ... */}
</Wrapper>
);
}
export default Landing We can list out specifically the named exports we need.
To set up the structure for the rest of the pages, which are single-page applications, and the routing solution is React Router v6. React Router Docs.
npm i react-router-domNow import the four components in App.js:
import { BrowserRouter, Routes, Route, Links } from 'react-router-dom'Now to use it within the App
function App() {
return (
<div className="App">
<Landing />
</div>
);
}The App with the imports and components:
import Landing from "./pages/Landing"
import { BrowserRouter, Routes, Route, Links } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<Routes>
<Landing />
</Routes>
</BrowserRouter>
);
}
export default App;- Wrap app component in
BrowserRouter - Then within set-up
Routescomponent - Inside Routes, we set-up each
Route - Each
Routehas propspathandelement -
pathis pathname to add to URL -
elementcould be anything, we can provide the HTML in element or we can get the component
e.g.,
<Route path="/" element={<div>Dashboard</div>} />If the user is logged-in, then goes right away to the dashboard. Otherwise, transfer the user to a landing page to register/log-in. The "/" forward slash signals the home page.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<div>Dashboard</div>}/>
<Landing />
</Routes>
</BrowserRouter>
);
}Let's set-up the other pages. Notice the error page will be at the end, if no matches.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<div>Dashboard</div>}/>
<Route path="/register" element={<div>Register</div>}/>
<Route path="/landing" element={<Landing />}/>
<Route path="*" element={<h1>Error</h1>}/>
</Routes>
</BrowserRouter>
);
}Now since we are devloping it in localhost:3000. Let's run the project:
npm startNow localhost:3000 should open up in browser.
- Homepage is the dashboard
- To see the landing page, go to this link ->
localhost:3000/landing - Register page ->
localhost:3000/register - Error page -> anything added after
localhost:3000/####that doesn't match above
- Create pages components : Dashboard, Error, Register
- Create
index.jsand import all the pages, and export them one by one (including Landing) - Import them in
App.js
index.js
import Dashboard from "./Dashboard";
import Error from "./Error";
import Landing from "./Landing";
import Register from "./Register";
export {
Dashboard,
Error,
Landing,
Register
}App.js
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Dashboard, Error, Landing, Register } from './pages'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />}/>
<Route path="/register" element={<Register />}/>
<Route path="/landing" element={<Landing />}/>
<Route path="*" element={<Error />}/>
</Routes>
</BrowserRouter>
);
}
export default App;import React from 'react'
import { Link } from 'react-router-dom'
import img from '../assets/images/not-found.svg'
import Wrapper from '../assets/wrappers/ErrorPage'
export default function Error() {
return (
<Wrapper className='full-page'>
<div>
<img src={img} alt="404 not found"/>
<h3>page not found</h3>
<p>We can't seem to find the page you're looking for.</p>
<Link to='/'>Back Home</Link>
</div>
</Wrapper>
)
}- The wrapper gives it full height of the viewport and centers the rest.
- Link the user back to home page
- Add imports to
Register.js
import React from 'react'
import Logo from '../components/Logo.js'
import Wrapper from '../assets/wrappers/RegisterPage'Register will be a form that includes Username, Email and Password.
It can toggle to a Log In form with just Email and Password
- The default state of Register page is these inputs
Let's create a JavaScript object that contains these values, called initialState:
const initialState = {
name: '',
email:'',
password:'',
isMember: false,
}React Beta Docs on Updating the Screen
Often, you'll want your component to "remember" some information and display it. To do this, add state to your component.
Functions starting with use are called Hooks, e.g., useState is a built-in Hook provided by React.
Now inside the function let's create a React Hook.
Let's import useState first:
import React, { useState } from 'react'We declare a state variable inside our component. You will get two things from useState: the current state values, and the function that lets you update it setCount. Any name can be given but the convention is to call them [something, setSomething]
const [values, setValues] = useState(initialState);We use array destructuring to give different names to the state variables.
- where
valuesrepresent the theinitialStateobject - and
setValuesfunction will control the state - The initial state argument is only used during the first render.
In other words, the first time Register component is display, the values will be initialState or more specifically a JavaScript object where its name, email, password and isMember is false. When you want to change state, call setValues() and pass the new value to it. If the you render the same component multiple times, each will get its own state.
Now add event handlers:
const handleChange = (e) => {
console.log(e.target)
}
const onSubmit = (e) => {
e.preventDefault();
console.log(e.target);
}Working on actual return of Register
Going to have a wrapper with full-page class for CSS styles to apply, along with a form. The Logo will be on top along with some header text like "Log In" or "Register". After we create a div with className "form-row".
return (
<Wrapper className='full-page'>
<form className='form' onSubmit={onSubmit} action="">
<Logo />
<h3>Log In</h3>
<div className="form-row">
</div>
</form>
</Wrapper>
)Inside "form-row" add the label and input. Along with a submit button after form-row div.
<div className="form-row">
<label htmlFor="name" className="form-label">Name</label>
<input type="text" value={values.name} name="name"
onChange={handleChange} className="form-input"/>
</div>
<button type="submit" className="btn btn-block">submit</button>I want to use those inputs from FormRow and pass them as props to other components. For example, the email for Profile component.
- Create
FormRow.jscomponent incomponentsfolder and update theindex.jsaccordingly - Move the code from
Registerof thediv className = 'form-row'out into the new component
Looking at the code:
<div className="form-row">
<label htmlFor="name" className="form-label">Name</label>
<input
type="text"
value={values.name}
name="name"
onChange={handleChange}
className="form-input" />
</div>The FormRow component will look for props, as we need to pass down things like type, value, name, onChange and also a labelText.
- I don't see labelText, why? Well because there will be instances where the variable or state may be camelCase, but I would like to display it properly (non-camelCase). I'll get back to this later as this comment was added some time after.
Let's receive the props in FormRow :
export default function FormRow({type, name, value, handleChange, labelText}) {And now to change the above code to use the props rather than hardcoding. Like this:
import React from 'react'
export default function FormRow({type, name, value, handleChange, labelText}) {
return (
<div className="form-row">
<label htmlFor={name} className="form-label">
{ labelText || name }
</label>
<input
type={type}
value={value}
name={name}
onChange={handleChange}
className="form-input"
/>
</div>
)
}- First import
import { FormRow, Logo } from '../components'- Using the component, and passing the necessary props
<FormRow
type="text"
name="name"
value={values.name}
handleChange={handleChange}
/>- Now reusing the component to make the other form rows:
<FormRow
type="email"
name="email"
value={values.email}
handleChange={handleChange}
/>
<FormRow
type="password"
name="password"
value={values.password}
handleChange={handleChange}
/>- Note how because we set up the
initalStateas an object with propertiesname,email&password,handleChangefunction will access both the name of the input and value, then access the proper value in intialState object afterwards. That is whyhandleChangecan be reused, because it is assigned to a prop and invoked in theFormRowcomponent.
Now see it rendered @ http://localhost:3000/register try typing into an input field, say Email and open up Developer Tools (Press F12 in Chrome) and see the console log the proper input type, name, class, and value. The value matches the email in the state.
Whenever you are dealing with forms, you'd want to set-up some form validation to catch things early on the client side rather than on the server side.
So I'll add an Alert component which signals to the user that they need to provide all input values, because one or more are empty.
- Creating the
Alertcomponent (rfc, set className to style, return an alert message)
Let's see how it looks in Register component, but first we should add a flag/boolean condition that signals whether alert should be seen. We can add this in initialState object, as showAlert.
As of now, it is easier to put these flags like isMember or showAlert. We can move them out later, because the question remains whether we should pass the state down from Register component, for it later to be shared by other components. Or should initialSDtate be move upwards to the closest component containing all of them. We will have to see.
For now let's just add it so we can see the Alert component rendered on Register. Import component, add it to initalState and conditionally render it under the "Log In" text.
import { Alert, FormRow, Logo } from '../components'
{/* ... */}
const initialState = {
name: '',
email: '',
password: '',
isMember: true,
showAlert: true,
}
{/* ... */}
<h3>Log In</h3>
{ values.showAlert && <Alert />}Now npm start in the Terminal to see it rendered on our page, localhost:3000.
- Press
F12or right-click the page toInspect. - On the tabs with Elements, Console, etc... keep going right until you find
ComponentsPage. - Click on
Registerin the Tree - Make sure to widen the Developer Tools View so you can see the right panel
- Right panel consists of the Component, props & hooks.
- Under hooks, there is a checkbox where we can toggle off
showAlertso lets do that - Alert toggles off correctly
Side Note - [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq)
We see the above error, which also shows the line
<input type="password" name="password" class="form-input" value>that triggers it. We should add one more attribute called autocomplete within the FormRow component to ensure autocomplete="on". Stack Overflow Post on autocomplete.
Finally, the isMember property from the initialState will be used.
We want a way to toggle between a Register form or Log In form.
Controlling:
- Inputs to display
- Changed the heading (from Register to Log In)
- On Submit, which Functions are involved (a register request or Log In request)
How to translate this to code?
- First, a toggle function that inverts the value. More specifically, I want to extract the current value from
valuesthats found in the state, and only changeisMemberto it's opposite value.
const toggleMember = () => {
setValues({...values, isMember: !values.isMember});
}- What is the
...? The Spread Syntax. - Since
initialStateis an object that stores our state as values, we want to "spread out" all the current values
- There are 3 locations where we use
isMembercondition.
-
<h3>that hasRegisterorLog In - On the inputs, we do not want to display the "name"
FormRowfield on Log In. But display it ony Register. - The actual toggle element for
isMember, which will be a button.
// The heading <h3>
<h3>{values.isMember ? "Log In" : "Register"}</h3>
// Name Input
{ !values.isMember && (
<FormRow
type="text"
name="name"
value={values.name}
handleChange={handleChange}
/>
)}- Creating the toggle button
- Displayed right after the submit button
- For now, it will be a
<p>that displays text whether the user isNot a member yet?or isAlready a member?. - Note that if form is "Log In", then it should show "Register"
- After that it displays a button that runs the function in step 1
<p>
<button
type='button'
onClick={toggleMember}
className="member-btn">
Register
</button>
</p>Before we move on, we have to discuss managing states in React. Link to article on managing states.
There are four main types of state you need to properly manage in your React apps:
- Local state
- Global state
- Server state
- URL state
Local state is data we manage in one or another component.
Local state is most often managed in React using the useState hook.
For example, local state would be needed to show or hide a modal component or to track values for a form component, such as form submission, when the form is disabled and the values of a form’s inputs.
Global state is data we manage across multiple components.
Global state is necessary when we want to get and update data anywhere in our app, or in multiple components at least.
A common example of global state is authenticated user state. If a user is logged into our app, it is necessary to get and change their data throughout our application.
Sometimes state we think should be local might become global.
Data that comes from an external server that must be integrated with our UI state.
Server state is a simple concept, but can be hard to manage alongside all of our local and global UI state.
There are several pieces of state that must be managed every time you fetch or update data from an external server, including loading and error state.
Fortunately there are tools such as SWR and React Query that make managing server state much easier.
Data that exists on our URLs, including the pathname and query parameters.
URL state is often missing as a category of state, but it is an important one. In many cases, a lot of major parts of our application rely upon accessing URL state. Try to imagine building a blog without being able to fetch a post based off of its slug or id that is located in the URL!
There are undoubtedly more pieces of state that we could identify, but these are the major categories worth focusing on for most applications you build.
Local state is perhaps the easiest kind of state to manage in React, considering there are so many tools built into the core React library for managing it.
-
useStateis the first tool you should reach for to manage state in your components.
It can take accept any valid data value, including primitive and object values. Additionally, its setter function can be passed down to other components as a callback function (without needing optimizations like useCallback).
import { useState } from "react";
function Layout() {
const [isSidebarOpen, setSidebarOpen] = useState(false);
return (
<>
<Sidebar isSidebarOpen={isSidebarOpen} closeSidebar={() => setSidebarOpen(false)} />
{/* ... */}
</>
);
}-
useReduceris another option that can be used for either local or global state. It is similar in many ways touseStateunder the hood, although instead of just an initial state it accepts a reducer.
The benefit of useReducer is that it provides a built-in way to perform a number of different state operations with the help of the reducer function, which makes it more dynamic overall than useState.
You can see the benefit of useReducer versus useState in this vote tracking example. All we have to do to update state is pass the callback function dispatch a string (which is then passed to the reducer) instead of the new state itself.
const initialState = { votes: 0 };
function reducer(state, action) {
switch (action.type) {
case 'upvote':
return {votes: state.votes + 1};
case 'downvote':
return {votes: state.votes - 1};
default:
throw new Error();
}
}
function VoteCounter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Current Votes: {state.votes}
<button onClick={() => dispatch({type: 'upvote'})}>Upvote</button>
<button onClick={() => dispatch({type: 'downvote'})}>Downvote</button>
</>
);
}Once you attempt to manage state across multiple components, things get a bit trickier.
You will reach a point in your application where patterns like “lifting state up” and passing callbacks down to update your state from components lead to lots and lots of props.
What do you do if you want to update a component’s state from basically anywhere in your app? You turn it into global state.
To manage it, however, you should opt for a third-party solution. Many developers are inclined to use built-in React features like the Context API to manage their state.
The reason to not use Context for global state management lies in the way it works. The default behavior for Context is to re-render all children components if the value provided to it as a prop changes.
To be clear: the Context API is not a state management solution. It is a way to avoid problems like props drilling (creating a bunch of props in components that don’t need it), but it is only helpful for reading state, not updating it.
For example, it is bad practice to combine useReducer and useContext:
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateProvider.Provider value={{ state, dispatch }}>
<ComponentA />
<ComponentB />
<ComponentC />
</StateProvider.Provider>
)
}In many cases, you do not want all children to update in response to a global state update, because all children may not be consuming or relying upon that global state. You only want to re-render if their props or state changes.
To manage your global state, use tried and tested third-party libraries.
The Global State of our App will store things user, jobs, isLoading.
Passing Data Deeply with Context
Context lets the parent component make some information available to any component in the tree below it—no matter how deep—without passing it explicitly through props.
Context lets a parent component provide data to the entire tree below it.
So we will be using useContext from 'react', a React Hook that lets you read and subscribe to context from your component. Here are the docs on useContext.
We will also be wrapping our App component in an AppProvider component.
Let's just get into it and solve challenges along the way.
-
Create
contextfolder withinsrc, with a file calledappContext.js -
Let's set up the imports in that file
import React from 'react';
import { useState, useReducer, useContext } from 'react'- Using useReducer() here as well
- Create initialState object to store our state
const initialState = {
isLoading: false,
showAlert: false,
alertText: '',
alertType: '',
}- Create Context called
AppContext
const AppContext = React.createContext();Context has two components has provider as well as consumer. We will be making the AppProvider component, which will wrap the App so we have to render its children.
Make sure to use the hook useState and export initialState.
import React from 'react';
import { useState, useReducer, useContext } from 'react'
const initialState = {
isLoading: false,
showAlert: false,
alertText: '',
alertType: '',
}
const AppContext = React.createContext();
export default function AppProvider(props) {
const { children } = props;
const [state, setState] = useState(initialState);
return (
<AppContext.Provider value = {{...state}}>
{children}
</AppContext.Provider>
)
}
export { initialState }- We export
AppProviderandinitialState - And we set up a custom hook so that we can avoid code duplication of:
import useContext, andAppContextin every component to have access in thevalueprop that will be passed inAppContext.Provider - In custom hook, called
useAppContext(because Functions starting withuseare called Hooks see Hooks)
const useAppContext = () => {
return useContext(AppContext)
}
export { initialState, useAppContext }Context lets a component receive information from distant parents without passing it as props.
Also export what we need from appContext such as initalState and useAppContext.
export default function Register() {
const [values, setValues] = useState(initialState);
const state = useAppContext();
console.log(state);
// ...Open up the app & developer tools on http://localhost:3000/register and see the AppContext logged.
We should see an Object which contains alertText, alertType, isLoading and showAlert. Now we can extract these values from the state object, or forego the state assignment altogether and just pull out those values. Like so:
const {isLoading, showAlert} = useAppContext();Now we can remove the showAlert property from Register's initialState object. And substitute anywhere we have values.showAlert with just showAlert as we use the global context instead.
Now to test that this all works, go to register page and open Chrome Developer Tools. Keep going right on the tabs to Components and now click on AppProvider and go to the hooks on the right panel. Toggle the showAlert fom false to true. We should now see that showAlert is data being shared by both components.
Let's create a reducer.js file in context directory
A React reducer is a function that accepts two parameters: a state and an action, and returns a new state based on the action. It is used with the useReducer hook, which is an alternative to useState for managing complex state logic.
Reducers are functions that take two arguments: the current state and the action to return a new state result. i.e., (state, action) = newState. The reducer accepets an action and the app's previous state and returns the new state. Action specifies what occured, and the reducers role is to return the new state as a result of that action.
For now just going to print out if the action.type and throw an Error. reducer.js will look like:
const reducer = (state, action) => {
throw new Error(`No such action: ${action.type}`);
}
export default reducerNow back to appContext.js:
- remove
useStatefrom imports - import
reducer - replace
useStatewithuseReducer
useReducer looks for a reduce function (which handles the dispatch) as the first parameter, and initalState.
Also since we no longer useState, that setState should be renamed to dispatch
We want to display Alert when one of the values are empty.
Let's create an actions.js file within context folder. Then we export a const variable DISPLAY_ALERT = 'SHOW_ALERT' (in all uppercase as convention).
import this variable in both reducer.js and appContext.js.
- Let's handle that action right away in the reducer. If
action.typeis equivalent toDISPLAY_ALERTthen return the current state as it is (spread out the values) but modify the following: -
showAlertproperty to true. - The
alertType: 'danger' - and
alertText:to Please provide all values!
Like this:
import { DISPLAY_ALERT } from "./actions";
const reducer = (state, action) => {
if(action.type === DISPLAY_ALERT) {
return {
...state,
showAlert: true,
alertType: 'danger',
alertText: 'Please provide all values!',
}
}
throw new Error(`No such action: ${action.type}`);
}Next in AppContext.js set up the function. Above the return but below the state and useReducer:
const displayAlert = () => {
dispatch({type:DISPLAY_ALERT});
}Inside the function body we dispatch(), where we dispatch an Object with the type of DISPLAY_ALERT.
So when working with useReducer we are passing in the object, and one thing we MUST pass in is the type property.
Optionally, we can provide other properties, in our case we will stick with payload. If we are providing some kind of value to reducer to use later in the actual function then we go with payload. But for now we just pass in the object, and type must be present.
const displayAlert = () => {
dispatch({type:DISPLAY_ALERT});
}Also add DISPLAY_ALERT in the value, since every time we set up a function we should pass it down in AppContext.Provider.
return (
<AppContext.Provider value = {{...state}}>
{children}
</AppContext.Provider>
)
// Pass function displayAlert down
return (
<AppContext.Provider value = {{...state, displayAlert}}>
{children}
</AppContext.Provider>
)- First
useAppContextand import - Two values that we are extracting from the state are
alertTypeandalertText, the dynamic values that will change depending on our actions (the ones we are dispatching) - Also change the hard coded
classNamefromalert-dangerto interpolatealertType
import React from 'react'
import { useAppContext } from '../context/appContext'
export default function Alert() {
const { alertType, alertText } = useAppContext();
return (
<div className={`alert alert-${alertType}`}>
{alertText}
</div>
)
}Takeaway: Now every time we use Alert component the values in our application will be provided from the Global Context
Goal: Display Alert when one of the input values is missing
Recall the handleChange method:
const handleChange = (e) => {
console.log(e.target)
}We look for the event, every time we type something in the input we set the values.
Ok we also have access to the initialState object or state in an object called values because of this:
const [values, setValues] = useState(initialState);Let's spread out all the values in the object. We also have access to the event or e.target, and more specifically the e.target.name and e.target.value, we can dynamically set that property with the value.
const handleChange = (e) => {
setValues({...values, [e.target.name]: e.target.value });
}We can create Dynamic Object Keys by taking advantage of Square Brackets Notation or more specifically the Computed Properties.
Now let's test out the handler:
cd client
npm run start- Go to localhost:3000/register
- Open Chrome Dev Tools, go to Components Pane, Hit
Register - Type into email input and we should see
emailproperty update its State in thehookssection
Now onto the onSubmit handler. First we have to destructure displayAlert from the global context. So this line:
const {isLoading, showAlert} = useAppContext();Turns into this line:
const {isLoading, showAlert, displayAlert} = useAppContext();Now lets destructure the values from the state in the handler. Then check if any of the values are empty:
const onSubmit = (e) => {
e.preventDefault();
const { name, email, password, isMember } = values;
}Next we check for if any of them are missing (let's check name only if isMember is false, and we are on the Register panel). If this is the case, then return, else let's log the values.
const onSubmit = (e) => {
e.preventDefault();
const { name, email, password, isMember } = values;
if( !email || !password || (!isMember && !name)){
displayAlert();
return;
}
console.log(values);
}Once Alert component is displayed, it does not disappear unless we refresh the page or navigate away. It should hide when we successfully register/log-in.
So let's create an export a const action of CLEAR_ALERT, then set up the imports in reducer.js and appContext.js.
-
Next in
reducer.jsset the global context, state of showAlert to false, alertType and alertText to empty. -
In
appContext.js, let's create a function ofclearAlert()that dispatchesCLEAR_ALERTafter 4 seconds.
Go to the job-tracker directory which contains client, so if in client folder just go one level up. We will set up the server so run this line:
npm init -yThis initializes npm, and creates a file package.json
The -y parameter is short for --yes, which automatically answers "yes" to any prompts that npm might print on the command line.
You can see list of parameters and npm init withh npm help init.
Next create a file server.js still in the job-tracker directory.
Now to run the server the command in the shell is:
node serverNode uses both. Before and still is using CommonJS where they have a require but ES6 can import. If the project front-end is in ES6 but server-side is CommonJS there may be bugs. CommonJS good to set up for simple API's, but full project go ES6.
CommonJS
const express = require('express')
const app = express()
ES6
import express from 'express'
const app = express()
But ES6 needs a bit more set-up to be used as a module. See the Node docs on Modules: Packages.
Node.js will treat the following as ES modules when passed to node as the initial input, or when referenced by import statements or import() expressions:
-
Files with an .mjs extension.
-
Files with a .js extension when the nearest parent package.json file contains a top-level "type" field with a value of "module".
{
"name": "job-tracker",
"version": "1.0.0",
"description": "A job tracking and management application.",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/DragonSenses/job-tracker.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/DragonSenses/job-tracker/issues"
},
"homepage": "https://github.com/DragonSenses/job-tracker#readme"
}Right after the main line, add this line: "type":"module",
{
"name": "job-tracker",
"version": "1.0.0",
"description": "A job tracking and management application.",
"main": "server.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/DragonSenses/job-tracker.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/DragonSenses/job-tracker/issues"
},
"homepage": "https://github.com/DragonSenses/job-tracker#readme"
}From now on will be able to use, import and export in our server as well.
I used to do CommonJS a lot and have it memorized. I thought it was difficult to set it up, but it was easier than I expected. So I definitely can vouch for using ES6 in Node, because more often than not front-end would run ES6 set-up (think create-react-app) so it makes senses to have the server run the same set-up. Although it is up to preference, for smaller apps its probably easy to type out those lines in CommonJS.
npm install expressNow in server.js
import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello');
})
const port = process.env.PORT || 4000;
app.listen(port, () => console.log(`Server is listening on port ${port}...`));Notice how we :
- import the
expressES6 module - Set up a custom port, preferably one that does not collide with our front-end's port: 3000
- We set a home route in
app.get()
Let's run the server:
node serverNow open up a browser and send a GET request @ localhost:4000
We should see the message. This indicates our set-up is good to go.
Here is a brilliant explanation of Middleware from TheOdinProject.
Middleware is a complicated word for a simple concept. A middleware is just a plain JavaScript function that Express will call for you between the time it receives a network request and the time it fires off a response (i.e. it’s a function that sits in the middle). You will eventually be using several of these functions that will run in a specific sequence for every request.
For example, you might have a logger (that prints details of the request to the console), an authenticator (that checks to see if the user is logged in, or otherwise has permission to access whatever they’re requesting) and a static-file server (if the user is requesting a static file then it will send it to them). All of these functions will be called in the order you specify every time there’s a request on the way to your app.get("/") function.
It is possible and common to write your own middleware functions (you’ll be doing that later) so let’s take a minute to demystify what they’re actually doing. Middleware functions are just plain JavaScript functions with a specific function signature (that is, it takes a specific set of arguments in a specific order).
The three middleware function arguments are: req, res, and next. Technically, these are just variables, so you could call them anything, but convention (and the express documentation) almost always give them these names.
middleware - software that acts as a bridge between an operating system or database and applications, especially on a network.
Let's say we have a request for some resource (e.g., localhost:4000/something) that doesn't exist we would get a default Express not found response: Cannot GET /something. We want a custom response to send back.
Let's create a folder called middleware. This is the folder that will contain our .js files, that contains our custom functions to run as a fallback before rendering the page.
Middleware lets you define custom functions that can be run before rendering either a page or a group of pages (layout).
We will have a not-found middleware and error middleware
REMEMBER: since we are using ES6 Modules in Node, we need to use .js as the extension for the files. So when we import we have to add .js at the end of the file path.
e.g.,
import notFoundMiddleware from './middleware/not-found.js'Let's create a not-found.js file in middleware directory:
const notFoundMiddleware = (req, res) =>
res.status(404).send('404! Oops, Route does not exist');
export default notFoundMiddlewareNow in server, we have to import the module and have app.use(notFoundMiddleWare) to signal that it is looking for all the http methods (GET, POST, etc.) and same for home route. Express first tries to match the request to all the routes that we have (right now just home route "/") and then if none of them match, then we go to app.use() to serve up the middleware.
notFound - looks for requests that do not match any of our current routes
error - looks for errors that are happening in our existing route
Create error-handler.js
It will have a function that accepts 4 parameters. This indicates to express that the first one will be error, and it will pass in that error.
- For now just log the error, and return a generic 500 response.
- In json, set up that indicates that there was an error
json( {"msg":"Error occured"}) - import this into
server.js
Make sure to place this last in the server. Later on when we have async errors in our controllers, and eventually handle custom Mongoose Errors (like node-express)
Here is what error-handler.js looks like:
const errorHandlerMiddleware = (err, req, res, next) => {
console.log(err);
res.status(500).json({msg:"Error occured."});
}
export default errorHandlerMiddlewareand now in the server
import notFoundMiddleware from './middleware/not-found.js'
import errorHandlerMiddleware from './middleware/error-handler.js'
import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello');
})
app.use(notFoundMiddleware);
app.use(errorHandlerMiddleware);
const port = process.env.PORT || 4000;
app.listen(port, () => console.log(`Server is listening on port ${port}...`));We can test it by throwing an error in the home route
app.get('/', (req, res) => {
throw new Error();
res.send('Hello');
})Now we see the json, {"msg":"Error occured."} when we head over to localhost:4000 and in the terminal we see the Error which comes from the server.js.
Now with the Middlewares in place we can set up the connection to MongoDB.
To add our application's config options we need a package dotenv which loads environment variables from a .env file into process.env. Storing configuration in the environment separate from code is based on the Twelve-Factor App Methodology.
-
Make sure all of this is in your
rootdirectory notclient -
Create
.envfile in the root of your project
S3_BUCKET="YOURS3BUCKET"
SECRET_KEY="YOURSECRETKEYGOESHERE"Eventually we will add our connection string, API keys, variables that store our sensitive data.
npm install dotenv- import and configure
dotenvas early as possible in application, in the server
Using import "as" to import under a different name.
import * as dotenv from 'dotenv'
dotenv.config()
import express from 'express'-
Now
process.envhas the keys and values you defined in your.envfile -
IMPORTANT:
.gitignore
We have to add the folders/files:
-/node_modules
.env
You do NOT want these to be added to the source control.
- Go with port 5000 to test
Add this to .env file
PORT=5000
Now remember the lines we added at the end of server.js where we interpolated the port variable:
const port = process.env.PORT || 4000;
app.listen(port, () => console.log(`Server is listening on port ${port}...`));WE can access the variable like so: process.env.PORT, and use it.
To test we just run the command in the terminal:
npm run startNOW the server is listening on port 5000, which means the set-up has been correct so far.
Now we have a way to access our sensitive variables such as API keys, connection strings to databases, and more through process.env object. We can prevent theft by securing our API keys this way.
Here is an interesting Medium article How to Hide Your API Keys by Sylwia Vargas. If you can't access it, here are the salient points:
-
Services like Google Cloud and AWS make it possible ot set restrictions of the API key
-
key might only be used within a given URL
-
Front End: Hide Your Keys (React)
-
Apart from securing the API key, we can also hide it.
-
IMPORTANT! If you created your React app with create-react-app, please be mindful of that your env variables will become a part of the build, meaning, they will be publicly available for anyone who’d inspect your files. You can still follow the steps below for the development phase so your API keys don’t get into github. Then, before deploying your page, delete the .env file and use the platform’s key management system (see below for Heroku and Netlify).
- Create a file called .env in the root of your project’s directory.
app's tree:
- your_project_folder
- node_modules
- public
- src
- .env <-- create it here
- .gitignore
- package-lock.json
- package.json- Inside the .env file, prepend REACT_APP_ to your API key name of choice and assign it.
REACT_APP_ is, in fact, a tool that create-react-app uses to identify these variables.
// .env
API_KEY=your_api_key <-- nope, this won't work
REACT_APP_API_KEY=your_api_key <-- yes!
// Example:
// REACT_APP_GOOGLE_API_KEY=123456- Add the .env file to your .gitignore file.
You don’t want this file to be committed to gitHub!
// .gitignore
# api keys
.env <-- add this line
# dependencies
/node_modules
...After you’ve saved .gitignore, run $ git status to make sure that .env is not on the list of changes to be committed.
- Access the API key via the
process.envobject.
To check that you can access your API key, go to your App.js file and add console.log at the top below the require (or import) statements. After saving the file and reloading the page, if the console log does not show your API key, try restarting the react server. And of course, make sure to remove the console.log line before committing your code.
// src/App.js
import React, { Component } from 'react';
import './App.css';
console.log(process.env.REACT_APP_GOOGLE_API_KEY)
class App extends Component {
...}Just know that the key may show up in your network requests.
So, if you wish to totally mask your key, you should make a backend that proxies your requests, and store the API key there.
This solution, however, also may not be optimal “because then how do you protect the access to the backend that proxies the request? Or are you gonna leave this backend endpoint public?”
In full transparency, reverse proxy solution is still vulnerable because you still need to protect access to it with an API key in the client side. Exadra37 points out:
The reverse proxy approach has the advantage that now your third party API key is in a environment you control, thus you can employ as many layers of API security measures as you can afford in order to prevent abuse of the third party service you are paying for.
Let's switch back the PORT on .env back to 4000.
I'll assume you have a MongoDB Atlas account, and are familiar with Databases.
If not, you can either go to the MongoDB University (if you are a slow but surely learner), or if you are an accelerated learner here is a great MongoDB Crash Course 2022, in just 30 minutes it will help you up catch up to speed.
In my opinion the crash course is a lot better in its conciseness, whereas the MongoDB University courses forces you to watch videos from their player (which may not be accessible for everyone). Don't even get me started on the fact that their MongoDB Atlas interface does not have Dark Mode, the top most wanted request in the last years but they still have not delivered. Anyways, I digress.
mongoose is a MongoDB object modeling tool designed to work in an asynchronouse environment.
- Create
dbfolder at the root andconnect.jsfile within it.
-
dbis our database folder -
connect.jswill have a function that sets up our URL
Stop the server and
- install
mongoose
npm install mongoose- Import mongoose in
connect.js
// Using Node.js `require()`
const mongoose = require('mongoose');
// Using ES6 imports
import mongoose from 'mongoose';- Setting up the connection.
We have to set up the function that looks for the URL Connection String.
Using mongoose's connect method returns a Promise. So in the server, we must set up async and await.
In connect.js
import mongoose from 'mongoose'
const connectDB = (URL) => {
return mongoose.connect(URL);
}
export default connectDB- Create a function to start the server only on successful connection
Only run the server, if the database connection was successful. The function called start has to be async and use await. We use the connectDB function that we will import in the server.js.
We will access the URL connection string through process.env.MONGO_URL. Move app.listen() after the the connectDB, then invoke the function right after.
const start = async () => {
try{
await connectDB(process.env.MONGO_URL);
app.listen(port, () => {
console.log(`Server is listening on port ${port}...`)
});
} catch(error){
console.log(error);
}
}
start();- Create
MONGO_URLin.env
Get your specific URL from MongoDB Atlas Interface Under Deployment > Database > Connect
- Connect your Application > Node.js 4.1 or later
- Get the connection string into application code, copy it and paste it in
MONGO_URL=YOUR_CONNECTION_STRINGin.env -
To name your collection in the database: right after
/in themongodb.net/?type the name of database collection you want, like so:mongodb.net/job-tracker?. Otherwise keeping it as is will store values in the collection undertest
- npm run start
Start the server and we should see server is listening on port etc.
Model-View-Controller (MVC) is a software design pattern that divides an application into three components: model, view and controller.
The model is responsible for managing the data and logic of the application.
The view is responsible for rendering the presentation of the model in a particular format.
The controller is responsible for handling user input and interaction, and controlling how data is displayed by selecting a view.
The main benefit of MVC is that it separates the concerns of an application, making it easier to maintain, test and reuse. MVC also allows for different views to use the same model, which can improve consistency and user experience. MVC is commonly used for developing user interfaces, especially web applications.
The implementation of MVC may vary depending on the programming language and framework you use. However, a general approach is to create three classes or components for each layer of MVC: model, view and controller12. The model class should contain the data and logic of your application. The view class should define how to present the data to the user. The controller class should handle user input and interaction, and select a view based on the request12.
For example, if you are using Java, you can create a model class that represents an entity such as an employee, a view class that displays employee details in HTML format, and a controller class that receives user requests and calls methods on the model and view classes.
Here is the response bing gave me:
To implement MVC in your Node.js application, you can use a framework such as Express.js or Sails.js that provides features and tools for creating MVC applications. You can also follow some general steps to structure your application into model, view and controller components:
- Create a package.json file that contains the metadata and dependencies of your application. You can use npm init command to generate this file.
- Install the required packages for building the MVC structure in Node.js. You will need at least express for routing and handling requests, ejs for templating views, mongoose for connecting to MongoDB database, and nodemon for restarting the server automatically15. You can use npm install command to install these packages.
- Create a config file that stores your configuration variables such as port number, database URL, secret key etc. This file exports a JavaScript object that contains these variables.
- Create a server file that initializes your express app, sets up middleware functions, connects to the database, and listens on a port. This file imports the config file and requires other modules such as express, mongoose etc.
- Create a routes folder that contains files for defining your routes and controllers. Each file exports a function that takes an express app as an argument and uses app.get(), app.post(), app.put() or app.delete() methods to register routes with callbacks or controller functions. You can also use express.Router() class to create modular route handlers.
- Create a controllers folder that contains files for defining your controller functions. Each file exports one or more functions that handle user input and interaction, perform business logic using models, and send responses using views. You can also use res.render() method to render ejs templates as views.
- Create a models folder that contains files for defining your data models using mongoose schema and model classes. Each file exports one or more model classes that represent entities or collections in your database. You can also define methods, validations, hooks etc on your models.
- Create a views folder that contains files for defining your HTML templates using ejs syntax. Each file has an .ejs extension and uses <% %> tags to embed JavaScript code or expressions. You can also use partials, layouts etc to reuse common elements in your views.
These are some general steps to implement MVC in Node.js using Express.js framework. However, you may need to modify or add more steps depending on your specific requirements and preferences.
Create controllers folder in the root directory. Recall that the controller is responsible for handling user input and interaction, and controlling how data is displayed by selecting a view.
In this case, we will have an authentication controller called authController.js, which will store these functionalities:
- log in
- register
- update user
So create that file and within, we have three functions:
const register = (req, res) => {
res.send('register user');
}
const login = (req, res) => {
res.send('login user');
}
const updateUser = (req, res) => {
res.send('updateUser');
}
export { register, login, updateUser }Now create the routes folder with authRoutes.js file which will export a function takes an express app as an argumentand uses app.get(), app.post(), app.put() or app.delete() methods to register routes with callbacks or controller functions.
Here we use express.Router() class to create modular route handlers.
import express from 'express'
const router = express.Router()
import { register, login, updateUser } from '../controllers/authController.js'Now we can set up our routes:
router.route('/register').post(register);
router.route('/login').post(login);
router.route('/updateUser').patch(updateUser);Then export our router:
export default routerSo back in the server.js, let's
- import
authRouter - set up auth route in path '/api/v1/auth' and pass in
authRouter
// Routers
import authRouter from './routes/authRoutes.js'
// right after app.get('/')
app.use('/api/v1/auth', authRouter);Since this is a POST route, we need to access the JSON that's past it. We do this with special built-in middleware from Express.
So add this line before app.get():
app.use(express.json());This makes the JSON data available to the controllers. With POST requests, we'll be looking for stuff which is JSON data that will be passed to us with the .json() middleware from Express.
CRUD functions.
Functionality:
- createJob
- getAllJobs
- updateJob
- deleteJob
- showStats
Because we are communicating with our database all of these functions are all async
In routes directory, create jobsRouter.js and import the functions from the jobs controller.
import express from 'express'
const router = express.Router()
import {
createJob,
getAllJobs,
updateJob,
deleteJob,
showStats,
} from '../controllers/jobsController.js'
export default routerThe route will be 'api/v1/jobs', so in server.js the jobsRouter will be passed in.
app.use('/api/v1/jobs', jobsRouter);and we set up the routes in jobsRouter
router.route('/').post(createJob).get(getAllJobs);
router.route('/stats').get(showStats);
router.route('/:id').delete(deleteJob).patch(updateJob);
export default router-
The route will be 'api/v1/jobs' will have
postandgetmethods. -
Then route in stats will access the data about the applications (how many are pending, interviews, etc.)
-
Then we pass in id, so we can remove the job, and update the job
-
/statsstring must be above:id(an actual value from MongoDB)
Express looks for routes, it may not find a job with this particular id with stats.
import jobsRouter from './routes/jobsRoutes.js'
//...
app.use('/api/v1/jobs', jobsRouter);Testing the routes in Postman
Create a collection named after your project name. Create two folders > Add a Folder
-
AuthandJobs
Inside Auth add New Request. [CTRL + E] to rename to "Register User" and change it to a POST request.
Click on the top right icon "Environment quick look" and "Add Globals"
Run the server and go to localhost:4000 in the browser, copy the address bar and paste it in the Initial Value field. Add base_url to the variable field of it, with type default.
Append 'api/v1' so Initial Value should look like:
http://localhost:4000/api/v1
Important now Save it in the top right. So we can access this variable.
Add this to the POST request
{{base_url}}/auth/register
{{base_url}} is how we will access our global variable.
-
Now
Sendthe request. We should seeregister userhas been returned, along with the relevant data in the console. -
base_url , register function, and rest of the routes is and will be working (unless we make any errors)
MVC splits a large/complex application to specific sections that have their own purpose.
Example:
- Request: User requests a specific page from a server
- Based on URL user is requesting, the server will send all of the request information to a specific controller
- Handles request flow
- Controller should never directly interact with data logic, only ever use Model to perform these interactions.
- Responsible for handling the entire request from the client
- Tells the rest of the server what to do with the request
- Acts as the middleman between Model and View
- Should not contain very much code
- Tells Model what to do and respond with what the Model returns
- Get Data: Controller receives request, asks the Model for information based on the request
- Handles data logic
- Interacts with database
- Handles all Validation, Saving, Updating, Deleting, Etc. of the data
- Does not handle User request, or what to do on failure or success (Controller does this)
- Get Presentation: After Model sends response back to Controller, Controller now then interacts with View to Render data to the user
- Handles data presentation
- Template file that dynamically renders HTML based on data Controller sends it
- Does not worry about final presentation of data, instead just cares only about how to present it
- View sends final presentation back to the controller
- Response: Controller handles sending that presentation back to the user
IMPORTANT: Model and View never interact with each other. Any interactions done between Model and View are done by Controller. Presentation of data and Logic of data are completely separated, facilitating the process of creating complex applications.
From stack overflow
| HTTP Verb | Operation |
|---|---|
| POST | Create |
| GET | Read |
| PUT | Update |
| DELETE | Delete |
| PATCH | Submits a *partial modification to a resource (e.g., only need to update one field for the resource) |
- GET
GET is the simplest type of HTTP request method; the one that browsers use each time you click a link or type a URL into the address bar. It instructs the server to transmit the data identified by the URL to the client. Data should never be modified on the server side as a result of a GET request. In this sense, a GET request is read-only.
- POST
The POST verb is mostly utilized to create new resources. In particular, it's used to create subordinate resources. That is, subordinate to some other (e.g. parent) resource.
On successful creation, return HTTP status 201, returning a Location header with a link to the newly-created resource with the 201 HTTP status.
- PUT
PUT is most-often utilized for update capabilities, PUT-ing to a known resource URI with the request body containing the newly-updated representation of the original resource.
- DELETE
DELETE is pretty easy to understand. It is used to delete a resource identified by a URI.
On successful deletion, return HTTP status 200 (OK) along with a response body, perhaps the representation of the deleted item (often demands too much bandwidth), or a wrapped response (see Return Values below). Either that or return HTTP status 204 (NO CONTENT) with no response body. In other words, a 204 status with no body, or the JSEND-style response and HTTP status 200 are the recommended responses.
- PATCH
Patch request says that we would only send the data that we need to modify without modifying or effecting other parts of the data. Ex: if we need to update only the first name, we pass only the first name.
Let's duplicate our earlier "Register User" request, [CTRL + D]
-
Create Login > Post > and add the URL
-
Create Update User > Patch > URL
Send to confirm they are working.
Note: Make sure to save the requests in Postman in the top right
If the route does not exist then we should have the not-found middleware kick in and send us a "Route does not exist".
- Add new Request Under Jobs > Post > {{base_url}}/jobs
Save it and send, should see "createJob"
-
New Request > Get All Jobs > GET > {{base_url}}/jobs
-
New Request > Show Stats > GET > {{base_url}}/jobs/stats
-
New Request > Update Job > PATCH > {{base_url}}/jobs/321
-
New Request > Delete Job > DELETE > {{base_url}}/jobs/321
The 321 could be any random id
Send them all and we should see the respective responses "createJob, getAllJobs, showStats, updateJob, deleteJob" with responses 200.
Now we are ready to create our Model
Create models folder in the root directory
Create User.js within it:
- import mongoose
- create User Schema
- Pass schema definition into a Model we can work with using
mongoose.model(modelNamne, schema)Our User Scehama will have the 3 required and 2 optional fields: { name, email, password, lastName, location}.
import mongoose from "mongoose";
const { Schema } = mongoose;
const UserSchema = new Schema({
name: {
type: String,
required: [true, 'Please provide name'],
minlength: 2,
maxlength: 20,
trim: true,
},
email: {
type: String,
required: [true, 'Please provide email'],
unique: true,
},
password: {
type: String,
required: [true, 'Please provide password'],
minlength: 6,
},
lastName: {
type: String,
trim: true,
maxlength: 20,
default:'lastName',
},
location: {
type: String,
trim: true,
maxlength: 30,
default:'my location',
},
});
export default mongoose.model('User', UserSchema);We need to validate the inputs from the User Schema.
I will be using mongoose's Validators, a middleware that has access to the fields. To be more specific, the Custom Validators from mongoose. It allows custom validation by passing a validatiton function more info here.
Let's set up validation for "email".
const UserSchema = new Schema({
// ...
email: {
type: String,
required: [true, 'Please provide email'],
validate: {
validator: function(v) {
return;
},
message: 'Please provide a valid email!'
},
unique: true,
},
// ...
});As we can see, we have a validate key, with the value an object having two properties.
- A validator function
- An error message
We will be using an external npm package called validator. The reason for using an external package instead of a custom made one ourselves is because this should be well-tested and gives us a variety of options. Right now, making our own validator is not a primary concern but later we can always remove this dependency and do so.
Install validator package
npm install validatorThen for ES6 imports
import validator from 'validator';Then pass in the reference to the function to validate, in User.js
import validator from 'validator';
const UserSchema = new Schema({
// ...
email: {
type: String,
required: [true, 'Please provide email'],
validate: {
validator: validator.isEmail,
message: 'Please provide a valid email!'
},
unique: true,
},
// ...
});Let's set up the Register in the authController.
Import the User Model, we try to create the user and catch for any errors.
So a try..catch is involved. For positive we send a 201 status and error is 500 status.
201 Created - request succeeded, and a new resource was created as a result.
500 Internal Server Erro - The server has encountered a situation it does not know how to handle.
MDN's HTTP Response Status Codes
import User from '../models/User.js'
const register = async (req, res) => {
try {
const user = await User.create(req.body);
res.status(201).json({user});
} catch(error){
res.status(500).json({ msg:'there was an error' });
}
res.send('register user');
}WE have the register in the Authentication Controller, creating the user is an asynchronous function, so have to add async and await.
NOTE: in server.js we have:
app.use(express.json());Go to Auth > Register User > Body > Raw Checkbox > From Text to JSON
What are we looking for?
The properties that are required in User see User.js
{
"name": "Tatsuya",
"password": "secretPassword",
"email":"1@2.com"
}Send the request, and we should see that in the Body the user is created.
WE can also see it in the MongoDB.
Submitting it by omitting a required value returns a 500 error, so lets remove the email.
Here is what we get after sending the request:
{
"user": {
"name": "Tatsuya",
"email": "1@2.com",
"password": "secretPassword",
"lastName": "lastName",
"location": "my location",
"_id": "640fe8ea9284cf58998d2b39",
"__v": 0
}
}Now in our MongoDB Database, we can see a directory called test and see a document logged under users.
Likewise, if we send a json where "email" value is an empty string
{
"name": "Tatsuya",
"password": "secretPassword",
"email":""
}We get
{
"msg": "there was an error"
}The error was 500 Internal Server Error.
We have name, email and password fields as required along with the email validate function as our validators. Any missing values provides us the error.
So when the email field was empty and is run through the validate function, we trigger the res.status(500).json({msg:'there as an error'}) line.
const register = async (req, res) => {
try {
const user = await User.create(req.body);
res.status(201).json({user});
} catch(error){
// Empty email will move control flow here
res.status(500).json({ msg:'there was an error' });
}
}But that is just hardcoded error msg, we need to pass our Error to our controller, and then to our errorHandlerMiddleware.
The way we can pass our error (from our route) to our controller
Express docs on Error Handling.
And here is TPO's Express 101 on middleware.
We have a middleware function:
function(req, res, next) {
// do stuff!
}Where
-
reqorrequestis an object that has data about the incoming request such as the exact URL that was visited, any parameters in the URL, thebodyof the request (useful if the user is submitting a form with some data in it) and many other things. -
resorresponseis an object that represents the response that Express is going to send back to the user. Typically, you use the information in the req to determine what you’re going to do with the res by calling res.send() or another method on the object. -
nextis a function that you see a little less often, but is very important to the functioning of your app. If you are writing or using some middleware that does not send a response back to the user’s client then you must call thenextfunction at the end of your middleware function. The next function simply tells express to move to the next middleware in the stack, but if you forget to call it then your app will pause and nothing will happen!
So inside the register function in authController, instead of the res.status(500).json({ msg:'there was an error' }); we remove it, go with next and pass the error in to the Error Handler.
const register = async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json({user});
} catch(error){
// Pass the error using next
next(error);
}
}Then the errorHandlerMiddleware can take the error and send it back, like this:
const errorHandlerMiddleware = (err, req, res, next) => {
res.status(500).json({msg: err});
}
export default errorHandlerMiddlewareNow let's try sending a request with a missing email field again (in Postman).
We get this:
{
"msg": {
"errors": {
"email": {
"name": "ValidatorError",
"message": "Please provide email",
"properties": {
"message": "Please provide email",
"type": "required",
"path": "email",
"value": ""
},
"kind": "required",
"path": "email",
"value": ""
}
},
"_message": "User validation failed",
"name": "ValidationError",
"message": "User validation failed: email: Please provide email"
}
}Using try..catch approach, instead of hardcoding those errors for each controller. Add next and pass the error in, set up errorHandler which will have the logic to deal with the error. Whether it is our error, a Mongoose error, etc.
The errorHandlerMiddleware is located at the end of our routes.
Check out express-async-errors npm package.
In terminal, root directory of our project
npm install express-async-errorsSo now we can replace our try..catch, let's remove it in authController.js
We change this:
import User from '../models/User.js';
const register = async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json({user});
} catch(error){
next(error);
}
}to this:
import User from '../models/User.js';
const register = async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({user});
}And just import the package in server.js
import 'express-async-errors';Let's run the server, and this time send a POST request with an empty name (in Postman):
node server{
"name": "",
"password": "secretPassword",
"email":"1@2.com"
}A POST request yields this:
{
"msg": {
"errors": {
"name": {
"name": "ValidatorError",
"message": "Please provide name",
"properties": {
"message": "Please provide name",
"type": "required",
"path": "name",
"value": ""
},
"kind": "required",
"path": "name",
"value": ""
}
},
"_message": "User validation failed",
"name": "ValidationError",
"message": "User validation failed: name: Please provide name"
}
}So the errors still pass on to the errorHandleMiddleware.
We install the library, but this time we import the specific constants for each Response Code.
Why should I install it?
- Cleaner to read, debug and maintain
- No need to remember the HTTP status codes
npm install http-status-codesNow in authController.js, we import `StatusCodes and replace:
res.status(201).json({user});with:
import { StatusCodes } from 'http-status-codes';Now head over to error-handler.js and import it and use the status code constant for 500: INTERNAL_SERVER_ERROR
import { StatusCodes } from 'http-status-codes';
const errorHandlerMiddleware = (err, req, res, next) => {
console.log(err);
const defaultError = {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
msg: '500 - Something went wrong, try again later',
}
res.status(defaultError.statusCode).json({msg: err});
}
export default errorHandlerMiddlewareNow we created an object called defaultError with the constant, and an error msg.
Instead of res.status(StatusCodes.Internal_SERVER_ERROR).json({msg: err}); we replace it with res.status(defaultError.statusCode).json({msg: err});
Notice right now we have {msg: err} instead of {defaultError.msg}, because right now we want to see the error (we received as the first argument to the function) printed out in Postman. This is to help with different types of errors such as MongoDB errors. This will be changed later.
We can re-use our Error Handling logic here in other projects.
First let's look at the error object we get. When we do a POST request.
{
"msg": {
"errors": {
"name": {
"name": "ValidatorError",
"message": "Please provide name",
"properties": {
"message": "Please provide name",
"type": "required",
"path": "name",
"value": ""
},
"kind": "required",
"path": "name",
"value": ""
}
},
"_message": "User validation failed",
"name": "ValidationError",
"message": "User validation failed: name: Please provide name"
}
}- An Error object with
errorsproperty. - Every field that is missing is going to be added as a property to
errors - In this case, a missing
namefield will add amsg.errors.nameproperty. - A missing
emailfield will add amsg.errors.emailproperty - Inside the missing fields we have a key
nameand value ofValidatorError
There are a couple ways to handle this error to provide meaningful information for the user.
- We can check for
ValidationErrorand give the user the error message instead.
const errorHandlerMiddleware = (err, req, res, next) => {
console.log(err);
const defaultError = {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
msg: '500 - Something went wrong, try again later',
}
if(err.name === 'ValidationError'){
defaultError.statusCode = StatusCodes.BAD_REQUEST;
defaultError.msg = err.message;
}
res.status(defaultError.statusCode).json({msg: defaultError.msg});
}- Have an array that goes through each missing field of the error object and print out their messages.
Notice if we have multiple empty fields:
{
"name": "",
"password": "secretPassword",
"email":""
}Our error object is:
{
"msg": {
"errors": {
"name": {
"name": "ValidatorError",
"message": "Please provide name",
"properties": {
"message": "Please provide name",
"type": "required",
"path": "name",
"value": ""
},
"kind": "required",
"path": "name",
"value": ""
},
"email": {
"name": "ValidatorError",
"message": "Please provide email",
"properties": {
"message": "Please provide email",
"type": "required",
"path": "email",
"value": ""
},
"kind": "required",
"path": "email",
"value": ""
}
},
"_message": "User validation failed",
"name": "ValidationError",
"message": "User validation failed: name: Please provide name, email: Please provide email"
}
}We can go through each property key in errors and extract out the message for each.
First we get the value of errors which is the object that contains the properties with the missing fields: name, email and password. We do this with Object.values().
Then we should map each missing field to its message property.
Let's join each string.
defaultError.msg = Object.values(err.errors)
.map( (field) => field.message)
.join(","); Now make sure to restart the server:
- Ctrl + C -> to stop the server
- node server.js -> to run the server
Now in Postman, run the request with empty fields and we should get:
{
"msg": "Please provide name,Please provide email,Please provide password"
}It should also give us a 400 Bad Request.
{
"name": "Luna",
"password": "LunaBerry",
"email":"notAValidEmail"
}Because we had the validate set-up, the error handling also covers valid emails and passwords.
Let's print out the error object when we have a non-unique email (signing up with an email in the database but different user).
res.status(defaultError.statusCode).json({msg: err});The POST request:
{
"name": "Luna",
"password": "LunaBerry",
"email":"1@2.com"
}The error object we get:
{
"msg": {
"noindex": 0,
"code": 11000,
"keyPattern": {
"email": 1
},
"keyValue": {
"email": "1@2.com"
}
}
}- Has a
codeof11000 - We have the offending field stored in
keyValue
Let's change it back so we can have a custom error message:
res.status(defaultError.statusCode).json({msg: defaultError.msg});Then we have to check if the err.code exists, and it is equal to 11000 then:
- Respond with a bad request
- Create a template string for the error message, that it is a non-unique value
- We can get the
emailfield if we access the objects key usingObject.keys()
if(err.code && (err.code === 11000)){
defaultError.statusCode = StatusCodes.BAD_REQUEST;
defaultError.msg = `${Object.keys(err.keyValue)} field has to be unique`;
}Restart the server, send the request with non-unique email to Postman, and we get:
{
"msg": "email field has to be unique"
}Let's check for errors in the Register controller (i.e., authController.js).
Checking for errors before it hits the middleware makes it less error-prone and allows for checking for specific errors.
We can access those input fields in the req.body so we can destructure it for the fields we check for. Let's add this to the register function.
const register = async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
// next(error);
throw new Error("Please provide all values");
}
// Instead of req.body, pass in the input fields
const user = await User.create({ name, email, password });
res.status(StatusCodes.CREATED).json({user});
}Now when the error is passed to the error middleware, we can have add it as a message to the defaultError.
const defaultError = {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
msg: err.message || '500 - Something went wrong, try again later',
}Let's create a custom error class to add on to this instead of the generic 500.
We create a CustomError class and extend from the JavaScript Error class. We create a custom class so we can extend the functionality, in this case I want to add the property of statusCode to it. Then instead of passing a generic Error we can pass in the CustomError instead to the middleware.
In authController.js
import User from '../models/User.js';
import { StatusCodes } from 'http-status-codes';
class CustomError extends Error {
constructor(message){
super(message);
this.statusCode = StatusCodes.BAD_REQUEST;
}
}
const register = async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
throw new CustomError("Please provide all values");
}
// ...Now check for the custom statusCode here:
const defaultError = {
statusCode: err.statusCode || StatusCodes.INTERNAL_SERVER_ERROR,
msg: err.message || '500 - Something went wrong, try again later',
}Let's pass in a POST request with an empty field and we should see a BAD_REQUEST 401 instead of a 500.
Now we see a 400 Bad Request.
{
"msg": "Please provide all values"
}We invoke a CustomError for our API.
class CustomError extends Error {
constructor(message){
super(message);
}
}
class BadRequestError extends CustomError {
constructor(message){
super(message);
this.statusCode = StatusCodes.BAD_REQUEST;
}
}
class NotFoundError extends CustomError {
constructor(message){
super(message);
this.statusCode = StatusCodes.NOT_FOUND;
}
}Let's create an errors folder @ root directory and set up the index.js to export all the custom errors we will make (each in their separate file).
Import the BadRequestError in authController.js to use it. Run the POST request with an empty field and we should see the proper msg and error.
Let's catch the duplicate email earlier and not rely on error handler.
Check the database if a user with that same email property exists using the User.findOne() method.
So in authController.js,
const userAlreadyExists = await User.findOne({email});
if(userAlreadyExists){
throw new BadRequestError(`The email: ${email} is already in use.`);
}We hash passwords for more security. It defends against passwords from being exposed to danger when a databased is compromised.
We can only compare the hashed values, we do not save the passwords.
Going to use bcrypt.js to aid us.
npm install bcryptjsThen we will use mongoose middleware, which allows us to perform some operations on the data before we save it as a document. In this case, we will hash the passwords (so using the pre middleware functions). mongoose pre middleware.
The methods we will be using from bcryptjs will be async.
Before we save the document (under User Schema), we want a pre middleware to hash the password.
UserSchema.pre('save');This will be invoked in two cases in the authController.js where we create the user with User.create() and updating the user in updateUser (when we have User.save()) but User.findOneAndUpdate() is a method that does not trigger the hook UserSchema.pre('save').
Let's pass in a callback function in there (with function keyword because we will use this).
UserSchema.pre('save', function(){
console.log(this.password);
});Now in Postman send a POST request in register user and we should have the JSON of the user's info, and in the sever we should see the password logged in the console.
Import bcryptjs in User Model, then in pre-save hook set up the functionality in there.
First we:
- Generate the "Salt"
- Salt is random data used as additional input to a one-way function that hashes data, such as a password
- More rounds of "Salt" means more security but also means longer processing time
await genSalt(10)
- Pass in the salt and password to the hash function
- Remember that in pre-save hook we can
console.log(this.password)meaning we have access to whatever password the user is passing in -
this.password = await bcryptjs.hash(this.password, salt);will allow us re-assign the password to a hashed and salted version of it
UserSchema.pre('save', async function(){
const salt = await bcryptjs.genSalt(10);
this.password = await bcryptjs.hash(this.password, salt);
});Later we will remove the password property from the response, but for now we are just testing the functionality.
- In Postman, send a request and see if the password we get as a response is hashed and salted.
I send this as the POST request on the register
{
"name": "Luna",
"password": "LunaBerry",
"email":"LunaBerry@gmail.com"
}Here is the response:
{
"user": {
"name": "Luna",
"email": "LunaBerry@gmail.com",
"password": "$2a$10$NX1mJKKq927f6DpOyOZmcOI/rnejRbh.YRXKMvARetbZ/2f48V3ju",
"lastName": "lastName",
"location": "my location",
"_id": "6413bc3372ae6c23df387a9a",
"__v": 0
}
}Mongoose instance methods will be our own custom document instance methods. We can use the Schema.methods object directly to save an instance method. Note: do not declare methods using ES6 arrow functions as they explicitly prevent binding this, so method will not have access to the document.
In this function we can access our document using this.
Another strategy to authenticate users and secure our app is to generate and pass a secure token between our back-end and front-end code. Doing so will make sure that our user’s username and password are not compromised and will also give us the ability to expire our user’s session for added security. The basic idea is that when a user signs in to our app, a secure token is created, and then for all subsequent requests that token is passed in the header of our request object.
We want to add a method that creates a JSON Web Token.
- Create custom instance method
createTokento our User Model
UserScema.methods.createToken = function () {
console.log(this);
}- In the controller, invoke the
createToken()method before we send an OK Status code
For now the createToken() just logs the user so we can access all the values, which we will use to pass on to JSON Web Token.
Install the jsonwebtoken package.
npm install jsonwebtokenThen go into the User Model, import the package and call the sign() method.
Here is the usage from the docs:
jwt.sign(payload, secretOrPrivateKey, [options, callback])We are going to use the user's id property to help create it, and have a secret key as a string, and also set the expiration to 1 day.
jwt.sign({ userId: this._id }, 'secretPrivateKey', { expiresIn: '1d' });We will return this JWT in the createToken method. Why this._id ? because its a private property, and when the User is created that is a property created before being saved to a database (when we logged it).
UserSchema.methods.createToken = function () {
return jwt.sign({ userId: this._id }, 'secretPrivateKey', { expiresIn: '1d' });
}Now instead of invoking createToken, assign the newly minted JWT to a variable named token in the Controller.
Also we send the status code and a json user we should also send back token.
Now in Postman send a POST request to register a user, and see the newly created json of user and token. This is what we will use for communication between front-end and server.
Every time a user makes a request in the front-end, the request will have the JWT, otherwise server sends an error response.
Instead of a string secretPrivateKey, let's store them as variables in our .env file.
Go to All Keys Generator to generate an Encryption Key, 256-bit.
Now let's add it to .env file under the variable name SECRET_KEY.
Let's also add LIFETIME variable and set it to 1d.
Now let's replace it with these variables in User Model's createToken method, like so:
UserSchema.methods.createToken = function () {
return jwt.sign(
{ userId: this._id }, process.env.SECRET_KEY, { expiresIn: process.env.LIFETIME });
}Now in order to complete our secure response we have to remove the password property.
In mongoose, we can use the select property in the SchemaType, see the docs.
In User.js, here's how we do that:
- Go into the user controller, and where we create the
UserSchema - Add
selectproperty and set it tofalse, this will make it such that the property will be excluded from the results
const UserSchema = new Schema({
/* ... */
password: {
type: String,
required: [true, 'Please provide password'],
minlength: 6,
// Set the select property to false
select: false,
},
/* ... */Now if we use a method like findOne (i.e., user.findOne()) then password will be excluded.
But since we are using User.create() in the authController.js, the password was not excluded.
In the authController.js we have the line
res.status(StatusCodes.CREATED).json({user, token});So instead we set user to an object with the properties we want to send.
res.status(StatusCodes.CREATED).json({user:{
email: user.email,
lastName: user.lastName,
location: user.location,
name: user.name
}, token});We excluded password but did include location (we will have a use for it soon so might as well).
First we want to be able to run both the back-end server and front-end application at the same time.
We want to connect the React front-end app & the back-end Express server.
Of course, we can just open up two terminals and have one in the client and one one the server.
Though a more convenient way is this dev dependency called concurrently that runs both server and client at the same time.
To make it work we have to go into our package.json and change the scripts.
Let's install some packages on the local scope (as a dev dependency).
- Let's install nodemon so it can listen to changes to our server and restart it
npm install --save-dev nodemon- Next install concrruently
npm i concurrently -D- Now set-up the scripts
First let's create the npm command for server and client separately, then combine them with concurrently. The combination will be our npm run start. Put this in the package.json in the root project directory.
"scripts": {
"server": "nodemon server --ignore client",
"client": "npm start --prefix client",
"start": "concurrently --kill-others-on-fail \" npm run server\" \" npm run client\""
},This was the old scripts:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},Notes:
- the
--ignore clientprevents nodemon from spinning up the server for every change on the front-end - the
--prefix clienttells react where to run it - the
--kill-others-on-failkills all the servers (ends the entire process) if just one of the servers fail
localhost:3000
- React front-end application
localhost:4000
- Express server
- Go to browser and make a
GETrequest, should seeHello
Now go to Postman and set-up a route
-
GETrequest - localhost:4000
- you should get
Helloresponse
Now if you set-up a fetch request in dashboard page, or any of the pages what will we get?
Let's
- import
useEffectfrom react - Create function
fetchDatawhich fetches thelocalhost:4000or the port that Express server is listening to - Use the
useEffect, once application (or dashpage) is navigated to, then you want to invoke it only when the component mounts. So set up dependency array as empty. - Inside callback function in
useEffectgofetchData - Now in the terminal run
npm run startand go to the browser forlocalhost:3000to the Dashboard main page.
In Dashboard.js
import React, { useEffect } from 'react';
export default function Dashboard() {
const fetchData = async () => {
const response = await fetch('http://localhost:4000');
const data = await response.json();
console.log(data);
}
useEffect( () => {
fetchData();
},[]);
return (
<h1>Dashboard</h1>
)
}We still do not have access to the server data or the Hello response, if we check the developer tools and console.
Does not work because both applications live on separate servers!
Here is the errors you see in the console:
Access to fetch at 'http://localhost:4000/' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
Failed to load resource: net::ERR_FAILED
Uncaught (in promise) TypeError: Failed to fetch
at fetchData (Dashboard.js:6:1)
at Dashboard.js:12:1
at commitHookEffectListMount (react-dom.development.js:23150:1)
at commitPassiveMountOnFiber (react-dom.development.js:24926:1)
at commitPassiveMountEffects_complete (react-dom.development.js:24891:1)
at commitPassiveMountEffects_begin (react-dom.development.js:24878:1)
at commitPassiveMountEffects (react-dom.development.js:24866:1)
at flushPassiveEffectsImpl (react-dom.development.js:27039:1)
at flushPassiveEffects (react-dom.development.js:26984:1)
at react-dom.development.js:26769:1
Access to fetch at 'http://localhost:4000/' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
Failed to load resource: net::ERR_FAILED
Uncaught (in promise) TypeError: Failed to fetch
at fetchData (Dashboard.js:6:1)
at Dashboard.js:12:1
at commitHookEffectListMount (react-dom.development.js:23150:1)
at invokePassiveEffectMountInDEV (react-dom.development.js:25154:1)
at invokeEffectsInDev (react-dom.development.js:27351:1)
at commitDoubleInvokeEffectsInDEV (react-dom.development.js:27330:1)
at flushPassiveEffectsImpl (react-dom.development.js:27056:1)
at flushPassiveEffects (react-dom.development.js:26984:1)
at react-dom.development.js:26769:1
at workLoop (scheduler.development.js:266:1WE can wrap it in a try..catch but it is only a stop-gap solution that just makes the application work but the problems still persist.
import React, { useEffect } from 'react';
export default function Dashboard() {
const fetchData = async () => {
try{
const response = await fetch('http://localhost:4000');
const data = await response.json();
console.log(data);
} catch (error){
console.log(error);
}
}
useEffect( () => {
fetchData();
},[]);
return (
<h1>Dashboard</h1>
)
}There is no access to the data passed across the servers. We still won't see the Hello from the server. Opening the console developer tools we still see the same errors.
Notice how one of the issues is that CORS policy has blocked the fetch request.
- Install cors package
- Set up cors as middleware in our server
- On the front-end, fetch the data from server
- Convert response data as JSOn
Let's install cors package, an Express middleware that can be used to enable CORS.
npm install corsIn the server.js
let express = require('express')
let cors = require('cors')
let app = express()
app.use(cors())Now in dashboard, we now can get the data with fetchData but the response is a simple string Hello. But in fetch we are looking for a JSON so in server.js we have to change this:
app.get('/', (req, res) => {
res.send('Hello');
})To this:
app.get('/', (req, res) => {
res.send({ msg: 'Hello' });
})Changing the string we send wrapped as an object with a property msg of "Hello".
When we push server into production, any front-end application can access the API and data.
If you want the app to be publicly available then that's ok. BUT for this job tracking app, you do NOT want this app to have that data available to any other front-end app.
In CORS, it can be configured where only the front-end of the app can access the code. The config with origin where it is explicitly stated which domain has access to the data.
But if you do not want to use full URL when building full URL (from localhost:5000 and refactored to another URL).
What if you can fetch the response in such a way that it doesn't have to be http://localhost:5000/ but rather /data.json.
Two issues we solved: reduce access from anywhere and not having to use the full URL
- front-end we can fetch usomg forward slash '/' and the endpoint (e.g.,
/.../auth.js). We can write code like this:
const response = await fetch('/data.json')- No need to use
CORSpackage, where we have to configure and explicity state which domain has access to the data
The front-end app will be static assets, so it will have full access to the API.
We will use build script to build a production-ready React app, which is a bunch of static assets.
Then on express server, we use express static to server it as a static asset.
Navigate to the client's package.json and add the following:
"proxy":"http://localhost:4000"Now in Dashboard we can just fetch the / route.
export default function Dashboard() {
const fetchData = async () => {
try{
const response = await fetch('/');
const data = await response.json();
console.log(data);
} catch (error){
console.log(error);
}
}
useEffect( () => {
fetchData();
},[]);
return (
<h1>Dashboard</h1>
)
}Restart the app with
npm run startWe can now access the data without CORS issues, and skip the localhost:4000.
When setting up the Proxy, we are talking about the fallback, the forward slash / will be the static server serving up our own index.html.
So if you do not have that resource on your React server, then you get back 404 and JSON error (not able to convert the resource to JSON).
See the production setup in create-react-app proxy docs.
/ - static server returns index.html with React app
/todos - static server returns index.html with React app
/api/todos - server handles any /api/* requests using the backend implementationSo when we run the server with proxy we get this error in the console:
Dashboard.js:11 SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSONThere is a successful response, but it is just trying to convert the response data into a JSON.
const response = await fetch('/');
const data = await response.json();Back to the server, instead of the forward slash route /, the route will be /api/v1 because all the routes start in the same manner.
This:
app.get('/', (req, res) => {
res.send('Hello');
})Turns into:
app.get('/api/v1', (req, res) => {
res.send('Hello');
})and in the Dashboard let's fetch this route:
import React, { useEffect } from 'react';
export default function Dashboard() {
const fetchData = async () => {
try{
const response = await fetch('/api/v1');
const data = await response.json();
console.log(data);
} catch (error){
console.log(error);
}
}
useEffect( () => {
fetchData();
},[]);
return (
<h1>Dashboard</h1>
)
}Now it works but if you were to put a route like /api/v1/something a resource not on React server, then you get the same error where it cannot convert data to JSON.
WE still have access to the data so if we switch the response:
const response = await fetch('/api/v1');to our fallback
const response = await fetch('/data.json');Then we still have access to the data.
If it is not a resource in the server, then use the proxy.
-
React
buildto build up as static assets and serve them on the Express server -
If hosting on different platforms, front-end app and server app, then you need configuration options provided by the hosting provider.
Finally we can go back to our app and start working on the logic. First let's clean out our Dashboard and remove fetchData and useEffect.
import React from 'react';
import { useEffect } from 'react';
export default function Dashboard() {
const fetchData = async () => {
try{
const response = await fetch('/api/v1');
const data = await response.json();
console.log(data);
} catch (error){
console.log(error);
}
}
useEffect( () => {
fetchData();
},[]);
return (
<h1>Dashboard</h1>
)
}to
import React from 'react';
export default function Dashboard() {
return (
<h1>Dashboard</h1>
)
}Next, update the initialState with 3 more properties in appContext.js
-
user,token, user`Location
const initialState = {
isLoading: false,
showAlert: false,
alertText: '',
alertType: '',
user: null,
token: null,
userLocation: '',
}Let's create the actions for Register. Add these in actions.js
export const REGISTER_USER_BEGIN = 'REGISTER_USER_BEGIN';
export const REGISTER_USER_SUCCESS = 'REGISTER_USER_SUCCESS';
export const REGISTER_USER_ERROR = 'REGISTER_USER_ERROR';These will be the actions that will be dispatched.
Let's import these actions into the reducer
import {
DISPLAY_ALERT,
CLEAR_ALERT,
REGISTER_USER_BEGIN,
REGISTER_USER_SUCCESS,
REGISTER_USER_ERROR,
} from "./actions";Now let's setup the register function in the global context so that we can keep track of the user.'
In appContext.js
const registerUser = async (currentUser) => {
console.log(currentUser);
};and also export it
return (
<AppContext.Provider value = {{...state,
displayAlert, registerUser }}>
{children}
</AppContext.Provider>
)Now destructure that method from the global context in Register.js:
const {isLoading, showAlert, displayAlert, registerUser} = useAppContext();Now in the onSubmit handler, once we are done check if any of the values are empty, then we setup the user. Check first if user is already a member (log-in function), else pass in currentUser object that contains those values.
const onSubmit = (e) => {
e.preventDefault();
const { name, email, password, isMember } = values;
if( !email || !password || (!isMember && !name)){
displayAlert();
return;
}
const currentUser = { name, email, password };
if(isMember){
// log in function here
console.log('Already a Member');
} else {
registerUser(currentUser);
}
console.log(values);
}Next, set the disabled attribute of submit button to isLoading. We do not want to allow the user to click submit again during an asynchronous request.
<button type="submit" className="btn btn-block" disabled={isLoading}>submit</button>We will toggle the isLoading later in the reducer.
npm run start- localhost:3000/register
- Click Register button
- Fill out the form
Now in the console we can see all the values submitted.
Now also test the isLoading disabled functionality.
Chrome dev tools > Components > AppProvider > hooks > toggle isLoading
Now try clicking the Submit button. It changes the cursor to disabled.
This prevents the user from submitting values making too many requests as we fetch. During the register or log-in request, user should not be able to click the Submit button.
We can use fetch to make HTTP requests, but we will be using another library called Axios. It has become a standard as it speeds up development.
First navigate to client directory.
cd clientLet's install Axios.
npm install axiosIn appContext.js
- import Axios in
appContext.js - dispatch action
register_user_begin, first thing withinregisterUser - which will set
isLoadinghook to true (indicates that we are performing HTTP request) - Need
async&awaitsince anaxios.post(url,[data])returns a promise
Should also save jobLocation to the initalState in global context.
So let's take it step-by-step,
const registerUser = async (currentUser) => {
dispatch({ type: REGISTER_USER_BEGIN });
};Handle this in the reducer (i.e., add the conditional statement within reducer)
else if(action.type === REGISTER_USER_BEGIN) {
return {
...state,
isLoading: true
};
}Return the current state and set isLoading to true which indicates that we start up the HTTP request.
Back to appContext.js we import axios then send our request. We have to wrap this in a try..catch block. We invoke an asynchronous function from axios we call post(), with the URL being /api/v1/auth/register. Here is the Axios API.
axios.post(url[, data[, config]]), first argument is the URL and 2nd argument is the object we are sending. In this case, we are sending the currentUser.
const registerUser = async (currentUser) => {
dispatch({ type: REGISTER_USER_BEGIN });
try{
const response = await axios.post('/api/v1/auth/register', currentUser);
console.log(response);
const { user, token, location } = response.data;
} catch(error){
}If there is an error in the response, it would go into the catch block. If all things work, then we can destructure out the user, token & location.
Then we can dispatch the action REGISTER_USER_SUCESS. Let's also pass in the payload object within the dispatch.
const registerUser = async (currentUser) => {
dispatch({ type: REGISTER_USER_BEGIN });
try{
const response = await axios.post('/api/v1/auth/register', currentUser);
console.log(response);
const { user, token, location } = response.data;
dispatch({
type: REGISTER_USER_SUCCESS,
payload: { user, token, location },
});
} catch(error){
}Now let's go into the reducer and handle REGISTER_USER_SUCCESS
else if(action.type === REGISTER_USER_SUCCESS){
return {
...state,
isLoading: false,
token: action.payload.token,
user: action.payload.user,
userLocation: action.payload.location,
jobLocation: action.payload.location,
showAlert: true,
alertType: 'success',
alertText: 'User Created! Redirecting ...'
};
}Notice how we take the values we passed in from the payload object user, token and location. Also went with showAlert along with a type success to indicate User was created.
catch(error){
console.log(error.response);
dispatch( {
type: REGISTER_USER_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();Also when both actions the showAlert is true, so we should hide it afterwards
Now to handle the REGISTER_USER_ERROR actiton in the reducer
else if(action.type === REGISTER_USER_ERROR){
return {
...state,
isLoading: false,
showAlert: true,
alertType: 'danger',
alertText: action.payload.msg,
};
}Should return the current state, isLoading will be false, and showAlert is still true but the type is danger and the msg is sitting inside the action.payload.msg.
Go to register page, submit a sample user. Open the developer tools in the console and we should see the data. Within the data, data is located in the data property with token, user, lastName, etc.
Head over to the components tab > AppProvider > hooks
We can see the user, name, lastName, location, token. All these values we set-up in our state.
If there is an error, we will see an error response with status 400 in the console. We can see more in the data property.
In the Register page, lets use useEffect and useNavigate hooks
React Router's useNavigate docs.
- import
useEffectfromreact - import
useNavigatefromreact-router-dom - Extract user from state using global context
- Invoke
useNavigatehook which will return anavigatewhich is a function that lets you navigate programmatically - Use the
navigatewithinuseEffect, also delay it so user can see the success alert
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const {user, ...more variables} = useAppContext();
export default function Register() {
useEffect( () => {
if(user) {
setTimeout( () => {
navigate('/');
}, 3000);
}
}, [user, navigate]);
}- The
useEffecthook is in this form:
useEffect(() => {
}, [user, navigate]);It has the callback function as the first argument, and the dependency array which we will pass the user and navigate. This means that it will be invoked on initial render and when user or navigate changes.
Now check if the user exists, then we navigate to the dashboard which is the / route.
useEffect(() => {
if(user){
navigate('/');
}
}, [user, navigate]);Optionally, we can add the delay here so that user can see the success alert.
useEffect(() => {
if(user){
setTimeout(() => {
navigate('/');
}, 3000);
}
}, [user, navigate]);Run the app and server and once the user registers successfully (or logs-in) they will navigate away from the register page and into the dashboard (the main home page).
In the dashboard, we need to check for user so if user does not exist, they will be logged out.
Lets register a new user. Once we are redirected to the Dashboard page, check Developer Tools and under Components > hooks > user
We can see the users information.
Let us hit F5 or refresh the page. Check the hook again and can see that user: null.
In appContext.js
const addUserToLocalStorage = ({ user, token, location }) => {
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('token', token);
localStorage.setItem('location', location);
}
const removeUserFromLocalStorage = ({ user, token, location }) => {
localStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('location');
}Now right inside registerUser lets invoke these functions.
const registerUser = async (currentUser) => {
try{
addUserToLocalStorage({ user, token, location });
} catch(error) {
}
}Now when application loads, let's extract these values from localStorage. Let's do this right above initialState as it should happen before it. Because in initalState we will set the default values to to what is in localStorage.
NOW when we sign up a new user we can see the data persist in Components > hook > user on refresh and it persists on localStorage as we can see in Application > Storage > Local Storage > http://localhost:3000/.
Let's install morgan a HTTP request logger middleware.
npm i morganThis is a package useful for development, so only make the app use it if the environment is still in development (i.e., not in production).
We can pass a predefined format string, format string of predefined tokens, or custom format function to morgan. We will be using predefined format string 'dev' which gives colored output based on response status for development use.
import morgan from 'morgan';
if(process.env.NODE_ENV !== 'production'){
app.use(morgan('dev'));
}Now in the console we can see the:
- HTTP method
- path
- response & response time
const login = (req, res) => {
res.send('login user');
}So what do we want to do during the log-in?
- Check inputs
- missing inputs lead to a
BadRequestError
- Match email to password
- If user does not exist, Authentication Error
- If password does not match, Authentication Error
So what we should create this Authentication Error, a 401 Unauthorized Status Code.
-
unauthenticated.jsin errors - import statuscodes, and custom-api error
- export the newly created error
import { StatusCodes } from "http-status-codes";
import CustomAPIError from "./custom-api";
class UnAuthenticatedError extends CustomAPIError {
constructor(messsage){
super(message);
this.statusCode = StatusCodes.UNAUTHORIZED;
}
}
export default UnAuthenticatedErrorNow update the index.js file within errors. We do not need to import/export custom-api as that will not be exposed elsewhere other than errors
import BadRequestError from "./bad-request.js";
import NotFoundError from "./not-found.js";
import UnAuthenticatedError from "./unauthenticated.js";
export {
BadRequestError,
NotFoundError,
UnAuthenticatedError,
}Part of the Authentication process is to confirm that passwords match. Let's create a mongoose instance method that will compare the passwords passed in to the hashed password that the user has in the database.
Since we are interacting with the databse, we head over to User.js in models and define custom document instance method comparePassword which uses bcryptjs's compare method in bcryptjs - Usage - Async.
UserSchema.methods.comparePassword = async function (candidatePassword) {
const isMatch = await bcryptjs.compare(candidatePassword, this.password);
return isMatch;
}-
candidatePasswordis the value passed in from the request body - Check it against the password within the database
Here is our log-in function is far:
const login = async (req, res) => {
const { email, password } = req.body;
if(!email || !password) {
throw new BadRequestError("Please provide all values");
}
// Get the user in db whose email matches with the one from request
const user = await User.findOne({ email });
if(!user) {
throw new UnAuthenticatedError("Invalid Credentials");
}
console.log(user);
// Compare password
const isPasswordCorrect = await user.comparePassword(password);
if(!isPasswordCorrect) {
throw new UnAuthenticatedError("Invalid Credentials");
}
}Now let's try out a POST request in Postman. Go to login request under Auth
- Body Tab
- raw
- Select JSON from dropdown menu
Let's login our user:
{
"email":"MiyukiShiba@gmail.com",
"password": "Tatsuya"
}We now get our response form send request -> login user
In Mongoose, select: false is used to exclude a field from the query results by default. It means that the field will not be returned by default in the data when you fetch the document. You can specify if this path should be included or excluded from query results by default.
In User Model let's add one extra property select: false to password:
const UserSchema = new Schema({
password: {
type: String,
required: [true, 'Please provide password'],
minlength: 3,
select: false,
},
});Now in Postman we send a login request with the same credentials, this is the response:
{
"msg": "Illegal arguments: string, undefined"
}What's going on? In login function we logged the user we got from the database, so in the nodejs express server the console outpuuted this:
[0] {
[0] _id: new ObjectId("6418d6ab92ff594a02b6f24a"),
[0] name: 'Miyuki',
[0] email: 'MiyukiShiba@gmail.com',
[0] lastName: 'lastName',
[0] location: 'my location',
[0] __v: 0
[0] }Along with the error:
Error: Illegal arguments: string, undefinedThis error is coming from the bcrypt library, if we trace the stack. The reason is that the user object we logged does not have a property of password.
Let's go to User model, and check the method comparePassword again:
UserSchema.methods.comparePassword = async function (candidatePassword) {
const isMatch = await bcryptjs.compare(candidatePassword, this.password);
return isMatch;
}Because we added select: false property to the UserSchema, the this.password is undefined. Its looking for password, in the document, which isn't there.
Recall: when setting up the register function and User.create along with user.createToken
// Instead of req.body, pass in the input fields
const user = await User.create({ name, email, password });
const token = user.createToken();
res.status(StatusCodes.CREATED).json({user:{
email: user.email,
lastName: user.lastName,
location: user.location,
name: user.name
}, token});The response shows how we were circumventing a way to not send the password to the front-end. We just hard-coded the values to send back. The alternative is set up a utils folder and create function(s) that gets the values we are looking for.
Originally the response was like this:
res.status(StatusCodes.CREATED).json({user, token, location: user.location });Now that we have the password field excluded from the query results, how do we access it for comparison in comparePassword?
In User.create() we will still get the password, and try to circumvent the response in a way so that password will be omitted. But in the login function we have
const user = await User.findOne({ email });and here we actually need the password.
The fix? Use select() in query.
Here is a similar situation in this Stack Overflow Response.
const user = await User.findOne({ email }).select('+password');Now send the login request to Postman and we now should see the response 200 OK "login user".
Also check the express server logging the user object with all the values (along with the password).
Takeaway select: false excludes that property from our response in document. But adding it we need go with select(propertyName)
- Create the JWT for the user at login
- Make the response, send the status
const token = user.createToken();
res.status( StatusCodes.OK ).json({ user, token, location: user.location });In Postman, send that login request:
{
"email":"MiyukiShiba@gmail.com",
"password": "Tatsuya"
}Response:
{
"user": {
"_id": "6418d6ab92ff594a02b6f24a",
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"password": "$2a$10$LK1joDA538wvqdcawIRkQe8WVlOmmeBzmvKh3qlDjRL2VpBpFku9C",
"lastName": "lastName",
"location": "my location",
"__v": 0
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2NzkzNTMzNjYsImV4cCI6MTY3OTQzOTc2Nn0.fhJ0sa1kEpjNhysZeFwBM-NWAzJRhoWUrQPxR0xaFnQ",
"location": "my location"
}Here is the issue: we are sending sensitive data back to the front-end (i.e., password).
Now how can we remove the password from the response? We can either hardcode it like in the register function. Or we can set the password property to undefined.
const token = user.createToken();
user.password = undefined;
res.status( StatusCodes.OK ).json({ user, token, location: user.location });Let's send that Login request from Postman again. Here is the response:
{
"user": {
"_id": "6418d6ab92ff594a02b6f24a",
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "lastName",
"location": "my location",
"__v": 0
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2NzkzNTM0NTgsImV4cCI6MTY3OTQzOTg1OH0.6qlfmsVbem-BWCTmDxBaPjSnfcjaPstSPQsKeNT1Zas",
"location": "my location"
}We get back the use object as our response, and the password is no longer there! Unlike register however, we have more information we are sending to the front-end. That's not a problem since in the front-end we look for specific properties anyways.
- Checks for empty values
- Check if user exist in database
- Check password
- Create JWT (JSON Web Token) for user login session
- Remove password from response
- Send the response, containing
user,token,location
Quite similar to the Register User
Create the actions that we will dispatch
- LOGIN_USER_BEGIN
- LOGIN_USER_SUCESS
- LOGIN_USER_ERROR
export const LOGIN_USER_BEGIN = 'LOGIN_USER_BEGIN';
export const LOGIN_USER_SUCCESS = 'LOGIN_USER_SUCCESS';
export const LOGIN_USER_ERROR = 'LOGIN_USER_ERROR';Import it to reducer and appContext.
Create the loginUser function in appContext. Pass it down to value.
import {
DISPLAY_ALERT,
CLEAR_ALERT,
REGISTER_USER_BEGIN,
REGISTER_USER_SUCCESS,
REGISTER_USER_ERROR,
LOGIN_USER_BEGIN,
LOGIN_USER_SUCCESS,
LOGIN_USER_ERROR,
} from "./actions";
export default function AppProvider(props) {
//...
const loginUser = async (currentUser) => {
console.log(currentUser);
};
return (
<AppContext.Provider value = {{...state,
displayAlert, registerUser, loginUser }}>
{children}
</AppContext.Provider>
)
}In register page, we add it to the onSubmit when user isMember is true:
const onSubmit = (e) => {
e.preventDefault();
const { name, email, password, isMember } = values;
if( !email || !password || (!isMember && !name)){
displayAlert();
return;
}
const currentUser = { name, email, password };
if(isMember){
loginUser(currentUser);
} else {
registerUser(currentUser);
}
};Now we fill out the loginUser function in the appContext.js. It is nearly the same as the registerUser function, with the only difference being the action dispatch and the URL.
const loginUser = async (currentUser) => {
dispatch({ type: LOGIN_USER_BEGIN });
try{
const { data } = await axios.post('/api/v1/auth/login', currentUser);
const { user, token, location } = data;
dispatch({
type: LOGIN_USER_SUCCESS,
payload: { user, token, location },
});
addUserToLocalStorage({ user, token, location });
} catch(error){
dispatch( {
type: LOGIN_USER_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};The action dispatches are also the same as register, only difference is the alertText for LOGIN_USER_SUCCESS. Just turn it into: alertText: 'Login Successful! Redirecting...'.
In React, a reducer is a function that takes in the current state and an action, and returns a new state. The dispatch function is used to send an action to the reducer. The action is an object that describes what happened, and the reducer uses this information to update the state. The dispatch function is used to send an action to the reducer. The action is an object that describes what happened, and the reducer uses this information to update the state.
Now we should see Invalid Credentials when we run the app and Log-In with an existing email but with the wrong password. The error message should limit the amount of information to give for security purposes, that is why Invalid Credentials is vague and does not let the user (or potential crook) whether it was an invalid email or password.
It is possible to refactor and lower code duplication since login and register seem so similar. This is an optional step.
Let's start with the actions:
We can remove REGISTER and LOGIN, replace them with just 3 actions named SETUP.
export const DISPLAY_ALERT = 'SHOW_ALERT';
export const CLEAR_ALERT = 'CLEAR_ALERT';
export const REGISTER_USER_BEGIN = 'REGISTER_USER_BEGIN';
export const REGISTER_USER_SUCCESS = 'REGISTER_USER_SUCCESS';
export const REGISTER_USER_ERROR = 'REGISTER_USER_ERROR';
export const LOGIN_USER_BEGIN = 'LOGIN_USER_BEGIN';
export const LOGIN_USER_SUCCESS = 'LOGIN_USER_SUCCESS';
export const LOGIN_USER_ERROR = 'LOGIN_USER_ERROR';
export const SETUP_USER_BEGIN = 'LOGIN_USER_BEGIN';
export const SETUP_USER_SUCCESS = 'LOGIN_USER_SUCCESS';
export const SETUP_USER_ERROR = 'LOGIN_USER_ERROR';Change the imports in global context to just SETUP instead of LOGIN and REGISTER.
In appContext.js, we have the two functions that are similar. The only two differences are the:
- POST URL endpoint
- dispatch action type
const registerUser = async (currentUser) => {
dispatch({ type: REGISTER_USER_BEGIN });
try{
const response = await axios.post('/api/v1/auth/register', currentUser);
const { user, token, location } = response.data;
dispatch({
type: REGISTER_USER_SUCCESS,
payload: { user, token, location },
});
addUserToLocalStorage({ user, token, location });
} catch(error){
dispatch( {
type: REGISTER_USER_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};
const loginUser = async (currentUser) => {
dispatch({ type: LOGIN_USER_BEGIN });
try{
const { data } = await axios.post('/api/v1/auth/login', currentUser);
const { user, token, location } = data;
dispatch({
type: LOGIN_USER_SUCCESS,
payload: { user, token, location },
});
addUserToLocalStorage({ user, token, location });
} catch(error){
dispatch( {
type: LOGIN_USER_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};We can reduce this to just one function setupUser. Instead of passing in just one parameter: currentUser, we can pass in an object containing the values that are different: the endpoint and dispatch action type.
Actually, we can reduce dispatch action type even further since the only thing different is the alertText. SO pass in 3 values: currentUser, endpoint, and alertText.
const setupUser = async ({ currentUser, endPoint, alertText }) => {
dispatch({ type: SETUP_USER_BEGIN });
try{
const { data } = await axios.post('/api/v1/auth/${endpoint}', currentUser);
const { user, token, location } = data;
dispatch({
type: SETUP_USER_SUCCESS,
payload: { user, token, location, alertText},
});
addUserToLocalStorage({ user, token, location });
} catch(error){
dispatch( {
type: SETUP_USER_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};In the dispatch where we send the payload, also pass in the alertText.
Don't forget to pass in setupUser function as a prop to AppProvider.
Now in reducer, let's use the action we receieved from the dispatch and use the information to update the state. The only thing that's different is the alertText, which we passed in as the part of the payload. Therefore it would be location in action.payload.alertText.
const reducer = (state, action) => {
// ... clearAlert, displayAlert
if(action.type === SETUP_USER_BEGIN) {
return { ...state, isLoading: true };
} else if(action.type === SETUP_USER_SUCCESS){
return {
...state,
isLoading: false,
token: action.payload.token,
user: action.payload.user,
userLocation: action.payload.location,
jobLocation: action.payload.location,
showAlert: true,
alertType: 'success',
alertText: action.payload.alertText,
};
} else if(action.type === SETUP_USER_ERROR){
return {
...state,
isLoading: false,
showAlert: true,
alertType: 'danger',
alertText: action.payload.msg,
};
}
throw new Error(`No such action: ${action.type}`);
}Now to wrap it all up, let's change how login and register functions are used in the Register.js page.
- Import
setupUserfrom theuseAppContext() - Where we invoke it:
if(isMember){
loginUser(currentUser);
} else {
registerUser(currentUser);
}We replace them, by passing in the object:
if(isMember){
setupUser({ currentUser, endPoint: 'login', alertText: 'Login Successful! Redirecting...'});
} else {
setupUser({ currentUser, endPoint: 'register', alertText: 'User Created! Redirecting...'});
}Now we refactored the login/register and reduced code duplication. This is a good approach. But for now, since the application isn't fully complete, I won't refactor it. I'll keep this here as a reference for later. One can certainly refactor now, the downside is that perhaps maybe a new featuire requires that login and register have different behaviors and must be separate.
Refactoring only works if the functionalities are similar. Once more features are added: (e.g., email verification) its easier to have two setups in place.
Our dashboard is the main route. We will have nested pages within our dashboard, which all have the same layout.
- Stats
- All Jobs
- Add Job
- Profile
Right now we have a Dashboard.js or Dashboard component. Then it is rendered in App.js. Let's delete Dashboard.js, fix imports/exports, replace it with a simple div in home route:
<Route path="/" element={<div>Dashboard</div>}/>Then we create a Dashboard directory in pages
- Create an index.js
- Create AddJob, AllJobs, Profile, Stats
- Also create SharedLayout
- Basic returns
return <h1>Add Job Page</h1>- Export all with
index.js - import all pages in
App.js
So what is SharedLayout? This will have the navbar and provide the pages the structure. The layout (top portion and sidebar) stays the same among the nested pages. The only changes are the content within the pages.
This is how Nested Pages are structured:
<Route path='/'>
<Route path="stats" element={<Stats />}/>
<Route path="all-jobs" element={<AllJobs />}/>
<Route path="add-job" element={<AddJob />}/>
<Route path="profile" element={<Profile />}/>
</Route>- A parent route "/"
- Routes placed inside
All inner routes are relative to the parent. e.g., the URL for Stats component would be /stats. If the parent route was /dashboard instead, then the URL for Stats component would be /dashboard/stats.
Also going to setup index page.
So now let's work on our Routes. Right now our App component has this:
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<div>Dashboard</div>}/>
<Route path="/register" element={<Register />}/>
<Route path="/landing" element={<Landing />}/>
<Route path="*" element={<Error />}/>
</Routes>
</BrowserRouter>
);
}What we can do is remove the home route's element, and give it a closing tag. Within it we have our nested pages. Notice how we do not need forward slash in front of the nested pages, because they are relative to the parent by default.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/">
<Route path="stats" element={<Stats/>}/>
<Route path="all-jobs" element={<AllJobs/>}/>
<Route path="add-job" element={<AddJob/>}/>
<Route path="profile" element={<Profile/>}/>
</Route>
<Route path="/register" element={<Register />}/>
<Route path="/landing" element={<Landing />}/>
<Route path="*" element={<Error />}/>
</Routes>
</BrowserRouter>
);
}Now run the app.
npm run startOpen up the browser and open up the following links:
- http://localhost:3000/stats
- http://localhost:3000/all-jobs
- http://localhost:3000/add-job
- http://localhost:3000/profile
WE can now hop from page to page in a nested layout.
Remember that in nested pages, the path is relative to the parent. So if we change the <Route path="/"> to <Route path="/dashboard">
Now the paths are going to be /dashboard/....
-http://localhost:3000/dashboard/stats
Takeaway: to setup a nested structure in react router, you need a parent route and its URLs, and routes nested inside it will be relative to the parent route. Set up the path and URL, and pass the element inside of it.
Pass in SharedLayout component as the element to the home route.
<Route path="/" element={<SharedLayout/>}>Then navigate to SharedLayout.js and implement it.
import React from 'react';
import { Outlet, Link } from "react-router-dom";
import Wrapper from '../../assets/wrappers/SharedLayout';
export default function SharedLayout() {
return (
<Wrapper>
<nav>
<Link to='add-job'>add job</Link>
<Link to='all-jobs'>all job</Link>
</nav>
<Outlet />
</Wrapper>
)
}First we wrap the SharedLayout in a Wrapper with styled components. The layout will also have a navigation bar with links to the other pages. This is where we use React Router Link. After the nav we have the React Router Outlet.
An <Outlet> should be used in parent route elements to render their child route elements. This allows nested UI to show up when child routes are rendered. If the parent route matched exactly, it will render a child index route or nothing if there is no index route.
Now with this, we can check the browser for one of the pages with the SharedLayout: http://localhost:3000/profile
We can see the links right above, which will navigate us to the other pages.
Issue: the home route "/" -> http://localhost:3000/
The home route no longer displays a page, just the navbar.
- We will set
statsas the first page to display on the home route.
To display the page in the home route, we have two options.
- In the
Register.js, when it navigates to the home route "/" instead it could navigate straight to one of the nested pages like /stats.
- The issue with this is that a user may explicitly go to the home page, it will still just display the nav bar.
- Instead of
pathprop for stats Route, go withindexprop:
<Routes>
<Route path="/" element={<SharedLayout/>}>
<Route index element={<Stats/>}/>
<Route path="all-jobs" element={<AllJobs/>}/>
<Route path="add-job" element={<AddJob/>}/>
<Route path="profile" element={<Profile/>}/>
</Route>
</Routes>Now navigating to the actual home page, it will be the stats page.
Go to: http://localhost:3000/
Note: if parent path was not "/" but rather "/dashboard" then index is still relative and will reference dashboard instead of home page.
A Protected Route. When the user logs out, it brings them to the landing page. So when a user tries to access the Dashboard without signing in, they will be redirected to the landing page. We programatically navigate them out if there is no user in our state.
- Create ProtectedRoute.js in
pagesfolder - Then wrap
SharedLayoutwithProtectedRoutecomponent inApp.js
It will use Navigate from react-router-dom. Since we want access to the user, we also want access to the global context (i.e., if there is no user in our state).
Within the component we destructure the user from the appContext. If user does not exist, then navigate to Landing page. Otherwise, return the children. The children is the <SharedLayout /> component we will wrap with ProtectedRoute in App.js.
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAppContext } from '../context/appContext';
export default function ProtectedRoute({ children }) {
const { user } = useAppContext();
if(!user){
return <Navigate to='/landing' />
}
return (
children
);
}Next we got to fix our imports/exports in index.js of pages.
Now import ProtectedRoute in App.js, and wrap SharedLayout with ProtectedRoute:
<Route
path="/"
element={
<ProtectedRoute>
<SharedLayout/>
</ProtectedRoute>
}
>Now run the app, clear localStorage of any user, try to access home route and it should redirected to landing page. Try accessing any of the nested pages as well and it will also redirect user to landing page.
The components we will have for the SharedLayout is:
- Navbar
- Large Sidebar
- Small Sidebar
Responsive design.
- 2 column layout:
- 1st column is Large sidebar/navbar
- 2nd column is the page
- Small sidebar is for mobile/small screens, a one column layout
Lets create those in components folder. Import the wrappers, fix the index.js for components.
SharedLayout will be using those components in this order: SmallSidebar, LargeSidebar, Navbar, Outlet
Right now:
export default function SharedLayout() {
return (
<Wrapper>
<nav>
<Link to='add-job'>add job</Link>
<Link to='all-jobs'>all job</Link>
</nav>
<Outlet />
</Wrapper>
)
}We set it to:
export default function SharedLayout() {
return (
<Wrapper>
<main className="dashboard">
<SmallSidebar />
<LargeSidebar />
<div>
</div>
</main>
<Outlet />
</Wrapper>
)
}Notice that in the Wrapper css for SmallSidebar and LargeSidebar, the LargeSidebar has display: none at anything below 992px. Once the width becomes 992px, the SmallSidebar is hidden and LargeSidebar takes over.
We have the components and a div which will be the 2nd column. We have css that controls which sidebar will be displayed.
The only thing that changes is the dashboard page displaying one of the nested pages. So an inner div with that classname, will also contain the Outlet.
<Wrapper>
<main className="dashboard">
<SmallSidebar />
<LargeSidebar />
<div>
<Navbar />
<div className="dashboard-page">
<Outlet />
</div>
</div>
</main>
</Wrapper>React icons is a library which gives us access to icons. To get icon, we need name and library where its from. WE can also click the icon we want and copy to clipboard.
Here is how to use it:
import { IconName } from "react-icons/fa";
export default function SharedLayout() {
return (
<IconName />
);
}We will be using icons for the rest of the project and for components.
cd client
npm i react-iconsNavbar will have a 3 column layout.
- 1st Column has a button that toggles the large sidebar
- 2nd Column contains the Logo and Dashboard text. On small screens it will have the logo in the middle, on larger screens it will have Dashboard text.
- 3rd Column contains two buttons, one for a dropdown the other for logout
import React from 'react';
import Wrapper from '../assets/wrappers/Navbar.js';
import { FaAlignLeft, FaUserCircle, FaCaretDown } from 'react-icons/fa';
import { useAppContext } from '../context/appContext.js';
import Logo from './Logo';
export default function Navbar() {
return (
<Wrapper>
<div className="nav-center">
<button className="toggle-btn" onClick={() => console.log('toggle sidebar')}>
<FaAlignLeft />
</button>
<div>
<Logo />
<h3 className="logo-text">Dashboard</h3>
</div>
<div className="btn-container">
<button type="button" className="btn" onClick={() => console.log('toggle dropdown')}>
<FaUserCircle />
User's Name
<FaCaretDown />
</button>
<div className="dropdown show-dropdown">
<button
type="button"
className="dropdown-btn"
onClick={() => console.log('log out user')}
>
logout
</button>
</div>
</div>
</div>
</Wrapper>
)
}As we can see the Navbar has 3 interactive pieces (the buttons) that will need some functionality.
Let's use state to toggle sidebar. We are going to have an action to dispatch to toggle it, we will also add a property to the initalState. Then we create a function to dispatch that action.
In appContext:
const toggleSidebar = () => {
dispatch({ type: TOGGLE_SIDEBAR });
};In reducer:
if(action.type === TOGGLE_SIDEBAR) {
return {
...state,
showSidebar: !state.showSidebar,
};
}We extract the function toggleSidebar in Navbar.js:
export default function Navbar() {
const { toggleSidebar } = useAppContext();
return (
<Wrapper>
<div className="nav-center">
<button
className="toggle-btn"
onClick={ toggleSidebar }>
<FaAlignLeft />
</button>
{/* ... */}Run the app, check the developer tools > Components > AppProvider > hooks > showSidebar
Every time we click it should toggle the hook.
Toggling the dropdown will be declared as a state variable using useState hook.
In Navbar we do 3 things: import useState and create showLogout and setShowLogout. Pass the setShowLogout to first button in btn-container Then conditionally render the 3rd button if the based on showLogout.
import { useState } from 'react';
export default function Navbar() {
const { toggleSidebar } = useAppContext();
const [showLogout, setShowLogout] = useState(false);
return (
<Wrapper>
<div className="nav-center">
<button
className="toggle-btn"
onClick={ toggleSidebar }>
<FaAlignLeft />
</button>
<div>
<Logo />
<h3 className="logo-text">Dashboard</h3>
</div>
<div className="btn-container">
<button
type="button"
className="btn"
onClick={() => setShowLogout(!showLogout)}
>
<FaUserCircle />
User's Name
<FaCaretDown />
</button>
<div className={showLogout ? "dropdown show-dropdown" : "dropdown"}>
<button
type="button"
className="dropdown-btn"
onClick={() => console.log('log out user')}
>
logout
</button>
</div>
</div>
</div>
</Wrapper>
)
}Clicking the Logout button should:
- Set the values in the state to null, empty, 0 etc.
- Moment values are set to null, user will be redirected to the landing page
Create action, import/export, dispatch action. Within the logoutUser function that dispatchs the action, it also calls removesUserFromLocalStorage.
In reducer.js we will be importing initialState (exported from appContext). To logout the user, unlike all the other actions so far, we do NOT want to return ...state but rather ...initialState. In other words, we do not want to return all the current values and just update a few of them. Instead return an empty object and grab the initialState (which is the default state). Why do this? It's more maintainable in that if we ever add new properties to initialState then we don't have to explicitly set it to null in the reducer instead we can just return the initalState object.
For any properties within initalState that have a condition (e.g., user) or properties that use localStorage (e.g., token) we have to explicitly override and set those to null.
const initialState = {
isLoading: false,
showAlert: false,
alertText: '',
alertType: '',
user: user ? JSON.parse(user) : null ,
token: token,
userLocation: userLocation || '',
jobLocation: userLocation || '',
showSidebar: false,
}
export { AppProvider, initialState, useAppContext }Just because we remove them from localStorage, it does not automatically update the state.
import { intialState } from './appContext';
if(action.type === LOGOUT_USER){
return {
...initialState,
user: null,
token: null,
userLocation: '',
jobLocation: '',
}
}Import logoutUser in actions, create the function, then pass it in the prop value of AppContext.Provider.
const logoutUser = () => {
dispatch({ type: LOGOUT_USER });
removeUserFromLocalStorage();
};Now that the functionality is out of the way, implement it in the Navbar
export default function Navbar() {
const { toggleSidebar, logoutUser, user } = useAppContext();
<FaUserCircle />
{ user?.name }
<FaCaretDown />
<button
type="button"
className="dropdown-btn"
onClick={ logoutUser }
>
logout
</button>- Destructure
logoutUserfrom appContext -
Optional chaining and rendering of
user.name - Change the function
onClickfor button
Now the moment we log out, it redirects to the landing page.
Check Developer Tools > AppPRovider > Hooks and all the hooks and state should be at the default values.
Going to have links as an a array of objects. We are going to iterate over this array and create a NavLink from react-router-dom.
Create a utils folder and create links.js.
import { IoBarChartSharp } from 'react-icons/io5';
import { MdQueryStats } from 'react-icons/md';
import { FaWpforms } from 'react-icons/fa';
import { ImProfile } from 'react-icons/im';
const links = [
{
id: 1,
text: 'stats',
path: '/',
icon: <IoBarChartSharp />,
},
{
id: 2,
text: 'all jobs',
path: 'all-jobs',
icon: <MdQueryStats />,
},
{
id: 3,
text: 'add job',
path: 'add-job',
icon: <FaWpforms />,
},
{
id: 4,
text: 'profile',
path: 'profile',
icon: <ImProfile />,
},
];
export default links;- imports: Wrapper, FaTimes icon, appContext, links, NavLink, Logo
- Icon is the close button
- CSS to
show-sidebar(which will be toggled) - navlinks
import React from 'react';
import Wrapper from '../assets/wrappers/SmallSidebar.js';
import { FaTimes } from 'react-icons/fa';
import { useAppContext } from '../context/appContext';
import links from '../utils/links';
import { NavLink } from 'react-router-dom';
import Logo from './Logo';
export default function SmallSidebar() {
return (
<Wrapper>
<div className="sidebar-container show-sidebar">
<div className="content">
<button className="close-btn" onClick={ () => console.log('toggle') }>
<FaTimes />
</button>
<header>
<Logo />
</header>
<div className="nav-links">nav links</div>
</div>
</div>
</Wrapper>
);
};The useState hook provides two things:
- A state variable to retain the data between renders.
- A state setter function to update the variable and trigger React to render the component again.
We will also change the div that has sidebar-container to conditionally render, if showSidebar is true then show-container show-sidebar css class will be set, if false then sidebar-container.
- from global context get the state variable for
showSidebar&toggleSidebar - Conditionally render with
showSidebar - Pass
toggleSidebarfunction intoclose-btnbutton
const { showSidebar, toggleSidebar } = useAppContext();
<div className={
showSidebar ? "sidebar-container show-sidebar" : "sidebar-container"
}> </div>
<button className="close-btn" onClick={ toggleSidebar }>- In
componentscreateNavlinks.js
For now in SmallSidebar.js we have a div containg the nav-links. We want to create the logic for the NavLink here.
<div className='nav-links'>
{links.map((link) => {
const { text, path, id, icon } = link;
return (
<NavLink
to={path}
key={id}
onClick={ toggleSidebar }
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
end
>
<span className='icon'>{icon}</span>
{text}
</NavLink>
);
})}
</div>For every link we have in links.js in utils folder, we destructure them for the necessary data to create a NavLink.
Notice the onClick has toggleSidebar. On a LargeSidebar don't use onClick but on smallSidebar do want to toggle the state. To close a sidebar everytime a user clicks on a link.
We will pass toggleSidebar in as a prop, to be passed from the parent container (i.e., SmallSidebar)
Finally, the className will conditionally add an active class whenever isActive. Since we are using styled-components the styles won't be colliding, especially when we render this component within another component.
export default function SmallSidebar() {
const { showSidebar, toggleSidebar } = useAppContext();
return (
<Wrapper>
<div className={
showSidebar ? "sidebar-container" : "sidebar-container show-sidebar"
}>
<div className="content">
<button className="close-btn" onClick={ toggleSidebar }>
<FaTimes />
</button>
<header>
<Logo />
</header>
// Pass function in as a prop
<NavLinks toggleSidebar={ toggleSidebar }/>
</div>
</div>
</Wrapper>
);
};Now on smaller screens that show small sidebar, we can see it in action.
Also notice how links.js is the data and separate from the rendering. NavLinks component will also be set-up in multiple places.
Large Sidebar will use showSidebar from app context to conditionally render the CSS class show-sidebar. By default showSidebar state is false in initialState, so the logic is flipped and it would have show-sidebar when the state variable is false. We want to have the component show by default.
Optional - toggle sidebar when a Navlink is pressed, just pass in the function as a prop to NavLinks like in SmallSideBar.js.
import React from 'react';
import Wrapper from '../assets/wrappers/LargeSidebar.js';
import { useAppContext } from '../context/appContext';
import Logo from './Logo';
import NavLinks from './NavLinks.js';
export default function LargeSidebar() {
const { showSidebar } = useAppContext();
return (
<Wrapper>
<div
className={
showSidebar ? "sidebar-container" : "sidebar-container show-sidebar"
}
>
<div className="content">
<header>
<Logo />
</header>
<NavLinks />
</div>
</div>
</Wrapper>
);
};The pages in the Dashboard will be sending requests. Within those requests, in the request header we have the authorization where we pass in the token of the user. We can see this in developer tools (press [F12] in Chrome) and in the Network tab, under Headers.
We need to setup the functions on the server to restrict access to certain resources. If the token is not present, send error response.
Let's create that middleware to check for the token. Back to the server at the root directory of the project, head over to middleware folder and create authenticate.js then setup the async function and export it.
const authenticate = async (req, res, next) => {
console.log('authenticate user');
next();
};
export default authenticateJust console.log() for now. Import this method in authRoutes and put it in the /updateUser route, a private route (whereas login & register are public routes). Let's name it authenticateUser.
import authenticateUser from '../middleware/authenticate.js';
router.route('/updateUser').patch(authenticateUser, updateUser);All the routes will be restricted, when it comes to jobs. We can do it two ways:
-
authenticateUserinserver, and place it in front ofjobsRouter
- Each and every request that goes to
/jobswill have that authentication
import authenticateUser from '../middleware/authenticate.js';
app.use('/api/v1/jobs', authenticateUser, jobsRouter);-
authenticateUserinjobsRoutesand do it manually for all routes
import authenticateUser from '../middleware/auth.js';
router.route('/stats').get(authenticateUser, showStates);
// ... do the same for all routesNow in Postman, test out the functionality -> should see the console.log('authenticate user'); when we make a Patch request of Update User. Don't need to pass anything in, just send it.
The response in Postman is updateUser string. Whereas in the terminal we can see:
[1] webpack compiled successfully
[0] authenticate user
[0] PATCH /api/v1/auth/updateUser 200 1.953 ms - 10The authenticate user string, meaning every request that goes to Update User will pass through the authenticate middleware.
Let's repeat the request with say Get Jobs in Postman.
[0] authenticate user
[0] GET /api/v1/jobs 200 0.688 ms - 10Awesome! The inital user authentication setup is working so far.
<NavLink
to={path}
key={id}
onClick={ toggleSidebar }
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
end
>Note that the without the end prop, this link is always active because every URL matches /. See the React Router Docs on Navlink.
Primer on JWT jwt.io.
Go to the section with header: "How do JSON Web Tokens work?"
Whenever the user wants to access a protected route or resource, the user agent should send the JWT, typically in the Authorization header using the Bearer schema. The content of the header should look like the following:
Authorization: Bearer <token>In Postman, go to Update User > Headers > Add Authorization and Bearer (token) as value.
This can be, in certain cases, a stateless authorization mechanism. The server's protected routes will check for a valid JWT in the
Authorizationheader, and if it's present, the user will be allowed to access protected resources. If the JWT contains the necessary data, the need to query the database for certain operations may be reduced, though this may not always be the case.
In authenticate.js
const authenticate = async (req, res, next) => {
const headers = req.headers;
const authHeader = req.headers.authorization;
console.log(headers);
console.log(authHeader);
next();
};Now send the request from Postman -> Update User
Response is Update User, but in the console we see authorization, Bearer and token.
[0] {
[0] authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2Nzk2MjQ1MzQsImV4cCI6MTY3OTcxMDkzNH0.-fDe_VqrLo0QodWB3p6ElHAe_oRWlYeTIHheO4IO2Ec',
[0] 'user-agent': 'PostmanRuntime/7.31.3',
[0] accept: '*/*',
[0] 'postman-token': '6b6e2d8a-b7a5-4861-9324-64af2b155a02',
[0] host: 'localhost:4000',
[0] 'accept-encoding': 'gzip, deflate, br',
[0] connection: 'keep-alive',
[0] 'content-length': '0'
[0] }
[0] Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2Nzk2MjQ1MzQsImV4cCI6MTY3OTcxMDkzNH0.-fDe_VqrLo0QodWB3p6ElHAe_oRWlYeTIHheO4IO2Ec
[0] PATCH /api/v1/auth/updateUser 200 3.335 ms - 10We will grab the Bearer token from our state, and set up an HTTP request that will go to our server. The server will check for authorization header, if its present, token is valid then we can access/modify resources. If not, then throw authentication error.
- For Register and Login routes, go to Tests
const jsonData = pm.response.json();
pm.globals.set('token', jsonData.token);We have jsonData.token because in the authController where we send the response back we have:
res.status(StatusCodes.CREATED).json({
user: {
email: user.email,
lastName: user.lastName,
location: user.location,
name: user.name
},
token,
location: user.location,
});The user object and token.
Now back to the Postman, remove the Authorization we added earlier. Update User Request > Params > Uncheck Authorization. We send the request and we have undefined.
{
[0] 'user-agent': 'PostmanRuntime/7.31.3',
[0] accept: '*/*',
[0] 'postman-token': 'e6d38261-d5de-4056-b0b6-e01fd31468e5',
[0] host: 'localhost:4000',
[0] 'accept-encoding': 'gzip, deflate, br',
[0] connection: 'keep-alive',
[0] 'content-length': '0'
[0] }
[0] undefined
[0] PATCH /api/v1/auth/updateUser 200 3.293 ms - 10So how do we set it up programatically? Go to Postman > Tests. Add the following:
const jsonData = pm.response.json();Then at the right, at Code Snippets > click Set Global Variable.
It gives this:
pm.globals.set("variable_key", "variable_value");Set it to this:
pm.globals.set("token", jsonData.token);Save it now.
Copy the same code and paste it into the Tests of Auth > Login.
Now send the Login request, now instead of manually putting in the:
Authorization: Bearer <token>In any of the routes, go to Authorization > Dropdown menu from "Inherit auth form p" to Bearer Token > In the name of the variable > {{token}}
{{token}} should be there right away. After a sending a login request.
Now we can see it in our global variables (the icon right below the close marker).
After a successful register/login then token will be added to Globals. Every time logged in to a new user {{token}} will be set automatically.
Now all routes will be protected as we repeat the same process > Authroizaiton > Bearer Token > Send request.
In authenticate.js middleware, import UnAuthentication error and if there is no header throw it.
import { UnAuthenticatedError } from "../errors/index.js";
const authenticate = async (req, res, next) => {
const authHeader = req.headers.authorization;
if(!authHeader){
throw new UnAuthenticatedError("Authentication Invalid");
}
next();
};
export default authenticateTry it in Postman > Update User > Send Request
Note: Update User request should not have any Tests script, if you get any errors "JSONerror unexpected token u".
Now try Authorization > NO Auth (Drop Down Menu) > Body
We see Authentication Invalid, 401 Response.
- We have to check authorization header
Recall that Authorization header uses the Bearer schema, so content of the header looks like:
Authorization: Bearer <token>- If authHeader is empty or does not start with "Bearer" then throw error.
- Get the token by splitting authHeader
const token = authHeader.split(' ')[1];Split it by one space, as token is a space after the Bearer. Then an array of two elements, take the second value (which is the token).
After grabbing token we should use the jwt.verify() function. When creating token we had used the proces.env.SECRET_KEY variable, which will be used again to verify here. This function returns the payload (what we passed in when creating the token).
const payload = jwt.verify(token, process.env.SECRET_KEY);Recall when creating the Token, we pass in the object payload { userId: this._id }
UserSchema.methods.createToken = function () {
return jwt.sign(
{ userId: this._id },
process.env.SECRET_KEY,
{ expiresIn: process.env.LIFETIME }
);
}Before sending the payload to the next middleware, set the request's user property to the payload's user object. So now in request, can access the user id.
If any of these steps failed: expired token, or tampered data then throw an error.
Let's log the payload first:
import jwt from 'jsonwebtoken';
import { UnAuthenticatedError } from "../errors/index.js";
const authenticate = async (req, res, next) => {
const authHeader = req.headers.authorization;
if(!authHeader || !authHeader.startsWith("Bearer")){
throw new UnAuthenticatedError("Authentication Invalid");
}
const token = authHeader.split(' ')[1];
try{
const payload = jwt.verify(token, process.env.SECRET_KEY);
console.log(payload);
next();
} catch(error){
throw new UnAuthenticatedError("Authentication Invalid");
}
};
export default authenticate Go to Postman > Update User > Authorization > Bearer Token > Send
Update User string is in the response. Check console:
[0] {
[0] userId: '6418d6ab92ff594a02b6f24a',
[0] iat: 1679740438,
[0] exp: 1679826838
[0] }We have:
-userId -issued time -exp: expiration
all the time is in milliseconds.
userId will be used in any of the controllers that rely on that user (e.g., look up all jobs of the user or Update User Info). In the controllers, we look at request object and get userId property.
next() pass it onto the controller.
Go to authController,
const updateUser = (req, res) => {
console.log(req.user);
res.send('updateUser');
}Postman > Update User > Send
In the terminal we see we have access to the user:
[0] { userId: '6418d6ab92ff594a02b6f24a' }Used later in the controllers. Has a valid token.
If we go to User model and change the expiresIn: value to say 100 (which means 100ms)
UserSchema.methods.createToken = function () {
return jwt.sign(
{ userId: this._id },
process.env.SECRET_KEY,
{ expiresIn: 100 }
);
}Now in Postman > Login User > Send request (to get a new token) Go to Update User > Send > Get 401 error
Token is already expired so the error handling should work.
The updateUser will look for 4 properties in the req.body:
- name
- lastName
- location
Throw an error if any are missing.
Then get the get the user object whose _id matches that of the req.user.userId.
Then for each property (email ... location), update them.
Use instance method save() for document.
Check User model, what are we passing in>
UserSchema.methods.createToken = function () {
return jwt.sign(
{ userId: this._id },
process.env.SECRET_KEY,
{ expiresIn: process.env.LIFETIME }
);
}In the payload, just userId. We are not changing id in the list of prooperties to update. If the properties we are changing are used to create the token, then it is a good idea to issue a new token.
const updateUser = (req, res) => {
const { email, name, lastName, location} = req.body;
if(!email || !name || !lastName || !location) {
throw new BadRequestError("Please provide all values");
}
res.send('updateUser');
};Check if any of the properties are empty. Still check even if it is optional, the front-end will check it anyways.
Next is the finding the user that matches the id. Update the properties. Then save the document. Create the token and send the response back wit hthe user, token, location.
const updateUser = async (req, res) => {
const { email, name, lastName, location} = req.body;
if(!email || !name || !lastName || !location) {
throw new BadRequestError("Please provide all values");
}
const user = await User.findOne({_id: req.user.userId});
user.email = email;
user.name = name;
user.lastName = lastName;
user.location = location;
await user.save();
const token = user.createToken();
res.status( StatusCodes.OK ).json({ user, token, location: user.location });
};Let's test it out. Navigate to Postman.
- Login Request, Send to get valid token
{
"user": {
"_id": "6418d6ab92ff594a02b6f24a",
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "lastName",
"location": "my location",
"__v": 0
},
"token": "...",
"location": "my location"
}- Patch Update User Request > Body > Raw > JSON
{
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "Shiba",
"location": "my location"
}Change one property -> lastName to Shiba.
We got an error:
{
"msg": "Illegal arguments: undefined, string"
}According to the error, there are illegal arguments undefined string. We can trace it in the console to bcrypt.hash.
The band-aid solution: comment out the following lines of code in the User model
UserSchema.pre('save', async function(){
// const salt = await bcryptjs.genSalt(10);
// this.password = await bcryptjs.hash(this.password, salt);
});The pre save hook is causing the issue, so just comment out the 2 lines and send the Patch request again in Postman.
Now look at the response (token is shortened to ellipsis):
{
"user": {
"_id": "6418d6ab92ff594a02b6f24a",
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "Shiba",
"location": "my location",
"__v": 0
},
"token": "...",
"location": "my location"
}The user is successfully updated, and the response does not have the password (because of select: false). So when query User.findOne() then password won't be returned.
Remember that not every method will trigger the .pre hook
UserSchema.pre('save', async function(){
// const salt = await bcryptjs.genSalt(10);
// this.password = await bcryptjs.hash(this.password, salt);
});Another alternative solution is to use User.findOneAndUpdate() instead of user.save().
Let's trace the execution. Notice that in bycryptjs.hash(this.password, salt); it is looking for
this.passwordIn the User Model, password's select property is false. In the authController when using User.findOne() we don't return the password. Now in updateUser function, in the line awaituser.save() it will trigger the UserSchema.pre('save',...) hook.
In the hook, we pass in the salt and this.password in bycrypt.hash(). Since we have password's select: false, then this.password will evaluate to undefined.
In login we had:
const user = await User.findOne({ email }).select('+password');If we do have the this.password, and if it is hashed (hashing the password that's already hashed). Now in the process, the login will also be affected. Now when trying to login with a password it won't match to the hashed value in the database.
So if we simply add the password:
const user = await User.findOne({_id: req.user.userId}).select('+password');The pre save hook will work, but login functionality won't work because password is hashed a 2nd time.
UserSchema.pre('save', async function(){
console.log(this.modifiedPaths());
console.log(this.isModified('name'));
// const salt = await bcryptjs.genSalt(10);
// this.password = await bcryptjs.hash(this.password, salt);
});this.modifiedPaths() returns all the paths we are modifying
- will allow us to check for which paths we are modifying
this.isModified('name') will check for a specific one
- if we are not modifying the password (which the updateUser route isn't doing), modifying everything else besides the password then just return.
Two issues are solved:
- Removes the error when the
this.passwordisn't provided - If
this.passwordexists in the instance, then won't hash it for the second time
UserSchema.pre('save', async function(){
console.log(this.modifiedPaths());
console.log(this.isModified('name'));
});Send a Postman Update User request without any updates:
{
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "Shiba",
"location": "my location"
}and we get an empty array (because nothing is modified), and false in the console.
[0] []
[0] false
[0] PATCH /api/v1/auth/updateUser 200 133.933 ms - 355Now say if we modified the name, so send a updateUser request with the name field changed then in the array we get a non-empty array with string name.
['name']Returns the value(s) we are updating (the ones that are different from the database). We can also check if a specific path is being modified with this.isModified('name').
So to check if not modifying the password, then don't do anything.
if(!this.isModified('password')) {
return;
}UserSchema.pre('save', async function(){
if(!this.isModified('password')) {
return;
}
const salt = await bcryptjs.genSalt(10);
this.password = await bcryptjs.hash(this.password, salt);
});If we are not modifying the password, then return and avoid re-hashing the password.
When user is being registered, password is being modified, therefore it will hash the password.
On the front-end we have a page with a form with 4 fields: name, last name, email, location.
- By default, set them up with values that user has in initial request (within the state)
- When user tries to make changes, it makes an
updateUserrequest, then values are updated and a new token is issued - Location will be the default value to
addJobpage later on
For now the function just logs the current user passed in. Pass down the function.
const updateUser = async (currentUser) => {
// Log user if all values are provided on update
console.log(currentUser);
};
return (
<AppContext.Provider value = {{...state,
displayAlert, registerUser, loginUser, toggleSidebar, logoutUser, updateUser }}>
{children}
</AppContext.Provider>
)Navigate to the Profile.js in pages.
Going to need to use values from initialState and functions from global context.
We are going to use state variables with useState. We create a local state for the form field values: name, email, lastName, location.
import React from 'react';
import { useState } from 'react';
import { useAppContext } from '../../context/appContext';
export default function Profile() {
const { user, showAlert, displayAlert, updateUser, isLoading }=
useAppContext();
const [name, setName] = useState(user?.name);
const [email, setEmail] = useState(user?.email);
const [lastName, setLastName] = useState(user?.lastName);
const [location, setLocation] = useState(user?.location);
return (
<h1>Profile</h1>
)
}Next let's import a few more things, going to use FormRow component and Alert component.
Then we import the styled component wrapper called DashboardFormPage, this js file with css inside will be the form used for AddJob page as well.
Next, create the submit handler function that checks for any empty values (return and do nothing if so) then calls updateUser() from global context (which right now just logs the user object).
const handleSubmit = (e) => {
e.preventDefault();
if(!name || !email || !lastName || !location){
displayAlert();
return;
}
updateUser({ name, email, lastName, location });
}Profile should return a <form> element. Within it a heading, conditionally rendered Alert component and the FormRow components. The FormRow props are type, name, value, and onChange
return (
<Wrapper>
<form action="" className="form" onSubmit={handleSubmit}>
<h3>profile</h3>
{showAlert && <Alert />}
<FormRow
type='text'
name='name'
value={name}
handleChange={(e) => setName(e.target.value)}
/>
</form>
</Wrapper>
)Notice how the difference from the Register and here is that since we have many local states instead of just one state which dynamically sets the state of the values. Here is what it looked like in Register.js to jog our memory:
export default function Register() {
const [values, setValues] = useState(initialState);
const handleChange = (e) => {
setValues({...values, [e.target.name]: e.target.value });
};
/* ... */
return (
<Wrapper className='full-page'>
<form className='form' onSubmit={onSubmit} action="">
<Logo />
<h3>{values.isMember ? "Log In" : "Register"}</h3>
{showAlert && <Alert />}
{ !values.isMember && (
<FormRow
type="text"
name="name"
value={values.name}
handleChange={handleChange}
/>
)}
<FormRow
type="email"
name="email"
value={values.email}
handleChange={handleChange}
/>
<FormRow
type="password"
name="password"
value={values.password}
handleChange={handleChange}
/>
<button type="submit" className="btn btn-block" disabled={isLoading}>submit</button>
<p>
{values.isMember ? "Not a member yet?" : "Already a member?"}
<button
type='button'
onClick={toggleMember}
className="member-btn">
{ values.isMember ? 'Register' : 'Log In' }
</button>
</p>
</form>
</Wrapper>
)
}So in Profile we just call the set function from the useState hook, and pass in the e.target.value. Whereas in the Register we call e.target.value within handleChange function so we can pass it to all FormRow components.
Fill out the rest of the FormRow components for every input we wish to save.
Finally create a submit button, that is disabled if isLoading is true. Conditionally render text based on isLoading.
<button className="btn btn-block" type='submit' disabled={isLoading}>
{isLoading ? 'Please Wait...' : 'Save Changes'}
</button>First let's test out our Profile Page so far. Pressing the submit button should print the user object in the console (because updateUser in appContext just logs it).
If we submit when a field is empty, say lastName, then it invokes displayAlert() which has the hardcoded "Please display all values" (see reducer).
Now we can move on to the HTTP request, but gotta comment out the form field checks in handleSubmit whil we set things up.
const handleSubmit = (e) => {
e.preventDefault();
// if(!name || !email || !lastName || !location){
// displayAlert();
// return;
// }
updateUser({ name, email, lastName, location });
}We can make HTTP request with Axios, but since requests will become increasingly more complex (providing the token, handling the error responses) we have to look at Axis configuration options.
We will cover multiple approaches so that we can determine in the future which options are the most apt for upcoming projects.
In the updateUser we won't have actions for now, going to just showcase the multiple approaches first before we implement the actions.
appContext.js
const updateUser = async (currentUser) => {
try {
const { data } = await axios.patch('/api/v1/auth/updateUser', currentUser, {
headers: {
Authorization: `Bearer ${state.token}`,
},
});
console.log(data);
} catch(error){
console.log(error.response);
}
}We have axios.patch() as we update the data. We have 3 arguments:
- the URL route
-
currentUseris the data we are passing in - options -> we can pass in the options with a
headersproperty, which we setAuthorization.
The token is located in the state, so we go with Bearer ${state.token}.
Just log the data we get back and the error.
Difference between useState and useReducer is reducer does not update state directly. It uses actions and dispatch.
Now let's try out our updateUser function in the Profile page. Run the app and navigate to 'http://localhost:3000/profile'.
Let's change that lastName to "Shiba", open up the developer tools > Network > Fetch/XHR
Downsides of this approach:
- Every request needs headers: Authorization `Bearer...
- Need to check 401 errors
Instead of manually adding Bearer token for every request, can use a global setup.
axios.defaults.headers.common['Authorization'] = `Bearer ${state.token}`;and now updateUser
const updateUser = async (currentUser) => {
console.log(currentUser);
try{
const { data } = await axios.patch('/api/v1/auth/updateUser', currentUser,);
console.log(data);
} catch(error) {
console.log(error.response);
}
};Downside in global setup:
const updateUser = async (currentUser) => {
console.log(currentUser);
try{
const { data } = await axios.patch('/api/v1/auth/updateUser', currentUser,);
const { data: extra } = await axios.get('https://some-other-api.com/some-route');
console.log(data);
console.log(extra);
} catch(error) {
console.log(error.response);
}
};IF we use axios.get() on another API, it sends our Bearer token.
Send updateUser request (Save Changes Button) > Developer Tools > Network > Fetch/XHR there are two Request Headers. It shows Authorization: Bearer Token.
const authFetch = axios.create({
baseURL: '/api/v1',
headers: {
Authorization: `Bearer ${state.token}`,
},
});
const updateUser = async (currentUser) => {
try {
const { data } = await authFetch.patch('/auth/updateUser', currentUser);
} catch (error) {
console.log(error.response);
}
};We can use authFetch, when sending to another api request, we don't have to send the Bearer token. Instead of using axios.patch() within updateUser.
Positive: Bearer token not found in a different request with a different URL. Downsides: Does not handle 401 responses (Authentication Errors).
Can attach functionality as requests leave application, and as requests are coming back. Kinda like middleware. Here is the docs.
// Add a request interceptor
axios.interceptors.request.use(function (config) {
// Do something before request is sent
return config;
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
// Add a response interceptor
axios.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
}, function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
});We want to add those headers in the config. Let's try implementing above:
// Axios custom instance
const authFetch = axios.create({
baseURL: '/api/v1',
});
// Axios request interceptor
authFetch.interceptors.request.use( (config) => {
config.headers.common['Authorization'] = `Bearer ${state.token}`;
return config;
}, (error) => {
return Promise.reject(error);
});Next create the response interceptor
// Axios response interceptor
authFetch.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.log(error.response);
return Promise.reject(error);
}
);In the docs, the error will handle any status codes that falls outside the range of 2xx
So let's add a bit more to the error handling, if it is a 401 then log.
// Axios response interceptor
authFetch.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.log(error.response);
if(error.response.status === 401){
console.log('Auth Error');
}
return Promise.reject(error);
}
); // Axios custom instance
const authFetch = axios.create({
baseURL: '/api/v1',
});
// Axios request interceptor
authFetch.interceptors.request.use(
function (config) {
config.headers.common['Authorization'] = `Bearer ${state.token}`;
return config;
},
function (error) {
return Promise.reject(error);
}
);
// Axios response interceptor
authFetch.interceptors.response.use(
function (response) {
return response;
},
function (error) {
console.log(error);
console.log(error.response);
if(error.response.status === 401){
console.log('Auth Error');
}
return Promise.reject(error);
}
);Why use interceptors? To keep track of error responses, and make decisions based on those error responses (or more specifically response interceptor) allows us too setup one logic for multiple requests. Same for all Dashboard pages.
TypeError: Cannot set properties of undefined (setting 'Authorization')
at AppProvider.dispatch.type (appContext.js:49:1)
at async updateUser (appContext.js:155:1)So checked stackoverflow.
So changed this line:
config.headers.common['Authorization'] = `Bearer ${state.token}`;to this line:
config.headers.Authorization = `Bearer ${state.token}`;Then later changed the line to:
config.headers['Authorization'] = `Bearer ${state.token}`;After much deliberation, from verifying the JWT signature @ jwt.io to checking this stackoverflow post on jwt, realizing that the token is encoded. And logging more enhanced errors.
I finally was able to track down the greatest issue in the why the server is sending a 401 unauthorized response:
[0] token is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2Nzk2MjQ1MzQsImV4cCI6MTY3OTcxMDkzNH0.-fDe_VqrLo0QodWB3p6ElHAe_oRWlYeTIHheO4IO2Ec
[0] TokenExpiredError: jwt expired
[0] at C:\Users\...\job-tracker\node_modules\jsonwebtoken\verify.js:190:21
[0] at getSecret (C:\Users\...\job-tracker\node_modules\jsonwebtoken\verify.js:97:14)
[0] at module.exports [as verify] (C:\Users\...\job-tracker\node_modules\jsonwebtoken\verify.js:101:10)
[0] at authenticate (file:///C:/Users/.../job-tracker/middleware/authenticate.js:15:25)
[0] at newFn (C:\Users\...\job-tracker\node_modules\express-async-errors\index.js:16:20)
[0] at Layer.handle [as handle_request] (C:\Users\...\job-tracker\node_modules\express\lib\router\layer.js:95:5)
[0] at next (C:\Users\...\job-tracker\node_modules\express\lib\router\route.js:144:13)
[0] at Route.dispatch (C:\Users\...\job-tracker\node_modules\express\lib\router\route.js:114:3)
[0] at newFn (C:\Users\...\job-tracker\node_modules\express-async-errors\index.js:16:20)
[0] at Layer.handle [as handle_request] (C:\Users\...\job-tracker\node_modules\express\lib\router\layer.js:95:5) {
[0] expiredAt: 2023-03-25T02:22:14.000Z
[0] }
[0] verification went wrong
[0] UnAuthenticatedError: Authentication Invalid
[0] at authenticate (file:///C:/Users/.../job-tracker/middleware/authenticate.js:23:11)
[0] at newFn (C:\Users\...\job-tracker\node_modules\express-async-errors\index.js:16:20)
[0] at Layer.handle [as handle_request] (C:\Users\...\job-tracker\node_modules\express\lib\router\layer.js:95:5)
[0] at next (C:\Users\...\job-tracker\node_modules\express\lib\router\route.js:144:13)
[0] at Route.dispatch (C:\Users\...\job-tracker\node_modules\express\lib\router\route.js:114:3)
[0] at newFn (C:\Users\...\job-tracker\node_modules\express-async-errors\index.js:16:20)
[0] at Layer.handle [as handle_request] (C:\Users\...\job-tracker\node_modules\express\lib\router\layer.js:95:5)
[0] at C:\Users\...\job-tracker\node_modules\express\lib\router\index.js:284:15
[0] at Function.process_params (C:\Users\...\job-tracker\node_modules\express\lib\router\index.js:346:12)
[0] at next (C:\Users\...\job-tracker\node_modules\express\lib\router\index.js:280:10) {
[0] statusCode: 401
[0] }
[0] PATCH /api/v1/auth/updateUser 401 2.166 ms - 32Token is expired! A TokenExpiredError along with the an object { expiredAt: 2023-03-25T02:22:14.000Z }.
TODO: Issue a new token by loggin in again, then try all the above Axios requests, etc.
Logged out, then logged back in.
(The button to link up the Landing Page to the Register page needs to be fixed):
<button className='btn btn-hero'>Login/Register</button>Going to Add a child component <Link> within:
<button className='btn btn-hero'>
<Link to={'/register'} style={{ textDecoration: 'none' }} >
Login/Register
</Link>
</button>A simpler solution is to remove the <button> altogether, as it keeps the stylings in the parent.
<Link to='/register' className='btn btn-hero'>
Login/Register
</Link>Now a properly styled Link routes to the register page.
Now clicking it to the login page, then log in properly.
Head over to the Profile page on the Dashboard. We see an alert of "Login Successful.. redirecting" so let's reduce the time to 4 seconds:
const clearAlert = () => {
setTimeout(() => {
dispatch({
type: CLEAR_ALERT,
})
}, 4000)
};Now the moment of truth -> set the Location to Hachioji (Kanto, Tokyo).
Ok this time instead of UnAuthenticatedError we are geting a BadRequestError 400:
[0] token is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2ODAxMzQ4ODcsImV4cCI6MTY4MDIyMTI4N30.NluIfiumCFssf1n2ypYGsoef3GcsYvYNBdlX78tGngI
[0] payload is: [object Object]
[0] userId is: 6418d6ab92ff594a02b6f24a
[0] BadRequestError: Please provide all values
[0] at updateUser (file:///C:/Users/.../job-tracker/controllers/authController.js:67:11)
[0] at newFn (C:\Users\...\node_modules\express-async-errors\index.js:16:20)
[0] at Layer.handle [as handle_request] (C:\Users\...\node_modules\express\lib\router\layer.js:95:5)
[0] at next (C:\Users\...\node_modules\express\lib\router\route.js:144:13)
[0] at authenticate (file:///C:/Users/.../job-tracker/middleware/authenticate.js:19:5)
[0] at newFn (C:\Users\...\node_modules\express-async-errors\index.js:16:20)
[0] at Layer.handle [as handle_request] (C:\Users\...\node_modules\express\lib\router\layer.js:95:5)
[0] at next (C:\Users\...\node_modules\express\lib\router\route.js:144:13)
[0] at Route.dispatch (C:\Users\...\node_modules\express\lib\router\route.js:114:3)
[0] at newFn (C:\Users\...\node_modules\express-async-errors\index.js:16:20) {
[0] statusCode: 400
[0] }
[0] PATCH /api/v1/auth/updateUser 400 1.640 ms - 35Which is good! We are getting closer.
Log the request body coming from updateUser
const updateUser = async (req, res) => {
const { email, name, lastName, location} = req.body;
console.log(`
email is \t ${email}
name is \t ${name}
lastName is \t ${lastName}
location is \t ${location}
`);
// ...
}And send the request again form Profile Page:
By the time it reaches the logging it turns out to be:
[0] email is undefined
[0] name is undefined
[0] lastName is undefined
[0] location is undefinedBut in the appContext.js:153 we have
{
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "Shiba",
"location": "Hachioji (Kanto, Tokyo)"
}Let's trace the execution:
Starting form the Profile page we
- click the button -> triggers
handleSubmit - Invokes
updateUserfromappContext
const updateUser = async (currentUser) => {
console.log(currentUser);
try{
const { data } = await authFetch.patch('/auth/updateUser', JSON.stringify(currentUser));
console.log(data);
} catch(error) {
// redundant error logging
// console.log(error.response);
}
};-
When it destructures the
datafromauthFetch.patch() -
Check
authFetch -
Problem is that we pass in a JSON.stringified version of
currentUsertoauthFetch
Pass in currentUser as an object instead:
const { data } = await authFetch.patch('/auth/updateUser', currentUser);Now sending a request with changes in profile route gives us a value of:
[0] {
[0] name: 'Miyuki',
[0] email: 'MiyukiShiba@gmail.com',
[0] lastName: 'Shiba',
[0] location: 'Hachioji (Kanto, Tokyo)'
[0] }
[0]
[0] email is MiyukiShiba@gmail.com
[0] name is Miyuki
[0] lastName is Shiba
[0] location is Hachioji (Kanto, Tokyo)
[0]
[0] []
[0] PATCH /api/v1/auth/updateUser 200 98.122 ms - 379And in developer tools
appContext.js:153
{
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "Shiba",
"location": "Hachioji (Kanto, Tokyo)"
}
appContext.js:156
{
"user": {
"_id": "6418d6ab92ff594a02b6f24a",
"name": "Miyuki",
"email": "MiyukiShiba@gmail.com",
"lastName": "Shiba",
"location": "Hachioji (Kanto, Tokyo)",
"__v": 0
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2ODAxMzgxMTEsImV4cCI6MTY4MDIyNDUxMX0.YPCCG0Vsk30qAQYrGXv1aQN7YO9VmKe5eMqT_rq8S5k",
"location": "Hachioji (Kanto, Tokyo)"
}Success! We finally resolved the issue. Big takeaway is that we must log-in so that we can issue a new token before updating the user. In Postman, re-issuing tokens are handled automatically. Its the reason why we need to send a Login request first before updating user (so the token isn't expired for the session).
AS you can see we had more descriptive errors. For completeness, let's trip up the "Auth Error" we setup back in the Axios response interceptor:
// Axios response interceptor
authFetch.interceptors.response.use(
function (response) {
return response;
},
function (error) {
console.log(error);
console.log(error.response);
if(error.response.status === 401){
console.log('Auth Error');
}
return Promise.reject(error);
}
);To do so let's keep all things the same in the Profile, and remove the token from the state. Or actualy just comment out the line where we set the Bear state token:
// Axios request interceptor
authFetch.interceptors.request.use(
function (config) {
// config.headers['Authorization'] = `Bearer ${state.token}`;
return config;
},
function (error) {
return Promise.reject(error);
}
);Now "Save Changes" in Profile page, and we should see the Auth Error in Chrome Developer tools console. We are not authorized to make these requests.
Axios Interceptors allows us to control, keep track of and make decisions based on those error responses.
List of things want to do in Update User:
- store in localStorage
- store in State
- Update database
- Dispatch action
Let's add it in actions.js:
export const UPDATE_USER_BEGIN = 'UPDATE_USER_BEGIN';
export const UPDATE_USER_SUCCESS = 'UPDATE_USER_SUCCESS';
export const UPDATE_USER_ERROR = 'UPDATE_USER_ERROR';Then dispatch the corresponding actions (in appContext.js):
const updateUser = async (currentUser) => {
dispatch({ type: UPDATE_USER_BEGIN });
try{
const { data } = await authFetch.patch('/auth/updateUser', currentUser);
const { user, location, token } = data;
dispatch({
type: UPDATE_USER_SUCCESS,
payload: { user, location, token },
});
addUserToLocalStorage({ user, location, token });
} catch(error) {
dispatch({
type: UPDATE_USER_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};- Set up the loading by dispatching
UPDATE_USER_BEGIN - Get
datafromPATCHrequest usingauthFetch - Destructure the
dataforuser, location, token - Dispatch
UPDATE_USER_SUCCESS, passing inuser,location,tokenpayload - Add
userto localStorage so data about newly updated user persists - Error handling, dispatch
UPDATE_USER_ERRORwith payload error msg - Invoke
clearAlert()aftertry..catch
Handling the Update User actions in reducer:
if(action.type === UPDATE_USER_BEGIN) {
return {
...state,
isLoading: true,
};
}
if(action.type === UPDATE_USER_SUCCESS) {
return {
...state,
isLoading: false,
token: action.payload.token,
user: action.payload.user,
userLocation: action.payload.location,
jobLocation: action.payload.location,
showAlert: true,
alertType: 'success',
alertText: 'User Profile Updated!'
};
}
if(action.type === UPDATE_USER_ERROR) {
return {
...state,
isLoading: false,
showAlert: true,
alertType: 'danger',
alertText: action.payload.msg,
};
}- Run the app
- Log out, then log in
- Go to Profile page
- Check database
- Update the Profile by changing one of the fields, then press Save Changes
- Alert should display, and state should be updated
- Successfully attaching token when sending requests with auth instance
- Error Alert displays when one of the values missing (the server error)
Improved logout functionality. Especailly the issue we had earlier, user should be logged out when tokens are expired.
Some errors we have when updateUser is called:
- Missing value -> Bad Request 400
- Generic 500 Server Error
- 401 Unauthorized error -> User should not be in the application in the first place. Token expired.
Instead of showcasing there is a 401 error Unauthorized, they should be logged out.
Right now when you get 401, it displays as an Alert flag in the Profile page. So let's change that so the user is logged out and needs to log back in.
In Axios Response interceptor, instead of logging Auth Error we can log out the user instead invoking logoutUser().
// Axios response interceptor
authFetch.interceptors.response.use(
function (response) {
return response;
},
function (error) {
console.log(error);
console.log(error.response);
if(error.response.status === 401){
console.log('Auth Error');
logoutUser();
}
return Promise.reject(error);
}
);Now if we comment out the Bearer token, we will be logged out when we try to updateUser (i.e., Press Save Changes in Profile page).
authFetch.interceptors.request.use(
function (config) {
// config.headers['Authorization'] = `Bearer ${state.token}`;
return config;
},
function (error) {
return Promise.reject(error);
}
);WE should see app kick the user back to the landing page. Now if we go to the Register page there is an Alert that displays Unauthorized.
Since there is a delay time when Alert component is displayed, the user can see the "Authentication Invalid" Alert message in the Login page immediately after logging out (when Token expired, etc.).
Let's remove that Alert.
WE only want to dispatch UPDATE_USER_ERROR in updateUser only if it isn't a 401.
const updateUser = async (currentUser) => {
dispatch({ type: UPDATE_USER_BEGIN });
try{
// get user data...
} catch(error) {
if(error.response.status !== 401){
dispatch({
type: UPDATE_USER_ERROR,
payload: { msg: error.response.data.msg },
});
}
}
clearAlert();
};Before moving on to the jobsController, jobs model and more let's now handle the front-end empty value validation in the Profile page. Let's uncomment the conditional statement in handleSubmit:
const handleSubmit = (e) => {
e.preventDefault();
if(!name || !email || !lastName || !location){
displayAlert();
return;
}
updateUser({ name, email, lastName, location });
}According to the useReducer docs:
By convention, it is common to write it as a switch statement. For each case in the switch, calculate and return some next state.
So I refactored the reducer function in reducer.js at this stage. Makes it easier to add new cases later for maintainability.
Job.js in models folder. Let's start setting up the template:
import mongoose from "mongoose";
import validator from 'validator';
import bcryptjs from 'bcryptjs';
import jwt from 'jsonwebtoken';
const { Schema } = mongoose;
const JobSchema = new Schema({
});The fields it will have are
- company
- position
- status
- jobType (full=time, part-time, intern)
- jobLocation
- timestamp
const JobSchema = new Schema({
company: {
type: String,
required: [true, 'Please provide company'],
maxLength: 50,
},
position: {
type: String,
required: [true, 'Please provide position'],
maxlength: 100,
},
status:{
type: String,
enum: ['interview', 'declined', 'pending'],
default: 'pending',
},
jobType:{
type: String,
enum: ['full-time', 'part-time', 'remote', 'internship'],
default: 'full-time',
},
jobLocation:{
type: String,
default: 'my city',
required: true,
},
});Now create a field createdBy which will indicate that it is created by the User and keep track of the time.
createdBy: {
type: mongooose.Types.ObjectId,
ref: 'User',
required: [true, 'Please provide the User'],
}, In mongoose we can add the timestamps option which assigns createdAt and updatedAt fields to schema. See the mongoose docs on options: timestamps.
So at the end of the Job model schema just add a comma, and set timestamps to true.
const JobSchema = new Schema({
// ...
}, { timestamps: true } );Finally, to use our schema definition, need to convert the JobSchema into a Model by passsing it into mongoose.model(modelName, schema). Let's do this right in the export.
export default mongoose.model('Job', JobSchema);Add the imports we plan to use:
import Job from '../models/Job.js';
import { StatusCodes } from 'http-status-codes';
import { BadRequestError, NotFoundError } from '../errors/index.js';When user creates a Job, they click a button and send a request that includes the fields within the JobSchema. The required fields are position and company. Let's destructure that from the req.body.
- Check if
positionorcompanyare empty, if true then throwBadRequestError
const createJob = async (req, res) => {
const { position, company } = req.body;
if(!position || !company) {
throw new BadRequestError('Please Provide All Values');
}
res.send('createJob');
}Next we want to set the createdBy property from the req.body and set it to the req.user.userId.
- Then we pass the
req.bodyinto aJob.create()to create ajob
Finally send back the HTTP status code that the job resource was created.
const createJob = async (req, res) => {
const { position, company } = req.body;
if(!position || !company) {
throw new BadRequestError('Please Provide All Values');
}
req.body.createdBy = req.user.userId;
const job = await Job.create(req.body);
res.status(StatusCodes.CREATED).json({ job });
}In the server.js we have the authenticateUser middleware:
app.use('/api/v1/jobs', authenticateUser, jobsRouter);In the authenticate.js we attach the user object with the userId property before we invoke next():
const authenticate = async (req, res, next) => {
const authHeader = req.headers.authorization;
if(!authHeader || !authHeader.startsWith("Bearer")){
throw new UnAuthenticatedError("Authentication Invalid");
}
const token = authHeader.split(' ')[1];
try{
const payload = jwt.verify(token, process.env.SECRET_KEY);
req.user = { userId: payload.userId };
next();
} catch(error){
throw new UnAuthenticatedError("Authentication Invalid");
}
};So we want to add a createdBy property to req.body since we will pass in req.body to the document we will create.
Let's send a Login request to get a new token issued. Then go to the Create Job request, and go to the Body tab > raw radio button > JSON dropdown and pass in:
{
"company" : "Google",
"position": "front-end developer"
}Send the request and check the Body output for a successful response.
{
"job": {
"company": "Google",
"position": "front-end developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"_id": "6426b205fbdb9da6e1e5a3a5",
"createdAt": "2023-03-31T10:12:21.562Z",
"updatedAt": "2023-03-31T10:12:21.562Z",
"__v": 0
}
}So far so good!
Now if we have an empty value and send the Create Job request, we should get back a msg with "Please provide all values" a 400 response.
Let's work on the front-end. Let's add some properties to the initialState.
const initialState = {
jobLocation: userLocation || '',
position: '',
company: '',
jobTypeOptions: ['full-time', 'part-time', 'remote', 'internship'],
jobType: 'full-time',
statusOptions: ['interview', 'declined', 'pending'],
status: 'pending',
isEditing: false,
editJobId: '',
}We have properties regarding the job, which match the back-end. We also have:
isEditingeditJobId
Because we want to be able to Edit the Job on the front-end, in All Jobs page where we click on one and edit and we will be redirected to Add Job page.
Why not have them in the local state values?
That's because in All Jobs when we want to edit a particular job it brings the user to the Add Job page so they can edit it there. In there we can set isEditing to true, get the editJobId and provide the input values to update the job.
To add the job and edit the job, it can all be found in one page in the Edit Job page. It's easier to modify those values in the global state.
Start with the imports:
import React from 'react';
import { FormRow, Alert } from '../../components';
import { useAppContext } from '../../context/appContext';
import Wrapper from '../../assets/wrappers/DashboardFormPage';Next destructure the values from global context. So far:
import React from 'react';
import { FormRow, Alert } from '../../components';
import { useAppContext } from '../../context/appContext';
import Wrapper from '../../assets/wrappers/DashboardFormPage';
export default function AddJob() {
const {
showAlert,
displayAlert,
position,
company,
jobLocation,
jobType,
jobTypeOptions,
status,
statusOptions,
} = useAppContext();
return (
<Wrapper>
</Wrapper>
);
}In the return, we will conditional render a heading based on isEditing flag. Also conditionally render the Alert component. Finally create a FormRow component for the job position.
return (
<Wrapper>
<form className='form'>
<h3>{isEditing ? 'edit job' : 'add job'}</h3>
{showAlert && <Alert />}
<div className="form-center">
<FormRow type="text" name="position" value={position} />
</div>
</form>
</Wrapper>
);Eventually we are going to invoke a function that comes from the state, global context. For now let's have a placeholder local function handleJobInput that just logs the event's name and value.
const handleJobInput = (e) =>{
const name = e.target.name;
const value = e.target.value;
console.log(`${name} : ${value}`);
}Now we can add that function to handle to the prop handleChange:
<FormRow
type="text"
name="position"
value={position}
handleChange={handleJobInput}
/>Add two more FormRow components for Company and JobLocation
<FormRow
type="text"
name="company"
value={company}
handleChange={handleJobInput}
/>
<FormRow
type="text"
name="jobLocation"
value={jobLocation}
handleChange={handleJobInput}
/>Interestingly, jobLocation is already filled out on the page (browser) because its already in our state. The label text is all one word "jobLocation" so lets split it up by using our prop labelText in our FormRow component.
<FormRow
type="text"
name="jobLocation"
value={jobLocation}
handleChange={handleJobInput}
labelText='job location'
/><div className="btn-container">
<button
type='submit'
className='btn btn-block submit-btn'
onClick={handleSubmit}
>
submit
</button>
</div>The submit handler:
const handleSubmit = (e) => {
e.preventDefault();
if(!position || !company || !jobLocation){
displayAlert();
return;
}
console.log('create job');
};With triple check form validation
- Front-End
- Controller
- mongoose
We will have drop down menus for our jobType and jobStatus.
Let's try hard coding one select menu.
-
divwithform-rowclass -
labelfor thejobType select- We have those options in the array within the state
- Grab the array, iterate over each item, map them to the string (
itemValue) andindex - In React, since we have a list we need to add the
keyprop and pass in a unique value, which in this case will beindex - Return an
optionelement
return(
<div className="form-row">
<label htmlFor="jobType" className='form-label'>
job type
</label>
<select
name='jobType'
value={jobType}
onChange={handleJobInput}
className='form-select'
>
{jobTypeOptions.map((itemValue, index) => {
return(
<option key={index} value={itemValue}>
{itemValue}
</option>
);
})}
</select>
</div>
);Now we should see the drop down menu when we click, and it should be "full-time" for Job Type. It won't change bcause the handleJobInput hasn't been wired up yet.
We have to repeat this code again, so in order to reduce code reduplication, let's create a component.
Create FormRowSelect.js in components folder, rfc. Then update the import/export in index.js.
Let's take the code we made for the dropdown menu and paste it into the component. Now let's fix up a few things, mainly the props. The React docs on Passing Props to a Component.
- Let's pass props to the child component
FormRowSelect. - Read props inside the child component
FormRowSelect
import React from 'react';
export default function FormRowSelect({labelText, name, value, handleChange, list}) {
return (
<div className="form-row">
<label htmlFor={name} className='form-label'>
{labelText || name}
</label>
<select
name={name}
value={value}
onChange={handleChange}
className='form-select'
>
{list.map((itemValue, index) => {
return(
<option key={index} value={itemValue}>
{itemValue}
</option>
);
})}
</select>
</div>
);
}With the FormRowSelect component made, we can reuse it for both the jobType and status form fields:
<FormRowSelect
name="status"
value={status}
handleChange={handleJobInput}
list={statusOptions}
/>
<FormRowSelect
labelText="job type"
name="jobType"
value={jobType}
handleChange={handleJobInput}
list={jobTypeOptions}
/>Now running the app and seeing the page on the browser we see the drop downs in action.
The handleChange function will be invoked when we update one of the inputs on a form field. When an input is changed, we want to update the state.
- Create action we want to dispatch
- Every time we create action, import in context/reducer and export it in actions
- Create the function within global context ->
appContext.jsand pass it down - Handle the case in
reducer, update state value with dynamic naming
actions.js
export const HANDLE_CHANGE = 'HANDLE_CHANGE';appContext.js
import { HANDLE_CHANGE } from "./actions";
const handleChange = ({ name, value }) => {
dispatch({
type: HANDLE_CHANGE,
payload: { name, value },
});
};
value = {{ handleChange }}reducer.js
import { HANDLE_CHANGE } from "./actions";
case HANDLE_CHANGE: {
return {
...state,
[action.payload.name]: action.payload.value,
};
}-
handleChangefunction expects an object with two properties:name, value. It then invokesdispatchpassing inaction.typeand thepayload. Finally it is passed down within thevalueprop ofAppContext.Provider
export default function AddJob() {
const {
// ...
handleChange
} = useAppContext();
const handleJobInput = (e) => {
const name = e.target.name;
const value = e.target.value;
console.log(`${name} : ${value}`);
};- Destructure the
handleChangefunction from global context - In
handleJobInputinvokehandleChange
const handleJobInput = (e) => {
const name = e.target.name;
const value = e.target.value;
handleChange({name, value});
};Every time we change one of the inputs in AddPage we also update the state.
Developer Tools > Components > AppProvider > hooks > Reducer
- Now type something within Position -> "Front-End Developer"
We see the hook updated with: position: "Front-End Developer".
Now add the company name: Adobe. Then hit Submit. In the console we will see create job, since all values in the form are provided.
Now we have a global handleChange function, handling every change to the inputs of the form AddJob page.
For convenience to the user, let's add a Clear button that resets all input fields to the default.
- Create action
CLEAR_VALUES, export it in actions, then import it in reducer/appContext - Create the function that dispatchs
CLEAR_VALUESand pass it down tovalueprop - Handle the action case within reducer
- Finally, create the
buttonin theAddJobpage and wire things together
appContext
const clearValues = () => {
dispatch({
type: CLEAR_VALUES,
});
};
value = {{..., clearValues }};Now how do we handle the CLEAR_VALUES action in the reducer?
- Should setup a temporary state where we handle all the job values from the
initialState - Note: if user edits a job, but then thinks otherwise and clears the job instead, then we should set
isEditingandeditJobIdto the default values.
reducer
case CLEAR_VALUES: {
const initialState = {
isEditing: false,
editJobId: '',
position: '',
company: '',
jobLocation: state.userLocation,
jobType: 'full-time',
status: 'pending',
};
return {
...state,
...initialState
};
}We have a temporary state initalState and reset the values related to job to the default. Then spread out the current state to expand it into a list of arguments and get the rest of the values. Then also spread the initalState which provides all the values for the all the properties related to job in the actual state.
- Next create the button for the AddJob form
const {
// ...
clearValues
} = useAppContext();
return (
return (
<Wrapper>
<form className='form'>
/* ... */
<div className="btn-container">
<button
type='submit'
className='btn btn-block submit-btn'
onClick={handleSubmit}
>
submit
</button>
<button
className='btn btn-block clear-btn'
onClick={(e) => {
e.preventDefault();
clearValues();
}}
>
clear
</button>
</div>
</div>
</form>
</Wrapper>
);Have to access the event object and prevent default (otherwise page refreshes). The button behaves this way because it is within the form.
Note: If Clear button comes before the submit button, pressing Enter on the keyboard hits the Clear button rather than submit.
In AddJob page, add the prop disabled={isLoading} to submit button:
<div className="btn-container">
<button
type='submit'
className='btn btn-block submit-btn'
onClick={handleSubmit}
disabled={isLoading}
>
submit
</button>Prevents user from submit form again when in the middle of a request.
When we go to Add Job page, when we fill out the form successfully and hit submit we should see an Alert that tells the user that a new job was created (to be displayed in All Jobs page).
First let's setup the actions we want to dispatch that determines the stages of Create Job.
export const CREATE_JOB_BEGIN = 'CREATE_JOB_BEGIN';
export const CREATE_JOB_SUCCESS = 'CREATE_JOB_SUCCESS';
export const CREATE_JOB_ERROR = 'CREATE_JOB_ERROR';Let's import them in the reducer and appContext.
const createJob = async () => {
dispatch({ type: CREATE_JOB_BEGIN });
try{
const {
position,
company,
jobLocation,
jobType,
status
} = state;
// authFetch
} catch(error){
}
};-
We dispatch the BEGIN action, then we in a
try..catchwe start the logic. -
We destructure out the values we need to create a job from the state.
- Calling
authFetchwith apost()as we want to post or create a job with the data we destructured. - The parameters to
post()arepost(url, data) - The
urlor route will be'/jobs'as seen inserver.js:
app.use('/api/v1/jobs', authenticateUser, jobsRouter);- The
datawe want to pass in are the properties we destructured out ofstateto create the job: - The Axios Interceptors will add that token to authenticate the user for us
So far:
await authFetch.post('/jobs', {
position,
company,
jobLocation,
jobType,
status
});Afterwards we can dispatch CREATE_JOB_SUCCESS. Also going to dispatch CLEAR_VALUES to update the state and reset them to default values to facilitate creating another job.
dispatch({ type: CREATE_JOB_SUCCESS });
dispatch({ type: CLEAR_VALUES });- Don't forget to pass the function in to the
valueprop ofAppContext.Provider!
In the catch, if we were to get a 401 error then we do not want to have an Alert display that the user is Unauthorized (as this will show in the Register). So let's just return.
If any other error than dispatch the CREATE_JOB_ERROR action and pass in the payload msg and update state accordingly.
catch(error){
if(error.response === 401) {
return;
}
dispatch({
type: CREATE_JOB_ERROR,
payload: { msg: error.response.data.msg },
});
}
};case CREATE_JOB_BEGIN: {
return{
...state,
isLoading: true
};
}
case CREATE_JOB_SUCCESS: {
return {
...state,
isLoading: false,
showAlert: true,
alertType: 'success',
alertText: 'New Job Created!'
};
}
case CREATE_JOB_ERROR: {
return {
...state,
isLoading: false,
showAlert: true,
alertType: 'danger',
alertText: action.payload.msg,
};
}After testing, the Alert won't disappear so need to add that in after the try..catch:
const createJob = async () => {
dispatch({ type: CREATE_JOB_BEGIN });
try{
const {
position,
company,
jobLocation,
jobType,
status
} = state;
await authFetch.post('/jobs', {
position,
company,
jobLocation,
jobType,
status
});
dispatch({ type: CREATE_JOB_SUCCESS });
dispatch({ type: CLEAR_VALUES });
} catch(error){
if(error.response === 401) {
return;
}
dispatch({
type: CREATE_JOB_ERROR,
payload: { msg: error.response.data.msg },
});
}
clearAlert();
};export default function AddJob() {
const {
// ...
createJob,
} = useAppContext();
const handleSubmit = (e) => {
e.preventDefault();
if(!position || !company || !jobLocation){
displayAlert();
return;
}
if(isEditing){
// editJob
return;
}
createJob();
};
// ...
}Comment out AddJob's validation checks:
const handleSubmit = (e) => {
e.preventDefault();
// if(!position || !company || !jobLocation){
// displayAlert();
// return;
// }
if(isEditing){
//
return;
}
createJob();
};Now if we submit a Bad Request with empty fields in position for example, we should get the Alert Please Provide All Values from the server. In the developer tools we have that 400 (Bad Request).
Now with a positive request, fill out the form properly and go ahead and submit the form for createJob.
Remember to uncomment the form validation on the front-end in handleSubmit, before moving on.
Planning the page. It will have a Search Form with search field, and drop downs.
Then below are all the jobs displayed (or all the jobs that match the search query).
Each job will contain its own card containing:
- job title / job position
- Location
- job type
- Status
- Date created
- Edit/Delete buttons
In the jobsController we should have a function getAllJobs, which gets all the jobs created by a specific user. We do this with Job.find().
In the authenticate middleware, if there is a valid token then we add user to the request req.
req.user = { userId: payload.userId };Then we can access the req.user property for its userId to find all the jobs created by the correct user.
const jobs = await Job.find({ createdBy: req.user.userId });Then after succesfully finding the data, we go with StatusCodes.OK for the response res.
Read and parse the data with json(). The values we want to display are:
jobstotalJobsnumOfPages
We are going to have pagination to display the jobs. Right now numOfPages is hardcoded to 1 but we should dynamically set this based on the jobs.length. In other words, the more jobs we have the more pages.
Here is how it will look in the controller:
const getAllJobs = async (req, res) => {
const jobs = await Job.find({ createdBy: req.user.userId });
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
}- Go to Postman
- Login to re-issue token
- Create jobs
- Get All Jobs
We should see in the Body:
{
"jobs": [
{
"_id": "6426b205fbdb9da6e1e5a3a5",
"company": "Google",
"position": "front-end developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-03-31T10:12:21.562Z",
"updatedAt": "2023-03-31T10:12:21.562Z",
"__v": 0
},
{
"_id": "642f36a5429866ccdadb11c5",
"company": "Uber",
"position": "Front-End Developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "Irvine, California",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-04-06T21:16:21.321Z",
"updatedAt": "2023-04-06T21:16:21.321Z",
"__v": 0
},
{
"_id": "6430ed5141edde8ddc9c697e",
"company": "Uber",
"position": "front-end developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-04-08T04:28:01.216Z",
"updatedAt": "2023-04-08T04:28:01.216Z",
"__v": 0
},
{
"_id": "6430ed6641edde8ddc9c6980",
"company": "FLT",
"position": "back-end developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-04-08T04:28:22.851Z",
"updatedAt": "2023-04-08T04:28:22.851Z",
"__v": 0
}
],
"totalJobs": 4,
"numOfPages": 1
}It works!
Now like before we should have a:
- Search Form
- Job Cards
- Pagination of Jobs
Job Cards have:
- job title / job position
- Location
- job type
- Status
- Date created
- Edit/Delete buttons
Now let's add the data to our state that we want to pass in to getAllJobs.
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });In appContext:
const initialState = {
jobs: [],
totalJobs: 0,
numOfPages: 1,
page: 1,
}- An array that will store all the
jobs - The total amount of jobs, or
jobs.length - Number of Pages
- The initial page (usually first page, hardcoded)
Let's create the actions to dispatch for All Jobs page.
export const GET_JOBS_BEGIN = 'GET_JOBS_BEGIN';
export const GET_JOBS_SUCCESS = 'GET_JOBS_SUCCESS';Notice we do not have GET_JOBS_ERROR like other previous actions.
Why do we omit the error?
Think about the scenarios that we would get an error.
| Scenario | To Do |
|---|---|
| Empty Array | Output - "No Jobs to Display" in the page |
| 401 Error / Unauthenticated | Logout User (already handled in interceptor) |
| 500 Error / Server is Down | Logout User |
| 400 Error | Logout User |
| 404 Error | Logout User |
Do we need to Alert in any of these cases?
If you are getting 404 in the All Jobs page then there is something wrong with the application.
Alerts persist across pages and can be seen if you quickly navigate. For example, if user creates a job and heads over to all jobs page then they may see the alert.
We need to clearAlert() before that happens, so add that in the reducer where showAlert: false.
Starting out the function we have to dispatch the action GET_JOBS_BEGIN. Then a try..catch that will handle the logic of getting data and updating the page. In the catch, we log the error.response then logoutUser() is invoked. Finally, after the try..catch we invoke clearAlert().
const getJobs = async () => {
dispatch({ type: GET_JOBS_BEGIN });
try{
} catch(error){
console.log(error.response);
logoutUser();
}
clearAlert();
};Now to setup the logic of the fetch within the try block.
- Use
authFetch()to get thedatawe need - Pass in the
urlwhich will be'/jobs' - Destructure
jobs,totalJobsandnumOfPagesfrom thedata - Dispatch the
GET_JOBS_SUCCESSaction to update the state of All Jobs page - Within that action, pass in the type and payload with the data we destructured out
const getJobs = async () => {
let url = '/jobs';
dispatch({ type: GET_JOBS_BEGIN });
try{
const data = authFetch(url);
const { jobs, totalJobs, numOfPages } = data;
dispatch({
type: GET_JOBS_SUCCESS,
payload: {
jobs,
totalJobs,
numOfPages
},
});
} catch(error){
console.log(error.response);
logoutUser();
}
clearAlert();
};Now let's pass the function down to the value prop.
<AppContext.Provider value = {{..., getJobs, }}> case GET_JOBS_BEGIN: {
return {
...state,
isLoading: true,
showAlert: false,
};
}
case GET_JOBS_SUCCESS: {
return {
...state,
isLoading: false,
jobs: action.payload.jobs,
totalJobs: action.payload.totalJobs,
numOfPages: action.payload.numOfPages,
};
}React docs on useEffect.
useEffect is a React Hook that lets you synchronize a component with an external system.
useEffect(setup, dependencies?);Here is a stackoverflow post on useEffect with no dependency array or an empty array.
-
Giving it an empty array acts like
componentDidMountas in, it only runs once. -
Giving it no second argument acts as both
componentDidMountandcomponentDidUpdate, as in it runs first on mount and then on every re-render. -
Giving it an array as second argument with any value inside, eg ,
[variable1]will only execute the code inside youruseEffecthook ONCE on mount, as well as whenever that particular variable (variable1) changes.
Here is the docs on the differences between dependencies useEffect dependencies.
Going to use useEffect hook for getJobs with an empty dependency array. It will act like componentDidMount and only run once.
- Search Form
- Job Cards
- Pagination of Jobs
Job Cards have:
- job title / job position
- Location
- job type
- Status
- Date created
- Edit/Delete buttons
Essentially this could be reduced to two container components:
- Search Container
- Jobs Container
We want to create those components and render them within the AllJobs component.
import React from 'react';
import { JobsContainer, SearchContainer} from '../../components';
export default function AllJobs() {
return (
<>
<SearchContainer />
<JobsContainer />
</>
);
}JobsContainer will be using three components: the Wrapper styled-component, the Job component and the Loading component.
The useEffect hook is used here. What I want: the moment the JobsContainer is rendered, then invoke getJobs() function.
import React from 'react';
import { useAppContext } from '../context/appContext';
import { useEffect } from 'react';
import Loading from './Loading';
import Job from './Job';
import Wrapper from '../assets/wrappers/JobsContainer';
export default function JobsContainer() {
const {
getJobs,
jobs,
isLoading,
page,
totalJobs,
} = useAppContext();
useEffect(() => {
getJobs()
}, []);
if (isLoading) {
return <Loading />;
}
if(jobs.length === 0 ){
return (
<Wrapper>
<h2>No Jobs to display...</h2>
</Wrapper>
);
}
return (
<Wrapper>
</Wrapper>
);
}-
Loadingcomponent conditionally renders whenisLoadingistrue
.loading {
width: 6rem;
height: 6rem;
border: 5px solid var(--grey-400);
border-radius: 50%;
border-top-color: var(--primary-500);
animation: spinner 2s linear infinite;
}
.loading-center {
margin: 0 auto;
}The component:
import React from 'react';
export default function Loading() {
return (
<div className='loading'></div>
);
}Now upon testing, the Loading component isn't centered right away.
So we want to pass in a prop called center. We do this because we may not always want the Loading component to be in the center.
if (isLoading) {
return <Loading center />;
}Then update the component accordingly, and conditionally render:
export default function Loading({ props }) {
const { center } = props;
return (
<div className={center ? 'loading loading-center': 'loading'}></div>
);
}If the jobs array is empty (a length of 0) then render a simple heading that says "No jobs to display"
Let's dynamically render a heading based on the number of jobs found.
| totalJobs | output |
|---|---|
| 0 | No jobs to display |
| 1 | 1 Job Found |
| 2 | 2 Jobs Found |
| n > 1 | n Jobs Found |
We want to add a s after "job" string when there is more than one.
return (
<Wrapper>
<h5>
{totalJobs} job{jobs.length > 1 && 's'} found
</h5>
</Wrapper>
);Then create a div that maps out each job to display, and renders a Job component for each.
Recall that in the array of jobs, it has an object job with properties such as company, position, status, jobType, jobLocation, createdBy, etc.
There is one more property we can use to uniquely identify the specific job: _id. So use that as the key to uniquely identify it within the list. Pass that to the props.
Next props to pass down is all the information (properties) within the job I want to pass down as well, so spread out the job object and pass it down.
<div className='jobs'>
{jobs.map((job) => {
return <Job key={job._id} {...job} />
})}
</div>Next display the pagination buttons that show when a totalJobs exceed a certain amount. For now, leave a comment to mark where it should be (right below the jobs div).
Since we are passing down the props, let's update Job component accordingly.
To Format Dates in the Job components, going to use a package called moment.js.
cd client
npm install moment --saveNow to add the date to the job we can do this:
import React from 'react';
import moment from 'moment.js';
export default function Job({props}) {
const { company, createdAt } = props;
let date = moment(createdAt);
date = date.format('MMM Do, YYYY'); // Apr 14th 23
return (
<div>
Job
<h5>{company}</h5>
<h5>{date}</h5>
</div>
);
}We pull out the createdAt prop to use as a date, then format it with moment.
Want to be able to delete and edit the job component, so should setup some placeholder function in appContext. For now it will just log the id, and the functionality will be implemented in depth later.
const setEditJob = (id) => {
console.log(`set edit job: ${id}`);
};
const deleteJob = (id) => {
console.log(`delete job : ${id}`);
};
value = {{ setEditJob, deleteJob, }}import React from 'react';
import moment from 'moment';
import { FaLocationArrow, FaBriefcase, FaCalendarAlt } from 'react-icons/fa';
import { Link } from 'react-router-dom';
import { useAppContext } from '../context/appContext';
import Wrapper from '../assets/wrappers/Job';
export default function Job({props}) {
const {
_id,
position,
company,
jobLocation,
jobType,
createdAt,
status
} = props;
const {
setEditJob,
deleteJob,
} = useAppContext();
let date = moment(createdAt);
date = date.format('MMM Do, YYYY'); // Apr 14th 23
return (
<Wrapper>
<header>
<div className="main-icon">{company.charAt(0)}</div>
<div className="info">
<h5>{position}</h5>
<p>{company}</p>
</div>
</header>
<div className="content">
<footer>
<div className="actions">
<Link
to='/add-job'
className="btn edit-btn"
onClick={() => deleteJob(_id)}
>
Edit
</Link>
<button
type='button'
className='btn delete-btn'
onClick={()=> deleteJob(_id)}
>
Delete
</button>
</div>
</footer>
</div>
</Wrapper>
);
}import React from 'react';
import Wrapper from '../assets/wrappers/JobInfo';
export default function JobInfo(props) {
const { icon, text } = props;
return (
<Wrapper>
<span className="icon">{icon}</span>
<span className="text">{text}</span>
</Wrapper>
);
}Now in Job
<div className="content">
<div className="content-center">
<JobInfo icon={<FaLocationArrow />} text={jobLocation} />
<JobInfo icon={<FaCalendarAlt />} text={date} />
<JobInfo icon={<FaBriefcase />} text={jobType} />
<div className={`status ${status}`}>{status}</div>
</div>Notice the line:
<div className={`status ${status}`}>{status}</div>in css wrapper:
.status {
border-radius: var(--borderRadius);
text-transform: capitalize;
letter-spacing: var(--letterSpacing);
text-align: center;
width: 100px;
height: 30px;
}
.pending {
background: #fcefc7;
color: #e9b949;
}
.interview {
background: #e0e8f9;
color: #647acb;
}
.declined {
color: #d66a6a;
background: #ffeeee;
}When we click on the Edit button, we want to change the values within the state. Namely, position, company, location, status and type. It also navigates to the add-job page with those values filled in.
First, let's edit the values in the state. Next, we will make a request to edit the values in the actual database.
To do:
- Set up the action
SET_EDIT_JOB - Update the function
setEditJobusingidas a parameter, and dispatch the action - Also another function
editJobthat will be just a placeholder for now
const setEditJob = (id) => {
console.log(`set edit job: ${id}`);
dispatch({ type: SET_EDIT_JOB, payload: { id } });
};
const editJob = () => {
console.log('edit job');
}
value = {{ ... , editJob }}
---
4. Now update the reducer to handle the action
```js
case SET_EDIT_JOB: {
return {
...state,
isEditing: true,
};
}- In the reducer, the logic to handle
SET_EDIT_JOB:
- Grab the job based on the id passed in from the payload
- TO do that, use the
jobsarray within the state and usefind()the matching_id - Destructure the needed values out of the job
- Update the current state by spreading it out, then change update the following values: isEditing, editJobId, position, company, jobLocation, jobType, and status
case SET_EDIT_JOB: {
const job = state.jobs.find( (job) => job._id === action.payload.id);
const {
_id, position, company, jobLocation, jobType, status
} = job;
return {
...state,
isEditing: true,
editJobId: _id,
position,
company,
jobLocation,
jobType,
status,
};
}-
Now what does
editJobfunction do? It will be invoked in theaddJobfunction. Head over to Add Job page and take outeditJobfunction from the appContext. -
In the
handleSubmitwhere we check forisEditingstate value. Within it we invokeeditJob()
Now we should see that all the state values in AppProvider context hooks are all filled out.
Now lets allow editing of the job in the server.
In jobController.js
const updateJob = async (req, res) => {
// Extract job ID from the request
const { id: jobId } = req.params;
// Extract company and position from the request's body
const { company, position } = req.body;
// Check if any of these values are empty
if (!company || !position) {
throw new BadRequestError('Please Provide All Values');
}
// Find the job in the database
const job = await Job.findOne({ _id: jobId });
// If job is not found
if (!job) {
throw new NotFoundError(`No job with id ${jobId}`);
}
// Ways to approach -> Should check if allowed to update job
// The newly updated job
let updatedJob;
res.status(StatusCodes.OK).json( { updatedJob });
};Let's update the job in the database through updatedJob. We have to look for the job whose id matches and then pass in the request's body. We update the job with mongoose's findOneAndUpdate function.
Here are the options we pass in:
-
new: true- will return a new document after update is applied -
runValidators: truewill specifically tell Mongoose to support validation onfindOneAndUpdate()operation. See Validation. - run validators will check if the property is there and if the passed in value for it is empty or does not match value required in database then it will pass an error. Only runs on what you provide in the
req.body
const updatedJob = await Job.findOneAndUpdate({ _id: jobId}, req.body, {
new: true,
runValidators: true,
});- Login, then GetAllJobs request
We have this:
{
"jobs": [
{
"_id": "6426b205fbdb9da6e1e5a3a5",
"company": "Google",
"position": "front-end developer",
"status": "pending",
"jobType": "full-time",
...Pass in the job's _id (i.e., 6426b205fbdb9da6e1e5a3a5) into the Update Job URL.
So in Postman the Update Job request's URL is like this:
Patch | {{URL}}/jobs/6426b205fbdb9da6e1e5a3a5
Then click Body > raw > JSON
Then pass in these values:
{
"company": "Google (Edited)",
"position": "full-stack developer",
"status": "pending"
}Now we have a 200 request with the JSON:
{
"updatedJob": {
"_id": "6426b205fbdb9da6e1e5a3a5",
"company": "Google (Edited)",
"position": "full-stack developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-03-31T10:12:21.562Z",
"updatedAt": "2023-04-22T13:25:01.233Z",
"__v": 0
}
}Notice, we can omit status and send a request that will work. But if we include status it must have among the enum values of pending, interview, declined. IF we passed in any other value then we will get a "something is not a valid enum value for path status.
In jobController.js some of the code remains the same
const updateJob = async (req, res) => {
const { id: jobId } = req.params;
const { company, position } = req.body;
if (!company || !position) {
throw new BadRequestError('Please Provide All Values');
}
const job = await Job.findOne({ _id: jobId });
if(!job) {
throw new NotFoundError(`No job with id ${jobId}`);
}
// ...
res.status(StatusCodes.OK).json( { updatedJob });
};But instead of findOneAndUpdate() operation we can take an alternative approach of updating the properties one-by-one.
We have access to the job. So we can do this:
// Update properties one by one
job.position = position;
job.company = company;
await job.save();
res.status(StatusCodes.OK).json( { job });So together:
const updateJob = async (req, res) => {
const { id: jobId } = req.params;
const { company, position } = req.body;
if (!company || !position) {
throw new BadRequestError('Please Provide All Values');
}
const job = await Job.findOne({ _id: jobId });
if(!job) {
throw new NotFoundError(`No job with id ${jobId}`);
}
job.position = position;
job.company = company;
await job.save();
res.status(StatusCodes.OK).json( { job });
};Then we go with await job.save() before sending a 200 code with job as a json.
The reason is because findOneAndUpdate() does not trigger the hook. Right now the Job model does not have a hook, but if it did then we should be aware of this fact.
On the other hand, await job.save() will trigger the hook.
- Notice we are only updating the values we pulled out from the
req.body()namely company and position. - Just remember to pull out every value needed to update (e.g.,
jobLocation)
So add job.jobLocation = jobLocation;. But if not going to pass in jobLocation in the request altogether and in Job model the default: 'my city' is removed. Then it will trigger an error. findOneAndUpdate() will not have this issue.
This response will trigger an error in Postman if we went with alternative approach and are updating the job.jobLocation = jobLocation.
{
"company": "Google (Edited)",
"position": "full-stack developer"
} That's because as we destructure the value out of req.body in the line:
const { company, position, jobLocation } = req.body;Then jobLocation will be undefined. Then we set it to job.jobLocation but we are looking for a value and not undefined.
If for some reason, we logged in as another user and that different user has access to Miyuki's job _id, then that other user can edit it.
In the server:
app.use('/api/v1/jobs', authenticateUser, jobsRouter);This other user is authenticated, but should not have access to Miyuki's jobs to edit.
Now a quick solution is just like in getAllJobs:
const getAllJobs = async (req, res) => {
// Find the jobs created the user from the request
const jobs = await Job.find({ createdBy: req.user.userId });
// Respond with 200 and a json containing the jobs, totalJobs, and pages
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};And in updateJob, we add two parameters in Job.findOne() with one being _id: jobId and the other being createdBy:
const updateJob = async (req, res) => {
const { id: jobId } = req.params;
const { company, position } = req.body;
if (!company || !position) {
throw new BadRequestError('Please Provide All Values');
}
// Also add createdBy so find job based on jobId and userId
const job = await Job.findOne({ _id: jobId, createdBy: req.user.userId });
if (!job) {
throw new NotFoundError(`No job with id ${jobId}`);
}
const updatedJob = await Job.findOneAndUpdate({ _id: jobId}, req.body, {
new: true,
runValidators: true,
});
res.status(StatusCodes.OK).json( { updatedJob });
};We have the property createdBy and use it as well in updateJob.
It works but the downside is that if there is an admin wants to modify something regarding the content. The adming cannot access and edit the jobs created by users.
A better approach is to setup permissions. Check userId matches the one in the job
In the updateJob we can check permissions by comparing if the userId of the request is equivalent to the one who created the job accessed with the createdBy() property.
// Check Permissions to allow Editing
req.user.userId(id) === job.createdBy(id);
// Does not match -> throw Unauthorized error
throw new UnAuthorizedError('Not authorized to access this route');
console.log(typeof req.user.userId);
console.log(typeof job.createdBy);Go to the Postman, add the log statements in the updateJob and send a Patch request. We should see in the server console the types each are.
[0] string
[0] object
PATCH /api/v1/jobs/6426b205fbdb9da6e1e5a3a5 200 94.142ms - 301
Notice that for req.user.userId() the id will be a string. Whereas job.createdBy() the id will be an object. If just comparing them with === then it would not work. We just have the basic logic out now.
WE can do this logic directly in the updateJob() operation in the jobsController but this logic can be re-used for other controllers (e.g., deleteJob()).
So let's setup a separate function to do this.
- Create a
utilsfolder at the base of the project - Inside, create
checkPermissions.js - Within the file, import
UnAuthorizedErrorand create the functioncheckPermissionsto export
import { UnAuthorizedError } from '../errors/index.js';
export default function checkPermissions() {
};This function will check the entire user object (which will be called requestUser) and the job id (which will be called resourceUserId). These will be the paramaters.
Why check for the entire user object? Because we also want to check if the user object is the admin. If only comparing resource ids then admin can't do anything because they did not create the resource.
- If the user object is an admin, then we just proceed with the functionality (or just
return). - Compare the ids (where ids also have matching data types) then throw an error.
import { UnAuthorizedError } from '../errors/index.js';
export default function checkPermissions(requestUser, resourceUserId) {
// Check if user object is admin
// Check if userId matches to that who created the resource
if(requestUser.userId === resourceUserId.toString()) {
return;
}
// Throw error in all other cases
throw new UnAuthorizedError("Not authorized to access this route");
};Now when we invoke this function in the jobsController what are the arguments to the params?
We pass in req.user to the requestUser param. And job.createdBy for the resourceUserId.
Login again to reissue a new token. Then send a patch request to update job in Postman.
It should work with a Patch and 200.
If there ever is an error, it won't reach the updatedJob portion in controller:
// Check Permissions of the user
checkPermissions(req.user, job.createdBy);
// Find and update the job, run validation & return a new document
const updatedJob = await Job.findOneAndUpdate({ _id: jobId}, req.body, {
new: true,
runValidators: true,
});Now let's run the request but with a different resource id, a job not created by the user logged in. Then we should also see the 401 unauthorized error.
Let's work on the deleteJob function in the jobsController.
It should
- Get
jobIdfrom the request - Look for the
jobusingjobId - If search fails (job does not exist) then throw an error
- Check the permissions
- Delete the job using
remove() - Respond with successful status code. No data needed to be sent to the front-end, so just send a message that deletion was successful
const deleteJob = async (req, res) => {
const { id: jobId } = req.params;
const job = await Job.findOne({ _id: jobId });
if(!job){
throw new NotFoundError(`No job with id: ${jobId}`);
}
checkPermissions(req.user, job.createdBy);
await job.remove();
res.send(StatusCodes.OK).json({ msg: 'Job removed successfully.' });
};Now go to the Postman and append the id to the URL for the Delete request. Something like this:
{{base_url}}/jobs/642f36a5429866ccdadb11c5
Make sure to change the Authorization to type: Bearer Token under the Delete request.
Another issue is that job.remove() is not recognized as a function so we need to find another way to remove the job object from the database.
After searching through, we found this:
Mongoose findOne() and Model.deleteOne() as remove() has been deprecated since v5.5.3 (see this Stackoverflow response).
So replace remove() with deleteOne().
Also some other fixes is to change:
-
res.send()tores.status().
Look into this error:
- [0] Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
Important to note that some methods bypass the middleware, in mongoose. See Middleware. The method deleteOne() is a document middleware function where this refers to the document.
Just like before we have to:
- Create the action to dispatch
- Create the function in appContext
- Handle the action in the reducer to update the state
For now, in the reducer we will just set isLoading: true
case DELETE_JOB_BEGIN: {
return {
...state,
isLoading: true,
};
}Now when implementing the function in global context, we want to make sure to call getJobs() because we want to synchronize the jobs in the state / localStorage with that of the database.
const deleteJob = async (jobId) => {
dispatch({ type: DELETE_JOB_BEGIN });
try {
await authFetch.delete(`/jobs/${jobId}`);
getJobs();
} catch (error){
console.log(error.response);
}
};We want to authFetch to get the base URL and interceptors, and invoke delete(). Using a template string, we have /jobs/${jobId}
Then call getJobs() because it deletes it from database, not local state. So want to synchronize both front-end and back-end and by doing so we can also let getJobs change the isLoading back to false.
What is different from delete and edit?
Delete is called within the component, or AllJobs page. On the other hand, edit redirects to a different component that is AddJob.
- Create the action to dispatch
export const EDIT_JOB_BEGIN = 'EDIT_JOB_BEGIN';
export const EDIT_JOB_SUCCESS = 'EDIT_JOB_SUCCESS';
export const EDIT_JOB_ERROR = 'EDIT_JOB_ERROR';- Create the function in appContext
- dispatch the begin action
- Extract the needed values from the state
- With
authFetchandpatch()the particular job with a dynmaic template string that contains the URL to the specific job (by passing in the param needed). Then pass all the data that needs to be updated inside the function. - Dispatch success
- Clear Values to reset the form fields
- log any errors
const editJob = async () => {
dispatch({ type: EDIT_JOB_BEGIN });
try {
const { position, company, jobLocation, jobType, status } = state;
await authFetch.patch(`/jobs/${editJobId}`, {
position,
company,
jobLocation,
jobType,
status,
});
dispatch({
type: EDIT_JOB_SUCCESS
});
dispatch({ CLEAR_VALUES });
} catch(error){
console.log(error);
}
}Some changes to above:
- Needed to get
editJobIdand that is found within thestateso it isstate.editJobId - Recall that if the user is Unauthorized and the error is
401we need to return, user shouldn't be in the page in the first place - Dispatch error in this case
const editJob = async () => {
dispatch({ type: EDIT_JOB_BEGIN });
try {
const { position, company, jobLocation, jobType, status } = state;
await authFetch.patch(`/jobs/${state.editJobId}`, {
position,
company,
jobLocation,
jobType,
status,
});
dispatch({
type: EDIT_JOB_SUCCESS
});
dispatch({ CLEAR_VALUES });
} catch(error){
if(error.response.status === 401) {
return;
}
dispatch({
type: EDIT_JOB_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};- Handle the action in the reducer to update the state
Let's setup the cases in the reducer to handle the actions:
case EDIT_JOB_BEGIN: {
return {
...state,
};
}
case EDIT_JOB_SUCCESS: {
return {
...state,
};
}
case EDIT_JOB_ERROR: {
return {
...state,
};
}We need to update the loading, alerts and alert type/text.
case EDIT_JOB_BEGIN: {
return {
...state,
isLoading: true,
};
}
case EDIT_JOB_SUCCESS: {
return {
...state,
isLoading: false,
showAlert: true,
alertType: 'success',
alertText: 'Job Updated!',
};
}
case EDIT_JOB_ERROR: {
return {
...state,
isEditing: false,
isLoading: false,
showAlert: true,
alertType: 'danger',
alertText: action.payload.msg,
};
}Problem is that every time we click Edit button it invokes a DELETE request.
In the developer tool's console:
reducer.js:234 Uncaught TypeError: Cannot destructure property '_id' of 'job' as it is undefined.SO let's do some extensive error logging in the reducer to find out what's going on.
case SET_EDIT_JOB: {
console.log("-------- Reducer: SET_EDIT_JOB --------");
const job = state.jobs.find( (job) => job._id === action.payload.id);
console.log(`
-------- job variable --------
${job}
-------- end of job variable --------`);
const {
_id, position, company, jobLocation, jobType, status
} = job;
console.log(`
-------- destructured properties of job variable --------
_id: ${job._id}
position: ${job.position}
company: ${job.company}
jobLocation: ${job.jobLocation}
jobType: ${job.jobType}
status: ${job.status}
-------- end of destructured properties --------`);
return {
...state,
isEditing: true,
editJobId: _id,
position,
company,
jobLocation,
jobType,
status,
};
}Result
set edit job: 644814de8e952783acf46457
reducer.js:232 -------- Reducer: SET_EDIT_JOB --------
reducer.js:236
-------- job variable --------
undefined
-------- end of job variable --------
reducer.js:232 -------- Reducer: SET_EDIT_JOB --------
reducer.js:236
-------- job variable --------
undefined
-------- end of job variable --------A common issue for undefined variables is asynchronous, usually have to wait for it to be initialized with data so need to setup async/await, Promise to resolve / reject to use .then().
But after some checking, it is not the case and this is the front-end where we have access to state.
So added more logging, this time on the state.jobs variable which is an array of jobs. Using Object.entries() we print out every entry:
for(let entry of Object.entries(state.jobs)){
console.log(entry);
}The result is
[
"0",
{
"_id": "6426b205fbdb9da6e1e5a3a5",
"company": "Google (Edit)",
"position": "front-end developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-03-31T10:12:21.562Z",
"updatedAt": "2023-04-24T06:15:31.359Z",
"__v": 0
}
]
[
"1",
{
"_id": "6430ed5141edde8ddc9c697e",
"company": "Uber",
"position": "front-end developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-04-08T04:28:01.216Z",
"updatedAt": "2023-04-08T04:28:01.216Z",
"__v": 0
}
]
[
"2",
{
"_id": "6447615520382764f288d790",
"company": "EIEN",
"position": "vtuber",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-04-25T05:12:53.868Z",
"updatedAt": "2023-04-25T05:12:53.868Z",
"__v": 0
}
]
[
"3",
{
"_id": "644814de8e952783acf46457",
"company": "test",
"position": "test",
"status": "pending",
"jobType": "full-time",
"jobLocation": "Hachioji (Kanto, Tokyo)",
"createdBy": "6418d6ab92ff594a02b6f24a",
"createdAt": "2023-04-25T17:58:54.823Z",
"updatedAt": "2023-04-25T17:58:54.823Z",
"__v": 0
}
]So we actually do have the _id property and it is not undefined, the issue lies in destructuring or trying to extract it properly as it is contained within an array and the 2nd part is the object that contains the property.
Let's go one step further and try to iterate through the values of the array. Let's log what we get first:
console.log(`Printing out every value within jobs:`);
console.log('---- Iterating via for-loop -> "state.jobs[i]"')
for(let i=0; i<state.jobs.length; i++){
console.log(state.jobs[i]);
}
console.log('---- Iterating via for-of -> "Object.values(state.jobs)"')
for(let value of Object.values(state.jobs)){
console.log(value);
}Printing out every value within jobs:
reducer.js:255 ---- Iterating via for-loop -> "state.jobs[i]"
reducer.js:257 {_id: '6426b205fbdb9da6e1e5a3a5', company: 'Google (Edit)', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}
reducer.js:257 {_id: '6430ed5141edde8ddc9c697e', company: 'Uber', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}
reducer.js:257 {_id: '6447615520382764f288d790', company: 'EIEN', position: 'vtuber', status: 'pending', jobType: 'full-time', …}
reducer.js:257 {_id: '644814de8e952783acf46457', company: 'test', position: 'test', status: 'pending', jobType: 'full-time', …}
reducer.js:260 ---- Iterating via for-of -> "Object.values(state.jobs)"
reducer.js:262 {_id: '6426b205fbdb9da6e1e5a3a5', company: 'Google (Edit)', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}
reducer.js:262 {_id: '6430ed5141edde8ddc9c697e', company: 'Uber', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}
reducer.js:262 {_id: '6447615520382764f288d790', company: 'EIEN', position: 'vtuber', status: 'pending', jobType: 'full-time', …}
reducer.js:262 {_id: '644814de8e952783acf46457', company: 'test', position: 'test', status: 'pending', jobType: 'full-time', …}Alright we got closer to the problem statement now: the issue is that when find() an array method is used, it's going through another set of array (which do not have the _id property) that contain the index and the actual javascript job object that contains the property
const job = state.jobs.find( (job) => job._id === action.payload.id);Needs to be changed to:
const job = Object.values(state.jobs).find( (job) =>
job._id === action.payload.id
);Another issue before this is that:
console.log(`action.payload.id: ${action.payload.id}`);action.payload.id: undefinedSo the comparison isn't working out.
That's because we renamed id to jobId in the payload we passed in:
const setEditJob = async (jobId) => {
console.log(`set edit job: ${jobId}`);
dispatch({
type: SET_EDIT_JOB,
payload: { jobId }
});
};So change it in reducer as well to action.payload.jobId.
Now we successfully can find the job using its jobId.
Now when we do Edit the job and it sends us to Add Page with Edit Job. All the values are in place in the form. But when we hit submit, the developer tool's console then leads to
reducer.js:357 Uncaught Error: No such action: undefined
at reducer (reducer.js:357:1)
at updateReducer (react-dom.development.js:16664:1)
at Object.useReducer (react-dom.development.js:17898:1)
at useReducer (react.development.js:1626:1)
at AppProvider (appContext.js:65:1)
at renderWithHooks (react-dom.development.js:16305:1)
at updateFunctionComponent (react-dom.development.js:19588:1)
at beginWork (react-dom.development.js:21601:1)
at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1)
at Object.invokeGuardedCallbackDev (react-dom.development.js:4213:1)Which means this line in reducer is invoked:
default: {
throw new Error(`No such action: ${action.type}`);
}It happens when we hit the submit button after click "Edit" on a job from All-Jobs page.
Looking into the Add Job page we see the submit handler:
const handleSubmit = (e) => {
e.preventDefault();
if(!position || !company || !jobLocation){
displayAlert();
return;
}
if(isEditing){
editJob();
return;
}
createJob();
};So it invokes editJob(), which is the function passed down from appContext:
const editJob = async () => {
dispatch({ type: EDIT_JOB_BEGIN });
try {
const { position, company, jobLocation, jobType, status } = state;
await authFetch.patch(`/jobs/${state.editJobId}`, {
company,
position,
jobLocation,
jobType,
status,
});
dispatch({
type: EDIT_JOB_SUCCESS,
});
dispatch({ CLEAR_VALUES });
} catch(error){
if(error.response.status === 401) {
return;
}
dispatch({
type: EDIT_JOB_ERROR,
payload: { msg: error.response.data.msg },
})
}
clearAlert();
};Let's start doing some error logging.
The thing is in the server a successful patch request is sent:
[0] GET /api/v1/jobs 200 39.789 ms - 1129
[0] GET /api/v1/jobs 304 39.613 ms - -
[0] PATCH /api/v1/jobs/644814de8e952783acf46457 200 85.776 ms - 292It just lands us on a different page.
After extensive error logging we can see that:
dispatch({ CLEAR_VALUES });Have to prepend the action with type.
const editJob = async () => {
console.log("======== editJob begin ========");
dispatch({ type: EDIT_JOB_BEGIN });
console.log("-------- dispatched EDIT_JOB_BEGIN --------");
try {
const { position, company, jobLocation, jobType, status } = state;
console.log("-------- destructured values from state --------");
console.log("-------- before the autchFetch.patch --------");
await authFetch.patch(`/jobs/${state.editJobId}`, {
company,
position,
jobLocation,
jobType,
status,
});
console.log("-------- after the autchFetch.patch --------");
dispatch({ type: EDIT_JOB_SUCCESS });
console.log("-------- after dispatching EDIT_JOB_SUCCESS --------");
dispatch({ type: CLEAR_VALUES });
console.log("-------- after dispatching CLEAR_VALUES --------");
} catch(error){
if(error.response.status === 401) {
return;
}
dispatch({
type: EDIT_JOB_ERROR,
payload: { msg: error.response.data.msg },
})
}
console.log("-------- Invoking clearAlert() --------");
clearAlert();
console.log("======== editJob end ========");
}; case SET_EDIT_JOB: {
console.log("-------- Reducer: SET_EDIT_JOB --------");
console.log(`
-------- jobs --------
state.jobs:
${state.jobs}
`);
console.log(`
typeof state.jobs:
${typeof state.jobs}
`);
console.log(`Printing out every entry within jobs:`);
for(let entry of Object.entries(state.jobs)){
console.log(entry);
}
console.log(`Printing out every value within jobs:`);
console.log('---- Iterating via for-loop -> "state.jobs[i]"')
for(let i=0; i<state.jobs.length; i++){
console.log(state.jobs[i]);
}
console.log('---- Iterating via for-of -> "Object.values(state.jobs)"')
for(let value of Object.values(state.jobs)){
console.log(value);
}
console.log(typeof Object.values(state.jobs));
console.log(Object.values(state.jobs));
console.log(`------- end of jobs --------`);
console.log(`action.payload.id: ${action.payload.jobId}`);
const job = Object.values(state.jobs).find( (job) =>
job._id === action.payload.jobId
);
// let job;
// for(let value of Object.values(state.jobs)){
// if(value._id === action.payload.jobId){
// job = value;
// }
// }
console.log(`
-------- job variable --------
job:
${job}
-------- end of job variable --------`);
const {
_id, position, company, jobLocation, jobType, status
} = job;
console.log(`
-------- destructured properties of job variable --------
_id: ${job._id}
position: ${job.position}
company: ${job.company}
jobLocation: ${job.jobLocation}
jobType: ${job.jobType}
status: ${job.status}
-------- end of destructured properties --------`);
return {
...state,
isEditing: true,
editJobId: _id,
position,
company,
jobLocation,
jobType,
status,
};
}Either include dependency array or leave it out in useEffect hook where we getJobs. Fix this later as it does not synchronize the jobs array correctly
After some debugging, came across another error:
Proxy error: Could not proxy request /api/v1/jobs from localhost:3000 to http://localhost:4000/.
See https://nodejs.org/api/errors.html#errors_common_system_errors for more information (ECONNREFUSED).Link to Github issue.
Original Code:
const getJobs = async () => {
let url = `/jobs`;
dispatch({ type: GET_JOBS_BEGIN });
try{
const data = authFetch(url);
const { jobs, totalJobs, numOfPages } = data;
dispatch({
type: GET_JOBS_SUCCESS,
payload: {
jobs,
totalJobs,
numOfPages
},
});
} catch(error){
console.log(error.response);
// logoutUser();
}
clearAlert();
};
useEffect( () => {
getJobs();
}, []);Changed code:
In Postman:
[0] POST /api/v1/auth/login 200 139.552 ms - 379
[0] -------- Authenticate Middleware --------
[0] [token]:
[0] eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2ODE5MDkxMzcsImV4cCI6MTY4MTk5NTUzN30.PIBvV1CZox56LEHiufrdlLfCZhnb3IsykJZoYg6T3iQ
[0] [payload]: [object Object]
[0] [userId]: 6418d6ab92ff594a02b6f24a
[0] ----- End of Authenticate Middleware -----
[0] GET /api/v1/jobs 200 43.069 ms - 1424but in Front-End we have:
[0] -------- Authenticate Middleware --------
[0] [token]:
[0] eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2ODE4ODM1NDksImV4cCI6MTY4MTk2OTk0OX0.gnJo_MQ_NWr3qIR0PXQN01gpd4ATYk4ExtA1dofq_NM
[0] [payload]: [object Object]
[0] [userId]: 6418d6ab92ff594a02b6f24a
[0] ----- End of Authenticate Middleware -----
[0] GET /api/v1/jobs 304 85.918 ms - -A 304 Response.
A 304 status code indicates that a conditional GET or HEAD request has been received and would have resulted in a 200 (OK) response if it were not for the fact that the condition evaluated to false.
This means that the requested resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match
Postman may be caching the response and sending a new request with updated headers, which is why you are getting a 200 status code
const getJobs = async () => {
let url = `/jobs`;
dispatch({ type: GET_JOBS_BEGIN });
try {
// Added await
const data = await authFetch(url);
console.log(`Data from authFetch:
${data}`);Now originally, without await, the dev tools console would print out [object Promise]. With await now it logs [object Object] which is what we want.
Ok so I logged a couple more things here:
const getJobs = async () => {
let url = `/jobs`;
dispatch({ type: GET_JOBS_BEGIN });
try {
const data = await authFetch(url);
console.log(`Data from authFetch:
${data}`);
console.log("-------- Printing out values of data --------")
let i = 0;
for (let value of Object.values(data)){
console.log(`value ${i++} is`);
console.log(value);
}
console.log("--------End of data --------")
const { jobs, totalJobs, numOfPages } = data;
console.log(`
jobs: \t\t ${jobs}
totalJobs: \t ${totalJobs}
numOfPages: \t ${numOfPages}`
);And look at the result in the developer tools!
Data from authFetch:
[object Object]
appContext.js:269 -------- Printing out values of data --------
appContext.js:272 value 0 is
appContext.js:273 {jobs: Array(5), totalJobs: 5, numOfPages: 1}jobs: Array(5)0: {_id: '6426b205fbdb9da6e1e5a3a5', company: 'Google', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}1: {_id: '642f36a5429866ccdadb11c5', company: 'Uber', position: 'Front-End Developer', status: 'pending', jobType: 'full-time', …}2: {_id: '6430ed5141edde8ddc9c697e', company: 'Uber', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}3: {_id: '6430ed6641edde8ddc9c6980', company: 'FLT', position: 'back-end developer', status: 'pending', jobType: 'full-time', …}4: {_id: '643f8548d3058bdd9f527b10', company: 'FLT', position: 'front-end developer', status: 'pending', jobType: 'full-time', …}length: 5[[Prototype]]: Array(0)numOfPages: 1totalJobs: 5[[Prototype]]: Objectconstructor: ƒ Object()hasOwnProperty: ƒ hasOwnProperty()isPrototypeOf: ƒ isPrototypeOf()propertyIsEnumerable: ƒ propertyIsEnumerable()toLocaleString: ƒ toLocaleString()toString: ƒ toString()valueOf: ƒ valueOf()__defineGetter__: ƒ __defineGetter__()__defineSetter__: ƒ __defineSetter__()__lookupGetter__: ƒ __lookupGetter__()__lookupSetter__: ƒ __lookupSetter__()__proto__: (...)get __proto__: ƒ __proto__()set __proto__: ƒ __proto__()
appContext.js:272 value 1 is
appContext.js:273 200
appContext.js:272 value 2 is
appContext.js:273 OK
appContext.js:272 value 3 is
appContext.js:273 AxiosHeaders {access-control-allow-headers: '*', access-control-allow-methods: '*', access-control-allow-origin: '*', content-encoding: 'gzip', content-type: 'application/json; charset=utf-8', …}access-control-allow-headers: "*"access-control-allow-methods: "*"access-control-allow-origin: "*"content-encoding: "gzip"content-type: "application/json; charset=utf-8"date: "Wed, 19 Apr 2023 13:46:25 GMT"etag: "W/\"590-V65cvvMK/K/Kx3+H7OARAgGh1Yw\""vary: "Accept-Encoding"x-powered-by: "Express"Symbol(Symbol.toStringTag): (...)[[Prototype]]: Object
appContext.js:272 value 4 is
appContext.js:273 {transitional: {…}, adapter: Array(2), transformRequest: Array(1), transformResponse: Array(1), timeout: 0, …}
appContext.js:272 value 5 is
appContext.js:273 XMLHttpRequest {onreadystatechange: null, readyState: 4, timeout: 0, withCredentials: false, upload: XMLHttpRequestUpload, …}
appContext.js:275 --------End of data --------
appContext.js:280
jobs: undefined
totalJobs: undefined
numOfPages: undefinedTo understand this look at what it was before:
const data = await authFetch(url);
const { jobs, totalJobs, numOfPages } = data;Which is trying to destructure data or the entire response. But the response comes in an object. Used Object.values() to print out the values within that data object.
Let's clean it up using Object.entries(). And see the console:
-------- Printing out values of data --------
appContext.js:277 (2) ['data', {…}]0: "data"1: jobs: (5) [{…}, {…}, {…}, {…}, {…}]numOfPages: 1totalJobs: 5[[Prototype]]: Objectlength: 2[[Prototype]]: Array(0)
appContext.js:277 (2) ['status', 200]0: "status"1: 200length: 2[[Prototype]]: Array(0)
appContext.js:277 (2) ['statusText', 'OK']0: "statusText"1: "OK"length: 2[[Prototype]]: Array(0)
appContext.js:277 (2) ['headers', AxiosHeaders]0: "headers"1: AxiosHeaders {access-control-allow-headers: '*', access-control-allow-methods: '*', access-control-allow-origin: '*', content-encoding: 'gzip', content-type: 'application/json; charset=utf-8', …}length: 2[[Prototype]]: Array(0)
appContext.js:277 (2) ['config', {…}]0: "config"1: {transitional: {…}, adapter: Array(2), transformRequest: Array(1), transformResponse: Array(1), timeout: 0, …}length: 2[[Prototype]]: Array(0)
appContext.js:277 (2) ['request', XMLHttpRequest]0: "request"1: XMLHttpRequest {onreadystatechange: null, readyState: 4, timeout: 0, withCredentials: false, upload: XMLHttpRequestUpload, …}length: 2[[Prototype]]: Array(0)
appContext.js:279 --------End of data --------
jobs: undefined
totalJobs: undefined
numOfPages: undefined-
At first thought the problem was that the appContext or global context was not being passed down or passing down jobs array. But it would be an empty array and not
undefined. Same goes withtotalJobsandnumOfPagesfor being undefined and not default values. -
Next start to go step by step. Tested backend, tested postman. Following from the server, to this line
app.use('/api/v1/jobs', authenticateUser, jobsRouter);then did extensive error logging for authenticate middleware and jobsRouter. -
Then looked into
jobsControllerandJobmodel. None of these were the issue yet. Postman has a 200 response so it is still good. Its just the front end. -
Finally, started logging and isolating the problem to the front end.
The main issue is that jobs array and jobs.length are being evaluated to undefined. So somehow, someway perhaps in the appContext and maybe in the function that these values are just not evaluating.
AS one can see from the console in developer tools, we hhave the response as an object named data.
This data object can be displayed like this:
let data = {
data: {...},
status: 200,
statusText: OK,
headers: AxiosHeaders,
config: {...},
request: XMLHttpRequest
}AS you can see, data itself is a nested prop of data object. Within that data property is lo and behold:
(2) ['data', {…}]
0: "data"
1 :
> jobs : (5) [{…}, {…}, {…}, {…}, {…}]
numOfPages : 1
totalJobs : 5
> [[Prototype]] : Object
length: 2
> [[Prototype]] : Array(0)IT IS WHERE OUR jobs array and numOfPages and totalJobs are!!!!!
HOORAY, WE FOUND THE ISSUE!
const getJobs = async () => {
let url = `/jobs`;
dispatch({ type: GET_JOBS_BEGIN });
try {
const data = await authFetch(url);
console.log(`Data from authFetch:
${data}`);
console.log("-------- Printing out values of data --------")
// let i = 0;
// for (let value of Object.values(data)){
// console.log(`value ${i++} is`);
// console.log(value);
// }
for (let entry of Object.entries(data)){
console.log(entry);
}
console.log("--------End of data --------")
const { jobs, totalJobs, numOfPages } = data.data;
console.log(`
jobs: \t\t ${jobs}
totalJobs: \t ${totalJobs}
numOfPages: \t ${numOfPages}`
);
dispatch({
type: GET_JOBS_SUCCESS,
payload: {
jobs,
totalJobs,
numOfPages,
},
});
} catch(error){
console.log(`Error triggered in getJobs() appContext.js!
Here is the Error Response:
${error.response}`);
// logoutUser();
}
clearAlert();
};
// useEffect( () => {
// getJobs();
// }, []);So now when we destructure the values out, instead of
const { jobs, totalJobs, numOfPages } = data;It is:
const { jobs, totalJobs, numOfPages } = data.data;Now let's go see the developer tools > Components > App Provider > hooks
and lo and behold, our issue is finally fixed. The state is finally updated!!!!
hooks
[
{
"name": "Reducer",
"value": {
"isLoading": false,
"showAlert": false,
"alertText": "",
"alertType": "",
"user": "{__v: 0, _id: \"6418d6ab92ff594a02b6f24a\", email: \"M…}",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NDE4ZDZhYjkyZmY1OTRhMDJiNmYyNGEiLCJpYXQiOjE2ODE4ODM1NDksImV4cCI6MTY4MTk2OTk0OX0.gnJo_MQ_NWr3qIR0PXQN01gpd4ATYk4ExtA1dofq_NM",
"userLocation": "Hachioji (Kanto, Tokyo)",
"showSidebar": false,
"position": "",
"company": "",
"jobLocation": "Hachioji (Kanto, Tokyo)",
"jobTypeOptions": "[\"full-time\", \"part-time\", \"remote\", \"internship\"]",
"jobType": "full-time",
"statusOptions": "[\"interview\", \"declined\", \"pending\"]",
"status": "pending",
"isEditing": false,
"editJobId": "",
"jobs": "[{…}, {…}, {…}, {…}, {…}]",
"totalJobs": 5,
"numOfPages": 1,
"page": 1
},
"subHooks": [],
"hookSource": {
"lineNumber": 3439,
"functionName": "AppProvider",
"fileName": "http://localhost:3000/static/js/bundle.js",
"columnNumber": 78
}
}
]YEAHHHHH!!!! WE GOT IT.
Fixed the issue of destructuring props. Instead of {props} in the parameter just put props:
export default function Loading(props) {likewise in Job:
export default function Job(props) {
const {
_id,
position,
company,
jobLocation,
jobType,
createdAt,
status
} = props;Get request has been receieved and would result in a 200 response if it were not for the fact that the condition evaluated to false.
Have to look deeper into this one later.
Everytime we navigate to the All Jobs page we get a GET 304 /api/v1/jobs
[0] GET /api/v1/jobs 304 39.820 ms - -We will want some mock data. We will use Mockaroo to setup test data for the application.
There is:
- Field Name
- Type
- Options
Here are the fields I put:
| Field Name | Type | Options |
|---|---|---|
| company | Company Name | --- |
| position | Job Title | --- |
| jobLocation | City | --- |
| jobType | Custom List | full-time, part-time, remote, internship |
| status | Custom List | interview, declined, pending |
| createdBy | Regular Expression | ^6[a-f0-9]{23}$ |
| createdAt | Datetime | 01/01/2022 to 01/01/2023 format: ISO 8601 (UTC) |
Then at the bottom banner it says [Generate Data] [Preview] [Save AS...] [Derive From Example] [More] buttons.
Let's click the Preview to confirm that it will create the data we want.
Here are some sample regex expression that satisfied these requirements:
- 24 characters long
- Starts with 6
- Rest of the characters are hexadecimal
^6[a-f0-9]{23}$
With this in place, let's check the Preview to see if this is the type of data we would like. If so, then click the Generate Data.
This would produce a file in your Downloads folder called MOCK_DATA.json. Put this at the root of our project.
Let's populate our database with the mock data we generated.
Create populate.js at the root of the project.
This script will populate our databse with the mock data from MOCK_DATA.json.
To do so we need to use Node.js File System, or more specifically its fsPromises.readFile() method. To asycnhronously read the entire contents of the file.
Here is an example of reading a package.json:
import { readFile } from 'node:fs/promises';
try {
const filePath = new URL('./package.json', import.meta.url);
const contents = await readFile(filePath, { encoding: 'utf8' });
console.log(contents);
} catch (err) {
console.error(err.message);
}Next we need to import the following:
- dotenv (to use our
.envfile) -
connectDBmethod from ourdbfolder -
Jobmodel
So in populate.js:
import { readFile } from 'fs/promises';
import dotenv from 'dotenv';
dotenv.config();
import connectDB from './db/connect.js';
import Job from './models/Job.js';Next we create the function populate()
async function populate(){
}
populate();and immediately invoke it after.
Let's setup the try..catch, then asynchronously connect to the database. After that we try to read the file from MOCK_DATA.json while making sure to parse it.
Also make a meaningful error message in the catch, and exiting the process of Node.js with a status code of 1. See Node.js process.exit.
async function populate(){
try{
await connectDB(process.env.MONGO_URL);
const jsonProducts = JSON.parse(
await readFile(new URL('./mock-data.json', import.meta.url))
);
} catch(error){
console.log(`Error occured in populate: ${error}`);
console.log(error.message);
process.exit(1);
}
}Finally create a Job out of the jsonProducts data we were able to parse from the file.
The Stats page will be made via MongoDB's Aggregation Pipeline.
An aggregation pipeline consists of one or more stages that process documents:
- Each stage performs an operation on the input documents. e.g., a stage can filter documents, group documents, and calculate values
- The documents that are output from a stage are passed to the next stage
- An aggregation piepline can return results for groups of documents. e.g., return the total, average, maximum, and minimum values
To use the Aggregation Pipeline in our controller, we need to consult mongoose's Aggregate method.
Let's create the function showStats in the job controller which should aggregate the Job documents by first matching the ones created by the user, then grouping up by status.
Import mongoose in jobsController to use the aggregation methods
import mongoose from 'mongoose';
const showStats = async (req, res) => {
let stats = await Job.aggregate([
{ $match: { createdBy: mongoose.Types.ObjectId(req.user.userId) } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);
res.status(StatusCodes.OK).json({ stats });
};- Get the all the job documents who matches the user
- Group them by their status, and give a count of 1
- The result of aggregate are JavaScript objects
- Reduce the objects to calculate a single value based on the array. Using JavaScript's reduce method.
import mongoose from 'mongoose';
const showStats = async (req, res) => {
let stats = await Job.aggregate([
{ $match: { createdBy: mongoose.Types.ObjectId(req.user.userId) } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);
stats = stats.reduce((acc, curr) => {
const { _id: title, count } = curr;
acc[title] = count;
return acc;
}, {});
res.status(StatusCodes.OK).json({ stats });
};If any of the stats for the status of a job are empty, then set it to 0. We create a set of defaultStats which will determine a stat for every job status, and only set to 0 if nullish.
jobsController.js;
const showStats = async (req, res) => {
let stats = await Job.aggregate([
{ $match: { createdBy: mongoose.Types.ObjectId(req.user.userId) } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);
stats = stats.reduce((acc, curr) => {
const { _id: title, count } = curr;
acc[title] = count;
return acc;
}, {});
const defaultStats = {
pending: stats.pending || 0,
interview: stats.interview || 0,
declined: stats.declined || 0,
};
res.status(StatusCodes.OK).json({ defaultStats });
};We will also store an array that determines the amount of monthly applications the user has made.
ALl of this we will respond as an OK status code along with parsing the defaultStats and monthlyApplications.
jobsController.js;
const showStats = async (req, res) => {
let stats = await Job.aggregate([
{ $match: { createdBy: mongoose.Types.ObjectId(req.user.userId) } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);
stats = stats.reduce((acc, curr) => {
const { _id: title, count } = curr;
acc[title] = count;
return acc;
}, {});
const defaultStats = {
pending: stats.pending || 0,
interview: stats.interview || 0,
declined: stats.declined || 0,
};
let monthlyApplications = [];
res.status(StatusCodes.OK).json({ defaultStats, monthlyApplications });
};In Postman, try to send a GET request in the stats url. Make sure to setup the Bearer token in Authorization.
In jobsController add new keyword before invoking ObjectId.
let stats = await Job.aggregate([
{ $match: { createdBy: new mongoose.Types.ObjectId(req.user.userId) } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);Did some extensive error logging. Let's save the current version of showStats:
const showStats = async (req, res) => {
console.log("======== Starting Show Stats | Backend ========");
let stats = await Job.aggregate([
{ $match: { createdBy: new mongoose.Types.ObjectId(req.user.userId) } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);
console.log(`Finished Job.aggregate(), logging stats:
type of stats: ${typeof stats}
stats: ${stats}`);
stats = stats.reduce((acc, curr) => {
const { _id: title, count } = curr;
acc[title] = count;
return acc;
}, {});
stats = stats.reduce((acc, curr) => {
const { _id: title, count } = curr;
acc[title] = count;
return acc;
}, {});
const defaultStats = {
pending: stats.pending || 0,
interview: stats.interview || 0,
declined: stats.declined || 0,
};
let monthlyApplications = [];
res.status(StatusCodes.OK).json({ defaultStats, monthlyApplications });
};Another error we see is that we have duplicate code. SO it is resulting in the 2nd stats not being treated as an array which contains the reduce() method. So it should be removed.
Finally the next GET request to {{base_url}}/jobs/stats is sent, and we finally get a proper response in JSON:
{
"defaultStats": {
"pending": 4,
"interview": 0,
"declined": 0
},
"monthlyApplications": []
}So the back-end works! Right now monthlyApplications are empty.
First we create the actions in actions.js:
export const SHOW_STATS_BEGIN = 'SHOW_STATS_BEGIN';
export const SHOW_STATS_SUCCESS = 'SHOW_STATS_SUCCESS';and import these to appContext and reducer:
SHOW_STATS_BEGIN,
SHOW_STATS_SUCCESS,Next we update the state for appContext.
Then in showStats function we should start dispatching the actions, authFetch the route, dispatch another action with payload containing the defaultStats and monthlyApplications.
const showStats = async () => {
dispatch({ type: SHOW_STATS_BEGIN });
const url = '/jobs/stats';
try{
const { data } = await authFetch(url);
dispatch({
type: SHOW_STATS_SUCCESS,
payload: {
stats: data.defaultStats,
monthlyApplications: data.monthlyApplications,
},
})
} catch(error){
console.log(error.response);
logoutUser();
}
clearAlert();
};
// Pass it to AppContext.Provider's value prop
value = { showStats }Now notice we pass in the data that we got from the backend into the payload, when we dispatch the show stats success. To be used in the front-end.
Handle the two new actions of the reducer.
case SHOW_STATS_BEGIN: {
return {
...state,
isLoading: true,
showAlert: false,
};
}
case SHOW_STATS_SUCCESS: {
return {
...state,
isLoading: false,
stats: action.payload.stats,
monthlyApplications: action.payload.monthlyApplications,
};
}We have to now work on the Stats component under pages.
import React from 'react';
import { useAppContext } from '../../context/appContext';
import { Loading } from '../../components';
export default function Stats() {
const {
showStats,
isLoading,
monthlyApplications,
} = useAppContext();
if(isLoading){
return <Loading center />;
}
return (
<h1>Stats Page</h1>
);
}If the page is in the process of retrieving data then we should render the Loading component. Next destructure out showStats function, isLoading and monthlyApplications from app context.
We should also use useEffect to synchronize changes with the showStats function.
import React, { useEffect } from 'react';
import { useAppContext } from '../../context/appContext';
import { Loading } from '../../components';
export default function Stats() {
const {
showStats,
isLoading,
monthlyApplications,
} = useAppContext();
useEffect(() => {
showStats();
}, []);
if(isLoading){
return <Loading center />;
}
return (
<h1>Stats Page</h1>
);
}**There will be two components rendered inside the stat page. At the top side we will have a StatsContainer and on the bottom will have a ChartsContainer that visually displays the applications only if monthlyApplications exceed 0.
Let's create those components under the components folder.
import React, { useEffect } from 'react';
import { useAppContext } from '../../context/appContext';
import { ChartsContainer, Loading, StatsContainer } from '../../components';
export default function Stats() {
const {
showStats,
isLoading,
monthlyApplications,
} = useAppContext();
useEffect(() => {
showStats();
}, []);
if(isLoading){
return <Loading center />;
}
return (
<>
<StatsContainer />
{ monthlyApplications.length > 0 && <ChartsContainer />}
</>
);
}Here we import the following components along with rendering it together inside a Fragment in React Fragment docs. Which is used with <>...</> a shortcut syntax.
import React from 'react';
import { useAppContext } from '../../context/appContext';
import Wrapper from '../assets/wrappers/StatsContainer';
export default function StatsContainer() {
const { stats } = useAppContext();
const defaultStats = [];
return (
<Wrapper>StatsContainer</Wrapper>
);
}For now we have defaultStats as an empty array, but we will populate them with the catageries we want to display the stats for the user.
What we will render will be every Stat, so we will have to use map(). It also means we have to create another component, a StatItem component.
Let's create one with rfc, then import it.
What to render in StatsContainer:
return (
<Wrapper>
{defaultStats.map((item, index) => {
return <StatItem key={index} {...item} />;
})};
</Wrapper>
);Next we will fill out the defaultStats array with objects containing each stat's count, an icon, color and background color.
import React from 'react';
import { useAppContext } from '../../context/appContext';
import Wrapper from '../assets/wrappers/StatsContainer';
import StatItem from './StatItem';
import { FaSuitcaseRolling, FaCalendarCheck, FaBug } from 'react-icons/fa';
export default function StatsContainer() {
const { stats } = useAppContext();
const defaultStats = [
{
title: 'pending applications',
count: stats.pending || 0,
icon: <FaSuitcaseRolling />,
color: '#e9b949',
bcg: '#fcefc7',
},
{
title: 'interviews scheduled',
count: stats.interview || 0,
icon: <FaCalendarCheck />,
color: '#647acb',
bcg: '#e0e8f9',
},
{
title: 'jobs declined',
count: stats.declined || 0,
icon: <FaBug />,
color: '#d66a6a',
bcg: '#ffeeee',
},
];
return (
<Wrapper>
{defaultStats.map((item, index) => {
return <StatItem key={index} {...item} />;
})};
</Wrapper>
);
}So for every Stat Item we render out, we should have the following:
-
title, or the name of the stat -
count, the stat count -
icon, an icon to represent the stat -
color, to color the Stat -
bcg, background color
Let's pass these in as props and destructure them out for usage.
import React from 'react';
import Wrapper from '../assets/wrappers/StatItem';
export default function StatItem(props) {
const { count, title, icon, color, bcg } = props;
return (
<Wrapper color={color} bcg={bcg}>
<header>
<span className="count">{count}</span>
<div className="icon">{icon}</div>
</header>
<h5 className="title">{title}</h5>
</Wrapper>
);
}In jobsController, we want to pass some parameters to the aggregation pipeline in this order:
- match, by those created by
thisuser - group, by its year and month, give it a count of 1 for each
- sort, by year and month,
- limit, by 6
Let's try to make a template and call it monthlyApplications
let monthlyApplications = await Job.aggregate([
{$match: },
{$group: },
{$sort: },
{$limit: },
]);We can create our Aggregation Pipeline in Mongoose, see the docs on Aggregation.
- The
$matchstage: Filters the Job documents to jobs made by the current user. Passes the remaining documents to the$groupstage. - The
$groupstage: Groups the remaining documents by their year & month.- Use
$sumto calculate the sum, available during$groupstage. See the docs on sum aggregate
- Use
- The
$sortstage: Sorts the documents by the total order value for each group in descending order (-1). - The
$limitstage: Limits it to 6 jobs
let monthlyApplications = await Job.aggregate([
{$match: { createdBy: mongoose.Types.ObjectId(req.user.userId) }},
{$group: {
_id: {
year: {
$year: '$createdAt'
},
month: {
$month: '$createdAt'
},
},
count: { $sum: 1 },
},
},
{$sort: { '_id.year': -1, '_id.month': -1 }},
{$limit: 6 },
]);So far we need to do an extra step, as testing in Postman shows that we want to transform the data into something more digestable. The dates are one of those things that need to be refactored.
We can map each item by its date and count so we need to destructure _id's then year and month, its count. Next create a date using these values.. The date should be in a more readable format. Let's use moment.js like we did in the front-end.
npm i momentThen create the date, and return it along with the count for each item in monthlyApplications.
Put this after the aggregation pipeline:
monthlyApplications = monthlyApplications.map((item) => {
const {
_id: { year, month },
count,
} = item;
const date = year;
});Remember that in moment the data accepts values 0-11, such that month must be subtracted by 1 to get the right month.
monthlyApplications = monthlyApplications.map((item) => {
const {
_id: { year, month },
count,
} = item;
const date = moment()
.month(month - 1)
.year(year)
.format('MMM YYYY');
return { date, count };
});After some Postman testing, the data passed in from the pipeline is actually in the reverse order that we want. So we need to append the map() operation with a reverse() operation.
monthlyApplications = monthlyApplications.map((item) => {
const {
_id: { year, month },
count,
} = item;
const date = moment()
.month(month - 1)
.year(year)
.format('MMM YYYY');
return { date, count };
}).reverse();First test it in Postman.
- Send a
POSTLogin Request - Send a
GETstats request
Here is the response:
{
"msg": "Class constructor ObjectId cannot be invoked without 'new'"
}In the server, because I setup extensive logging, I can see:
[0] POST /api/v1/auth/login 200 110.330 ms - 379
[0] ======== Starting Show Stats | Backend ========
[0] Finished Job.aggregate(), logging stats:
[0] type of stats: object
[0] stats: [object Object]
[0] Finished reduce(), logging stats:
[0] type of stats: object
[0] stats: [object Object]
[0] TypeError: Class constructor ObjectId cannot be invoked without 'new'
[0] at showStats (file:///C:/Users/.../GitHub/job-tracker/controllers/jobsController.js:162:42)
[0] at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
[0] GET /api/v1/jobs/stats 500 36.479 ms - 68So the error matches up, a general HTTP 500 server-side error.
The offending line of code:
{$match: { createdBy: mongoose.Types.ObjectId(req.user.userId) }},The docs on mongoose's ObjectId.
ObjectId is a class, and ObjectIds are objects.
So let's add new in front of mongoose.Types.ObjectId() to treat it as a proper class constructor which creates the ObjectId with the passed in req.user.userId as parameter.
// ...
let monthlyApplications = await Job.aggregate([
{$match: { createdBy: new mongoose.Types.ObjectId(req.user.userId) }},
// ...Now send a GET stats request, and now we have the output we wanted:
{
"defaultStats": {
"pending": 4,
"interview": 0,
"declined": 0
},
"monthlyApplications": [
{
"date": "Mar 2023",
"count": 1
},
{
"date": "Apr 2023",
"count": 3
}
]
}Going to work on the ChartsContainer.
import React, {useState} from 'react';
import { useAppContext } from '../context/appContext';
import Wrapper from '../assets/wrappers/ChartsContainer';
export default function ChartsContainer() {
const { monthlyApplications: data } = useAppContext();
return (
<Wrapper>
<h4>Monthly Applications</h4>
<button type='button' onClick={}>
</button>
</Wrapper>
);
}Going to import Wrapper, get monthlyApplications from global context and rename to data (for shorter name to pass as props).
In the ChartsContainer we are going to need to create 2 components to visually display the stats on the front page. One of them is a BarChart component, which we should be able to to create a state variable for so that we can re-render visually if a stat has changed.
The next component to create is the AreaChart which will be the graph that supports the BarChart component.
In order to speed up development time and make the charts more visually appealing we need a package with reusuable charting library component called Recharts. Remember to install this in the client folder, the front-end.
npm install rechartsLet's setup the React Functional Component along with the things we plan to use from recharts.
With help of the recharts guide we can get started with visually displaying our stats.
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
export default function BarChart() {
return (
<div>BarChart</div>
);
}Here we have an error, BarChart has already been declared. I would like to keep the name BarChart.js as a component so instead we should rename the function to BarChartComponent. Then the React element we want to return from the function should be a ResponsiveContainer that contains the BarChart component from recharts.
Let's pass in the data first from the props and pass it to BarChart. Also give BarChart a margin of top: 50.
export default function BarChartComponent({ data }) {
return (
<ResponsiveContainer width='100%' height={300}>
<BarChart data={data} margin={{ top: 50 }}>
</BarChart>
</ResponsiveContainer>
);
}Next let's add the remaining components that want to be drawn.
export default function BarChartComponent({ data }) {
return (
<ResponsiveContainer width='100%' height={300}>
<BarChart data={data} margin={{ top: 50 }}>
<CartesianGrid />
<XAxis />
<YAxis />
<Tooltip />
<Bar />
</BarChart>
</ResponsiveContainer>
);
}For the CartesianGrid we can give it the props stroke = #ccc and a strokeDasharray ="3 3" for a dashed line for increased readability.
For the XAxis the dataKey will be 'date'. As for the YAxis, the prop allowDecimals={false} so we won't have decimals.
Tooltip will remain as is.
Bar will have a prop dataKey of count, with a barSize of 75, and a fill of #2cb1bc.
export default function BarChartComponent({ data }) {
return (
<ResponsiveContainer width='100%' height={300}>
<BarChart data={data} margin={{ top: 50 }}>
<CartesianGrid stroke="#ccc" strokeDasharray="3 3"/>
<XAxis dataKey='date' />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey='count' fill='#2cb1bc' barSize={75} />
</BarChart >
</ResponsiveContainer>
);
}Similarly, AreaChart component will be made the same way. In AreaChart.js, under components folder, the function name will be AreaChartComponent. The imports are all the same except for Bar and BarChart, they will be replaced with Area and AreaChart.
import React from 'react';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis
} from 'recharts';
export default function AreaChartComponent() {
return (
<AreaChart>
<Area />
</AreaChart>
);
}Just like in the recharts guide, that was the 1st step.
In this case, it is AreaChart. With Area within.
Wrap the entire AreaChart with `ResponsiveContainer. Then add the rest of the components we imported.
export default function AreaChartComponent() {
return (
<ResponsiveContainer width='100%' height={300}>
<AreaChart>
<CartesianGrid />
<XAxis />
<YAxis />
<Tooltip />
<Area />
</AreaChart>
</ResponsiveContainer>
);
}export default function AreaChartComponent({ data }) {
return (
<ResponsiveContainer width='100%' height={300}>
<AreaChart data={data} margin={{ top:50 }}>
<CartesianGrid stroke="#ccc" strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis allowDecimals={false} />
<Tooltip />
<Area type='monotone' dataKey='count' stroke='#2cb1bc' fill='#bef8fd'/>
</AreaChart>
</ResponsiveContainer>
);
}With the components made, we can now use them withinChartsContainer`
- Import AreaChart component and BarChart component directly
import AreaChart from './AreaChart';
import BarChart from './BarChart';- Set
barChartas a state variable, withsetBarChartas the set function. We pass intrueas the .
const [barChart, setBarChart] = useState(true);- Create a button that will have its
onClickhandlersetBarChartor not. This will toggle betweenAreaChartorBarChart - After the button, conditionally render the corresponding Chart component based on the state variable
barChart
<button type='button' onClick={() => setBarChart(!barChart)}>
{barChart ? 'AreaChart' : 'BarChart'}
</button>
{barChart ? <BarChart data={data} /> : <AreaChart data={data} />}The next step is to filter out the results of Get All Jobs.
Let's start in the back-end. Back in the jobsController.js we will modify the getAllJobs function:
const getAllJobs = async (req, res) => {
// Find the jobs created the user from the request
const jobs = await Job.find({ createdBy: req.user.userId });
// Respond with 200 and a json containing the jobs, totalJobs, and pages
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};Here are the things we plan to do:
- From the request's
querywe should destructure outsearch,status,jobTypeandsort - Create a query Object that stores one property ->
createdBywhich is set toreq.user.userIdto keep track of the user who made the request
const { search, status, jobType, sort } = req.query;
const queryObject = {
createdBy: req.user.userId,
};- Next we have to find every Job document in the model that relates to the user. We do not use await.
- At this stage we should chain each sort condition
- Now we use await to the result and store the result inside a variable called
jobs
// NO AWAIT
let result = Job.find(queryObject);
// chain sort conditions
const jobs = await result;- Finally, send back the response with a OK status code along with converting it into
jsoncontainingjobs,totalJobs,numOfPages. This is already included.
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });- Just to save what we have so far:
const getAllJobs = async (req, res) => {
// Create queryObject that keeps track of the user
const queryObject = {
createdBy: req.user.userId,
};
// Find the jobs created by the user from the request
const jobs = await Job.find(queryObject);
// Destructure the necessary variables from request's query
const { search, status, jobType, sort } = req.query;
// Find the job results of the user WITHOUT await
let result = Job.find(queryObject);
// Chain sort conditions to filter results
// TODO later...
// Await jobs filtered out by sort conditions
const jobsFiltered = await result;
// Respond with 200 and a json containing the jobs, totalJobs, and pages
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};We want to add one more property to the queryObject called status.
Status will keep track of the stages of the query and sort. For now we will just check for if status is not 'all' then we have to set the status of the queryObject.
Updated version:
const getAllJobs = async (req, res) => {
// Destructure the necessary variables from request's query
const { search, status, jobType, sort } = req.query;
// Create queryObject that keeps track of the user
const queryObject = {
createdBy: req.user.userId,
};
// Set the status of the query if not `all`
if (status !== 'all'){
queryObject.status = status;
}
// Find the jobs created by the user from the request (WITHOUT await)
let result = Job.find(queryObject);
// Chain sort conditions to filter results
// TODO later...
// Await jobs filtered out by sort conditions
const jobs = await result;
// Respond with 200 and a json containing the jobs, totalJobs, and pages
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};Adding one more property to queryObject, which will be the jobType if it is not set to 'all'.
const getAllJobs = async (req, res) => {
// Destructure the necessary variables from request's query
const { search, status, jobType, sort } = req.query;
// Create queryObject that keeps track of the user
const queryObject = {
createdBy: req.user.userId,
};
// Set the status of the query if not `all`
if (status !== 'all'){
queryObject.status = status;
}
// Set the jobType of the query if not 'all'
if (jobType !== 'all'){
queryObject.jobType = jobType;
}
// Find the jobs created by the user from the request (WITHOUT await)
let result = Job.find(queryObject);
// Chain sort conditions to filter results
// TODO later...
// Await jobs filtered out by sort conditions
const jobs = await result;
// Respond with 200 and a json containing the jobs, totalJobs, and pages
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};Now work on the search feature of getAllJobs.
Search code:
if (search) {
queryObject.position = { $regex: search, $options: 'i' };
}
let result = Job.find(queryObject);
// Chain Sort Conditions
if(sort === 'latest') {
result = result.sort('-createdAt');
}
// ...Inside getAllJobs:
const getAllJobs = async (req, res) => {
const { search, status, jobType, sort } = req.query;
const queryObject = {
createdBy: req.user.userId,
};
if (status !== 'all'){
queryObject.status = status;
}
if (jobType !== 'all') {
queryObject.jobType = jobType;
}
// Add position property to queryObject if search is non-empty
if (search) {
queryObject.position = { $regex: search, $options: 'i' };
}
let result = Job.find(queryObject);
// Chain sort conditions
// if(sort === ...) { }
const jobs = await result;
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};Let's add more conditions to filter out our results.
// Chain Sort Conditions
if(sort === 'latest') {
result = result.sort('-createdAt');
}
if(sort === 'oldest') {
result = result.sort('createdAt');
}
if(sort === 'a-z') {
result = result.sort('position');
}
if(sort === 'z-a') {
result = result.sort('-position');
}So far sort conditions are by createdAt or date made and alphabetical order or reverse alphabetical order.
In appContext let's set up thhe search and all its variables that's needed in initialState.
What we add should look something like this:
const initialState = {
// ...
search: '',
searchStatus: 'all',
searchType: 'all',
sort: 'latest',
sortOptions: ['latest', 'oldest', 'a-z', 'z-a'],
}We add search, searchStatus, searchType, sort & sortOptions as properties to initialState object.
Next we would like a way to clear out our search filters. Let's create a function that does this, and pass it into the value prop of AppProvider. For now, it just logs a string to show that its been invoked.
const clearFilters = () => {
console.log('Clear Filters');
}Next we should remember the handleChange function inside appContext:
const handleChange = ({ name, value }) => {
dispatch({
type: HANDLE_CHANGE,
payload: { name, value },
});
};Going to reuse this functionality to handle our Search function.
Let's setup SearchContainer.js.
Our imports should have a Wrapper css file defining our styled-component. And we will also reuse components FormRow & FormRowSelect, both found in the same folder components as SearchContainer.
The JSX element we should return is the Wrapper that includes a form element that containers a heading and div with a class of form-center. Within that we will have FormRow component.
import React from 'react';
import Wrapper from '../assets/wrappers/SearchContainer';
import { FormRow, FormRowSelect } from '.';
export default function SearchContainer() {
return (
<Wrapper>
<form action="" className="form">
<h4>search form</h4>
<div className="form-center">
<FormRow>
</FormRow>
</div>
</form>
</Wrapper>
);
}Next let's import the global context, and then destructure all the properties we need from the initialState to create the search functionality.
import { useAppContext } from '../context/appContext';
export default function SearchContainer() {
const {
isLoading,
handleChange,
search,
searchStatus,
searchType,
sort,
sortOptions,
statusOptions,
jobTypeOptions,
clearFilters,
} = useAppContext();
return (
// ...
);
}Notice how we also destructured handleChange. To refresh, here it is in the appContext:
const handleChange = ({ name, value }) => {
dispatch({
type: HANDLE_CHANGE,
payload: { name, value },
});
};It dispatches an action, which is then handled in the reducer. Takes in two parameters: name and value.
Let's take a look at AddJob.js under /pages/Dashboard/ where we use this functionality:
const handleJobInput = (e) => {
const name = e.target.name;
const value = e.target.value;
handleChange({name, value});
};It simply passes in the event target's name and value as the payload, and in the reducer:
case HANDLE_CHANGE: {
return {
...state,
[action.payload.name]: action.payload.value,
};
}Recall how this was used:
- Note how because we set up the
initalStateas an object with propertiesname,email&password,handleChangefunction will access both the name of the input and value, then access the proper value in intialState object afterwards. That is whyhandleChangecan be reused, because it is assigned to a prop and invoked in theFormRowcomponent.
Let's complete our FormRow component, which will function as the search bar.
<FormRow
type='text'
name='search'
value={search}
handleChange={handleSearch}
>
</FormRow>Let's also create the event handler function (called handleSearch) within SearchContainer.
const handleSearch = (e) => {
if(isLoading) return;
handleChange({ name: e.target.name, value: e.target.value });
}Notice how we should not invoke handleChange if the search is still ongoing (indicated by the isLoading variable).
Let's now put it together to handle a change whenever the user types search input into the FormRow component rendered inside the Wrapper.
export default function SearchContainer() {
const {
//...
} = useAppContext();
const handleSearch = (e) => {
if(isLoading) return;
handleChange({ name: e.target.name, value: e.target.value });
}
return (
<Wrapper>
<form action="" className="form">
<h4>search form</h4>
<div className="form-center">
<FormRow
type='text'
name='search'
value={search}
handleChange={handleSearch}
>
</FormRow>
</div>
</form>
</Wrapper>
);
}We imported FormRowSelect earlier so we can make the user select options to aid in the search.
User should be able to search by status and jobType.
Then create 2 more buttons that should:
- Sort the search results by the options provided by the user
- Clear any previous options that filtered results so search can be done from a clean slate. This will use
clearFiltersfunction taken fromappContext.
Let's create the clear button first, which should be the last item in the form:
<button
className='btn btn-block btn-danger'
disabled={isLoading}
onClick={handleSubmit}
>
clear filters
</button>Now create the handleSubmit function which prevents the normal submit behavior, then calls clearFilters().
const handleSubmit = (e) => {
e.preventDefault();
clearFilters();
};Next let's work on the other search options for the form, which the user will interact with via a FormRowSelect component.
Let's refresh on what a FormRowSelect component looks like:
export default function FormRowSelect({labelText, name, value, handleChange, list}) {
return (
<div className="form-row">
<label htmlFor={name} className='form-label'>
{labelText || name}
</label>
<select
name={name}
value={value}
onChange={handleChange}
className='form-select'
>
{list.map((itemValue, index) => {
return(
<option key={index} value={itemValue}>
{itemValue}
</option>
);
})}
</select>
</div>
);
}Notice the parameters have labelText, name, value, handleChange and list.
Let's create one that aids the user in filtering by job status:
What is this FormRowSelect supposed to use? We destructured out statusOptions earlier from appContext, which is an array in the initalState object.
statusOptions contains the following:
const initialState = {
// ...
statusOptions: ['interview', 'declined', 'pending'],
// ...
}Each of these options will be render in FormRowSelect during the mapping process. Let's also include one more option that covers all statusOptions (i.e., all). So our list prop for the FormRowSelect should be an array that includes statusOptions and all, likeso:
list={['all', ...statusOptions]}The rest of the props should be easy to infer from the context, so let's create it:
<FormRowSelect
labelText='job status'
name='searchStatus'
value={searchStatus}
handleChange={handleSearch}
list={['all', ...statusOptions]}
>
</FormRowSelect>Let's create another FormRowSelect this time with job type.
<FormRowSelect
labelText='job type'
name='searchType'
value={searchType}
handleChange={handleSearch}
list={['all', ...jobTypeOptions]}
>
</FormRowSelect>Finally, a FormRowSelect for sort.
<FormRowSelect
name='sort'
value={sort}
handleChange={handleSearch}
list={['all', ...sortOptions]}
>
</FormRowSelect>Now after
- Login to reissue an new token
- Navigate to All Jobs page
We should see so far the Search Form with the proper labels and button.
Now immediately after select something from the drop downs of job type and status, clicking the [Clear] button seems to have not done anything. We still need to code it!
In action.js:
export const CLEAR_FILTERS = 'CLEAR_FILTERS';Now in appContext.js:
import {
// ...
CLEAR_FILTERS,
} from "./actions";
const clearFilters = () => {
dispatch({ type: CLEAR_FILTERS });
}And finally in the reducer:
case CLEAR_FILTERS: {
return {
...state,
search: '',
searchStatus: 'all',
searchType: 'all',
sort: 'latest',
};
}Return everything in the state, then set the following values to their respective default values.
-
searchto empty string -
searchStatustoall -
searchTypetoall -
sorttolatest
Now that the button is finally wired up and implemented, we can get back to working on the AllJobs page!
It is time to refactor the getJobs function in AppContext. So far:
const getJobs = async () => {
let url = `/jobs`;
dispatch({ type: GET_JOBS_BEGIN });
try {
const data = await authFetch(url);
const { jobs, totalJobs, numOfPages } = data.data;
dispatch({
type: GET_JOBS_SUCCESS,
payload: {
jobs,
totalJobs,
numOfPages,
},
});
} catch(error){
console.log(`Error triggered in getJobs() appContext.js!
Here is the Error Response:
${error.response}`);
logoutUser();
}
clearAlert();
};getJobs should be able to call the user's data on all their jobs. But now that we have the search functionality , we should integrate it. How? With the API & URL.
-
API is a set of commands, functions , protocols, and objects that programmers can use to create software or interact with an external system
-
API consists of: Endpoint, Paths, Parameters & Authentication
-
Endpoint is the starting URL, while Paths and Parameters narrow down on specific piece of data from an external server
-
If Endpoints are the root of the tree (of the file/folder structure hierarchy) then we need to add a branch of
Paths. -
Parametersgo at the end of the URL after a question mark, with a key value pair (e.g.,?contains=debugging). -
Every subsequent query follows an ampersand after the question mark.
e.g., for an API that contains programming jokes, given parameters that:
- Blacklists NSFW
- Type of joke is single line
- Contains the word "debugging"
- Parameters:
?blacklistFlags=nsfw&type=single&contains=debugging https://sv443.net/jokeapi/v2/joke/Programming?blacklistFlags=nsfw&type=single&contains=debugging
-
Finally, there is Authentication of the API, which is used to monetize and limit usage to a threshold (e.g., A call to Open Weather Map API requires your personal
appid)
In the getJobs function, we simply have the path which will be appended to the endpoint:
let url = `/jobs`;But when we search, we want to be able to add our parameters, to request specific information on the user's data.
Let's modify the URL before we begin to dispatch the action GET_JOBS_BEGIN.
We want to include every search parameters as part of the URL. So first we destructure out any variables that deals with search in the state:
const { search, searchStatus, searchType, sort } = state;Now append the all parameters except search to the /jobs path, and assign it to the url.
let url = `/jobs?status=${searchStatus}&jobType=${searchType}&sort=${sort}`;We will add search only if search is non-empty and the user filled something in. Let's add it as the last parameter after checking:
if(search) {
url = url + &search=${search};
}
Now that we have the search variables setup, we need to make sure to add it to the JobsContainer. Also update the useEffect's dependency array to include the search variables.
export default function JobsContainer() {
const {
getJobs,
jobs,
isLoading,
page,
totalJobs,
search,
searchStatus,
searchType,
sort,
} = useAppContext();
useEffect(() => {
getJobs();
}, [search, searchStatus, searchType, sort]);
// ...Let's go back to the jobsController, we need to take another look at the getAllJobs function.
const getAllJobs = async (req, res) => {
const { search, status, jobType, sort } = req.query;
const queryObject = {
createdBy: req.user.userId,
};
if (status !== 'all'){
queryObject.status = status;
}
if (jobType !== 'all'){
queryObject.jobType = jobType;
}
if (search) {
queryObject.position = { $regex: search, $options: 'i' };
}
let result = Job.find(queryObject);
if(sort === 'latest') {
result = result.sort('-createdAt');
}
if(sort === 'oldest') {
result = result.sort('createdAt');
}
if(sort === 'a-z') {
result = result.sort('position');
}
if(sort === 'z-a') {
result = result.sort('-position');
}
const jobs = await result;
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });
};There is something we are missing that we planned to add at some point: pagination!.
Where: pagination should happen after we receive the total amount of jobs from the awaited result.
How: We can setup the pagination by defining how many results we want to limit a page to
We can do so by manipulating the result variable, but we need to find out what exactly we are working it in order to perform operations on it. The line of code that declares and assigns a value to result:
let result = Job.find(queryObject);To find out what it returns, as a refresher here are the docs: on Model.find().
It returns a <<Query>>, which allows us to use methods such as limit() and skip().
Let's create the limit and skip variables first:
const limit = 10;
const skip = 1;Then we modify the query by chaining the operations we need to modify it:
result = result.skip(skip).limit(limit); Finally, since the query operations are async we need to await the new results again:
// Await jobs filtered out by sort conditions
let jobs = await result;
// Pagination Variables
const limit = 10;
const skip = 1;
// Chain operations to modify the query based on the page variables
result = result.skip(skip).limit(limit);
// Since the query operations are async, we need to await again
jobs = await result;I changed the variable declaration from const to let for jobs since we will have to reuse it again.
For now skip is 1, but when we setup pagination we need to be able to skip the results that have already been shown. e.g., if we are on page 2 (with page 1 being the start) we want to skip the results of page 1. On page 3, we need to skip the results of page 1 and 2.
We need to do some math in order to distribute the amount of jobs to each page. Things to note:
// Await jobs filtered out by sort conditions
let jobs = await result;
// Pagination Variables
const limit = 10;
const skip = 1;
// Chain operations to modify the query based on the page variables
result = result.skip(skip).limit(limit);
// Since the query operations are async, we need to await again
jobs = await result;We are going to need to do some calculation to find out the best way to limit the results, setup skip points, then calculate the result.
Let's try to make the problem a bit concrete, let's say we have 75 total job results returned from the query. How do we best divide this while keeping in mind the limit (i.e., 10) and the skip(i.e., 1).
For 75 results, we want to have a number of 8 pages. With 7 pages showing 10 results each, and the final page showing 5 results.
Let's redefine our pagination variables to leave that as default. What we want to do is be able to change the number of pages, how much to limit, and how many to skip based off that.
Recall that in the request, which we refer to as req.query, we have the variables page and limit. Let's assign our variables with these.
First we need to find out the page number, with the default being 1 (in global context it is 1). Based on that page number we will decide to skip the remaining elements. If we are on the 1st page, we skip none. On 2nd page, we skip 10 (or the first 10 results since we already showed that). On 3rd page, we skip the first 20 results. And so forth.
// Pagination Variables
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Await jobs filtered out by sort conditions AND processed through pagination
const jobs = await result;As of now we have the response as:
// Respond with 200 and a json containing the jobs, totalJobs, and pages
res.status(StatusCodes.OK)
.json({ jobs, totalJobs: jobs.length, numOfPages: 1 });We hard-coded numOfPages and calculated totalJobs based on the length of the array.
We need to update these so they reflect the real amount. We have to store them as variables and pass them into the response:
res.status(StatusCodes.OK)
.json({ jobs, totalJobs, numOfPages });The totalJobs should be the total amount of jobs within the query. But the query has been filtered out!
How do we count the amount of jobs within the filtered out documents? Let's use mongoose's countDocuments() method, whichh counts number of doucments matching filter in a database collection.
const totalJobs = await Job.countDocuments(queryObject);We also need to calculate the number of pages.
const numOfPages = totalJobs/limit;Actually, given the previous example of 75 job results. The numOfPages will turn out to be 75/10 = 7, because of integer division. We need an extra page to accomodate the last 5 results.
We could just add +1 to the totalJobs/limit expression, but in the case that the limit = 1 then we would have an extra page. So what if we instead rounded up the expression with Math.ciel(), this would yield the extra page needed to accomodate the results without having to give us the extra page.
Finally back to front-end, we have to enclose all of the logic above somehow with a container for our pagination! This will wrap up the JobsContainer component. So let's create it under components and call it PageBtnContainer.js.
Next we should setup the imports in JobsContainer
import PageBtnContainer from './PageBtnContainer';
export default function JobsContainer() {
const {
// ...
page,
numOfPages,
} = useAppContext();
return (
// ...
);
}Now inside the render of JobsContainer, we can finally setup the pagination:
Right now it looks like this:
return (
<Wrapper>
<h5>
{totalJobs} job{jobs.length > 1 && 's'} found
</h5>
<div className='jobs'>
{jobs.map((job) => {
return <Job key={job._id} {...job} />
})}
</div>
{/* pagination */}
</Wrapper>
);What we want to put under pagination is that if the number of pages is greater than 1 (the default) then we should render the PagesBtnContainer. Otherwise, if it is only just 1 then it should just remain on the single page and no need to render the component.
return (
<Wrapper>
{/* Rendering the Jobs... */}
{/* pagination */}
{numOfPages > 1 && <PageBtnContainer />}
</Wrapper>
);We will be using page variable later when we implement the PageBtnContainer.
Let's work on the imports, for now I'd like to use to icons from react-icons/hi to represent the buttons for the pages.
Next we import the appContext and Wrapper to style the component
import { HiChevronDoubleLeft, HiChevronDoubleRight } from 'react-icons/hi';
import { useAppContext } from '../context/appContext';
import Wrapper from '../assets/wrappers/PageBtnContainer';We use appContext so that we can use the variables page and numOfPages. So we destructure those out of the global context.
const { page, numOfPages } = useAppContext();Next we should create two event handler functions which will handle navigation between pages, a previous button and a next button.
const prevPage = () => {
console.log('previous page');
};
const nextPage = () => {
console.log('next page');
};Just like other components before, we will return a styled-component named Wrapper. Then create two buttons that will store the previous and next buttons.
return (
<Wrapper>
<button className="prev-btn" onClick={prevPage}>
<HiChevronDoubleLeft />
prev
</button>
<button className="next-btn" onClick={nextPage}>
next
<HiChevronDoubleRight />
</button>
</Wrapper>
);Let's also create a div named btn-container, in between the buttons.
<div className="btn-container">buttons</div>This will contain the buttons for the pages, so user can click on the button and navigate directly to the page.
We will have to create a new component for it to render. On second thought, it would be better to just render it within the btn-container
Let's work on the logic that converts our pages into a pageNumber button. We also have to keep in mind that the current page should css class of pageBtn active so the user knows where they are currently. These buttons will provide an alternative way for the user to navigate through pages directly.
Before that we need to convert pages somehow to a number. This is a case whhere we can use Array.from. It takes an iterable or array-like value and makes a "real" Array from it. We can then call array methods on it.
The syntax for Array.from(obj[, mapFn, thisArg])
Optional Arguments:
-
mapFncan be a function that will be applied to each element before adding it to the array -
thisArgallows us to setthisfor it.
Let's create a variable called pages that uses Array.from() to create our indices so that it can be mapped later to a button.
const pages = Array.from(
{ length: numOfPages },
(index) => {
return index++;
}
);Then we can finally render the page buttons within btn-container:
<div className="btn-container">
{pages.map((pageNumber) => {
return (
<button>
{pageNumber}
</button>
);
})}
</div>Let's add the props to the button during the mapping.
-
The
typeshould be button.<button type='button' > {pageNumber} </button>
-
Remember that we want the user to know which
pageBtnis the active one, so we must set its CSS class to active if thepageNumberis equal to the currentpageclassName={(pageNumber === page) ? 'pageBtn active' : 'pageBtn'}
-
Next the
keyshould be set topageNumberfor each page mappedkey={pageNumber}
-
Finally, let's give it a
onClickfunction to let us know the button is working. So just log the current page:onClick={() => console.log(page)}
Now lets setup the logic for the next and previous buttons that change the page.
- We have to create the action
CHANGE_PAGE. - Create a function that dispatches this action along with the the payload for a page in
appContext. - Handle the action in reducer
- Use the passed dowwn
changePagefunction in thePageBtnContainercomponent
actions
export const CHANGE_PAGE = 'CHANGE_PAGE';- Import the action, create
changePagefunction with apageparameter, and pass that function down to thevalueprop.
appContext
import {
// ...
CHANGE_PAGE,
} from "./actions";
const changePage = (page) => {
dispatch({
type: CHANGE_PAGE,
payload: { page }
});
};
return (
<AppContext.Provider value = {
{
...state,
// ...
changePage,
}}>
{children}
</AppContext.Provider>
)- Handle the action in the reducer
case CHANGE_PAGE: {
return {
...state,
page: action.payload.page
};
}- Use the function in
PageBtnContainer, by destructuring it out of app context. Then use that function in the onClick of the page buttons.
When we map the pages to a button, we gave it a prop of:
<button
onClick={() => console.log(page)}
>
{pageNumber}
</button>We want to change the onClick's function to that of changePage.
const {
page,
numOfPages,
changePage,
} = useAppContext();
return (
<Wrapper>
{ /* prev button */ }
<div className="btn-container">
{pages.map((pageNumber) => {
return (
<button
type='button'
className={(pageNumber === page) ? 'pageBtn active' : 'pageBtn'}
key={pageNumber}
onClick={() => changePage(page)}
>
{pageNumber}
</button>
);
})}
</div>
{ /* next button */ }
</Wrapper>
);Actually there is an error here, what we should pass down is not page but pageNumber.
// Instead of
onClick={() => changePage(page)}
// Use this
onClick={() => changePage(pageNumber)}That's because when pageChange is handled in thhe reducer, it will go specifically to that page and re-render. What we need is not the page itself but the pageNumber. We will see how that is used as well in the previous and next buttons.
Now that the page change works for the page buttons. We need to work on the prev and next button's onClick handlers.
We can re-use the same code to change the page, we just have to setup the logic such that we know what page to change to in the handlers.
We have to think of the edge cases where when calling previous page, i.e., when we are already at the first page. We can either:
- Set the page back to 1
- Make the button unusable
- Route back to the other end of the pages (i.e., a call on previous on the first page will go to the last page)
I will go with the last one where it treats the buttons navigation as a way to traverse the pages in a circular manner. This is what I think is best for use experience as they don't have to click multiple nexts to get to the last page.
Let's work on the previous button:
const prevPage = () => {
console.log('previous page');
};We should have a variable that stores the next page number, that is page - 1, then changePage to that number.
const prevPage = () => {
let prevPage = page - 1;
if (prevPage < 1) {
prevPage = numOfPages;
}
changePage(prevPage);
};Similarly, for the nextPage()
const nextPage = () => {
let nextPage = page + 1;
if(nextPage > numOfPages){
nextPage = 1;
}
changePage(nextPage);
};Now we have to go to appContext and add page state. Also add page as a parameter to the url.
const getJobs = async () => {
// Destructure variables that deals with search parameters
const { search, searchStatus, searchType, sort, page } = state;
let url = `/jobs?page=${page}&status=${searchStatus}&jobType=${searchType}&sort=${sort}`;
// ...
}We want this to be able to trigger a page change when user calls for it.
In the JobsContainer, lets destructure page from context and have a useEffect() hook to synchronize our page change.
const {
// ...
page,
numOfPages,
} = useAppContext();
useEffect(() => {
getJobs();
}, [search, searchStatus, searchType, sort, page]);Recall in the AddJob page when we used handleChange() as a way to change state values.
In appContext
const handleChange = ({ name, value }) => {
dispatch({
type: HANDLE_CHANGE,
payload: { name, value },
});
};In AddJob
const handleJobInput = (e) => {
const name = e.target.name;
const value = e.target.value;
handleChange({name, value});
};In reducer
case HANDLE_CHANGE: {
return {
...state,
[action.payload.name]: action.payload.value,
};
}Then later we reused handleChange for the search functionality:
In SearchContainer
const handleSearch = (e) => {
if (isLoading) return;
handleChange({ name: e.target.name, value: e.target.value });
};Now every time we invoke handleChange, we want to make sure that the information is updated and also reset the page back to 1.
Let's make it so that in the reducer handleChange performs this behavior, in which it resets the active page to 1 in the state:
case HANDLE_CHANGE: {
return {
...state,
page: 1,
[action.payload.name]: action.payload.value,
};
}We got a:
[0] GET /api/v1/jobs?page=1&status=all&jobType=all&sort=latest 500 38.139 ms - 102
[0] MongooseError: Query was already executed: Job.find({ createdBy: new ObjectId("6418d6ab92ff594a02b6f24a...
[0] at model.Query.exec (C:\...\GitHub\job-tracker\node_modules\mongoose\lib\query.js:4307:17)
[0] at Query.then (C:\...\GitHub\job-tracker\node_modules\mongoose\lib\query.js:4440:15)
[0] at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
[0] originalStack: 'Error\n' +
[0] ' at model.Query.exec (C:\...\GitHub\\job-tracker\\node_modules\\mongoose\\lib\\query.js:4311:28)\n' +
[0] ' at Query.then (C:\...\GitHub\\job-tracker\\node_modules\\mongoose\\lib\\query.js:4440:15)\n' +
[0] ' at process.processTicksAndRejections (node:internal/process/task_queues:95:5)'
[0] }
[0] GET /api/v1/jobs?page=1&status=all&jobType=all&sort=latest 500 35.709 ms - 102or more specifically
[0] MongooseError: Query was already executed: Job.find({ createdBy: new ObjectId("6418d6ab92ff594a02b6f24a...We can find what handles this in the jobsController.
Here are the docs about Duplicate Query Execution in mongoose.
Mongoose no longer allows executing the same query object twice. If you do, you'll get a
Query was already executed error. Executing the same query instance twice is typically indicative of mixing callbacks and promises, but if you need to execute the same query twice, you can call Query#clone() to clone the query and re-execute it.
This is interesting so we need to find the issue where our callbacks or promises are mixed up. I know that what triggers this error happens during the search functionality, so in the jobsController.
That means we should look into any time I used await as there often is a callback. Here is mdn's async function reference. It states that:
Code after each
awaitexpression can be thought of as existing in a.thencallback. In this way a promise chain is progressively constructed with each reentrant step through the function. The return value forms the final link in the chain.
So lets fix our promises and callbacks.
Possibly offending code:
// Await jobs filtered out by sort conditions
let jobs = await result;
// Pagination Variables
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Chain operations to modify the query based on the page variables
result = result.skip(skip).limit(limit);
// Await filtered jobs processed through pagination
jobs = await result;Let's try removing the first let jobs = await result;
So now we have:
// Pagination Variables
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Chain operations to modify the query based on the page variables
result = result.skip(skip).limit(limit);
// Await filtered jobs by sort conditions & processed through pagination
const jobs = await result;It fixed the issue! Now we can finally see the AllJobs page working well.
Right now it seems that no pagination occurs because we do not have enough in the jobs array. So let's add some so that we can see how it works.
Alright we got it to work, at 11 job postings we can see another issue.
The Prev and Next buttons work fine, they go through the right page. However, when the page buttons are rendered they appear as NaN, so it looks like this: << Prev [NaN NaN] Next >>.
Let's trace our components.
-AllJobs page
|-
|- PageBtnContainer
So we have the error in PageBtnContainer component. Also another thing to notice is that:
[0] GET /api/v1/jobs?page=NaN&status=all&jobType=all&sort=latest 304 78.415 ms - -It also shows up as NaN in the url.
This is what the code looks like in appContext
const getJobs = async () => {
// Destructure variables that deals with search parameters
const { search, searchStatus, searchType, sort, page } = state;
let url = `/jobs?page=${page}&status=${searchStatus}&jobType=${searchType}&sort=${sort}`;So it means page state variable seems to have the issue.
const getJobs = async () => {
// Destructure variables that deals with search parameters
const { search, searchStatus, searchType, sort, page } = state;
console.log(`page in the state is: ${page}
type is: ${typeof page}`);After this statement the log is:
page in the state is: 1
type is: numberHowever, logging the same thing in PageBtnContainer before the render:
return (
<Wrapper>
<button className="prev-btn" onClick={prevPage}>
<HiChevronDoubleLeft />
prev
</button>
{console.log(`
page: ${page}
type is: ${typeof page}
`)}
<div className="btn-container">
{pages.map((pageNumber) => {Yields
page: 1
type is: numberSo it seems it isn't page state variable that's wrong, but rather pageNumber during the mapping.
<div className="btn-container">
{pages.map((pageNumber) => {
console.log(`pageNumber is ${pageNumber}
Type is: ${typeof pageNumber}`);
return (Going a bit further and logging pageNumber, in the console it yields:
pageNumber is NaN
Type is: numberLet's look at all the things pageNumber is involved in. First off, its the mapping of an array-like object into an array called pages. So we need to find out if there is a problem with pages and the usage of Array.from().
Here is the code:
const pages = Array.from(
{ length: numOfPages },
(index) => {
return index++;
}
);So right before the mapping but after logging the pageNumber we write another log statement on pages:
return (
<Wrapper>
<button className="prev-btn" onClick={prevPage}>
<HiChevronDoubleLeft />
prev
</button>
{console.log(`
--------
page: ${page}
type is: ${typeof page}
--------`)}
{console.log(`
--------
pages: ${pages}
type is: ${typeof pages}
--------`)}
<div className="btn-container">
{pages.map((pageNumber) => {
console.log(`pageNumber is ${pageNumber}
Type is: ${typeof pageNumber}`);In the console:
PageBtnContainer.js:47
--------
page: 1
type is: number
--------
PageBtnContainer.js:53
--------
pages: NaN,NaN
type is: object
--------So looks like we found our culprit.
We can diagnose the issue starting with the pages object. Again we use Array.from() to turn an iterable/array-like value into a "real" Array.
const pages = Array.from(
{ length: numOfPages },
(index) => {
return index++;
}
);It makes a new array and copies all items to it. It's syntax:
Array.from(obj[, mapFn, thisArg])The optional second argument mapFn can be a function that will be applied to each element before adding it to the array, and thisArg allows us to set this for it.
Example:
let range = {
from: 1,
to: 5
};
// 1. call to for..of initially calls this
range[Symbol.iterator] = function() {
// ...it returns the iterator object:
// 2. Onward, for..of works only with the iterator object below, asking it for next values
return {
current: this.from,
last: this.to,
// 3. next() is called on each iteration by the for..of loop
next() {
// 4. it should return the value as an object {done:.., value :...}
if (this.current <= this.last) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
}
};
};
for (let num of range) {
console.log(num); // 1, then 2, 3, 4, 5
}
let arr = Array.from(range, num => num * num);
console.log(arr) // 1, 4, 9, 16, 25Looking at the example above we can see that it has somehow changes during the mapping process.
Let's log the index within pages during the mapping function.
const pages = Array.from(
{ length: numOfPages },
(index) => {
console.log(`
index in pages is: ${index}
index is type: ${typeof index}`);
return index++;
}
);Here are the results
index in pages is: undefinedSo it is a problem with how the Array.from() is creating our pages. Looking into the docs of mdn MDN: Array.from()
We can see that:
...
mapFnonly receives two arguments (element,index) without the whole array, because the array is still under construction.
We forgot to pass in the element argument to the mapFn. We want this to not be accessed by anyone but since JavaScript has no inherent privacy of classes (as of May, 2023) we should follow the convention of using _ underscore for private methods and variables.
Going to name it _e to show that it is private and should not be used. We also don't really use it within the mapping as we just need the index. So here is the solution:
const pages = Array.from(
{ length: numOfPages },
(_e, index) => {
console.log(`
index in pages is: ${index}
index is type: ${typeof index}`);
return index++;
}
);And with that the page buttons properly render as a number instead of a NaN.
So another issue we have is that the page buttons rendered are 0 and 1 respectively. We wanted it to start at page 1.
Again looking at the code above, the issue is within the mapping when we return our index.
We use index++.
index + 1 will ensure that page 1 will be the first value. So let's make that change.
const pages = Array.from(
{ length: numOfPages },
(_e, index) => {
return index + 1;
}
);This also fixes the issue when the active page button is not getting the active css class.
Let's begin removing the log statements that we used to debug our app. Will keep a list here:
-
components |- PageBtnContainer
-
controllers |- authController |- jobsController
-
middleware |- authenticate |- error-handler
Let's also handle any warnings, eslint errors, etc.
Let's create a script that will build the client of the front-end application. In package.json, at the root of the project folder, we have:
"scripts": {
"server": "nodemon server --ignore client",
"client": "npm start --prefix client",
"start": "concurrently --kill-others-on-fail \" npm run server\" \" npm run client\""
},Let's add the build-client script, which will chade directory to client and run the build:
"scripts": {
"build-client": "cd client && npm run build",
"server": "nodemon server --ignore client",
"client": "npm start --prefix client",
"start": "concurrently --kill-others-on-fail \" npm run server\" \" npm run client\""
},Since we want to package our front-end app and serve it as a static asset. Here is the express docs on Serving static files in Express.
We are going to do the following in the server:
- We are going to need to grab the absolute path directory name using the variable
__dirname. - Then we will use
express.static(), a built-in middleware to serve static files. - We will use
path.resolve(), using the absolute path__dirnameto the directory containing the source file. Note that this method will create the first absolute path that can be constructured from right to left. A stackoverflow post on the difference between path.join vs path.resolve with __dirname
The imports:
import path from 'path';
import { dirname } from 'path';
import { fileURLToPath } from 'url';-
dirnamefrompathis a method that returns the directory name of apathNodejs path.dirname() -
fileURLtoPathensures a cross-platform valid absolute path string. It converts a URL string into a fully-resolve platform-specific Node.js file path Nodejs url.fileURLToPath(url) - WE will have that url in
import.meta.ural
if(process.env.NODE_ENV !== 'production'){
app.use(morgan('dev'));
}
const __dirname = dirname(fileURLToPath(import.meta.url));When we are going to deploy, we want to serve up the front-end as a static asset
- Use express with
app.use() - Serve up static asset with
express.static() - Use
path.resolve()to resolve a sequence of paths or path segements into an absolute path. Nodejs path.resolve()
app.use(express.static(path.resolve(__dirname, './client/build')));Now after the routes, let's define a GET route for HTTP GET method in Express. Here is the Express docs on Routing.
- The server should send a file (i.e., the front-end static asset) back as a response to a
GETrequest. It should give back anindex.htmlfrom the./client/builddirectory.
app.get('*', function(request, response){
response.sendFile(path.resolve(__dirname, './client/build', 'index.html'));
});Now that the server can serve up the front-end as a static file as a response to a GET request. We should also take caution on ensuring security for the users.
-
helmet helps secure Express apps by setting HTTP response headers.
-
xss-clean is a Node.js Connect middleware to sanitize user input coming from POST body, GET queries, and url params.
-
express-mongo-sanitize is a Express 4.x middleware which sanitizes user-supplied data to prevent MongoDB Operator Injection.
-
express-rate-limit is a Basic rate-limiting middleware for Express. Use to limit repeated requests to public APIs and/or endpoints such as password reset.
npm i helmet
npm i xss-clean
npm i express-mongo-sanitize
npm i express-rate-limitNow to use them within the server, import then have express app use it. Put this after express.json().
import helmet from 'helmet';
import xss from 'xss-clean';
import mongoSanitize from 'express-mongo-sanitize';
app.use(helmet());
app.use(xss());
app.use(mongoSanitize());As of June 1, 2023 the package xss-clean is no longer supported. So we need to remove it.
npm docs on Uninstalling packages and dependencies.
npm uninstall xss-cleanInstead, use xss-filters.
Use the npm package
- xss-filters secure output filtering to prevent XSS (Cross-site Scripting).
npm install xss-filters --saveThen on the server we require, or in our case import:
import xssFilters from 'xss-filters';Then we can now use it within the server directly:
var express = require('express');
var app = express();
var xssFilters = require('xss-filters');
app.get('/', function(req, res){
var firstname = req.query.firstname; //an untrusted input collected from user
res.send('<h1> Hello, ' + xssFilters.inHTMLData(firstname) + '!</h1>');
});
app.listen(3000);The documentation on xss-filters, stops malicious & untrusted inputs from being executed as scripts.
Any time the user inputs data, before we save it to the database we should sanitize the inputs.
Let's try it in the jobsController, with createJob.
const createJob = async (req, res) => {
// Extract values from the request body
const { position, company } = req.body;
// Check if any of the values are empty
if(!position || !company) {
throw new BadRequestError('Please Provide All Values');
}
// Set the createdBy property to that of the user in the request
req.body.createdBy = req.user.userId;
// Create the job in the database
const job = await Job.create(req.body);
// Respond with 201, and a json of the job
res.status(StatusCodes.CREATED).json({ job });
};When we create the job in the database, we should also sanitize the input from the request body. To do so, we use xssFilters.inHTMLData(inputData) on any inputs we are extracting from the request POST body.
In this case, we should use xssFilters on company and position. Let's look at the code once again:
import xssFilters from 'xss-filters';
const createJob = async (req, res) => {
const { position, company } = req.body;
if(!position || !company) {
throw new BadRequestError('Please Provide All Values');
}
req.body.createdBy = req.user.userId;
// Create the job in the database
const job = await Job.create(req.body);
res.status(StatusCodes.CREATED).json({ job });
};Before we add the request details into the database in the following line:
// Create the job in the database
const job = await Job.create(req.body);We need to sanitize the inputs and resave it to the request object.
// Sanitize the inputs
req.body.company = xssFilters.inHTMLData(company);
req.body.position = xssFilters.inHTMLData(position);So now we have 6 steps within createJob:
const createJob = async (req, res) => {
// 1. Extract values from the request body
const { position, company } = req.body;
// 2. Check if any of the values are empty
if(!position || !company) {
throw new BadRequestError('Please Provide All Values');
}
// 3. Sanitize user inputs and save it to the request body
req.body.company = xssFilters.inHTMLData(company);
req.body.position = xssFilters.inHTMLData(position);
// 4. Set the createdBy property for req.body to that of the user
req.body.createdBy = req.user.userId;
// 5. Create the job in the database
const job = await Job.create(req.body);
// 6. Respond with 201, and a json of the job
res.status(StatusCodes.CREATED).json({ job });
};Going to test some inputs into createJob using Postman.
- Valid Job JSON
{
"company" : "CeVIO",
"position": "Software Developer"
}Response:
{
"job": {
"company": "CeVIO",
"position": "Software Developer",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"_id": "647bc8f362dd3429be947bbb",
"createdAt": "2023-06-03T23:12:51.055Z",
"updatedAt": "2023-06-03T23:12:51.055Z",
"__v": 0
}
}- Reflected XSS in the
positionparameter
Let's have the following JavaScript code as an input:
<script>alert(1)</script>Input:
{
"company" : "CeVIO",
"position": "<script>alert(1)</script>"
}Result:
{
"job": {
"company": "CeVIO",
"position": "<script>alert(1)</script>",
"status": "pending",
"jobType": "full-time",
"jobLocation": "my city",
"createdBy": "6418d6ab92ff594a02b6f24a",
"_id": "647bc97b62dd3429be947bbd",
"createdAt": "2023-06-03T23:15:07.550Z",
"updatedAt": "2023-06-03T23:15:07.550Z",
"__v": 0
}
}Looks like it did a proper xss-filter by converting the script inside the position parameter:
{
"position": "<script>alert(1)</script>"
}Into
{
"job": {
"position": "<script>alert(1)</script>",
}
}Thwarting JavaScript executions.
- XSS with
imgpayload
{
"company" : "CeVIO",
"position": "<img src=//evil-DNS>"
}Result:
{
"job": {
"company": "CeVIO",
"position": "<img src=//evil-DNS>"
}
}- XSS with
iframe
Let's try it in the company parameter
{
"company" : "<iframe src='jar://test.html'></iframe>",
"position": "Developer"
}Result:
{
"job": {
"company": "<iframe src='jar://test.html'></iframe>",
"position": "Developer",
}
}In jobsController, let's continue to sanitize each input.
- For the
getJobs()within our search functionality, let's add it:
const getAllJobs = async (req, res) => {
// 1. Destructure the necessary variables from request's query
const { search, status, jobType, sort } = req.query;
// 2. Sanitize the search input from the query
req.query.search = xssFilters.inHTMLData(search);
// ...
}- For when we
updateJobor when user is clicks onEdit
const updateJob = async (req, res) => {
// 1. Extract job ID from the request
const { id: jobId } = req.params;
// 2. Extract company and position from the request's body
const { company, position } = req.body;
// 3. Check if any of these values are empty
if (!company || !position) {
throw new BadRequestError('Please Provide All Values');
}
// 4. Sanitize user inputs and save it to the request body
req.body.company = xssFilters.inHTMLData(company);
req.body.position = xssFilters.inHTMLData(position);
// ...
}Now we finished the jobsController let's move on to the authController.
We don't need to sanitize the inputs within the login / register as the User model will handle the validation checks. As for password, the pre-save hook will encrypt the password input anyways.
However, where we do want to sanitize is when the user updates their profile in the Profile page. So before we save the new details of the user in the database, we should sanitize here.
So far this is what updateUserl ooks like in authController:
const updateUser = async (req, res) => {
const { email, name, lastName, location} = req.body;
if(!email || !name || !lastName || !location) {
throw new BadRequestError("Please provide all values");
}
const user = await User.findOne({_id: req.user.userId});
user.email = email;
user.name = name;
user.lastName = lastName;
user.location = location;
await user.save();
const token = user.createToken();
res.status( StatusCodes.OK ).json({ user, token, location: user.location });
};We have to sanitize the inputs from req.bodybefore they get assigned.
const updateUser = async (req, res) => {
const { email, name, lastName, location} = req.body;
if(!email || !name || !lastName || !location) {
throw new BadRequestError("Please provide all values");
}
const user = await User.findOne({_id: req.user.userId});
// Sanitize the inputs before saving the updated info in the user
user.email = xssFilters.inHTMLData(email);
user.name = xssFilters.inHTMLData(name);
user.lastName = xssFilters.inHTMLData(lastName);
user.location = xssFilters.inHTMLData(location);
await user.save();
const token = user.createToken();
res.status( StatusCodes.OK ).json({ user, token, location: user.location });
};express-mongo-sanitize docs for more details.
An alternative to bypass the middleware and use the module directly is to use the sanitize function, which will sanitize inputs against query selector injection attacks. This is when you retrieve user input, and you parse that input to execute the Mongo command.
At first I was doing this without realizing that the express-mongo-sanitize package was already declared as a piece of express middleware, before defining routes.
In server:
// To remove data using these defaults:
app.use(mongoSanitize());The changes have already been rolled back, but will store the changes here for posterity.
In authController.js,
const register = async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
// next(new Error()); // If not using http-status-codes
throw new BadRequestError("Please provide all values");
}
// Sanitize user input
const cleanEmail = sanitize(req.body.email);
const cleanName = sanitize(req.body.name);
const userAlreadyExists = await User.findOne({cleanEmail});
if(userAlreadyExists){
throw new BadRequestError(`The email: ${cleanEmail} is already in use.`);
}
// Instead of req.body, pass in the input fields
const user = await User.create({ cleanName, cleanEmail, password });
const token = user.createToken();
res.status(StatusCodes.CREATED).json({
user: {
email: user.email,
lastName: user.lastName,
location: user.location,
name: user.name
},
token,
location: user.location,
});
};
const login = async (req, res) => {
const { email, password } = req.body;
if(!email || !password) {
throw new BadRequestError("Please provide all values");
}
// Sanitize user input
const cleanEmail = sanitize(req.body.email);
// Get the user in db whose email matches with the one from request
const user = await User.findOne({ cleanEmail }).select('+password');
if(!user) {
throw new UnAuthenticatedError("Invalid Credentials");
}
// Compare password
const isPasswordCorrect = await user.comparePassword(password);
if(!isPasswordCorrect) {
throw new UnAuthenticatedError("Invalid Credentials");
}
const token = user.createToken();
user.password = undefined;
res.status( StatusCodes.OK ).json({ user, token, location: user.location });
};npm i express-rate-limitUsed to limit repeated requests to public APIs and/or endpoints such as password reset.
This means we are going to use it to limit how many times one can attempt to log-in or register. So use this package within the log-in and register route.
Let's write out the code in authRoutes.js
The import:
import rateLimiter from 'express-rate-limit';Now use it to create our API's limiter:
const apiLimiter = rateLimiter({
// ...
});Here are our post requests for the /register and /login routes:
router.route('/register').post(register);
router.route('/login').post(login);We will use the limiter on these routes specifically. All we have to do is add our limiter as the first argument to post.
Express Routing, of notable interest is express.Router section which we use here an instance of a complete middleware and routing system.
Express router.route(path), describes router.route() method which is a single route thats used to handle HTTP verbs with optional middleware.
We will pass in the limiter as middleware in the post() like so:
router.route('/register').post(apiLimiter, register);
router.route('/login').post(apiLimiter, login);Now in the express-rate-limit docs under Usage, we can see an examples of how to use the rate limiter. The most relevant are:
- To use it in a 'regular' web server (e.g. anything that uses
express.static()), where the rate-limiter should only apply to certain requests:
import rateLimit from 'express-rate-limit'
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
})
// Apply the rate limiting middleware to API calls only
app.use('/api', apiLimiter)- To create multiple instances to apply different rules to different endpoints:
import rateLimit from 'express-rate-limit'
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
})
app.use('/api/', apiLimiter)
const createAccountLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // Limit each IP to 5 create account requests per `window` (here, per hour)
message:
'Too many accounts created from this IP, please try again after an hour',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
})
app.post('/create-account', createAccountLimiter, (request, response) => {
//...
})In our particular apiLimiter, we'd want to limit the requests to 10 every 15 minutes.
-
legacyHeadersis set totruefor backward compatibility. It will send on all response the legacy rate limit headers for limit (X-RateLimit-Limit), current usage (X-RateLimit-Remaining) and reset time (if the store provides it). And also the middleware sends theRetry-Afterheader on all blocked requests. -
standardHeadersenable support for headers conforming to the ratelimit standardization draft adoptbed by the IETF (RateLimit-Limit,RateLimit-Remaining, and, if the store supports it,RateLimit-Reset). If set to true, the middleware also sends theRetry-Afterheader on all blocked requests. May be used in conjunction with, or instead of thelegacyHeadersoption.
const apiLimiter = rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 requests per `window` (here, per 15 minutes)
message:
'Too many accounts created from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});What is debounce?
Debouncing is a programming practice used to remove unwanted input noise form buttons, switches or other user input. It is used to prevent extra activations or slow functions from triggering too often.
Debounce is a term that comes from electronics. It refers to the process of removing the small ripple of current that forms when a mechanical switch is pushed in an electrical circuit that makes a series of short contacts.
An example is pressing a button on a TV remote, the signal travels to the microchip of the remote so quickly that before you manage to release the button, it bounces, and the microchip registers your click multiple times. To mitigate this, once a signal from the button is received, the microchip stops processing signals from the button for a few microseconds while it's physically impossible for you to press it again.
In software development, debounce is used to delay a function call until after a certain amount of time has passed since the last time the function was called. This can be useful for handling user input, such as when you want to wait until the user has finished typing before performing an action.
Debounce time is a a time specified by the system developer, during which a signal must be present so that it can be recognized and can be further processed by the system. Debounce times prevent brief glitches from being mistakenly recognized as a signal.
For example, in computer programming, debounce time is the maximum interval between clicks on a key. It's used to refer to the speed at which your mouse can click again after a first click.
In SearchContainer.js:
import React from 'react';
import Wrapper from '../assets/wrappers/SearchContainer';
import { FormRow, FormRowSelect } from '.';
import { useAppContext } from '../context/appContext';
export default function SearchContainer() {
const {
isLoading,
handleChange,
search,
searchStatus,
searchType,
sort,
sortOptions,
statusOptions,
jobTypeOptions,
clearFilters,
} = useAppContext();
const handleSearch = (e) => {
if (isLoading) return;
handleChange({ name: e.target.name, value: e.target.value });
};
const handleSubmit = (e) => {
e.preventDefault();
clearFilters();
};
return (
<Wrapper>
<form action="" className="form">
<h4>search form</h4>
<div className="form-center">
<FormRow
type='text'
name='search'
value={search}
handleChange={handleSearch}
>
</FormRow>
<FormRowSelect
labelText='job status'
name='searchStatus'
value={searchStatus}
handleChange={handleSearch}
list={['all', ...statusOptions]}
>
</FormRowSelect>
<FormRowSelect
labelText='job type'
name='searchType'
value={searchType}
handleChange={handleSearch}
list={['all', ...jobTypeOptions]}
>
</FormRowSelect>
<FormRowSelect
name='sort'
value={sort}
handleChange={handleSearch}
list={['all', ...sortOptions]}
>
</FormRowSelect>
<button
className='btn btn-block btn-danger'
disabled={isLoading}
onClick={handleSubmit}
>
clear filters
</button>
</div>
</form>
</Wrapper>
);
};Here is what we will add to make the debounce.
-
useMemo()React Hook lets you cache the result of a calculation between re-renders. It is used to optimize the performance of your application by memoizing the output of a function so that it does not need to be recalculated every time there is a change in the application. The hook returns a memoized value that will only be recomputed when one of its dependencies has changed. -
Memoization is caching return values. By storing the results of expensive function calls and returning the cached result when the same inputs occur again.
-
useState()a React Hook that lets you add a state variable to your component. In this case, our state variable will belocalSearch.
Let's import the above hooks:
import { useState, useMemo } from 'react';Then create our state variables along with the set function.
export default function SearchContainer() {
const [localSearch, setLocalSearch ] = useState('');
// ...
}Notice how the initial state of localSearch is an empty string ''.
When we hit the clear filters button, which activates the handleSubmit function, we should also reset the state variable to an empty string. Do this by using the set function setLocalSearch.
const handleSubmit = (e) => {
e.preventDefault();
setLocalSearch('');
clearFilters();
}; Let's remove the isLoading check within handleSearch for now, since the functionality will change.
const handleSearch = (e) => {
// if (isLoading) return;
handleChange({ name: e.target.name, value: e.target.value });
};Create the debounce function.
const debounce = () => {
let timerId;
return (e) => {
setLocalSearch(e.target.value);
clearTimeout(timerId);
timerId = setTimeout(() => {
handleChange({ name: e.target.name, value: e.target.value });
}, 1000);
};
}- It sets the
localSearchto that of the event target's value. - Then
setTimeoutwill return atimer identifiertimerIdthat we can use to cancel the execution. - Then we use
setTimeoutto invokehandleChangefunction once after the interval of time 1000ms.
This will effective suspend calls to handleChange() until there's 1000ms of inactivty, then invokes it once with the latest arguments.
Let's use the debounce function together with useMemo() hook.
const optimizedDebounce = useMemo(() => debounce(), [localSearch]);Now within the FormRow search component:
return (
<Wrapper>
<form action="" className="form">
<h4>search form</h4>
<div className="form-center">
<FormRow
type='text'
name='search'
value={search}
handleChange={handleSearch}
>
</FormRow>Lets replace the value to localSearch and give the handleChange the optimizedDebounce function.
return (
<Wrapper>
<form action="" className="form">
<h4>search form</h4>
<div className="form-center">
<FormRow
type='text'
name='search'
value={localSearch}
handleChange={optimizedDebounce}
>
</FormRow>So far these two lines of code:
const debounce = () => {
let timerId;
return (e) => {
setLocalSearch(e.target.value);
clearTimeout(timerId);
timerId = setTimeout(() => {
handleChange({ name: e.target.name, value: e.target.value });
}, 1000);
};
}
const optimizedDebounce = useMemo(() => debounce(), [debounce]);Work but eslint(react-hooks/exhaustive-deps) throws a problem:
The 'debounce' function makes the dependencies of useMemo Hook (at line 45) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of 'debounce' in its own useCallback() Hook.- So instead went with
useMemo()with an empty dependencies array which will calculate the value only once, on mount.
const optimizedDebounce = useMemo(() => {
debounce();
}, []);- Without dependencies array, it will calculate the value on every render.
const optimizedDebounce = useMemo(() => {
debounce();
});A stackoverflow post on how to resolve react-hooks/exhaustive-deps warning.
Since I'm only using the debounce function in the useMemo callback, then it makes sense to define it in there. Then use debounce's dependencies in the deps array.
const optimizedDebounce = useMemo(() => {
const debounce = () => {
let timerId;
return (e) => {
setLocalSearch(e.target.value);
clearTimeout(timerId);
timerId = setTimeout(() => {
handleChange({ name: e.target.name, value: e.target.value });
}, 1000);
};
}
return debounce();
}, []);Now it gives the warning `React Hook useMemo has a missing dependecy: 'handleChange'. So let's add that to deps.
const optimizedDebounce = useMemo(() => {
const debounce = () => {
let timerId;
return (e) => {
setLocalSearch(e.target.value);
clearTimeout(timerId);
timerId = setTimeout(() => {
handleChange({ name: e.target.name, value: e.target.value });
}, 1000);
};
}
return debounce();
}, [handleChange]);Now we have our optimized debounce method to help improve the search functionality.
At this point in the project, the app is fully working and has the features that it should have since the inception of the project. Just needs some testing and we could publish it to production.
However, if you care about security then the next steps after Testing are going to be major updates to the entirety of the app. You can measure the pros and cons, but as of now there may be a security risk for the users.
Anything worth doing, is worth doing right.
So for the rest of the journey, we are ensuring even more security.
Caution is advised, the next updates are major changes that could break the app. Please follow meticulously.
It is time to test the App.
- Create a login button for test user
- Create a new (test) user
- Populate DB with jobs
Let's add the button right below the submit button on the register page:
const Register = () => {
// ...
return (
<Wrapper className='full-page'>
<form className='form' onSubmit={onSubmit} action="">
// ...
<button type='submit' className='btn btn-block' disabled={isLoading}>
submit
</button>
<button
type='button'
className='btn btn-block btn-hipster'
disabled={isLoading}
onClick={() => {
registerUser({
currentUser: { email: 'testUser@test.com', password: 'test' },
endPoint: 'login',
alertText: 'Login Successful! Redirecting...',
});
}}
>
{isLoading ? 'loading...' : 'demo app'}
</button>
// ...
</form>
</Wrapper>
)
}The button
<button
type='button'
className='btn btn-block btn-hipster'
disabled={isLoading}
onClick={() => {
registerUser({
currentUser: { email: 'testUser@test.com', password: 'test' },
endPoint: 'login',
alertText: 'Login Successful! Redirecting... (Test)',
});
}}
>
{isLoading ? 'loading...' : 'demo app'}
</button>- We want to check for test user in authenticate middleware
- Create new property on user object (testUser? true/false)
- Create new middleware (testUser)
- Check for test user, if true then send back BadRequest Error
- Add testUser middleware in front of routes you want to restrict access to
Check if the payload.userId is the same as testUserID and save this boolean in ./middleware/authenticate.js.
const testUser = (payload.userId === 'testUserId');Also add this boolean property testUser to the request's user property.
So this:
req.user = { userId: payload.userId};Becomes:
const testUser = (payload.userId === 'testUserId');
req.user = { userId: payload.userId, testUser};Now the testUser middleware will throw a BadRequest if that boolean property req.user.testUser is true.
So in ./middleware/testUser.js
import { BadRequestError } from '../errors/index.js';
function testUser(req, res, next) {
if(req.user.testUser){
throw new BadRequestError('Test User. Read Only!');
}
next();
};
export default testUser;In ./routes/jobsRoutes.js
import express from 'express';
const router = express.Router();
import {
createJob,
getAllJobs,
updateJob,
deleteJob,
showStats,
} from '../controllers/jobsController.js'
router.route('/').post(createJob).get(getAllJobs);
router.route('/stats').get(showStats);
router.route('/:id').delete(deleteJob).patch(updateJob);
export default router- Import the
testUsermiddleware
import testUser from '../middleware/testUser.js';- Restrict testUser access in the
POST,DELETEandPATCHroutes
router.route('/').post(testUser, createJob).get(getAllJobs);
router.route('/stats').get(showStats);
router.route('/:id').delete(testUser, deleteJob).patch(testUser, updateJob);Now in the ./routes/authRoutes.js let's also restrict access of the testUser in the patch route.
authRoutes.js so far:
import express from 'express';
const router = express.Router();
import { register, login, updateUser } from '../controllers/authController.js';
import authenticateUser from '../middleware/authenticate.js';
import rateLimiter from 'express-rate-limit';
const apiLimiter = rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 requests per `window` (here, per 15 minutes)
message:
'Too many accounts created from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
router.route('/register').post(apiLimiter, register);
router.route('/login').post(apiLimiter, login);
router.route('/updateUser').patch(authenticateUser, updateUser);
export default router- Import the
testUsermiddleware
import testUser from '../middleware/testUser.js';- Restrict testUser access in the
PATCHroute
router.route('/register').post(apiLimiter, register);
router.route('/login').post(apiLimiter, login);
// Restrict testUser here
router.route('/updateUser').patch(authenticateUser, testUser, updateUser);Now run the app with npm run start. On the landing page, click log-in. We should see the [Demo App] button for the testUser.
Now this is what we will have to do:
- Open up Postman and head to the
POSTRegister User
{
"name": "testUserId",
"password": "test",
"email":"testUser@test.com"
}In the Login Page on the browser,
In the email field type: testUser@test.com
For the password type: test
Then click [Login] button
At this point, going to roll back all the previous changes that accomodated a demo user to test the app. A tester can simply log-in with simpler credentials: test@test.com and test as password.
This is quite a complex topic to cover, and I am not too experienced regarding in this portion. I am going off of various sources on the issue between storing the JSON WEB TOKEN (JWT) in localStorage or a cookie.
A source I used extensively was Jerry Ng's post on Auth
According to this Stackoverflow: Should JWT be stored in localStorage or cookie,
- localStorage is subjected to XSS and generally it's not recommended to store any sensitive information in it.
- With Cookies we can apply the flag "httpOnly" which mitigates the risk of XSS. However if we are to read the JWT from Cookies on backend, we then are subjected to CSRF.
Some vocabulary, earlier we've discussed this when we sanitized the inputs but its good to cover it again: XSS.
-
Cross Site Scripting (XSS) attacks are a type of injection, in which malicious scripts are injected into otherwise benign and trusted websites. XSS attacks occur when an attacker uses a web application to send malicious code, generally in the form of a browser side script, to a different end user. Source.
-
Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing. If the victim is a normal user, a successful CSRF attack can force the user to perform state changing requests like transferring funds, changing their email address, and so forth. If the victim is an administrative account, CSRF can compromise the entire web application.Source
So is it best to store JWT in cookies?
On every request to server, the JWT will be read from Cookies and added in the AUthorization header using Bearer scheme. The server can then verify the JWT in the request header (as opposed to reading it from the cookies).
Let's stamp out some definitions first.
-
Client - client application. In this context, usually specifically talking about our web browsers (e.g., Edge, Chrome, Opera)
-
Server - computers that serve up the files
-
Request/Response Headers - HTTP headers let the client and server pass additional information with an HTTP request or response.
- Note there are case-insensitive names followed by a colon (
:), then by its value. - Whitespace before the value is ignored.
- Note there are case-insensitive names followed by a colon (
- Also called: HTTP cookie, web cookie, or browser cookie.
- Stored in the browser's Cookies storage.
- They are used for authentication, personalization and tracking.
- A cookie is received in name-value pairs via the
Set-Cookieresponse header in a request. With this, your cookie will automatically be kept in the browser's Cookies storage (document.cookie).
- Cookies with
HttpOnly,SecureandSameSite=Strictflags are more secure.
e.g., with the HttpOnly flag, the cookies are not accessible through JavaScript, thus making it safer against XSS attacks.
MDN's Using HTTP cookies has more info.
Cross-Site Scripting attack.
XSS is a type of vulnerability where an attacker injects JavaScript that will run on your page.
The Web Storage (e.g., localStorage) is accessible through JavaScript on the same domain. Consequently, Web Storage is vulnerable to XSS attacks.
Basic XSS attacks attempt to inject JavaScript through form inputs, where the attacker puts code into a form to see if it is run by the browser and can be viewed by other users.
code example:
alert(localStorage.getItem('your-secret-token'));Cross-Site Request Forgery attack.
Cookies are vulnerable to CSRF attacks.
No cookies = No CSRF attacks.
As browsers automatically send Cookies with all requests, CSRF attacks make use of this to gain authenticated access to a trusted site.
Cross-site request forgery see ways to prevent on Wikipedia.
Also called: "Cookies" or "Cookie Jar".
-
Client-side storage where HTTP cookies are stored.
-
Important Note: browsers automatically send cookies (no client-side code needed) along with every request via the cookie request header. This is exactly why Cookie (storage) is vulnerable to CSRF attacks.
To view Cookies press: [F12] > Application > Storage > Cookies
-
localStorage- data persisted even when the browser is closed and reopened -
sessionStorage- data persisted only for the duration of the page session
Client-side storage, used to store data in key-value pairs on the user's client.
Vulnerable to XSS attacks. Not ideal for storing private/sensitive/authentication-related data.
| - | Local/Session Storage | Cookies (Storage) |
|---|---|---|
| JavaScript | Accessible through JavaScript on the same domain | Cookies, when used with the HttpOnly cookie flag, are not accessible through JavaScript |
| XSS attacks | Vulnerable to XSS attacks | Immune to XSS (with HttpOnly flag) |
| CSRF attacks | Immune to CSRF attacks | Vulnerable to CSRF attacks |
| Mitigation | Do not store private/sensitive/authentication-related data here | Make use of CSRF tokens or other prevention methods |
JSON Web Tokens is an open standard (RFC 7519). All JWTs are tokens.
Usually stored in Local Storage or Cookies (storage).
- JWT is not encrypted by any means, it is encoded in Base64.
- To decode any JWT use jwt.io
Often used with token-based authentication, horizontal scaling is easier when using JWT.
This is because the verification of JWT does not require any communication between the servers and databases. In other words, the authentication can be stateless.
- JWT is a token format
- A cookie is an HTTP state management mechanism
- A web cookie can contain JWT and can be stored within your browser’s Cookies storage
| Token-Based | Session-Based |
|---|---|
| Stateless | Stateful |
| The authentication state is NOT stored anywhere on the server-side | The authentication state is stored on the server side (DB) |
| Easier to scale horizontally | Harder to scale horizontally |
| Commonly uses JWT for authentication | Commonly uses Session ID |
Typically sent to the server via an HTTP Request Authorization Header (e.g., Bearer <token>). Can use Cookie too. |
Usually sent to the server in the Cookie request header |
| Harder to revoke a user session | Able to revoke user session with ease |
Bearer token is a string (e.g., JWT) that goes into the Authorization header of any HTTP request. Unlike a browser cookie, it is not automatically stored anywhere, thus making this CSRF impossible.
To use a Bearer token, we'll need to explicitly store the JWT somewhere in our client (Cookies storage or Local Storage) and add that JWT to our HTTP Authorization header while making requests.
If your cookie (e.g., with a JWT) is set with the HttpOnly flag, retrieving your token from the client side would be impossible with JavaScript.
Using Local Storage makes our JWT vulnerable to XSS.
At this point, it may sound like using Cookie to store JWT is our only option. But remember, this makes our website vulnerable to CSRF attacks.
Same-site cookies can effectively prevent CSRF attacks, but it has its limitations
What follows assume we won't use Same-Site cookies.
Without JWT,
From the video 100% Stateless with JWT (JSON Web Token) by Hubert Sablonniere.
Looks similar to the original Cookie-to-header token approach but with a few modifications:
- The anti-CSRF token is returned in a separate response header (e.g.,
X-CSRF-Token) instead of theSet-Cookieresponse header - Sign and set a JWT on the
Set-Cookieresponse header
- The user logs in, the server would sign a JWT with
csrfTokenas part of the JWT claim (for verification in Step 6).
- The generated
csrfTokenshould be unpredictable and unique per-user session.
{
"email": "your@email.com",
"exp": 1666798498,
"csrfToken": "1449bd3e-41c2-45cb-a538-73c7ad80ca2c",
"iat": 1666794898
}-
The JWT would then be stringified into a cookie which will be set into the
Set-Cookieresponse header. The randomly generatedcsrfTokenon the other hand will be set in theX-CSRF-Tokenresponse header. -
With the
Set-Cookieheader present in the response header, our browser would automatically store the JWT in the Cookies (storage). ThecsrfTokenpresent in theX-CSRF-Tokenheader will be extracted and set in the browser’s Local Storage. -
When a request (e.g. GET /hello) is triggered, our browser will fetch the
csrfTokenfrom the Local Storage. -
The JWT from the Cookies (storage) and the
csrfTokenretrieved from the Local Storage will be sent to the server in the request header. -
The server will verify the JWT and check
csrfTokenfrom the request header against thecsrfTokenclaim inside the JWT to verify if the CSRF Token is valid.
Going to refactor a lot of code here to implement a lot of what I learned.
Using the Express docs on res.cookie(), this is how we would attach a cookie to the login response using res.cookie:
/controllers/authController.js
const login = async (req, res) => {
// ...
const token = user.createJWT();
const oneDay = 1000 * 60 * 60 * 24;
res.cookie('token', token, {
httpOnly: true,
expires: new Date(Date.now() + oneDay),
secure: process.env.NODE_ENV === 'production',
});
}The syntax of res.cookie() is
res.cookie(name, value [, options]);-
nameis'token' -
valueistoken -
optionsparameter is an object properties that we can set:
-
httpOnlyset totrue, will flag the cookie to be accessible only by the web server.
- This would mean it is not accessible through JavaScript making it more safer against XSS attacks
-
expiresset to the current data + one day. Expiry date of the cookie in GMT. If not specified or set to 0, creates a session cookie. -
secureset toprocess.env.NODE_ENV === 'production'. Marks the cookie to be used with HTTPS only.
Let's move up the oneDay variable up the at the top of the file authController.js after imports.
import User from '../models/User.js';
import { StatusCodes } from 'http-status-codes';
import { BadRequestError } from '../errors/index.js';
import { UnAuthenticatedError } from '../errors/index.js';
import xssFilters from 'xss-filters';
const oneDay = 1000 * 60 * 60 * 24;Then for the register, login and updateUser controllers, attach the cookie before sending the res.status().json().
const register = async (req, res) => {
// ... logic
res.cookie('token', token, {
httpOnly: true,
expires: new Date(Date.now() + oneDay),
secure: process.env.NODE_ENV === 'production',
});
res.status(StatusCodes.CREATED).json({
// ...
});
};
// Repeat for login and updateUser functionsWe need a way to parse the cookie coming back from the Front-End.
- install cookie-parser on the server
So in the root directory where the server.js is located, run:
npm i cookie-parserThen use it in /server.js
import cookieParser from 'cookie-parser';
app.use(express.json());
app.use(cookieParser());So far with JWT, the authenticate middleware:
import jwt from 'jsonwebtoken';
import { UnAuthenticatedError } from "../errors/index.js";
const authenticate = async (req, res, next) => {
const authHeader = req.headers.authorization;
if(!authHeader || !authHeader.startsWith("Bearer")){
throw new UnAuthenticatedError("Authentication Invalid");
}
const token = authHeader.split(' ')[1];
try{
const payload = jwt.verify(token, process.env.SECRET_KEY);
req.user = { userId: payload.userId };
next();
} catch(error){
throw new UnAuthenticatedError("Authentication Invalid");
}
};
export default authenticate Let's log the cookie in the authenticate middleware
middleware/auth.js
const auth = async (req, res, next) => {
console.log(req.cookies)
// ...
}We can see the req.cookies has a property of token that stores our JWT.
Since we no longer are trying to extract the token from the req.headers.authorization we can just check for the jwt within the req.cookies.
So the code before the try..catch block, will be replaced:
const authHeader = req.headers.authorization;
if(!authHeader || !authHeader.startsWith("Bearer")){
throw new UnAuthenticatedError("Authentication Invalid");
}
const token = authHeader.split(' ')[1];To this:
const token = req.cookies.token;
if(!token) {
throw new UnAuthenticatedError("Authentication Invalid");
}Remove token from JSON response.
In controllers/authController, lets remove the token for the register controller's JSON response:
const register = async (req, res) => {
// ...
res.status(StatusCodes.CREATED).json({
user: {
email: user.email,
lastName: user.lastName,
location: user.location,
name: user.name
},
// token removed
location: user.location,
});
};Repeat the process for login and updateUser.
Caution is advised, these are major changes that could break the app. Please follow each change meticulously.
In client/src/context/appContext,
const user = localStorage.getItem('user');
const token = localStorage.getItem('token');
const userLocation = localStorage.getItem('location');
const initialState = {
isLoading: false,
showAlert: false,
alertText: '',
alertType: '',
user: user ? JSON.parse(user) : null ,
token: token,
userLocation: userLocation || '',
showSidebar: false,
position: '',
company: '',
jobLocation: userLocation || '',
jobType: 'full-time',
jobTypeOptions: ['full-time', 'part-time', 'remote', 'internship'],
status: 'pending',
statusOptions: ['interview', 'declined', 'pending'],
isEditing: false,
editJobId: '',
jobs: [],
totalJobs: 0,
numOfPages: 1,
page: 1,
stats: {},
monthlyApplications: [],
search: '',
searchStatus: 'all',
searchType: 'all',
sort: 'latest',
sortOptions: ['latest', 'oldest', 'a-z', 'z-a'],
}Remove the 3 items using localStorage and ONLY the token property of initialState:
const user = localStorage.getItem('user');
const token = localStorage.getItem('token');
const userLocation = localStorage.getItem('location');
const initialState = {
token: token,
}In initialState fix and reset the properties: user, userLocation, jobLocation.
const initialState = {
user: null,
userLocation: '',
jobLocation: '',
}Remove the request interceptor, which looks like this:
// Axios request interceptor
authFetch.interceptors.request.use(
function (config) {
config.headers['Authorization'] = `Bearer ${state.token}`;
return config;
},
function (error) {
return Promise.reject(error);
}
);We will keep the response interceptor.
Still in /client/src/context/appContext,
const addUserToLocalStorage = ({ user, token, location }) => {
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('token', token);
localStorage.setItem('location', location);
};
const removeUserFromLocalStorage = () => {
localStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('location');
};Remove these localStorage functions. And anywhere they were used, remove them.
- In
registerUser,loginUserandupdateUserremove:
addUserToLocalStorage({ user, token, location });- In
logoutUserremove:
removeUserFromLocalStorage();List of each action that has token removed:
- REGISTER_USER_SUCCESS
- LOGIN_USER_SUCCESS
- LOGOUT_USER
- UPDATE_USER_SUCCESS
Going to store LOGOUT_USER code here as an example. Remove token for now, as for userLocation and jobLocation it will be dealt with.
case LOGOUT_USER: {
return {
...initialState,
user: null,
token: null,
userLocation: '',
jobLocation: '',
};
}loginUser, registerUser, updateUser and their payloadds. Any place where token is found in appContext and was removed form reducer.
With this in place, we lose the usage of token on the front-end and end our reliance on localStorage.
But one glaring issue stands out: How do we keep track of our current user!?
Fear not, for after Destruction comes Creation. Let's make something that tracks our currentUser.
Starting from the place we initiated the destruction,
in /controllers/authController.js
const getCurrentUser = async (req, res) => {
const user = await User.findOne({ _id: req.user.userId });
res.status(StatusCodes.OK).json({
user,
location: user.location
});
};
export { register, login, updateUser, getCurrentUser };- Create
getCurrentUserfunction which will find the user from the database through thereq.user.userIdparameter. Then re-send back a json response that contains theuserandlocation. - export the function
This way we don't save the current user nor the user's location in localStorage.
Now to update the routes:
in /routes/authRoutes.js
import {
register,
login,
updateUser,
getCurrentUser,
} from '../controllers/authController.js';Update the imports to include the getCurrentUser function. Then create the route for it:
router.route('/register').post(apiLimiter, register);
router.route('/login').post(apiLimiter, login);
router.route('/updateUser').patch(authenticateUser, updateUser);
router.route('/getCurrentUser').get(authenticateUser, getCurrentUser);Create GET_CURRENT_USER actionss in /client/src/context/actions.js
export const GET_CURRENT_USER_BEGIN = 'GET_CURRENT_USER_BEGIN';
export const GET_CURRENT_USER_SUCCESS = 'GET_CURRENT_USER_SUCCESS';Then import these actions in both appContext.js and reducer.js.
Now we need to be able to send the current user as part of the request.
- Set the state value
userLoadingininitialState, with a default value oftrue
const initialState = {
userLoading: true,
// ...
};- Then create the
getCurrentUser()function that will dispatch the corresponding actions.
const getCurrentUser = async () => {
dispatch({ type: GET_CURRENT_USER_BEGIN });
try{
const { data } = await authFetch('/auth/getCurrentUser');
const { user, location } = data;
dispatch({
type: GET_CURRENT_USER_SUCCESS,
payload: { user, location },
});
} catch(error) {
if(error.response.status === 401) {
return;
}
logoutUser();
}
};Let's have it run once through the useEffect() hook by giving it an empty array. This will have the behavior of componentDidMount as in, it only runs once. So import useEffect from react, then invoke the function within it:
useEffect(() => {
getCurrentUser();
}, []);Let's handle those actions that we've dispatched.
case GET_CURRENT_USER_BEGIN: {
return {
...state,
userLoading: true,
showAlert: false
};
}
case GET_CURRENT_USER_SUCCESS: {
return {
...state,
userLoading: false,
user: action.payload.user,
userLocation: action.payload.location,
jobLocation: action.payload.location,
};
}For GET_CURRENT_USER_BEGIN we just return the state, and set the userLoading to true and showAlert to false.
For GET_CURRENT_USER_SUCCESS we return the state while:
- Set
userLoadingto false to indicate that we've retrieved the current user so it is no longer loading - Save the
user,userLocationandjobLocationfrom the payload
One more thing to modify in the reducer, was earlier in for the LOGOUT_USER action when we removed the token we had some stray properties that just resetted the same values from above. It will now be dealt with:
case LOGOUT_USER: {
return {
...initialState,
user: null,
userLocation: '',
jobLocation: '',
};
}Return the initialState, and replace the rest with userLoading: false.
case LOGOUT_USER: {
return {
...initialState,
userLoading: false,
};
}Now that we included current user and userLoading prop, a few other components needs changing.
Let's say in the scenario that the current user is still being fetched, and userLoading has yet to resolve to false. We had ProtectedRoute component which wrapped our main pages and would kick them back to the landing page when the user does not exist (at the time the user was found in localStorage).
We want to be able to fix this in such a way that it would load the user in, and by the time userLoading is resolved it would bring us onto the main pages or home route /.
Remember way back in /client/App.js
function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<ProtectedRoute>
<SharedLayout/>
</ProtectedRoute>
}
>
// ...We wrapped the SharedLayout component with a ProtectedRoute, a component which would kick an unauthorized user back to the landing page if they weren't supposed to be here.
/client/pages/ProtectedRoute.js
export default function ProtectedRoute({ children }) {
const { user } = useAppContext();
if(!user){
return <Navigate to='/landing' />
}
return (
children
);
}It checked for the user within appContext. To which we should add userLoading too.
We'll show the <Loading /> component when userLoading is true.
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAppContext } from '../context/appContext';
import Loading from '../components/Loading';
export default function ProtectedRoute({ children }) {
const { user, userLoading } = useAppContext();
if(userLoading) {
return <Loading />;
}
if(!user){
return <Navigate to='/landing' />
}
return (
children
);
};Now on the Landing Page, we had something like this:
import React from 'react';
import main from '../assets/images/main.svg';
import Wrapper from '../assets/wrappers/LandingPage';
import Logo from '../components/Logo';
import { Link } from 'react-router-dom';
function Landing() {
return (
<Wrapper>
<nav>
<Logo />
</nav>
<div className="container page">
<div className="info">
<h1>Job <span>Tracking</span> App</h1>
<h4>Track and manage all your job applications in one place.</h4>
<p>
Are you also feeling lost as a job seeker? I created this app to ease
the process of applying. To give you that organization and certainty fraught
with a time where there is little to none. Keep sending out applications.
I know it is discouraging, but you have to bump the number of applications up.
You are not alone in this struggle, I'm also in the same process.
I hope this app will prevent you from growing too despondent. <strong>"If I can ease one
life the aching, I shall not live in vain" - Emily Dickinson</strong>
</p>
<Link to='/register' className='btn btn-hero'>
Login/Register
</Link>
</div>
<img src={main} alt="job hunt" className='img main-img'></img>
</div>
</Wrapper>
);
}
export default Landing Or simplified:
function Landing() {
return (
<Wrapper>
{ /* ... */ }
</Wrapper>
);
}
export default Landing Now we want our Landing page to check for the user in appContext and if current user exists and userLoading is resolved to false, it should navigate the user back to the home route or Stats page.
Going to use a React , or the alternative syntax <>...</>, lets you group elements without a wrapper node.
We are going to need to return something alongside the Wrapper this time, which is the code that checks for the user and Navigates them back to the home page.
So in our simplified example:
- We add two imports
useAppContextandNavigatefromreact-router-dom - Extract
userfrom the global context - Return a React Fragment that contains:
- A conditional check for the
userwhich Navigates them back to the home page if the current user exists - The
Wrapperand the rest of the code
- A conditional check for the
import { Navigate } from 'react-router-dom';
import { useAppContext } from '../context/appContext';
function Landing() {
const { user } = useAppContext();
return (
<>
{user && <Navigate to='/' />}
<Wrapper>
{ /* ... */ }
</Wrapper>
</>
);
}
export default Landing In /controllers/authController.js, create the logout function
const logout = async (req, res) => {
res.cookie('token', 'logout', {
httpOnly: true,
expires: new Date(Date.now() + 1000),
});
res.status(StatusCodes.OK).json({
msg: 'User logged out!'
});
};In /routes/authRoutes.js, import logout and add the GET route method.
See Express Routing - Route methods.
import {
// ...
logout,
} from '../controllers/authController.js';
router.route('/register').post(apiLimiter, register);
router.route('/login').post(apiLimiter, login);
router.get('/logout', logout);
router.route('/updateUser').patch(authenticateUser, updateUser);
router.route('/getCurrentUser').get(authenticateUser, getCurrentUser);Back in /client/src/context/appContext.js, we should update our logout function:
const logoutUser = () => {
dispatch({ type: LOGOUT_USER });
};Turns into:
const logoutUser = async () => {
await authFetch.get('/auth/logout');
dispatch({ type: LOGOUT_USER });
};Finally, our project is now complete. Congrats to anyone reading this.
What's next is to get it up and running.
- In
/client, removebuildandnode_modules - In
server, or the root directory of the project, removenode_modules - These will be re-installed with a script later
In package.json in the server, setup the production scripts:
{
"scripts": {
"install-dependencies": "npm run install-client && npm install",
"setup-production": "npm run install-client && npm run build-client && npm install",
"install-client": "cd client && npm install",
"build-client": "cd client && npm run build",
"server": "nodemon server --ignore client",
"client": "npm start --prefix client",
"start": "concurrently --kill-others-on-fail \" npm run server\" \" npm run client\""
},
}Now we can just type into the command
node serverApp must run locally.
Hosting on render
To host our full-stack app, we should make it a Web Service. Make an account, link your GitHub, and go to the Dashboard.
Create New Web Service, and connect your GitHub repository.
- Going to give it a unique name of
dragons-job-tracker. - Region where web service runs, Oregon (US West).
- The build command is
npm run setup-production - The start command is
npm run start - Add secret file
.env - Health Check Path ->
/landing - Auto-Deploy -> yes
Package management, also known as dependency management, involves updating packages and dependencies within a project. Tools like npm (Node Package Manager) facilitate updating packages to their latest versions.
Useful commands:
We can run the following command to check for outdated packages in our project:
npm outdatedThe wanted column in the npm outdated command refers to the maximum version of a package that satisfies the semver range specified in your package.json. Here's what it means:
- If a semver range is defined in your
package.json, thewantedversion represents the latest version within that range. - If there's no semver range (e.g., when running
npm outdated --globalor if the package isn't included inpackage.json), thewantedversion shows the currently-installed version.
In summary, wanted indicates the version you should update to based on your package constraints. If you prefer the latest version, consider updating to the one shown in the latest column.
Semver (short for Semantic Versioning) is a versioning system used in the Node.js ecosystem, particularly by npm (Node Package Manager). It provides a consistent way to manage package dependencies. Here are the key points about semver:
-
Version Format:
- Semver follows the format
MAJOR.MINOR.PATCH. - MAJOR: Indicates breaking changes.
- MINOR: Introduces new features without breaking existing functionality.
- PATCH: Fixes issues or provides backward-compatible updates.
- Semver follows the format
-
Usage in npm:
- All packages published to npm are assumed to follow semver semantics.
- Package authors use semver to define dependency versions bundled with their packages.
-
Example:
- Suppose a package has version
1.2.3.- Incrementing the MAJOR version (e.g.,
2.0.0) implies breaking changes. - Incrementing the MINOR version (e.g.,
1.3.0) adds features without breaking compatibility. - Incrementing the PATCH version (e.g.,
1.2.4) includes backward-compatible fixes.
- Incrementing the MAJOR version (e.g.,
- Suppose a package has version
semver helps maintain compatibility and ensures smooth package updates.
To install only the wanted versions of each npm package run the following command:
chore: Update dependencies to latest semver range
npm update --saveOr we can run npm install with specific requirements.
To install the latest minor version:
npm install package-name@"^2.x.x"To install a package right before the latest major update run the following command:
npm install package-name@"<next-major.0.0"For example:
npm install package-name@"<3.0.0" Would install the latest right before 3.0.0 (e.g. 2.11.1)
(May 20, 2024)
npm outdated
Package Current Wanted Latest Location Depended by
concurrently 7.6.0 7.6.0 8.2.2 node_modules/concurrently job-tracker
dotenv 16.0.3 16.4.5 16.4.5 node_modules/dotenv job-tracker
express 4.18.2 4.19.2 4.19.2 node_modules/express job-tracker
express-rate-limit 6.7.0 6.11.2 7.2.0 node_modules/express-rate-limit job-tracker
helmet 7.0.0 7.1.0 7.1.0 node_modules/helmet job-tracker
http-status-codes 2.2.0 2.3.0 2.3.0 node_modules/http-status-codes job-tracker
jsonwebtoken 9.0.0 9.0.2 9.0.2 node_modules/jsonwebtoken job-tracker
moment 2.29.4 2.30.1 2.30.1 node_modules/moment job-tracker
mongoose 7.0.1 7.6.11 8.4.0 node_modules/mongoose job-tracker
nodemon 2.0.21 2.0.22 3.1.0 node_modules/nodemon job-tracker
validator 13.9.0 13.12.0 13.12.0 node_modules/validator job-trackerchore: Update dependencies to latest semver range
npm update --save