Build a Student Portal with RBAC
Build a multi-role university portal with session auth and route protection.
Introduction
30 Second Summary
Think about how a university works. Students check their grades, accountants handle tuition payments, and admins manage student records. Each person sees only what they need to see.
In this project, you will build a university student portal where three types of users log in and see completely different dashboards based on their role. You will implement the same access control pattern that real-world applications use to keep data secure.
What You'll Build
A Node.js web application with Express.js, SQLite, and session-based authentication that restricts access to routes based on user roles.
By the end of this project, you'll have:
- A login system with hashed passwords and session-based authentication that keeps user data secure.
- A Role-Based Access Control middleware that protects routes for students, accountants, and admins.
- Three distinct dashboards showing different data depending on who is logged in, plus a password reset feature.
- Secret Mission: Add tuition fee blocking so students who have not paid see a restricted dashboard.
Are there any prerequisites?
You should have basic JavaScript knowledge (variables, functions, arrays, objects) and familiarity with HTML and CSS. No prior experience with Node.js or databases is required.
Set Up the Project
Before writing any application code, you need a proper project structure. A Node.js project uses npm (Node Package Manager) to track dependencies and run scripts.
In this step, you will create the project folder, install the five packages the portal depends on, and set up the directory structure that keeps routes, views, and middleware organized.
In this step, get ready to:
- Initialize a Node.js project with npm.
- Install all five dependencies the portal needs.
- Create the folder structure for routes, views, middleware, and static files.
Create the project directory and initialize it
Every Node.js project starts with a folder and a package.json file that tracks metadata and dependencies. The npm init command generates this file for you.
- Switch back to VS Code and open a new terminal by clicking Terminal in the top menu bar, then New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir student-portal
cd student-portal
- Initialize the project with npm by running:
npm init -y
What does npm init -y do?
The npm init command creates a package.json file in your project. The -y flag accepts all the default values so you skip the interactive prompts.
You will customize this file shortly with the correct scripts and metadata.
You should see a message confirming the package.json file was created.
Seeing 'command not found' for npm?
This means Node.js is not properly installed or not in your system PATH. Restart VS Code and try again. If it persists, reinstall Node.js from the official site and ensure you check the option to add it to PATH during installation.
Help me fix my npm installation
Install dependencies and create the folder structure
The portal relies on five packages that handle the web server, templates, database, password security, and user sessions. Installing them now means they are ready when you start writing application code.
- Install all five dependencies by running:
npm install express ejs better-sqlite3 bcryptjs express-session
What are these five packages?
- express is the web server framework that handles HTTP requests and routing.
- ejs is a templating engine that lets you write HTML pages with dynamic data.
- better-sqlite3 is a fast, file-based database with no external server to install.
- bcryptjs hashes passwords so they are never stored as plain text.
- express-session manages login sessions so users stay authenticated between page loads.
You should see a progress bar while packages download, followed by a summary of added packages.
Install failing with build errors?
The better-sqlite3 package uses prebuilt binaries for Node.js LTS versions. If you see compilation errors, make sure your Node.js version is 18 or higher by running node --version. Updating to the latest LTS version typically resolves build issues.
Help me fix better-sqlite3 build errors
Now create the folder structure that keeps your code organized. Each folder has a clear responsibility.
- Create all the project folders by running these commands:
mkdir database middleware routes public
mkdir views
mkdir views/student views/admin views/accountant
What is each folder for?
- database/ holds the setup script and the SQLite database file.
- middleware/ holds the authentication and authorization logic.
- routes/ holds the Express route handlers for each role.
- views/ and its subfolders hold the EJS templates for each user role.
- public/ holds static assets like CSS.
- Confirm the folders exist by running:
ls
You should see database, middleware, node_modules, package-lock.json, package.json, public, routes, and views listed in the output.
Configure package.json with start and setup scripts
The default package.json from npm init -y has a placeholder test script and generic metadata. You need two custom scripts: one to start the server and one to set up the database.
- Open the student-portal folder in VS Code by clicking File in the top menu bar, then Open Folder.
- Select the student-portal folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, click package.json to open it.
- Select all the existing content and replace it with the following:
{
"name": "student-portal",
"version": "1.0.0",
"description": "University Student Information System with Role-Based Access Control",
"main": "app.js",
"scripts": {
"start": "node app.js",
"setup-db": "node database/setup.js"
},
"keywords": ["student", "portal", "rbac", "express"],
"author": "",
"license": "MIT",
"dependencies": {
"bcryptjs": "3.0.3",
"better-sqlite3": "12.11.1",
"ejs": "6.0.1",
"express": "5.2.1",
"express-session": "1.19.0"
}
}
What did you change?
- main is set to app.js because that will be your server entry point.
- scripts.start runs the server with npm start.
- scripts.setup-db runs the database seeding script with npm run setup-db.
- The dependency versions are pinned to ensure consistent behavior.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
VS Code showing a JSON error?
Make sure you replaced ALL the original content, including the opening and closing braces. A stray character from the old file causes JSON parsing errors. Press Cmd+A (macOS) or Ctrl+A (Windows) to select everything, then paste again.
Help me fix my package.json error
✔️ Awesome, I've got everything!
Great. Make sure you have saved package.json before moving on.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "student-portal",
"version": "1.0.0",
"description": "University Student Information System with Role-Based Access Control",
"main": "app.js",
"scripts": {
"start": "node app.js",
"setup-db": "node database/setup.js"
},
"keywords": ["student", "portal", "rbac", "express"],
"author": "",
"license": "MIT",
"dependencies": {
"bcryptjs": "3.0.3",
"better-sqlite3": "12.11.1",
"ejs": "6.0.1",
"express": "5.2.1",
"express-session": "1.19.0"
}
}
Your project skeleton is in place. Next up, you will build the database and fill it with sample students, courses, and grades.
Build the Database and Seed Sample Data
Your project structure is ready. Now you need somewhere to store user accounts, course information, and enrollment records.
In this step, you'll create a SQLite database with three tables and populate it with sample data. This gives you realistic test users and courses to work with as you build the login system and role-specific dashboards.
In this step, get ready to:
- Create a database setup script with three tables: users, courses, and enrollments.
- Seed the database with sample users, courses, and enrollment records.
- Run the setup script to generate the database file.
Create the database tables
Every web application that handles user accounts needs a database. Your portal needs to track three things: who can log in (and what role they have), what courses exist, and which students are enrolled in which courses.
- In VS Code, create a new file called setup.js inside the database/ folder.
- Paste the following code into database/setup.js:
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
// Create and open the database file
const db = new Database(path.join(__dirname, 'portal.db'));
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Create the three tables
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('student', 'accountant', 'admin')),
full_name TEXT NOT NULL,
email TEXT NOT NULL,
tuition_paid INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS enrollments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
grade TEXT DEFAULT 'N/A',
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (course_id) REFERENCES courses(id)
);
`);
What does this code do?
This script uses better-sqlite3 to create a database file called portal.db inside the database/ folder. The WAL pragma improves read/write performance.
- The users table stores login credentials, roles, and a tuition_paid flag.
- The courses table holds course codes and names.
- The enrollments table links students to courses with a grade. This is a many-to-many relationship.
Why three separate tables?
Splitting data across related tables is called normalization. Instead of repeating course details for every student, you store courses once and use the enrollments table to connect students to their courses.
This means you can update a course name in one place and every enrolled student sees the change.
Seed the database with sample data
Empty tables are not useful for testing. You need sample users with different roles, courses to enroll in, and enrollment records with grades.
- Add the following code to the bottom of database/setup.js, below the closing `); of the db.exec call:
// Prepare reusable insert statements
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (username, password_hash, role, full_name, email, tuition_paid)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertCourse = db.prepare(`
INSERT OR IGNORE INTO courses (code, name) VALUES (?, ?)
`);
const insertEnrollment = db.prepare(`
INSERT OR IGNORE INTO enrollments (user_id, course_id, grade) VALUES (?, ?, ?)
`);
// Hash the shared demo password
const passwordHash = bcrypt.hashSync('password123', 10);
// Seed 4 users: 2 students, 1 accountant, 1 admin
insertUser.run('john_student', passwordHash, 'student', 'John Smith', 'john@university.edu', 1);
insertUser.run('jane_student', passwordHash, 'student', 'Jane Doe', 'jane@university.edu', 0);
insertUser.run('bob_accountant', passwordHash, 'accountant', 'Bob Wilson', 'bob@university.edu', 1);
insertUser.run('alice_admin', passwordHash, 'admin', 'Alice Johnson', 'alice@university.edu', 1);
// Seed 4 courses
insertCourse.run('CS101', 'Introduction to Computer Science');
insertCourse.run('CS201', 'Data Structures and Algorithms');
insertCourse.run('CS301', 'Database Systems');
insertCourse.run('MATH101', 'Calculus I');
// Seed enrollments linking students to courses with grades
insertEnrollment.run(1, 1, 'A');
insertEnrollment.run(1, 2, 'B+');
insertEnrollment.run(1, 4, 'A-');
insertEnrollment.run(2, 1, 'B');
insertEnrollment.run(2, 3, 'N/A');
console.log('Database setup complete! Sample data seeded.');
db.close();
What does this code do?
- db.prepare() creates reusable insert statements. The ? placeholders get filled in safely when you call .run().
- bcrypt.hashSync('password123', 10) hashes the password so it is never stored in plain text. The 10 is the salt rounds (how many times the hash is applied).
- john_student has tuition_paid = 1 (paid), while jane_student has tuition_paid = 0 (unpaid). This difference becomes important in the Secret Mission.
- INSERT OR IGNORE means running the script multiple times will not create duplicate records.
What is password hashing?
Hashing converts a password into an unreadable string. Even if someone accesses your database, they cannot reverse the hash back into the original password.
The bcryptjs library is a pure JavaScript implementation that requires zero native compilation. It is API-compatible with the native bcrypt package but installs without C++ build tools.
- Save database/setup.js.
Run the setup script to create the database
The setup-db script in package.json points to the file you just wrote. Running it creates the portal.db file and populates it with sample data.
- Run the database setup script in your terminal:
npm run setup-db
- You should see this output in the terminal:
Database setup complete! Sample data seeded.
- Confirm the database file was created by running:
ls database/
- You should see both setup.js and portal.db in the output.
Seeing an error when running the script?
- If you see a "Cannot find module" error, make sure you ran npm install in Step 1. The better-sqlite3 and bcryptjs packages must be installed.
- If you see a build error from better-sqlite3, make sure your Node.js version is 18 or higher. Run node --version to check.
- If the file is not in the right place, confirm you created setup.js inside the database/ folder (not the project root).
Why isn't my database setup script running?
✔️ Awesome, I've got everything!
Great. Double check you've saved database/setup.js and that portal.db appears in your database/ folder.
ⓧ I'd like to double check the full code
Here is the complete database/setup.js file:
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
const db = new Database(path.join(__dirname, 'portal.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('student', 'accountant', 'admin')),
full_name TEXT NOT NULL,
email TEXT NOT NULL,
tuition_paid INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS enrollments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
grade TEXT DEFAULT 'N/A',
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (course_id) REFERENCES courses(id)
);
`);
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (username, password_hash, role, full_name, email, tuition_paid)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertCourse = db.prepare(`
INSERT OR IGNORE INTO courses (code, name) VALUES (?, ?)
`);
const insertEnrollment = db.prepare(`
INSERT OR IGNORE INTO enrollments (user_id, course_id, grade) VALUES (?, ?, ?)
`);
const passwordHash = bcrypt.hashSync('password123', 10);
insertUser.run('john_student', passwordHash, 'student', 'John Smith', 'john@university.edu', 1);
insertUser.run('jane_student', passwordHash, 'student', 'Jane Doe', 'jane@university.edu', 0);
insertUser.run('bob_accountant', passwordHash, 'accountant', 'Bob Wilson', 'bob@university.edu', 1);
insertUser.run('alice_admin', passwordHash, 'admin', 'Alice Johnson', 'alice@university.edu', 1);
insertCourse.run('CS101', 'Introduction to Computer Science');
insertCourse.run('CS201', 'Data Structures and Algorithms');
insertCourse.run('CS301', 'Database Systems');
insertCourse.run('MATH101', 'Calculus I');
insertEnrollment.run(1, 1, 'A');
insertEnrollment.run(1, 2, 'B+');
insertEnrollment.run(1, 4, 'A-');
insertEnrollment.run(2, 1, 'B');
insertEnrollment.run(2, 3, 'N/A');
console.log('Database setup complete! Sample data seeded.');
db.close();
Your database is ready with test accounts for every role. Next up, you'll build the login system that authenticates users against this data and starts their session.
Create the Login System with Sessions
Your database is stocked with users, courses, and enrollment records. Now you need a way for those users to prove who they are when they visit the portal.
In this step, you'll build the Express server, create a login system that remembers users with sessions, and write middleware that controls access based on each user's role.
In this step, get ready to:
- Set up the Express server with EJS templates and session middleware.
- Create authentication routes and a login page.
- Write RBAC middleware that protects routes by user role.
Set up the Express server in app.js
Every Express app starts with a main entry file that configures the server. This file wires together the view engine, middleware, and routes so incoming requests get handled correctly.
- Create a new file called app.js in the root of your student-portal folder and paste the following code:
const express = require('express');
const session = require('express-session');
const path = require('path');
const authRoutes = require('./routes/auth');
const studentRoutes = require('./routes/student');
const adminRoutes = require('./routes/admin');
const accountantRoutes = require('./routes/accountant');
const app = express();
const PORT = 3000;
// Tell Express to use EJS for rendering HTML pages
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Parse form submissions and serve static files
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
// Configure session middleware to remember logged-in users
app.use(session({
secret: 'student-portal-secret-key',
resave: false,
saveUninitialized: false
}));
// Mount route handlers
app.use('/', authRoutes);
app.use('/student', studentRoutes);
app.use('/admin', adminRoutes);
app.use('/accountant', accountantRoutes);
// Redirect the root URL to the login page
app.get('/', (req, res) => {
res.redirect('/login');
});
// Start the server
app.listen(PORT, () => {
console.log(`Student Portal running at http://localhost:${PORT}`);
});
What does this code do?
- app.set('view engine', 'ejs') tells Express to render .ejs templates as HTML pages.
- express.urlencoded() parses form data (like login credentials) from POST requests.
- The session() middleware creates a session for each visitor. Once a user logs in, their info is stored in the session so they stay authenticated across page loads.
- app.use('/', authRoutes) mounts the auth routes at the root path. Each route group handles a different role's pages.
Notice that app.js imports route files for student, admin, and accountant. Those files don't exist yet, so the server would crash if you started it now.
- Create placeholder route files so the server can start by running these commands in your terminal:
echo "const express = require('express'); const router = express.Router(); module.exports = router;" > routes/student.js
echo "const express = require('express'); const router = express.Router(); module.exports = router;" > routes/admin.js
echo "const express = require('express'); const router = express.Router(); module.exports = router;" > routes/accountant.js
Why create placeholders?
Node.js throws a Cannot find module error if a require() points to a file that doesn't exist. These empty routers satisfy the imports until you build the real dashboards in the next step.
- Save app.js.
Seeing a module not found error?
- Make sure all three placeholder files (routes/student.js, routes/admin.js, routes/accountant.js) were created inside the routes/ folder.
- Check that your terminal is inside the student-portal folder before running the echo commands.
help me fix a Cannot find module error in my Express app
Create the authentication routes and login page
The auth routes handle the login flow. When a user submits their credentials, the server checks them against the database, creates a session, and redirects to the appropriate dashboard.
- Create a new file called routes/auth.js and paste the following code for the login routes:
const express = require('express');
const bcrypt = require('bcryptjs');
const Database = require('better-sqlite3');
const path = require('path');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
// Show the login page (redirect if already logged in)
router.get('/login', (req, res) => {
if (req.session && req.session.user) {
return res.redirect('/dashboard');
}
res.render('login', { error: null });
});
// Handle login form submission
router.post('/login', (req, res) => {
const { username, password } = req.body;
// Look up the user in the database
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
// Check if user exists and password matches the stored hash
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.render('login', { error: 'Invalid username or password' });
}
// Store user info in the session
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
full_name: user.full_name,
email: user.email,
tuition_paid: user.tuition_paid
};
res.redirect('/dashboard');
});
What does this code do?
- GET /login renders the login page. If the user is already logged in, it skips the form and sends them straight to their dashboard.
- POST /login reads the submitted username and password, looks up the user in SQLite, and uses bcrypt.compareSync() to compare the plain-text password against the stored hash.
- If the credentials match, the user's details are saved into req.session.user. This session object persists across requests so the server knows who is making each subsequent request.
- Below the POST /login route you just pasted, add the remaining routes for logout, dashboard redirect, and password reset:
// Destroy session and redirect to login
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/login');
});
});
// Redirect to the correct dashboard based on role
router.get('/dashboard', (req, res) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
const role = req.session.user.role;
if (role === 'student') return res.redirect('/student/dashboard');
if (role === 'accountant') return res.redirect('/accountant/dashboard');
if (role === 'admin') return res.redirect('/admin/dashboard');
res.redirect('/login');
});
// Show the password reset form
router.get('/reset-password', (req, res) => {
res.render('reset-password', { error: null, success: null });
});
// Handle password reset submission
router.post('/reset-password', (req, res) => {
const { username, email, new_password, confirm_password } = req.body;
if (new_password !== confirm_password) {
return res.render('reset-password', { error: 'Passwords do not match', success: null });
}
if (new_password.length < 6) {
return res.render('reset-password', { error: 'Password must be at least 6 characters', success: null });
}
// Verify username and email match a real account
const user = db.prepare('SELECT * FROM users WHERE username = ? AND email = ?').get(username, email);
if (!user) {
return res.render('reset-password', { error: 'No account found with that username and email', success: null });
}
// Hash the new password and update the database
const newHash = bcrypt.hashSync(new_password, 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, user.id);
res.render('reset-password', { error: null, success: 'Password reset successfully! You can now login.' });
});
module.exports = router;
What do these routes do?
- GET /logout destroys the session, which forgets the logged-in user and sends them back to the login page.
- GET /dashboard acts as a traffic controller. It reads the user's role from the session and redirects to the matching dashboard URL.
- The reset-password routes verify that a username and email combination exists before allowing a password change. This is a simplified flow suitable for learning.
- Save routes/auth.js.
Now the login page needs a visual form. The EJS template renders HTML with embedded JavaScript for dynamic content like error messages.
- Create a new file called views/login.ejs and paste the following template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Student Portal - Login</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>University Student Portal</h1>
<h2>Login</h2>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<form action="/login" method="POST">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
<p><a href="/reset-password">Forgot your password?</a></p>
</div>
</body>
</html>
What does this template do?
- The <% if (error) { %> block is EJS syntax. It only renders the error paragraph when the server passes a non-null error value.
- The form sends a POST request to /login with username and password fields.
- The stylesheet link points to /style.css which you'll create in a later step. For now, the page works without styling.
- Save views/login.ejs.
✔️ Awesome, I've got everything!
Great. Double check you've saved both routes/auth.js and views/login.ejs.
ⓧ I'd like to double check the full code
Here is the complete routes/auth.js:
const express = require('express');
const bcrypt = require('bcryptjs');
const Database = require('better-sqlite3');
const path = require('path');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/login', (req, res) => {
if (req.session && req.session.user) {
return res.redirect('/dashboard');
}
res.render('login', { error: null });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.render('login', { error: 'Invalid username or password' });
}
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
full_name: user.full_name,
email: user.email,
tuition_paid: user.tuition_paid
};
res.redirect('/dashboard');
});
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/login');
});
});
router.get('/dashboard', (req, res) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
const role = req.session.user.role;
if (role === 'student') return res.redirect('/student/dashboard');
if (role === 'accountant') return res.redirect('/accountant/dashboard');
if (role === 'admin') return res.redirect('/admin/dashboard');
res.redirect('/login');
});
router.get('/reset-password', (req, res) => {
res.render('reset-password', { error: null, success: null });
});
router.post('/reset-password', (req, res) => {
const { username, email, new_password, confirm_password } = req.body;
if (new_password !== confirm_password) {
return res.render('reset-password', { error: 'Passwords do not match', success: null });
}
if (new_password.length < 6) {
return res.render('reset-password', { error: 'Password must be at least 6 characters', success: null });
}
const user = db.prepare('SELECT * FROM users WHERE username = ? AND email = ?').get(username, email);
if (!user) {
return res.render('reset-password', { error: 'No account found with that username and email', success: null });
}
const newHash = bcrypt.hashSync(new_password, 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, user.id);
res.render('reset-password', { error: null, success: 'Password reset successfully! You can now login.' });
});
module.exports = router;
Here is the complete views/login.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Student Portal - Login</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>University Student Portal</h1>
<h2>Login</h2>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<form action="/login" method="POST">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
<p><a href="/reset-password">Forgot your password?</a></p>
</div>
</body>
</html>
Login page not rendering?
- Make sure views/login.ejs is inside the views/ folder, not the project root.
- Check that app.js has app.set('view engine', 'ejs') before the route mounting lines.
help me fix an EJS template rendering error in my Express app
Write the RBAC middleware
Middleware functions sit between the incoming request and your route handler. They can block a request before it ever reaches the route code. This is how you enforce access control without repeating checks in every single route.
- Create a new file called middleware/auth.js and paste the following code:
// Check if a user is logged in
function isAuthenticated(req, res, next) {
if (req.session && req.session.user) {
return next();
}
res.redirect('/login');
}
// Check if the logged-in user has one of the allowed roles
function authorize(...roles) {
return (req, res, next) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
if (!roles.includes(req.session.user.role)) {
return res.status(403).render('forbidden', { user: req.session.user });
}
next();
};
}
module.exports = { isAuthenticated, authorize };
What does this middleware do?
isAuthenticated checks if a session with user data exists. If it does, next() passes the request along to the actual route. If not, the user gets redirected to the login page.
authorize(...roles) is a factory function. You call it with one or more role names (like authorize('admin')) and it returns a middleware that only lets users with a matching role through. Everyone else gets a 403 Forbidden page.
What is Role-Based Access Control?
Role-Based Access Control (RBAC) assigns permissions based on a user's role rather than checking each user individually. A student gets student permissions. An admin gets admin permissions. Your middleware enforces this at the route level.
- Save middleware/auth.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all files before testing.
ⓧ I'd like to double check the full code
Here is the complete middleware/auth.js:
function isAuthenticated(req, res, next) {
if (req.session && req.session.user) {
return next();
}
res.redirect('/login');
}
function authorize(...roles) {
return (req, res, next) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
if (!roles.includes(req.session.user.role)) {
return res.status(403).render('forbidden', { user: req.session.user });
}
next();
};
}
module.exports = { isAuthenticated, authorize };
Here is the complete app.js:
const express = require('express');
const session = require('express-session');
const path = require('path');
const authRoutes = require('./routes/auth');
const studentRoutes = require('./routes/student');
const adminRoutes = require('./routes/admin');
const accountantRoutes = require('./routes/accountant');
const app = express();
const PORT = 3000;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: 'student-portal-secret-key',
resave: false,
saveUninitialized: false
}));
app.use('/', authRoutes);
app.use('/student', studentRoutes);
app.use('/admin', adminRoutes);
app.use('/accountant', accountantRoutes);
app.get('/', (req, res) => {
res.redirect('/login');
});
app.listen(PORT, () => {
console.log(`Student Portal running at http://localhost:${PORT}`);
});
Time to test the full login flow.
- Start the server by running this command:
npm start
- Open your browser and visit http://localhost:3000. You should see the login form with a title, username field, and password field.
- Enter john_student in the username field.
- Enter password123 in the password field.
- Click Login.
You should be redirected to /student/dashboard which shows an error page. That's expected. The student dashboard route exists as an empty placeholder, so there's nothing to display yet.
Server won't start or login doesn't work?
- If you see Cannot find module in the terminal, one of the route placeholder files is missing. Rerun the echo commands from earlier.
- If login shows "Invalid username or password", check that you ran npm run setup-db in the previous step to populate the database.
- If the page loads but looks unstyled, that's normal. You'll add style.css in the next step.
help me debug why my Express login form is not working
Your login system is working. The server authenticates users against hashed passwords in the database and redirects them based on their role. Next up, you'll build the actual dashboards that each role sees after logging in.
Build Role-Specific Dashboards with RBAC
Your login system is working and redirecting users based on their role. The missing piece is what they actually see when they land on their dashboard.
In this step, you will create three entirely different dashboards. Each role (student, admin, accountant) gets a unique view of the data, and the RBAC middleware you built in the last step will block anyone from sneaking into a page they do not belong on.
In this step, get ready to:
- Create the student dashboard showing enrolled courses and grades.
- Create the admin and accountant dashboards with role-specific data views.
- Add a forbidden page, style the entire portal, and verify RBAC blocks unauthorized access.
Create the student route and dashboard
Students need to see their enrolled courses and grades. The route will query the database for this data and pass it to an EJS template.
- Create the student route file by adding the following code to routes/student.js:
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { authorize } = require('../middleware/auth');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/dashboard', authorize('student'), (req, res) => {
// Query enrolled courses and grades for this student
const enrollments = db.prepare(`
SELECT courses.code, courses.name, enrollments.grade
FROM enrollments
JOIN courses ON enrollments.course_id = courses.id
WHERE enrollments.user_id = ?
`).all(req.session.user.id);
res.render('student/dashboard', {
user: req.session.user,
enrollments: enrollments
});
});
module.exports = router;
What does this code do?
The authorize('student') middleware runs before the route handler. If the logged-in user does not have the student role, they get a 403 response and never reach this code.
The SQL query joins the enrollments and courses tables to pull course codes, names, and grades for the specific student. The result is passed to the template as enrollments.
- Save routes/student.js.
Seeing a MODULE_NOT_FOUND error?
Make sure the file is saved at exactly routes/student.js inside your project folder. The require('../middleware/auth') path assumes you are one folder deep inside routes/.
help me debug a MODULE_NOT_FOUND error in my student route
Now create the template that renders the student's course data.
- Add the following code to views/student/dashboard.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Student Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<span>Welcome, <%= user.full_name %> (Student)</span>
<a href="/logout">Logout</a>
</nav>
<div class="container">
<h1>My Courses and Results</h1>
<% if (enrollments.length === 0) { %>
<p>You are not enrolled in any courses yet.</p>
<% } else { %>
<table>
<thead>
<tr>
<th>Course Code</th>
<th>Course Name</th>
<th>Grade</th>
</tr>
</thead>
<tbody>
<% enrollments.forEach(enrollment => { %>
<tr>
<td><%= enrollment.code %></td>
<td><%= enrollment.name %></td>
<td><%= enrollment.grade %></td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
</div>
</body>
</html>
What does this template do?
The template uses EJS tags (<%= %>) to output data and (<% %>) for control flow. It loops through the enrollments array and renders a table row for each course.
If the student has no enrollments, a helpful message is shown instead of an empty table.
- Save views/student/dashboard.ejs.
- You will not see a visible change yet. The stylesheet and remaining route files come next.
File not saving in the right place?
Confirm the file lives inside views/student/ (not views/ directly). The route calls res.render('student/dashboard', ...) which maps to views/student/dashboard.ejs.
help me fix an EJS template not found error
Create the admin and accountant routes and dashboards
The admin sees all students and can edit their details. The accountant sees tuition payment status across students.
- Create the admin route file by adding the following code to routes/admin.js:
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { authorize } = require('../middleware/auth');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/dashboard', authorize('admin'), (req, res) => {
// Fetch all students for the admin to manage
const students = db.prepare("SELECT * FROM users WHERE role = 'student'").all();
res.render('admin/dashboard', { user: req.session.user, students: students });
});
router.post('/update-student', authorize('admin'), (req, res) => {
// Update a student's name and email from the admin form
const { student_id, full_name, email } = req.body;
db.prepare('UPDATE users SET full_name = ?, email = ? WHERE id = ?').run(full_name, email, student_id);
res.redirect('/admin/dashboard');
});
module.exports = router;
What does this code do?
The admin route has two handlers. The GET route fetches all students from the database and renders the admin dashboard. The POST route receives form data to update a student's name and email, then redirects back to the dashboard.
Both routes use authorize('admin') so only admin users can access them. This is Role-Based Access Control in action.
- Save routes/admin.js.
- Create the admin dashboard template by adding the following code to views/admin/dashboard.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<span>Welcome, <%= user.full_name %> (Admin)</span>
<a href="/logout">Logout</a>
</nav>
<div class="container">
<h1>Student Management</h1>
<% students.forEach(student => { %>
<div class="student-card">
<form action="/admin/update-student" method="POST">
<input type="hidden" name="student_id" value="<%= student.id %>">
<p><strong>Username:</strong> <%= student.username %></p>
<label>Full Name:</label>
<input type="text" name="full_name" value="<%= student.full_name %>">
<label>Email:</label>
<input type="email" name="email" value="<%= student.email %>">
<p><strong>Tuition:</strong> <%= student.tuition_paid ? 'Paid' : 'Not Paid' %></p>
<button type="submit">Update Student</button>
</form>
</div>
<% }); %>
</div>
</body>
</html>
What does this template do?
Each student is rendered inside a card with an editable form. The hidden student_id field tells the POST route which student to update. The admin can change the name and email, then click Update Student to save.
- Save views/admin/dashboard.ejs.
- Create the accountant route file by adding the following code to routes/accountant.js:
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { authorize } = require('../middleware/auth');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/dashboard', authorize('accountant'), (req, res) => {
// Fetch all students with their tuition payment status
const students = db.prepare("SELECT id, username, full_name, email, tuition_paid FROM users WHERE role = 'student'").all();
res.render('accountant/dashboard', { user: req.session.user, students: students });
});
module.exports = router;
What does this code do?
The accountant route queries all students but only selects the columns relevant to financial status: id, username, full_name, email, and tuition_paid. Each role only sees data relevant to their job.
- Save routes/accountant.js.
- Create the accountant dashboard template by adding the following code to views/accountant/dashboard.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accountant Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<span>Welcome, <%= user.full_name %> (Accountant)</span>
<a href="/logout">Logout</a>
</nav>
<div class="container">
<h1>Student Tuition Status</h1>
<table>
<thead>
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Tuition Status</th>
</tr>
</thead>
<tbody>
<% students.forEach(student => { %>
<tr>
<td><%= student.username %></td>
<td><%= student.full_name %></td>
<td><%= student.email %></td>
<td class="<%= student.tuition_paid ? 'paid' : 'unpaid' %>">
<%= student.tuition_paid ? 'Paid' : 'Not Paid' %>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</body>
</html>
What does this template do?
The accountant sees a table with every student's tuition status. The paid and unpaid CSS classes will colour-code the status column once you add the stylesheet.
- Save views/accountant/dashboard.ejs.
- You will not see a visible change yet. The forbidden page and stylesheet come in the next substep.
Unsure about the folder structure?
At this point you should have views/admin/dashboard.ejs and views/accountant/dashboard.ejs each in their own subfolder. Double check that the folder names match exactly (all lowercase, no typos).
help me check my views folder structure for the admin and accountant dashboards
Add the forbidden page, style the portal, and test RBAC
When a user tries to access a route they are not authorized for, the authorize middleware renders a forbidden page. You need to create that template so the 403 response has something to show.
- Create the forbidden page by adding the following code to views/forbidden.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>403 - Access Denied</h1>
<p>You do not have permission to access this page.</p>
<a href="/dashboard">Go to your dashboard</a>
</div>
</body>
</html>
When does this page appear?
Remember the authorize function in middleware/auth.js? When a user's role is not in the allowed list, it calls res.status(403).render('forbidden', ...). This template is what gets rendered.
- Save views/forbidden.ejs.
Now add the stylesheet that all your templates reference. This single file styles the entire portal.
- Create the stylesheet by adding the following code to public/style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333;
}
nav {
background-color: #2c3e50;
color: white;
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
nav a {
color: white;
text-decoration: none;
background-color: #e74c3c;
padding: 8px 16px;
border-radius: 4px;
}
.container {
max-width: 800px;
margin: 40px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 20px;
color: #2c3e50;
}
h2 {
margin-bottom: 15px;
color: #34495e;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
}
label {
font-weight: bold;
margin-top: 5px;
}
input[type="text"],
input[type="password"],
input[type="email"] {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 12px;
background-color: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-top: 10px;
}
button:hover {
background-color: #34495e;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #2c3e50;
color: white;
}
tr:hover {
background-color: #f5f5f5;
}
.error {
color: #e74c3c;
background-color: #fce4e4;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.success {
color: #27ae60;
background-color: #e8f8f0;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.paid {
color: #27ae60;
font-weight: bold;
}
.unpaid {
color: #e74c3c;
font-weight: bold;
}
.student-card {
border: 1px solid #ddd;
padding: 20px;
margin-bottom: 15px;
border-radius: 4px;
}
.student-card p {
margin-bottom: 8px;
}
a {
color: #2c3e50;
}
What does this stylesheet cover?
- Global reset and base typography for a consistent look.
- A dark navigation bar with a red logout button.
- A centered white card container for all page content.
- Table styles for the student and accountant dashboards.
- Colour-coded .paid (green) and .unpaid (red) classes for tuition status.
- Card styles for the admin's editable student forms.
- Save public/style.css.
Styles not loading?
Confirm the file is at public/style.css (not public/styles.css). Your templates reference /style.css and Express serves files from the public/ folder via express.static.
help me debug why my CSS file is not loading in Express
Time to test the full RBAC system. You will log in as each role and confirm they see the correct dashboard.
- Stop the running server with Ctrl+C in your terminal.
- Restart the server by running:
npm start
- Open http://localhost:3000 in your browser.
- Log in with username john_student and password password123.
- You should see the student dashboard with a table of courses and grades (CS101, CS201, MATH101).
- Click Logout.
- Log in with username alice_admin and password password123.
- You should see the admin dashboard with editable student cards for John Smith and Jane Doe.
- Click Logout.
- Log in with username bob_accountant and password password123.
- You should see the accountant dashboard with a tuition status table showing John as Paid (green) and Jane as Not Paid (red).
Now test that RBAC blocks unauthorized access.
- Click Logout.
- Log in as john_student again.
- Manually type http://localhost:3000/admin/dashboard in your browser's address bar.
- You should see the 403 Access Denied page with the message "You do not have permission to access this page."
Why does this matter?
Without the authorize middleware, any logged-in user could manually visit any dashboard URL and see data meant for other roles. The middleware pattern checks the user's role on every request before the route handler runs.
Seeing an error instead of a dashboard?
If you see a "Cannot find module" error in your terminal, one of your route files has a typo in its require path. Check that require('../middleware/auth') and require('better-sqlite3') are spelled correctly.
If you see a blank page or missing styles, confirm public/style.css exists and its filename matches what your templates reference.
help me debug my Express role-based dashboards not loading
✔️ Awesome, I've got everything!
Great. Make sure all files are saved and your test results match the descriptions above.
ⓧ I'd like to double check the full code
Here is the complete code for each file created in this step.
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { authorize } = require('../middleware/auth');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/dashboard', authorize('student'), (req, res) => {
const enrollments = db.prepare(`
SELECT courses.code, courses.name, enrollments.grade
FROM enrollments
JOIN courses ON enrollments.course_id = courses.id
WHERE enrollments.user_id = ?
`).all(req.session.user.id);
res.render('student/dashboard', {
user: req.session.user,
enrollments: enrollments
});
});
module.exports = router;
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { authorize } = require('../middleware/auth');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/dashboard', authorize('admin'), (req, res) => {
const students = db.prepare("SELECT * FROM users WHERE role = 'student'").all();
res.render('admin/dashboard', { user: req.session.user, students: students });
});
router.post('/update-student', authorize('admin'), (req, res) => {
const { student_id, full_name, email } = req.body;
db.prepare('UPDATE users SET full_name = ?, email = ? WHERE id = ?').run(full_name, email, student_id);
res.redirect('/admin/dashboard');
});
module.exports = router;
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { authorize } = require('../middleware/auth');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/dashboard', authorize('accountant'), (req, res) => {
const students = db.prepare("SELECT id, username, full_name, email, tuition_paid FROM users WHERE role = 'student'").all();
res.render('accountant/dashboard', { user: req.session.user, students: students });
});
module.exports = router;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Student Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<span>Welcome, <%= user.full_name %> (Student)</span>
<a href="/logout">Logout</a>
</nav>
<div class="container">
<h1>My Courses and Results</h1>
<% if (enrollments.length === 0) { %>
<p>You are not enrolled in any courses yet.</p>
<% } else { %>
<table>
<thead>
<tr>
<th>Course Code</th>
<th>Course Name</th>
<th>Grade</th>
</tr>
</thead>
<tbody>
<% enrollments.forEach(enrollment => { %>
<tr>
<td><%= enrollment.code %></td>
<td><%= enrollment.name %></td>
<td><%= enrollment.grade %></td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<span>Welcome, <%= user.full_name %> (Admin)</span>
<a href="/logout">Logout</a>
</nav>
<div class="container">
<h1>Student Management</h1>
<% students.forEach(student => { %>
<div class="student-card">
<form action="/admin/update-student" method="POST">
<input type="hidden" name="student_id" value="<%= student.id %>">
<p><strong>Username:</strong> <%= student.username %></p>
<label>Full Name:</label>
<input type="text" name="full_name" value="<%= student.full_name %>">
<label>Email:</label>
<input type="email" name="email" value="<%= student.email %>">
<p><strong>Tuition:</strong> <%= student.tuition_paid ? 'Paid' : 'Not Paid' %></p>
<button type="submit">Update Student</button>
</form>
</div>
<% }); %>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accountant Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<span>Welcome, <%= user.full_name %> (Accountant)</span>
<a href="/logout">Logout</a>
</nav>
<div class="container">
<h1>Student Tuition Status</h1>
<table>
<thead>
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Tuition Status</th>
</tr>
</thead>
<tbody>
<% students.forEach(student => { %>
<tr>
<td><%= student.username %></td>
<td><%= student.full_name %></td>
<td><%= student.email %></td>
<td class="<%= student.tuition_paid ? 'paid' : 'unpaid' %>">
<%= student.tuition_paid ? 'Paid' : 'Not Paid' %>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>403 - Access Denied</h1>
<p>You do not have permission to access this page.</p>
<a href="/dashboard">Go to your dashboard</a>
</div>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333;
}
nav {
background-color: #2c3e50;
color: white;
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
nav a {
color: white;
text-decoration: none;
background-color: #e74c3c;
padding: 8px 16px;
border-radius: 4px;
}
.container {
max-width: 800px;
margin: 40px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 20px;
color: #2c3e50;
}
h2 {
margin-bottom: 15px;
color: #34495e;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
}
label {
font-weight: bold;
margin-top: 5px;
}
input[type="text"],
input[type="password"],
input[type="email"] {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 12px;
background-color: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-top: 10px;
}
button:hover {
background-color: #34495e;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #2c3e50;
color: white;
}
tr:hover {
background-color: #f5f5f5;
}
.error {
color: #e74c3c;
background-color: #fce4e4;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.success {
color: #27ae60;
background-color: #e8f8f0;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.paid {
color: #27ae60;
font-weight: bold;
}
.unpaid {
color: #e74c3c;
font-weight: bold;
}
.student-card {
border: 1px solid #ddd;
padding: 20px;
margin-bottom: 15px;
border-radius: 4px;
}
.student-card p {
margin-bottom: 8px;
}
a {
color: #2c3e50;
}
Your portal now has three fully functional dashboards, each locked down by role. Next up, you will add a password reset feature and push the finished project to GitHub.
Add Password Reset and Push to GitHub
Your portal has authentication and role-based dashboards, but there is no way for users to recover a forgotten password. In a real application, this would be a critical missing feature.
In this step, you will add a password reset flow that verifies a user's identity through their username and email. Then you will push the entire project to GitHub so you have a clean, documented repository to share.
In this step, get ready to:
- Add password reset routes and a reset form view.
- Create a .gitignore and README for your project.
- Push your project to a GitHub repository.
Add password reset routes to the auth router
The login page already has a "Forgot your password?" link pointing to /reset-password. You need to create the routes that handle that page.
- In routes/auth.js, find the line that says module.exports = router; at the bottom of the file.
- Add the following password reset routes directly above that line:
router.get('/reset-password', (req, res) => {
res.render('reset-password', { error: null, success: null });
});
router.post('/reset-password', (req, res) => {
const { username, email, new_password, confirm_password } = req.body;
if (new_password !== confirm_password) {
return res.render('reset-password', { error: 'Passwords do not match', success: null });
}
if (new_password.length < 6) {
return res.render('reset-password', { error: 'Password must be at least 6 characters', success: null });
}
const user = db.prepare('SELECT * FROM users WHERE username = ? AND email = ?').get(username, email);
if (!user) {
return res.render('reset-password', { error: 'No account found with that username and email', success: null });
}
const newHash = bcrypt.hashSync(new_password, 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, user.id);
res.render('reset-password', { error: null, success: 'Password reset successfully! You can now login.' });
});
What does this code do?
- The GET route renders the reset form with empty error and success messages.
- The POST route first checks that the two password fields match, then checks minimum length.
- It verifies identity by looking up a user whose username AND email both match the form input.
- If the user is found, it hashes the new password with bcrypt and updates the database.
- Save routes/auth.js.
Seeing a syntax error when saving?
Make sure you pasted the new routes above module.exports = router;, not below it. Code after the export is unreachable.
Check that you did not accidentally delete the closing }); of the dashboard route above your new code.
Help me debug a syntax error in my auth routes.
Create the password reset form view
The reset routes render a view called reset-password, but that file does not exist yet.
- Create a new file called reset-password.ejs inside your views/ folder.
- Add the following HTML form to the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Student Portal - Reset Password</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>Reset Password</h1>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<% if (success) { %>
<p class="success"><%= success %></p>
<% } %>
<form action="/reset-password" method="POST">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required>
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
<button type="submit">Reset Password</button>
</form>
<p><a href="/login">Back to Login</a></p>
</div>
</body>
</html>
What does this template do?
- It displays error or success messages conditionally using EJS tags.
- The form collects username, email, new password, and password confirmation.
- It posts to /reset-password where your new POST route handles the logic.
- Save views/reset-password.ejs.
- Restart your server by pressing Ctrl+C in the terminal and running:
npm start
- Visit http://localhost:3000/reset-password in your browser. You should see the Reset Password form with fields for username, email, new password, and confirm password.
- Enter john_student as the username, john@university.edu as the email, and any new password (at least 6 characters) in both password fields.
- Click Reset Password. You should see a green success message saying the password was reset.
- Click Back to Login and log in with john_student using your new password to confirm it works.
Reset form not loading?
Make sure you saved the file as reset-password.ejs (with the hyphen) inside the views/ folder, not inside a subfolder like views/student/.
Check that you restarted the server after saving your route changes.
Help me fix my password reset page not loading.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete routes/auth.js file:
const express = require('express');
const bcrypt = require('bcryptjs');
const Database = require('better-sqlite3');
const path = require('path');
const router = express.Router();
const db = new Database(path.join(__dirname, '..', 'database', 'portal.db'));
router.get('/login', (req, res) => {
if (req.session && req.session.user) {
return res.redirect('/dashboard');
}
res.render('login', { error: null });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.render('login', { error: 'Invalid username or password' });
}
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
full_name: user.full_name,
email: user.email,
tuition_paid: user.tuition_paid
};
res.redirect('/dashboard');
});
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/login');
});
});
router.get('/dashboard', (req, res) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
const role = req.session.user.role;
if (role === 'student') return res.redirect('/student/dashboard');
if (role === 'accountant') return res.redirect('/accountant/dashboard');
if (role === 'admin') return res.redirect('/admin/dashboard');
res.redirect('/login');
});
router.get('/reset-password', (req, res) => {
res.render('reset-password', { error: null, success: null });
});
router.post('/reset-password', (req, res) => {
const { username, email, new_password, confirm_password } = req.body;
if (new_password !== confirm_password) {
return res.render('reset-password', { error: 'Passwords do not match', success: null });
}
if (new_password.length < 6) {
return res.render('reset-password', { error: 'Password must be at least 6 characters', success: null });
}
const user = db.prepare('SELECT * FROM users WHERE username = ? AND email = ?').get(username, email);
if (!user) {
return res.render('reset-password', { error: 'No account found with that username and email', success: null });
}
const newHash = bcrypt.hashSync(new_password, 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, user.id);
res.render('reset-password', { error: null, success: 'Password reset successfully! You can now login.' });
});
module.exports = router;
And here is views/reset-password.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Student Portal - Reset Password</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>Reset Password</h1>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<% if (success) { %>
<p class="success"><%= success %></p>
<% } %>
<form action="/reset-password" method="POST">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required>
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
<button type="submit">Reset Password</button>
</form>
<p><a href="/login">Back to Login</a></p>
</div>
</body>
</html>
Create .gitignore and README.md
Before pushing to GitHub, you need a .gitignore to keep generated files out of the repository. You also need a README.md so anyone visiting your repo knows what the project does and how to run it.
- Create a new file called .gitignore in the root of your student-portal folder and add the following content:
node_modules/
database/portal.db
Why exclude these files?
The node_modules/ folder is huge and can be recreated with npm install. The database file is generated by npm run setup-db, so there is no need to store either in version control.
- Save .gitignore.
- Create another new file called README.md in the root of your student-portal folder and add the following content:
# University Student Portal
A Node.js web application demonstrating Role-Based Access Control (RBAC) with three user roles: Student, Accountant, and Admin.
## Features
- Session-based authentication with password hashing
- Role-Based Access Control (Student, Accountant, Admin)
- Student dashboard with course enrollment and grades
- Admin panel for managing student details
- Accountant view for tuition payment status
- Password reset functionality
## Tech Stack
- Node.js with Express.js
- EJS templating
- SQLite (better-sqlite3)
- bcryptjs for password hashing
- express-session for session management
## Setup
1. Clone the repository
2. Run `npm install`
3. Run `npm run setup-db` to create and seed the database
4. Run `npm start` to start the server
5. Visit `http://localhost:3000`
## Demo Accounts
| Username | Password | Role |
|----------|----------|------|
| john_student | password123 | Student |
| jane_student | password123 | Student |
| bob_accountant | password123 | Accountant |
| alice_admin | password123 | Admin |
## License
MIT
What does this README do?
A good README tells visitors what your project does, what tools it uses, and how to get it running locally. Including demo credentials makes it easy for someone reviewing your work to test the different roles immediately.
- Save README.md.
- Confirm both files exist by running:
ls -a
- You should see .gitignore and README.md listed alongside your other project files.
Don't see .gitignore in the listing?
Files starting with a dot are hidden by default. The -a flag shows them. Make sure you used ls -a and not just ls.
If the file is missing, double-check that your editor saved it with the leading dot in the filename.
Help me find my .gitignore file.
Initialize Git and push to GitHub
With all your code written and documented, it is time to create a Git repository and push it to GitHub.
- Stop your running server with Ctrl+C if it is still running.
- Initialize a Git repository by running this command:
git init
- Stage all your project files by running:
git add .
- Create your first commit by running:
git commit -m "Initial commit: Student Portal with RBAC"
What did these commands do?
- git init created a new local Git repository in your project folder.
- git add . staged every file (except those in .gitignore) for the next commit.
- git commit saved a snapshot of all staged files with a descriptive message.
- Open your browser and go to GitHub.
- Click the + icon in the top-right corner, then click New repository.
- Enter student-portal as the repository name.
- Leave all other settings at their defaults (Public, no README, no .gitignore).
- Click Create repository.
- GitHub will show you quick setup commands. Copy the repository URL (it looks like https://github.com/your-username/student-portal.git).
- Back in your terminal, connect your local repo to GitHub by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/student-portal.git"]]
- Push your code to GitHub by running:
git push -u origin main
Using master instead of main?
If you get an error about main not existing, your default branch might be called master. You can check your branch name with git branch and use that name in the push command instead.
- Refresh your GitHub repository page in the browser. You should see all your project files listed with the README displayed below them.
Push failed or asking for authentication?
GitHub may prompt you to authenticate. If you see a browser popup, sign in with your GitHub credentials.
If you get a permission denied error, make sure the repository URL matches what GitHub showed you and that you are signed into the correct account.
Help me fix a Git push error.
Your student portal is complete and published. You have a working multi-role application with authentication, authorization, password reset, and clean documentation on GitHub. Next up, take on the Secret Mission to add tuition fee blocking.
Secret mission
Add Tuition Fee Blocking
Right now, every student sees their courses regardless of payment status. In a real university system, students who haven't paid tuition would have restricted access. Can you add tuition fee blocking so unpaid students see a warning instead of their dashboard?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free GitHub repository. There are no ongoing costs or paid resources to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder (student-portal) on your Desktop.
- SQLite database file (database/portal.db) inside the project folder.
- GitHub repository containing your pushed project code.
Keep everything running
No action needed. Choose this if you are still actively building or want to reference the project later.
- The project lives on your machine and GitHub at no cost.
- Run npm start anytime from the student-portal folder to demo it again.
- The SQLite database file takes negligible disk space.
Pause - I'll come back to this later
Stop the running server to free up the terminal. Your files stay on disk and on GitHub for when you return.
- Press Ctrl+C in the terminal where the server is running to stop it.
- Everything else stays in place. Run npm start again when you are ready to resume.
Delete - I don't want to use this again
Remove all project resources from your machine and GitHub to start fresh.
- Press Ctrl+C in the terminal to stop the server if it is still running.
- Delete the local project folder by running this command from your Desktop:
macOS
rm -rf ~/Desktop/student-portal
Windows
rmdir /s /q "$HOME\Desktop\student-portal"
- Delete your GitHub repository:
- Open your repository page on GitHub.
- Click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Follow the confirmation prompts to complete the deletion.
Nice Work!
Nice Work!
You just built a fully functional university student portal with Role-Based Access Control using Node.js and Express. Three different user roles each see completely different dashboards, protected by middleware you wrote from scratch.
You've learned how to:
- Build session-based authentication with bcrypt password hashing and an Express web server rendering pages with EJS.
- Create Role-Based Access Control middleware that protects routes by user role, serving three distinct dashboards (Student, Admin, Accountant) with different data per role.
- Implement a password reset feature with username and email verification, and push a clean, documented project to GitHub.
- Secret Mission: Added tuition fee blocking that restricts students who have not paid from viewing their courses and grades.
Ready to quiz yourself?