Secure Your CRM with Auth and Staff

Add JWT login, password hashing, and a protected CRM dashboard to your Express API.

Introduction

30 Second Summary

Every app you use daily has a login page. Behind that simple form, your password gets scrambled before storage, temporary tokens grant you access, and expired sessions get renewed silently in the background.

In this project, you will add a complete authentication system to your existing Express API and build a protected CRM dashboard in your Next.js frontend that only logged-in staff can access.

What You'll Build

Open your browser to localhost:3000/dashboard, enter your admin credentials, and land on a protected CRM dashboard showing your staff name and clearance level.

By the end of this project, you'll have:

  • A working login form where you type a username and password, land on your dashboard on success, or see an "Invalid credentials" error. Your password hashing means even if someone accesses your MongoDB database directly, every password is an unreadable scramble.
  • A token refresh flow that keeps staff logged in for 7 days. Close the browser, come back tomorrow, and your session silently renews without asking for credentials again.
  • A production build pipeline where one command compiles your entire TypeScript backend to plain JavaScript in a dist/ folder, ready to transfer to your Ubuntu VPS.
  • Secret Mission: A staff management panel where you create, edit, and remove team accounts with different clearance levels, and watch the API refuse to delete protected admin accounts.

Are there any prerequisites?

This project picks up where Project 2 left off. You need your agence-amour (Next.js) and agence-amour-api (Express TypeScript) folders from that project, with MongoDB running locally.

Your backend already has the authenticateJWT middleware, a User model with clearance levels, and protected article routes. This project builds the login system that feeds tokens into that existing security layer.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're turning your existing auth middleware (which already checks tokens) into a complete login system with password security and a browser-based dashboard for your staff.

Add Password Hashing and the Production Build Pipeline

Your User model currently stores passwords as plain readable text. If anyone gains access to your MongoDB database, they can read every password immediately.

This step solves two problems at once. You will add bcryptjs to scramble passwords before they hit the database, and create a production build pipeline that compiles your TypeScript to plain JavaScript ready for your Ubuntu VPS.

In this step, get ready to:
  • Install password hashing and environment config packages.
  • Update your User model with automatic password hashing and a protected account flag.
  • Add a TypeScript compiler config and build scripts so your code compiles to production-ready JavaScript.
Install missing packages and create environment config

Three packages are missing from your project. bcryptjs hashes passwords so they are unreadable in the database. cookie-parser reads refresh token cookies from the browser. dotenv loads secrets from a file so they never appear in your code.

  • Install the runtime dependencies by running this command in your agence-amour-api terminal:
npm install bcryptjs cookie-parser dotenv
  • Install the TypeScript type definitions and the TypeScript compiler by running:
npm install -D @types/bcryptjs @types/cookie-parser typescript

Why two install commands?

Runtime dependencies (npm install) ship with your app to the VPS. Dev dependencies (npm install -D) are only needed during development and build time. The TypeScript compiler and type definitions are dev-only tools.

You should see output listing the added packages and an updated package.json.

Now your project needs a place to store secrets like your JWT signing keys and database connection string. A .env file keeps these out of your code so they never get committed to Git.

  • Create a new file called .env in the root of your agence-amour-api folder and paste this content:
PORT=4000
MONGODB_URI=mongodb://127.0.0.1:27017/agence-amour
JWT_SECRET=your-jwt-secret-here-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-here-change-in-production
NODE_ENV=development

What do these values do?

  • PORT sets which port your server listens on.
  • MONGODB_URI tells Mongoose where to find your database.
  • JWT_SECRET signs your short-lived access tokens.
  • JWT_REFRESH_SECRET signs your long-lived refresh tokens (a separate secret so one leak does not compromise both).
  • NODE_ENV controls whether cookies use secure mode (HTTPS-only in production).
  • Save .env.
  • Confirm the file exists by running:
ls .env

You should see .env printed in the output.

Don't see .env listed?

Make sure you created the file in the agence-amour-api folder, not a subfolder. Check that the filename starts with a dot. On macOS, files starting with a dot are hidden in Finder by default, but they still show in ls output.

Help me figure out why my .env file isn't showing up

Add password hashing and the protected account flag to your User model

Your existing User.ts model stores passwords as-is. You need a pre-save hook that automatically hashes the password every time a user is created or their password changes.

  • Open models/User.ts in VS Code and replace the entire file contents. Start with the imports and interface at the top:
import { Schema, model, Document } from 'mongoose';
import bcryptjs from 'bcryptjs';

// Define the shape of a User document
export interface IUser extends Document {
  username: string;
  email: string;
  password: string;
  clearanceLevel: 1 | 2 | 3;
  permissions: string[];
  isProtected: boolean;
  comparePassword(candidatePassword: string): Promise<boolean>;
}

What does this interface do?

IUser tells TypeScript exactly what fields every User document has. The comparePassword method signature means every User instance can check a login attempt against its stored hash. The isProtected flag marks accounts that cannot be deleted through the API.

  • Directly below the interface, paste the schema definition with the identity and access fields:
const userSchema = new Schema<IUser>({
  username: {
    type: String,
    required: true,
    unique: true,
    trim: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
    lowercase: true,
  },
  password: {
    type: String,
    required: true,
    select: false,
  },
  clearanceLevel: {
    type: Number,
    enum: [1, 2, 3],
    required: true,
    default: 3,
  },
  permissions: {
    type: [String],
    default: [],
  },
  isProtected: {
    type: Boolean,
    default: false,
  },
}, {
  timestamps: true,
});

What does select: false do?

Setting select: false on the password field means Mongoose never includes it in query results by default. When your API returns user data, the hashed password is automatically excluded. You must explicitly ask for it with .select('+password') when you need it (like during login).

  • Finally, paste the pre-save hook, the comparePassword method, and the model export below the schema:
// Hash password before saving to database
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  this.password = await bcryptjs.hash(this.password, 12);
  next();
});

// Compare a login attempt against the stored hash
userSchema.methods.comparePassword = async function (candidatePassword: string): Promise<boolean> {
  return bcryptjs.compare(candidatePassword, this.password);
};

const User = model<IUser>('User', userSchema);
export default User;

How does the pre-save hook work?

Every time you call .save() on a User, Mongoose runs this hook first. It checks whether the password field actually changed (isModified). If it did, bcryptjs hashes it with 12 salt rounds. If it did not change, the hook skips hashing so updates to other fields stay fast.

The comparePassword method does the reverse at login time. It takes a plain-text attempt and checks it against the stored hash without ever decrypting the hash.

  • Save models/User.ts.
  • Verify your changes compile correctly by running:
npm run dev

You should see Connected to MongoDB and Server running on http://localhost:4000 in your terminal. This confirms your updated model has no TypeScript errors.

  • Press Ctrl+C to stop the dev server.

Seeing a TypeScript error?

Check that you imported bcryptjs with a default import (import bcryptjs from 'bcryptjs'), not a named import. Also confirm the file has no leftover code from the previous version of User.ts mixed in with the new code.

Help me fix TypeScript errors in my User.ts model

✔️ Awesome, I've got everything!

Great. Make sure models/User.ts is saved before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your models/User.ts contains all three sections in order:

  • Imports and the IUser interface (with isProtected and comparePassword).
  • The full schema with select: false on password, isProtected field, and timestamps: true.
  • The pre-save hook (12 salt rounds), comparePassword method, and the default export.
Add tsconfig.json and production build scripts

Your dev server uses tsx to run TypeScript directly, but your VPS needs plain JavaScript. A tsconfig.json file tells the TypeScript compiler how to transform your code into a dist/ folder of production-ready .js files.

  • Create a new file called tsconfig.json in the root of your agence-amour-api folder and paste this configuration:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "sourceMap": true
  },
  "include": [
    "server.ts",
    "middleware/**/*",
    "models/**/*",
    "routes/**/*",
    "scripts/**/*"
  ],
  "exclude": ["node_modules", "dist", "uploads"]
}

What does this config do?

  • outDir: "./dist" puts all compiled JavaScript into a dist/ folder, keeping your source clean.
  • include lists which files to compile. Only your source code, not node_modules or uploaded files.
  • strict: true enables all TypeScript strictness checks so type errors surface at build time, not at runtime on the VPS.
  • Save tsconfig.json.

Now you need scripts in package.json that trigger the build and run the compiled output.

  • Open package.json and find the "scripts" section. It currently looks like this:
"scripts": {
  "start": "tsx server.ts",
  "dev": "tsx watch server.ts"
}
  • Replace the entire "scripts" block with this expanded version that adds build, serve, and seed commands:
"scripts": {
  "start": "tsx server.ts",
  "dev": "tsx watch server.ts",
  "build": "tsc",
  "serve": "node dist/server.js",
  "seed": "tsx scripts/seed-admin.ts"
}

What do the new scripts do?

  • build runs the TypeScript compiler (tsc) which reads tsconfig.json and outputs JavaScript to dist/.
  • serve runs the compiled JavaScript with plain Node.js. This is what your VPS will use.
  • seed runs a script to create your initial admin accounts. You will build this script in the next step.
  • Save package.json.

Before you run the build, think about what you expect to happen. The compiler will read every .ts file listed in your include config. Do you think it will succeed on the first try, or surface a type error?

  • Test the full build-and-run cycle by running:
npm run build
  • Confirm the dist/ folder was created by running:
ls dist/

You should see compiled .js and .js.map files listed, including server.js.

  • Now run the compiled server to confirm it works as production JavaScript:
node dist/server.js

You should see Connected to MongoDB and Server running on http://localhost:4000. Your API is running from compiled code, exactly how it will run on the VPS.

  • Press Ctrl+C to stop the server.

Build failed with type errors?

If tsc reports errors, read the first error message carefully. It names the file and line number. Common causes: a missing import, a type mismatch in your middleware, or leftover code from the previous User.ts that conflicts with the new interface.

Help me fix my TypeScript build errors

Server crashes with 'Cannot find module' after build?

Make sure you ran node dist/server.js from the agence-amour-api root folder, not from inside dist/. The compiled code uses relative paths that expect node_modules to be in the parent directory.

Help me fix 'Cannot find module' errors when running my compiled server

Your backend now hashes every password automatically and compiles to production-ready JavaScript. Next up, you will build the login, refresh, and logout endpoints so staff can actually get a token by signing in.

Build the Login, Refresh, and Logout Endpoints

Your authenticateJWT middleware can verify tokens, but there is no way for a staff member to actually get a token.

This step builds the "front door" where staff exchange their username and password for a token pair: a short-lived access token (15 minutes) and a long-lived refresh token (7 days stored in an HttpOnly cookie).

In this step, get ready to:
  • Create the login endpoint that verifies credentials and issues both tokens.
  • Add refresh, logout, and "who am I" endpoints for a complete auth flow.
  • Seed two protected admin accounts and test the full login cycle with curl.
Create the auth routes file and login endpoint

The login endpoint is where a staff member sends their username and password and receives tokens in return.

  • Create a new file called routes/auth.ts in your agence-amour-api folder.
  • Add the imports, router setup, and the credential-checking portion of the login endpoint by pasting this code:
import express, { Response, Router } from 'express';
import jwt from 'jsonwebtoken';
import User from '../models/User';
import { authenticateJWT, AuthenticatedRequest } from '../middleware/auth';

const authRouter: Router = express.Router();

// POST /login - verify credentials and issue tokens
authRouter.post('/login', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const { username, password } = req.body;

  if (!username || !password) {
    res.status(400).json({ error: 'Username and password are required.' });
    return;
  }

  // select('+password') overrides the select:false on the schema
  const user = await User.findOne({ username }).select('+password');
  if (!user) {
    res.status(401).json({ error: 'Invalid credentials.' });
    return;
  }

  const isMatch = await user.comparePassword(password);
  if (!isMatch) {
    res.status(401).json({ error: 'Invalid credentials.' });
    return;
  }

What does this code do?

This sets up the first half of the login handler. It extracts username and password from the request body, then uses .select('+password') to explicitly include the password field (which your schema hides by default with select: false).

The comparePassword method you built in Step 1 safely checks the login attempt against the stored hash. If anything fails, the response gives a generic "Invalid credentials" message so attackers cannot tell whether the username or password was wrong.

  • Now add the token generation, cookie setting, and response directly below in the same file:
  // Short-lived access token (15 minutes)
  const accessToken = jwt.sign(
    { id: user._id, clearanceLevel: user.clearanceLevel },
    process.env.JWT_SECRET || 'fallback_secret',
    { expiresIn: '15m' }
  );

  // Long-lived refresh token (7 days) stored in HttpOnly cookie
  const refreshToken = jwt.sign(
    { id: user._id },
    process.env.JWT_REFRESH_SECRET || 'fallback_refresh_secret',
    { expiresIn: '7d' }
  );

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: '/',
  });

  res.json({
    accessToken,
    user: {
      id: user._id,
      username: user.username,
      email: user.email,
      clearanceLevel: user.clearanceLevel,
      permissions: user.permissions,
    },
  });
});

Why two tokens with different lifetimes?

The access token expires in 15 minutes. If it gets stolen, the damage window is short. The refresh token lives 7 days inside an HttpOnly cookie that JavaScript cannot read, so cross-site scripting attacks cannot steal it.

When the access token expires, the browser silently sends the refresh cookie to get a new one. Staff stay logged in for up to 7 days without ever re-entering their password.

  • Save routes/auth.ts.
  • Confirm the file exists by running:
ls routes/

You should see auth.ts listed alongside articles.ts.

Don't see auth.ts?

Make sure you created the file inside the routes/ folder, not the project root. If it ended up in the wrong place, move it with mv auth.ts routes/auth.ts.

help me figure out why my auth.ts file is not in the routes folder

Add refresh and logout endpoints, then seed the admin accounts

The login endpoint issues tokens, but you also need a way to renew them silently, log out, and check who is currently authenticated.

  • In routes/auth.ts, find the closing }); of the login handler (the last line you pasted).
  • Add the refresh endpoint directly below it:
// POST /refresh - silently renew an expired access token
authRouter.post('/refresh', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const refreshToken = req.cookies?.refreshToken;

  if (!refreshToken) {
    res.status(401).json({ error: 'Refresh token required.' });
    return;
  }

  try {
    const decoded = jwt.verify(
      refreshToken,
      process.env.JWT_REFRESH_SECRET || 'fallback_refresh_secret'
    ) as { id: string };

    const user = await User.findById(decoded.id);
    if (!user) {
      res.status(401).json({ error: 'User not found.' });
      return;
    }

    const accessToken = jwt.sign(
      { id: user._id, clearanceLevel: user.clearanceLevel },
      process.env.JWT_SECRET || 'fallback_secret',
      { expiresIn: '15m' }
    );

    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: 'Invalid or expired refresh token.' });
  }
});

What does the refresh endpoint do?

It reads the refresh token from the HttpOnly cookie (not the request body, because JavaScript cannot access HttpOnly cookies). If the token is valid and the user still exists, it issues a fresh 15-minute access token.

This lets the frontend silently renew access without forcing staff to re-login every 15 minutes.

  • Add the logout and "who am I" endpoints directly below the refresh handler:
// POST /logout - clear the refresh token cookie
authRouter.post('/logout', (_req: AuthenticatedRequest, res: Response): void => {
  res.clearCookie('refreshToken', { path: '/' });
  res.json({ message: 'Logged out successfully.' });
});

// GET /me - return the current authenticated staff member's info
authRouter.get('/me', authenticateJWT, async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  if (!req.user) {
    res.status(401).json({ error: 'Not authenticated.' });
    return;
  }

  const user = await User.findById(req.user.id);
  if (!user) {
    res.status(404).json({ error: 'User not found.' });
    return;
  }

  res.json({
    id: user._id,
    username: user.username,
    email: user.email,
    clearanceLevel: user.clearanceLevel,
    permissions: user.permissions,
    isProtected: user.isProtected,
  });
});

export default authRouter;

What do these endpoints do?

  • POST /logout clears the refresh token cookie so the browser can no longer renew tokens silently.
  • GET /me uses your existing authenticateJWT middleware to verify the access token, then returns the staff member's profile info. This is how the frontend knows who is logged in.
  • Save routes/auth.ts.
  • Verify all your TypeScript compiles cleanly by running:
npx tsc --noEmit

No output means all your TypeScript files pass type-checking. If you see errors, check for typos in your imports or function signatures.

Seeing TypeScript errors?

The most common issue is a missing import. Confirm the top of routes/auth.ts imports authenticateJWT and AuthenticatedRequest from '../middleware/auth'. Also check that req.cookies does not show a type error. The @types/cookie-parser package you installed in Step 1 adds the cookies property to the request type.

help me fix TypeScript errors in my auth routes file

Now you need users to test with. You will create a seed script that inserts two protected admin accounts into MongoDB.

  • Create a new folder called scripts/ in your agence-amour-api folder by running:
mkdir scripts
  • Create a new file called scripts/seed-admin.ts and paste the first part of the seed script:
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import User from '../models/User';

dotenv.config();

async function seedAdmin() {
  // Connect to MongoDB using the same URI as the server
  await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/agence-amour');

  // Create developer admin (your account)
  const existingAdmin = await User.findOne({ username: 'admin' });
  if (!existingAdmin) {
    await User.create({
      username: 'admin',
      email: 'accountability@love-agency.com',
      password: 'change-me-immediately',
      clearanceLevel: 1,
      permissions: ['all'],
      isProtected: true,
    });
    console.log('Developer admin created (username: admin)');
  } else {
    console.log('Developer admin already exists.');
  }
  • Add the second admin account and the closing logic directly below:
  // Create client admin (website owner's account)
  const existingClient = await User.findOne({ username: 'client-admin' });
  if (!existingClient) {
    await User.create({
      username: 'client-admin',
      email: 'contact@love-agency.com',
      password: 'change-me-immediately',
      clearanceLevel: 1,
      permissions: ['all'],
      isProtected: true,
    });
    console.log('Client admin created (username: client-admin)');
  } else {
    console.log('Client admin already exists.');
  }

  console.log('Seed complete. Both accounts are protected (cannot be deleted via API).');
  console.log('IMPORTANT: Change both passwords after first login!');
  process.exit(0);
}

seedAdmin();

Why are both accounts marked isProtected?

The isProtected: true flag prevents these accounts from being deleted through the API. Even a Level 1 admin cannot accidentally remove the core developer or client accounts.

The password 'change-me-immediately' is a placeholder. The pre-save hook in your User model automatically hashes it with bcryptjs before storing it in the database.

  • Save scripts/seed-admin.ts.
  • Run the seed script to create both admin accounts:
npm run seed

You should see output confirming both accounts were created:

Developer admin created (username: admin)
Client admin created (username: client-admin)
Seed complete. Both accounts are protected (cannot be deleted via API).
IMPORTANT: Change both passwords after first login!

Seeing a connection error?

Make sure MongoDB is running. If you installed it via Homebrew, start it with brew services start mongodb-community. Also confirm your .env file has MONGODB_URI=mongodb://127.0.0.1:27017/agence-amour (the seed script reads from .env via dotenv).

help me fix the MongoDB connection error when running my seed script

Wire auth routes into server.ts and test the full flow

The auth routes exist and admin accounts are seeded. Now you need to register the routes in your server so incoming requests reach them.

  • In server.ts, replace the entire file contents with this updated version:
import express from 'express';
import mongoose from 'mongoose';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import articlesRouter from './routes/articles';
import authRouter from './routes/auth';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 4000;

// Allow the Next.js frontend to send cookies
app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true,
}));
app.use(express.json());
app.use(cookieParser());

mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/agence-amour')
  .then(() => console.log('Connected to MongoDB'))
  .catch((err) => console.error('MongoDB connection error:', err));

app.use('/api/auth', authRouter);
app.use('/api/articles', articlesRouter);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What changed in server.ts?

  • dotenv is imported and dotenv.config() is called so your secrets load from .env.
  • cookieParser is imported and registered as middleware so Express can read refresh token cookies.
  • authRouter is imported and mounted at /api/auth.
  • credentials: true is added to the CORS config so the browser is allowed to send cookies cross-origin to your API.
  • Save server.ts.

Your terminal running tsx watch should automatically restart. You should see:

Connected to MongoDB
Server running on http://localhost:4000

Server crashed on restart?

If you see an import error mentioning routes/auth, confirm routes/auth.ts exists and the import path in server.ts says './routes/auth' (no .ts extension). If you see a cookieParser error, confirm the import is import cookieParser from 'cookie-parser' (default import, not destructured).

help me fix the server crash after updating server.ts with auth routes

Now for the moment of truth. Before you test, predict: do you think the login request will succeed or return an error?

  • Test the login endpoint by running this curl command:
curl -v -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"username": "admin", "password": "change-me-immediately"}'

You should see a JSON response body containing an accessToken field and a user object. In the response headers, look for a Set-Cookie line containing refreshToken= with HttpOnly at the end.

Why use curl -v?

The -v flag shows verbose output including response headers. This is how you can confirm the Set-Cookie header is being sent. Without -v, you would only see the JSON body and miss the cookie.

  • Copy the accessToken value from the login response (the long string after "accessToken":").
  • Test the /me endpoint by running:
curl http://localhost:4000/api/auth/me -H "Authorization: Bearer your-access-token-here"

Replace your-access-token-here with the token you copied. You should see your admin user's info (username, email, clearanceLevel).

  • Confirm that accessing /me without a token fails by running:
curl http://localhost:4000/api/auth/me

You should see a 401 response with an error message. Your auth middleware correctly rejects unauthenticated requests.

Login returns 401 Invalid credentials?

Double-check that you ran npm run seed and saw the confirmation messages. If the admin already existed from a previous attempt, the password might differ. Drop the users collection and re-seed:

mongosh --eval "use agence-amour" --eval "db.users.drop()"
npm run seed

help me debug why login returns Invalid credentials even though I seeded the admin account

✔️ Awesome, I've got everything!

Great. Double check you have saved both routes/auth.ts and server.ts.

ⓧ I'd like to double check the full code

Your routes/auth.ts should contain these sections in order. Scroll up and confirm each chunk is present:

  • Imports (express, jwt, User, authenticateJWT, AuthenticatedRequest) and const authRouter declaration.
  • POST /login handler (credential validation, select('+password'), comparePassword, token generation, cookie setting, JSON response).
  • POST /refresh handler (reads cookie, verifies refresh token, issues new access token).
  • POST /logout handler (clears refreshToken cookie).
  • GET /me handler (uses authenticateJWT middleware, returns user info).
  • export default authRouter at the bottom.

Your server.ts should match this exactly:

import express from 'express';
import mongoose from 'mongoose';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import articlesRouter from './routes/articles';
import authRouter from './routes/auth';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 4000;

app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true,
}));
app.use(express.json());
app.use(cookieParser());

mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/agence-amour')
  .then(() => console.log('Connected to MongoDB'))
  .catch((err) => console.error('MongoDB connection error:', err));

app.use('/api/auth', authRouter);
app.use('/api/articles', articlesRouter);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your API now has a complete authentication flow. Staff can log in, stay authenticated silently via token refresh, and log out cleanly. Next up, you will build the browser-based login page and protected dashboard so your staff never have to touch curl.

Build the CRM Login Page and Protected Dashboard

Your API login, refresh, and logout endpoints all work perfectly from curl. You proved it in the last step.

Your staff will never use curl. They need a browser page where they type their username and password, press login, and land on a dashboard. In this step, you will build the "front door" of your CRM as a Next.js login page with route protection that bounces unauthorized visitors back to the login screen automatically.

In this step, get ready to:
  • Create a login page at /dashboard/login that sends credentials to your API.
  • Add Next.js middleware that blocks unauthenticated access to all /dashboard routes.
  • Build a protected dashboard page showing staff name and clearance level.
Create the login page at /dashboard/login

The login page is a Client Component. It renders a form with username and password fields, sends those credentials to your API, and stores the returned access token in memory for future requests.

  • Switch to your agence-amour Next.js project terminal. If the dev server is not already running, start it by running:
  • Create the folder structure for the login page by running:
mkdir -p app/dashboard/login
  • Create a new file at app/dashboard/login/page.tsx and add the component logic:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const router = useRouter();

  // Send credentials to the API and store the access token
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError('');
    const res = await fetch('http://localhost:4000/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    if (!res.ok) { setError(data.error || 'Login failed.'); return; }
    // Store token in sessionStorage (cleared when tab closes)
    sessionStorage.setItem('accessToken', data.accessToken);
    router.push('/dashboard');
  }

What does this code do?

The 'use client' directive makes this a Client Component so it can use React state and handle form submissions in the browser.

The credentials: 'include' setting tells the browser to accept and store the HttpOnly refresh token cookie that the API sends back. The access token goes into sessionStorage so it persists across page navigations but disappears when you close the tab.

  • Still in the same file, add the JSX return below the handleSubmit function:
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="w-full max-w-md p-6 border rounded-lg shadow">
        <h1 className="text-2xl font-bold mb-4">CRM Login</h1>
        <form onSubmit={handleSubmit} className="space-y-4">
          {error && <p className="text-red-500">{error}</p>}
          <input type="text" placeholder="Username" value={username}
            onChange={(e) => setUsername(e.target.value)}
            className="w-full p-2 border rounded" />
          <input type="password" placeholder="Password" value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full p-2 border rounded" />
          <button type="submit"
            className="w-full p-2 bg-blue-600 text-white rounded hover:bg-blue-700">
            Log In
          </button>
        </form>
      </div>
    </div>
  );
}
  • Save app/dashboard/login/page.tsx.
  • Open your browser to http://localhost:3000/dashboard/login. You should see a centered card with Username and Password fields and a blue Log In button.

Don't see the login form?

  • Make sure your Next.js dev server is running in the agence-amour folder. Run npm run dev if it is not.
  • Confirm the file is saved at exactly app/dashboard/login/page.tsx inside your agence-amour project.
  • Check the terminal for TypeScript errors. Missing imports or a stray bracket will prevent the page from compiling.

Ask for help: why isn't my login page showing up at /dashboard/login?

Add Next.js middleware to protect /dashboard routes

Right now anyone can visit /dashboard directly. Next.js middleware intercepts every request to a matched route before the page even begins to render. If the visitor has no valid session cookie, the middleware redirects them to the login page instantly.

  • Create a new file called middleware.ts at the root of your agence-amour project (the same level as app/). Add the following code:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Allow access to the login page itself
  if (pathname === '/dashboard/login') {
    return NextResponse.next();
  }

  // Check for the refresh token cookie (set by the API on login)
  const refreshToken = request.cookies.get('refreshToken');

  if (!refreshToken) {
    // No session found - redirect to login
    return NextResponse.redirect(
      new URL('/dashboard/login', request.url)
    );
  }

  return NextResponse.next();
}

// Only run on /dashboard and its sub-routes
export const config = {
  matcher: '/dashboard/:path*',
};

What does this middleware do?

  • The matcher tells Next.js to only run this function on requests to /dashboard and any sub-routes like /dashboard/staff.
  • It explicitly allows /dashboard/login through (otherwise you could never reach the login page).
  • For every other dashboard path, it checks for the refreshToken cookie. This cookie is set by your API on successful login. Cookies are scoped by domain (not port), so a cookie set by localhost:4000 is visible to localhost:3000.
  • If the cookie is missing, the user has not logged in. The middleware redirects them to /dashboard/login before the page renders.

Why middleware.ts instead of proxy.ts?

Next.js v16 renamed middleware.ts to proxy.ts. The old name still works (deprecated but not removed). Using middleware.ts keeps your project backward-compatible if you are on v15 or earlier.

  • Save middleware.ts.
  • Open a new browser tab and navigate to http://localhost:3000/dashboard. You should be instantly redirected to /dashboard/login because you have not logged in yet.

Not getting redirected?

  • Make sure middleware.ts is at the project root (same level as app/), not inside app/ or a subfolder.
  • If you previously logged in from curl and still have a refreshToken cookie, clear cookies for localhost in your browser settings, then try again.
  • Restart the Next.js dev server after creating middleware for the first time. It sometimes needs a fresh start to pick up the new file.

Ask for help: why isn't my middleware redirecting unauthenticated users?

Build the dashboard home page with a navigation sidebar

When a staff member logs in successfully, they should land on a dashboard that greets them by name and shows navigation links based on their clearance level. Level 1 admins see all sections. Lower clearance levels see fewer links.

  • Create the dashboard page file by running:
touch app/dashboard/page.tsx
  • Open app/dashboard/page.tsx and add the data-fetching logic:
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

export default function DashboardPage() {
  const [user, setUser] = useState<any>(null);
  const router = useRouter();

  useEffect(() => {
    // Retrieve the access token stored during login
    const token = sessionStorage.getItem('accessToken');
    if (!token) { router.push('/dashboard/login'); return; }

    // Fetch the current staff member's info from the API
    fetch('http://localhost:4000/api/auth/me', {
      headers: { Authorization: `Bearer ${token}` },
      credentials: 'include',
    })
      .then(res => res.ok ? res.json() : Promise.reject())
      .then(data => setUser(data))
      .catch(() => router.push('/dashboard/login'));
  }, [router]);

  if (!user) return <p className="p-8">Loading...</p>;

  // Define sidebar links with minimum clearance level required
  const navLinks = [
    { name: 'Articles', href: '/dashboard/articles', minLevel: 3 },
    { name: 'Preregistrations', href: '/dashboard/preregistrations', minLevel: 2 },
    { name: 'Contacts', href: '/dashboard/contacts', minLevel: 2 },
    { name: 'Staff', href: '/dashboard/staff', minLevel: 1 },
  ];

What does this code do?

  • On mount, the component reads the accessToken from sessionStorage (saved during login). If missing, it pushes the user back to login.
  • It calls GET /api/auth/me with the token in an Authorization header. Your API verifies the JWT and returns the staff member's profile.
  • The navLinks array defines each sidebar link with a minLevel property. A Level 1 admin (lowest number, highest access) sees all links. A Level 3 staff member only sees Articles.
  • Below the navLinks array, add the JSX return to complete the component:
  return (
    <div className="flex min-h-screen">
      <nav className="w-64 border-r p-4">
        <h2 className="font-bold mb-4">Navigation</h2>
        {navLinks
          .filter(link => user.clearanceLevel <= link.minLevel)
          .map(link => (
            <a key={link.name} href={link.href}
              className="block py-2 px-3 rounded hover:bg-gray-100">
              {link.name}
            </a>
          ))}
      </nav>
      <main className="flex-1 p-8">
        <h1 className="text-2xl font-bold">Welcome, {user.username}</h1>
        <p className="mt-2">Email: {user.email}</p>
        <p>Clearance Level: {user.clearanceLevel}</p>
      </main>
    </div>
  );
}

How does the sidebar filter work?

The filter user.clearanceLevel <= link.minLevel means a staff member sees a link only if their clearance number is at or below the link's minimum. Level 1 (highest privilege) passes all checks. Level 3 only passes for links with minLevel: 3.

  • Save app/dashboard/page.tsx.

Before you test the full login flow, predict what will happen: if you go to http://localhost:3000/dashboard/login and enter admin with password change-me-immediately, where do you think you will end up?

  • Make sure your API backend is running (npm run dev in the agence-amour-api folder).
  • Open http://localhost:3000/dashboard/login in your browser.
  • Enter admin in the Username field.
  • Enter change-me-immediately in the Password field.
  • Click Log In.

You land on the dashboard showing "Welcome, admin" with clearance level 1 displayed. The sidebar shows all four navigation links (Articles, Preregistrations, Contacts, Staff) because Level 1 has full access.

  • Now open a new incognito window and navigate to http://localhost:3000/dashboard (without logging in). Confirm you get redirected to /dashboard/login.

Login form shows an error?

  • Check that your API server is running on port 4000. The login page posts to http://localhost:4000/api/auth/login.
  • Make sure credentials: true is set in your API's CORS config and the origin is http://localhost:3000. Without this, the browser blocks cross-origin cookies.
  • Verify you ran the seed script previously. The admin account must exist in MongoDB for login to succeed.

Ask for help: my login request fails or I get a CORS error.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files: app/dashboard/login/page.tsx, middleware.ts, and app/dashboard/page.tsx.

ⓧ I'd like to double check the full code

Scroll back through this step and confirm each of these code blocks is present in your files, in order:

  • app/dashboard/login/page.tsx: the handleSubmit logic chunk followed by the JSX return chunk.
  • middleware.ts: the full middleware with matcher: '/dashboard/:path*'.
  • app/dashboard/page.tsx: the data-fetching logic chunk followed by the JSX return chunk.

Your CRM now has a real entrance. Staff can type their username and password in a browser, receive a secure token, and land on a protected dashboard. Unauthorized visitors get bounced back instantly. Next up, you will extend this dashboard with a full staff management panel in the Secret Mission.

Secret mission

Build the Staff Management Panel

Your CRM has authentication, but no way to manage your team. Build a full staff management panel where Level 1 admins can create, edit, and remove staff accounts. Add permission selection for Level 2 staff and protect critical accounts from accidental deletion.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The agence-amour-api project folder (TypeScript backend with auth routes and production build).
  • The MongoDB service running via Homebrew (stores staff and article data).
  • The agence-amour Next.js project (updated with /dashboard routes).

Keep everything running

No action needed. Choose this if you plan to continue building on this project in the next session.

  • Run npm run dev in both project folders to restart your servers whenever you come back.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your code and database stay intact for next time.

  • Press Ctrl+C in both terminal windows to stop the backend and frontend dev servers.
  • MongoDB keeps running as a background service. No action needed there.
  • When you come back, run npm run dev in each project folder to restart.

Delete - I don't want to use this again

Remove the authentication data this project created. Your code stays on your Desktop for reference.

  • Press Ctrl+C in both terminal windows to stop the backend and frontend dev servers.
  • To reset authentication and remove all staff accounts, open the MongoDB shell and drop the users collection by running:
mongosh

Then run these commands inside the shell:

use agence-amour
db.users.drop()
  • Type exit to leave the MongoDB shell.
  • If you also want to stop MongoDB entirely, run:
brew services stop mongodb-community

What does dropping the collection do?

Running db.users.drop() deletes the entire users collection from your agence-amour database. This removes the admin and client-admin accounts you seeded, plus any staff accounts you created during the project.

If you ever want to start fresh, you can re-run npm run seed from the agence-amour-api folder to recreate the protected admin accounts.

That's a Wrap!

That's a Wrap!

Nice work! You've just secured your CRM with a complete authentication system, from password hashing all the way to a protected browser-based dashboard that only your staff can access.

You learned how to:

  • Add bcrypt password hashing to your User model so passwords are scrambled in the database and never stored as readable text.
  • Build a complete login system with JWT access tokens (15min) and refresh tokens (7 days) stored in HttpOnly cookies that JavaScript cannot steal.
  • Add a production build pipeline that compiles TypeScript to dist/ for your VPS, and build a protected Next.js CRM dashboard with middleware-based route guarding.
  • Secret Mission: Build a full staff management CRUD panel with Level 2 permission selection, protected admin accounts that cannot be deleted, and authorization gating for Level 1 only.

Ready to quiz yourself?