Introduction
Throughout this week, we've explored the key components of MERN stack integration: connecting a React frontend to an Express backend, making API requests with Axios, and implementing authentication flows. Now it's time to put everything together in a complete project.
For this weekend project, we'll develop a Task Management Application with authentication, allowing users to register, log in, and manage their personal tasks. We'll approach this project using George Polya's 4-step problem-solving procedure, which provides a structured framework for tackling complex development challenges.
Project Scope
Our Task Management Application will include the following features:
- User registration and authentication
- JWT-based authorization with refresh tokens
- Create, read, update, and delete (CRUD) operations for tasks
- Task filtering and sorting capabilities
- User profile management
- Responsive UI design
George Polya's 4-Step Problem Solving Procedure
Before diving into the implementation, let's understand the 4-step problem-solving procedure proposed by mathematician George Polya in his book "How to Solve It" (1945). This approach provides a structured way to tackle complex problems:
- Understand the Problem: Clearly define what you're trying to accomplish.
- Devise a Plan: Develop a strategy or algorithm to solve the problem.
- Execute the Plan: Implement your strategy meticulously.
- Look Back and Reflect: Review your solution, evaluate its effectiveness, and identify improvements.
We'll apply these steps to our MERN stack application development process, breaking down each phase into concrete tasks.
Real-world analogy: Polya's approach is similar to how architects design and construct buildings. They first understand client requirements, then create blueprints, next oversee construction, and finally evaluate the finished building against the original requirements.
Step 1: Understand the Problem
The first step is to thoroughly understand what we're trying to build. This involves defining requirements, identifying potential challenges, and setting clear goals for our application.
Key Questions to Ask
- What is the core functionality of our application? A task management system where users can create, organize, and track their tasks.
- Who are the users? Individuals who need to manage personal or professional tasks.
- What data needs to be stored? User information, authentication tokens, and task details.
- What are the security requirements? Secure user authentication, data privacy, and protection against common vulnerabilities.
- What is the expected user experience? Intuitive, responsive, and efficient task management.
Project Requirements
User Authentication:
- Registration with email and password
- Login with JWT and refresh token mechanism
- Password reset capability
- Profile management
Task Management:
- Create new tasks with title, description, priority, and due date
- View tasks with filtering and sorting options
- Update task details and status
- Delete tasks
- Mark tasks as complete/incomplete
Technical Requirements:
- MongoDB database for data storage
- Express.js backend with RESTful API
- React frontend with responsive design
- Node.js runtime environment
- JWT authentication flow
- Proper error handling and validation
Define The Data Models
Let's define the core data models for our application:
// User Model
const userSchema = {
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
}
// Task Model
const taskSchema = {
title: { type: String, required: true },
description: { type: String },
status: { type: String, enum: ['pending', 'in-progress', 'completed'], default: 'pending' },
priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
dueDate: { type: Date },
user: { type: ObjectId, ref: 'User', required: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
}
Identify Potential Challenges
- Security: Protecting user data and preventing unauthorized access
- State Management: Maintaining consistent application state across components
- API Design: Creating intuitive and efficient endpoints
- User Experience: Building an interface that is both functional and user-friendly
- Error Handling: Managing and communicating errors gracefully
Step 2: Devise a Plan
With a clear understanding of the problem, we can now devise a plan for building our application. This includes creating a project structure, defining the component hierarchy, and mapping out the development workflow.
Project Structure
task-management-app/
├── .env # Environment variables
├── .gitignore # Git ignore file
├── package.json # Project dependencies and scripts
├── README.md # Project documentation
├── client/ # React frontend
│ ├── public/ # Static files
│ ├── src/ # Source code
│ │ ├── components/ # React components
│ │ ├── context/ # Context API providers
│ │ ├── hooks/ # Custom hooks
│ │ ├── pages/ # Page components
│ │ ├── services/ # API service functions
│ │ ├── utils/ # Utility functions
│ │ ├── App.js # Main App component
│ │ └── index.js # Entry point
│ └── package.json # Frontend dependencies
└── server/ # Express backend
├── config/ # Configuration files
├── controllers/ # Request handlers
├── middleware/ # Custom middleware
├── models/ # Mongoose models
├── routes/ # API routes
├── utils/ # Utility functions
├── server.js # Entry point
└── package.json # Backend dependencies
Component Hierarchy
Let's map out the key components of our React frontend:
API Endpoints
Next, let's define the API endpoints our application will need:
| Endpoint | Method | Description | Authentication |
|---|---|---|---|
| /api/auth/register | POST | Register a new user | Public |
| /api/auth/login | POST | Login user and get tokens | Public |
| /api/auth/refresh-token | POST | Refresh access token | Public (with refresh token) |
| /api/auth/me | GET | Get current user information | Private |
| /api/auth/logout | POST | Logout user | Private |
| /api/tasks | GET | Get all tasks for user | Private |
| /api/tasks/:id | GET | Get a single task | Private |
| /api/tasks | POST | Create a new task | Private |
| /api/tasks/:id | PUT | Update a task | Private |
| /api/tasks/:id | DELETE | Delete a task | Private |
| /api/profile | GET | Get user profile | Private |
| /api/profile | PUT | Update user profile | Private |
Authentication Flow
Development Workflow
Let's plan our development workflow, breaking it down into manageable steps:
- Setup Project Structure
- Initialize Git repository
- Create frontend and backend folders
- Set up package.json files and install dependencies
- Backend Development
- Create database models
- Implement authentication system
- Create RESTful API endpoints
- Add validation and error handling
- Frontend Development
- Set up React app with routing
- Create authentication context and service
- Build authentication components
- Implement task management components
- Add styling and responsive design
- Integration and Testing
- Connect frontend to backend
- Test all features and user flows
- Fix bugs and improve UX
- Deployment
- Prepare for production
- Deploy to hosting platform
Step 3: Execute the Plan
Now that we have a solid plan, let's start implementing our application. We'll follow the workflow outlined above, focusing on key components and functionality.
Initial Setup
Project Initialization
# Create project directory
mkdir task-management-app
cd task-management-app
# Initialize Git repository
git init
# Create README and .gitignore
touch README.md
touch .gitignore
# Add node_modules and environment files to .gitignore
echo "node_modules\n.env\n.DS_Store" > .gitignore
# Initialize npm and install concurrently for running both servers
npm init -y
npm install concurrently nodemon --save-dev
# Update package.json with scripts
# Add the following to package.json:
# "scripts": {
# "start": "node server/server.js",
# "server": "nodemon server/server.js",
# "client": "npm start --prefix client",
# "dev": "concurrently \"npm run server\" \"npm run client\"",
# "install-all": "npm install && cd client && npm install && cd ../server && npm install"
# }
# Create frontend with Create React App
npx create-react-app client
# Create server directory and initialize
mkdir server
cd server
npm init -y
Backend Dependencies
# Install backend dependencies
npm install express mongoose bcryptjs jsonwebtoken cors dotenv express-validator
# For development
npm install nodemon --save-dev
Frontend Dependencies
# Navigate to client directory
cd ../client
# Install frontend dependencies
npm install axios react-router-dom formik yup react-toastify date-fns
Backend Implementation
Database Configuration (server/config/db.js)
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
Environment Variables (server/.env)
NODE_ENV=development
PORT=5000
MONGO_URI=mongodb://localhost:27017/task-manager
JWT_SECRET=your_jwt_secret_here
JWT_EXPIRE=1h
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here
REFRESH_TOKEN_EXPIRE=7d
User Model (server/models/User.js)
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Please provide a name'],
trim: true,
maxlength: [50, 'Name cannot be more than 50 characters']
},
email: {
type: String,
required: [true, 'Please provide an email'],
unique: true,
trim: true,
lowercase: true,
match: [
/^([\w-\.]+@([\w-]+\.)+[\w-]{2,4})?$/,
'Please provide a valid email'
]
},
password: {
type: String,
required: [true, 'Please provide a password'],
minlength: [6, 'Password must be at least 6 characters'],
select: false
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Encrypt password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) {
return next();
}
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Update the 'updatedAt' field on every update
userSchema.pre('findOneAndUpdate', function(next) {
this.set({ updatedAt: Date.now() });
next();
});
// Method to compare password
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Task Model (server/models/Task.js)
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Please provide a task title'],
trim: true,
maxlength: [100, 'Title cannot be more than 100 characters']
},
description: {
type: String,
trim: true,
maxlength: [500, 'Description cannot be more than 500 characters']
},
status: {
type: String,
enum: ['pending', 'in-progress', 'completed'],
default: 'pending'
},
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium'
},
dueDate: {
type: Date
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Update the 'updatedAt' field on every update
taskSchema.pre('findOneAndUpdate', function(next) {
this.set({ updatedAt: Date.now() });
next();
});
module.exports = mongoose.model('Task', taskSchema);
Authentication Controller (server/controllers/authController.js)
const User = require('../models/User');
const jwt = require('jsonwebtoken');
const { validationResult } = require('express-validator');
// Helper function to generate tokens
const generateTokens = (userId) => {
// Generate access token
const accessToken = jwt.sign(
{ id: userId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
);
// Generate refresh token
const refreshToken = jwt.sign(
{ id: userId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRE }
);
return { accessToken, refreshToken };
};
// @desc Register user
// @route POST /api/auth/register
// @access Public
exports.register = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, email, password } = req.body;
try {
// Check if user already exists
let user = await User.findOne({ email });
if (user) {
return res.status(400).json({
success: false,
message: 'User already exists'
});
}
// Create new user
user = new User({
name,
email,
password
});
await user.save();
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user._id);
res.status(201).json({
success: true,
accessToken,
refreshToken,
user: {
id: user._id,
name: user.name,
email: user.email
}
});
} catch (err) {
console.error(err.message);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Login user
// @route POST /api/auth/login
// @access Public
exports.login = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password } = req.body;
try {
// Find user by email and include password
const user = await User.findOne({ email }).select('+password');
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check if password matches
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user._id);
res.json({
success: true,
accessToken,
refreshToken,
user: {
id: user._id,
name: user.name,
email: user.email
}
});
} catch (err) {
console.error(err.message);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Refresh access token
// @route POST /api/auth/refresh-token
// @access Public
exports.refreshToken = async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: 'No refresh token provided'
});
}
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// Generate new tokens
const { accessToken, refreshToken: newRefreshToken } = generateTokens(decoded.id);
res.json({
success: true,
accessToken,
refreshToken: newRefreshToken
});
} catch (err) {
console.error(err.message);
res.status(401).json({
success: false,
message: 'Invalid refresh token'
});
}
};
// @desc Get current user
// @route GET /api/auth/me
// @access Private
exports.getMe = async (req, res) => {
try {
// req.user.id comes from the auth middleware
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.json({
success: true,
user: {
id: user._id,
name: user.name,
email: user.email,
createdAt: user.createdAt
}
});
} catch (err) {
console.error(err.message);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
Tasks Controller (server/controllers/taskController.js)
const Task = require('../models/Task');
const { validationResult } = require('express-validator');
// @desc Get all tasks for current user
// @route GET /api/tasks
// @access Private
exports.getTasks = async (req, res) => {
try {
// Get query parameters for filtering
const { status, priority, sortBy } = req.query;
// Build query
const query = { user: req.user.id };
// Add status filter if provided
if (status) {
query.status = status;
}
// Add priority filter if provided
if (priority) {
query.priority = priority;
}
// Build sort options
let sortOptions = {};
if (sortBy) {
switch (sortBy) {
case 'dueDate_asc':
sortOptions = { dueDate: 1 };
break;
case 'dueDate_desc':
sortOptions = { dueDate: -1 };
break;
case 'priority_asc':
sortOptions = { priority: 1 };
break;
case 'priority_desc':
sortOptions = { priority: -1 };
break;
case 'createdAt_asc':
sortOptions = { createdAt: 1 };
break;
case 'createdAt_desc':
sortOptions = { createdAt: -1 };
break;
default:
sortOptions = { createdAt: -1 };
}
} else {
// Default sort by creation date (newest first)
sortOptions = { createdAt: -1 };
}
// Find tasks with query and sort
const tasks = await Task.find(query).sort(sortOptions);
res.json({
success: true,
count: tasks.length,
data: tasks
});
} catch (err) {
console.error(err.message);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Get single task
// @route GET /api/tasks/:id
// @access Private
exports.getTask = async (req, res) => {
try {
const task = await Task.findById(req.params.id);
if (!task) {
return res.status(404).json({
success: false,
message: 'Task not found'
});
}
// Check task belongs to user
if (task.user.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Not authorized to access this task'
});
}
res.json({
success: true,
data: task
});
} catch (err) {
console.error(err.message);
// Handle invalid ObjectId
if (err.kind === 'ObjectId') {
return res.status(404).json({
success: false,
message: 'Task not found'
});
}
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Create new task
// @route POST /api/tasks
// @access Private
exports.createTask = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { title, description, status, priority, dueDate } = req.body;
try {
const newTask = new Task({
title,
description,
status: status || 'pending',
priority: priority || 'medium',
dueDate: dueDate || null,
user: req.user.id
});
const task = await newTask.save();
res.status(201).json({
success: true,
data: task
});
} catch (err) {
console.error(err.message);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Update task
// @route PUT /api/tasks/:id
// @access Private
exports.updateTask = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { title, description, status, priority, dueDate } = req.body;
// Build task object
const taskFields = {};
if (title !== undefined) taskFields.title = title;
if (description !== undefined) taskFields.description = description;
if (status !== undefined) taskFields.status = status;
if (priority !== undefined) taskFields.priority = priority;
if (dueDate !== undefined) taskFields.dueDate = dueDate;
try {
let task = await Task.findById(req.params.id);
if (!task) {
return res.status(404).json({
success: false,
message: 'Task not found'
});
}
// Check task belongs to user
if (task.user.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Not authorized to update this task'
});
}
// Update task
task = await Task.findByIdAndUpdate(
req.params.id,
{ $set: taskFields },
{ new: true }
);
res.json({
success: true,
data: task
});
} catch (err) {
console.error(err.message);
// Handle invalid ObjectId
if (err.kind === 'ObjectId') {
return res.status(404).json({
success: false,
message: 'Task not found'
});
}
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Delete task
// @route DELETE /api/tasks/:id
// @access Private
exports.deleteTask = async (req, res) => {
try {
const task = await Task.findById(req.params.id);
if (!task) {
return res.status(404).json({
success: false,
message: 'Task not found'
});
}
// Check task belongs to user
if (task.user.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Not authorized to delete this task'
});
}
await task.remove();
res.json({
success: true,
message: 'Task removed'
});
} catch (err) {
console.error(err.message);
// Handle invalid ObjectId
if (err.kind === 'ObjectId') {
return res.status(404).json({
success: false,
message: 'Task not found'
});
}
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
Authentication Middleware (server/middleware/auth.js)
const jwt = require('jsonwebtoken');
const User = require('../models/User');
exports.protect = async (req, res, next) => {
let token;
// Check for token in Authorization header
if (
req.headers.authorization &&
req.headers.authorization.startsWith('Bearer')
) {
// Format: Bearer <token>
token = req.headers.authorization.split(' ')[1];
}
// Check if token exists
if (!token) {
return res.status(401).json({
success: false,
message: 'Not authorized to access this route'
});
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user from database
const user = await User.findById(decoded.id);
if (!user) {
return res.status(401).json({
success: false,
message: 'User not found'
});
}
// Set user on request object
req.user = {
id: user._id
};
next();
} catch (err) {
return res.status(401).json({
success: false,
message: 'Not authorized to access this route'
});
}
};
Validation Middleware (server/middleware/validation.js)
const { check } = require('express-validator');
exports.registerValidation = [
check('name', 'Name is required').not().isEmpty(),
check('email', 'Please include a valid email').isEmail(),
check('password', 'Password must be at least 6 characters').isLength({ min: 6 })
];
exports.loginValidation = [
check('email', 'Please include a valid email').isEmail(),
check('password', 'Password is required').exists()
];
exports.taskValidation = [
check('title', 'Title is required').not().isEmpty(),
check('status', 'Status must be one of: pending, in-progress, completed')
.optional()
.isIn(['pending', 'in-progress', 'completed']),
check('priority', 'Priority must be one of: low, medium, high')
.optional()
.isIn(['low', 'medium', 'high']),
check('dueDate', 'Due date must be a valid date')
.optional()
.isISO8601()
.toDate()
];
Routes Setup
// server/routes/auth.js
const express = require('mongoose');
const router = express.Router();
const {
register,
login,
getMe,
refreshToken
} = require('../controllers/authController');
const { protect } = require('../middleware/auth');
const {
registerValidation,
loginValidation
} = require('../middleware/validation');
router.post('/register', registerValidation, register);
router.post('/login', loginValidation, login);
router.post('/refresh-token', refreshToken);
router.get('/me', protect, getMe);
module.exports = router;
// server/routes/tasks.js
const express = require('express');
const router = express.Router();
const {
getTasks,
getTask,
createTask,
updateTask,
deleteTask
} = require('../controllers/taskController');
const { protect } = require('../middleware/auth');
const { taskValidation } = require('../middleware/validation');
router.route('/')
.get(protect, getTasks)
.post(protect, taskValidation, createTask);
router.route('/:id')
.get(protect, getTask)
.put(protect, taskValidation, updateTask)
.delete(protect, deleteTask);
module.exports = router;
Server Setup (server/server.js)
const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
require('dotenv').config();
// Connect to database
connectDB();
// Initialize express
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Define routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/tasks', require('./routes/tasks'));
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Server Error'
});
});
// Set port
const PORT = process.env.PORT || 5000;
// Start server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Frontend Implementation
API Service (client/src/services/api.js)
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json'
}
});
// Request interceptor for adding the auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for handling token refresh
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await axios.post('/api/auth/refresh-token', {
refreshToken
});
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
processQueue(null, accessToken);
return api(originalRequest);
} catch (err) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
processQueue(err, null);
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
Auth Service (client/src/services/authService.js)
import api from './api';
export const authService = {
register: async (userData) => {
const response = await api.post('/auth/register', userData);
if (response.data.accessToken) {
localStorage.setItem('accessToken', response.data.accessToken);
localStorage.setItem('refreshToken', response.data.refreshToken);
}
return response.data;
},
login: async (credentials) => {
const response = await api.post('/auth/login', credentials);
if (response.data.accessToken) {
localStorage.setItem('accessToken', response.data.accessToken);
localStorage.setItem('refreshToken', response.data.refreshToken);
}
return response.data;
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
},
getCurrentUser: async () => {
const response = await api.get('/auth/me');
return response.data.user;
},
refreshToken: async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post('/auth/refresh-token', { refreshToken });
if (response.data.accessToken) {
localStorage.setItem('accessToken', response.data.accessToken);
localStorage.setItem('refreshToken', response.data.refreshToken);
}
return response.data;
},
isAuthenticated: () => {
return !!localStorage.getItem('accessToken');
}
};
Task Service (client/src/services/taskService.js)
import api from './api';
export const taskService = {
getTasks: async (params = {}) => {
const response = await api.get('/tasks', { params });
return response.data;
},
getTask: async (id) => {
const response = await api.get(`/tasks/${id}`);
return response.data;
},
createTask: async (taskData) => {
const response = await api.post('/tasks', taskData);
return response.data;
},
updateTask: async (id, taskData) => {
const response = await api.put(`/tasks/${id}`, taskData);
return response.data;
},
deleteTask: async (id) => {
const response = await api.delete(`/tasks/${id}`);
return response.data;
}
};
Auth Context (client/src/context/AuthContext.js)
import React, { createContext, useReducer, useEffect } from 'react';
import { authService } from '../services/authService';
// Create context
export const AuthContext = createContext();
// Initial state
const initialState = {
isAuthenticated: false,
user: null,
loading: true,
error: null
};
// Reducer function
const authReducer = (state, action) => {
switch (action.type) {
case 'LOGIN_SUCCESS':
case 'REGISTER_SUCCESS':
return {
...state,
isAuthenticated: true,
user: action.payload,
loading: false,
error: null
};
case 'USER_LOADED':
return {
...state,
isAuthenticated: true,
user: action.payload,
loading: false
};
case 'AUTH_ERROR':
case 'LOGIN_FAIL':
case 'REGISTER_FAIL':
case 'LOGOUT':
return {
...state,
isAuthenticated: false,
user: null,
loading: false,
error: action.payload
};
case 'CLEAR_ERRORS':
return {
...state,
error: null
};
default:
return state;
}
};
// Provider component
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
// Load user on initial render if token exists
useEffect(() => {
const loadUser = async () => {
if (authService.isAuthenticated()) {
try {
const user = await authService.getCurrentUser();
dispatch({
type: 'USER_LOADED',
payload: user
});
} catch (err) {
console.error('Error loading user:', err);
dispatch({ type: 'AUTH_ERROR' });
}
} else {
dispatch({ type: 'AUTH_ERROR' });
}
};
loadUser();
}, []);
// Register user
const register = async (formData) => {
try {
const data = await authService.register(formData);
dispatch({
type: 'REGISTER_SUCCESS',
payload: data.user
});
return data;
} catch (err) {
console.error('Register error:', err);
dispatch({
type: 'REGISTER_FAIL',
payload: err.response?.data?.message || 'Registration failed'
});
throw err;
}
};
// Login user
const login = async (formData) => {
try {
const data = await authService.login(formData);
dispatch({
type: 'LOGIN_SUCCESS',
payload: data.user
});
return data;
} catch (err) {
console.error('Login error:', err);
dispatch({
type: 'LOGIN_FAIL',
payload: err.response?.data?.message || 'Invalid credentials'
});
throw err;
}
};
// Logout user
const logout = () => {
authService.logout();
dispatch({ type: 'LOGOUT' });
};
// Clear errors
const clearErrors = () => {
dispatch({ type: 'CLEAR_ERRORS' });
};
return (
<AuthContext.Provider
value={{
isAuthenticated: state.isAuthenticated,
user: state.user,
loading: state.loading,
error: state.error,
register,
login,
logout,
clearErrors
}}
>
{children}
</AuthContext.Provider>
);
};
Task Context (client/src/context/TaskContext.js)
import React, { createContext, useReducer } from 'react';
import { taskService } from '../services/taskService';
// Create context
export const TaskContext = createContext();
// Initial state
const initialState = {
tasks: [],
currentTask: null,
loading: true,
error: null
};
// Reducer function
const taskReducer = (state, action) => {
switch (action.type) {
case 'GET_TASKS':
return {
...state,
tasks: action.payload,
loading: false
};
case 'GET_TASK':
return {
...state,
currentTask: action.payload,
loading: false
};
case 'ADD_TASK':
return {
...state,
tasks: [action.payload, ...state.tasks],
loading: false
};
case 'UPDATE_TASK':
return {
...state,
tasks: state.tasks.map(task =>
task._id === action.payload._id ? action.payload : task
),
currentTask: action.payload,
loading: false
};
case 'DELETE_TASK':
return {
...state,
tasks: state.tasks.filter(task => task._id !== action.payload),
loading: false
};
case 'TASK_ERROR':
return {
...state,
error: action.payload,
loading: false
};
case 'SET_LOADING':
return {
...state,
loading: true
};
case 'CLEAR_TASK':
return {
...state,
currentTask: null
};
case 'CLEAR_ERRORS':
return {
...state,
error: null
};
default:
return state;
}
};
// Provider component
export const TaskProvider = ({ children }) => {
const [state, dispatch] = useReducer(taskReducer, initialState);
// Get all tasks
const getTasks = async (filters = {}) => {
try {
dispatch({ type: 'SET_LOADING' });
const response = await taskService.getTasks(filters);
dispatch({
type: 'GET_TASKS',
payload: response.data
});
} catch (err) {
console.error('Get tasks error:', err);
dispatch({
type: 'TASK_ERROR',
payload: err.response?.data?.message || 'Error fetching tasks'
});
}
};
// Get a single task
const getTask = async (id) => {
try {
dispatch({ type: 'SET_LOADING' });
const response = await taskService.getTask(id);
dispatch({
type: 'GET_TASK',
payload: response.data
});
} catch (err) {
console.error('Get task error:', err);
dispatch({
type: 'TASK_ERROR',
payload: err.response?.data?.message || 'Error fetching task'
});
}
};
// Create a task
const createTask = async (taskData) => {
try {
dispatch({ type: 'SET_LOADING' });
const response = await taskService.createTask(taskData);
dispatch({
type: 'ADD_TASK',
payload: response.data
});
return response.data;
} catch (err) {
console.error('Create task error:', err);
dispatch({
type: 'TASK_ERROR',
payload: err.response?.data?.message || 'Error creating task'
});
throw err;
}
};
// Update a task
const updateTask = async (id, taskData) => {
try {
dispatch({ type: 'SET_LOADING' });
const response = await taskService.updateTask(id, taskData);
dispatch({
type: 'UPDATE_TASK',
payload: response.data
});
return response.data;
} catch (err) {
console.error('Update task error:', err);
dispatch({
type: 'TASK_ERROR',
payload: err.response?.data?.message || 'Error updating task'
});
throw err;
}
};
// Delete a task
const deleteTask = async (id) => {
try {
dispatch({ type: 'SET_LOADING' });
await taskService.deleteTask(id);
dispatch({
type: 'DELETE_TASK',
payload: id
});
} catch (err) {
console.error('Delete task error:', err);
dispatch({
type: 'TASK_ERROR',
payload: err.response?.data?.message || 'Error deleting task'
});
throw err;
}
};
// Clear current task
const clearCurrentTask = () => {
dispatch({ type: 'CLEAR_TASK' });
};
// Clear errors
const clearErrors = () => {
dispatch({ type: 'CLEAR_ERRORS' });
};
return (
<TaskContext.Provider
value={{
tasks: state.tasks,
currentTask: state.currentTask,
loading: state.loading,
error: state.error,
getTasks,
getTask,
createTask,
updateTask,
deleteTask,
clearCurrentTask,
clearErrors
}}
>
{children}
</TaskContext.Provider>
);
};
Private Route Component (client/src/components/routing/PrivateRoute.js)
import React, { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../../context/AuthContext';
const PrivateRoute = ({ children }) => {
const { isAuthenticated, loading } = useContext(AuthContext);
if (loading) {
return <div>Loading...</div>;
}
return isAuthenticated ? children : <Navigate to="/login" />;
};
export default PrivateRoute;
App Component with Routes (client/src/App.js)
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
// Components
import Navbar from './components/layout/Navbar';
import PrivateRoute from './components/routing/PrivateRoute';
// Pages
import Home from './pages/Home';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import TaskDetail from './pages/TaskDetail';
import TaskForm from './pages/TaskForm';
import Profile from './pages/Profile';
import NotFound from './pages/NotFound';
// Context Providers
import { AuthProvider } from './context/AuthContext';
import { TaskProvider } from './context/TaskContext';
// Styles
import './App.css';
function App() {
return (
<AuthProvider>
<TaskProvider>
<Router>
<div className="App">
<Navbar />
<div className="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route
path="/tasks/:id"
element={
<PrivateRoute>
<TaskDetail />
</PrivateRoute>
}
/>
<Route
path="/tasks/new"
element={
<PrivateRoute>
<TaskForm />
</PrivateRoute>
}
/>
<Route
path="/tasks/edit/:id"
element={
<PrivateRoute>
<TaskForm />
</PrivateRoute>
}
/>
<Route
path="/profile"
element={
<PrivateRoute>
<Profile />
</PrivateRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</div>
<ToastContainer position="bottom-right" />
</Router>
</TaskProvider>
</AuthProvider>
);
}
export default App;
Key UI Components
Let's implement some of the key UI components for our application:
Login Page (client/src/pages/Login.js)
import React, { useState, useContext, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { toast } from 'react-toastify';
import { AuthContext } from '../context/AuthContext';
const LoginSchema = Yup.object().shape({
email: Yup.string()
.email('Invalid email')
.required('Email is required'),
password: Yup.string()
.required('Password is required')
});
const Login = () => {
const { login, isAuthenticated, error, clearErrors } = useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
// Redirect if already authenticated
if (isAuthenticated) {
navigate('/dashboard');
}
// Show error notification if error exists
if (error) {
toast.error(error);
clearErrors();
}
// eslint-disable-next-line
}, [isAuthenticated, error]);
const handleSubmit = async (values, { setSubmitting }) => {
try {
await login(values);
toast.success('Login successful!');
navigate('/dashboard');
} catch (err) {
// Error is handled in the AuthContext and displayed via useEffect
} finally {
setSubmitting(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<h2>Welcome Back</h2>
<p className="auth-subtitle">Sign in to access your tasks</p>
<Formik
initialValues={{ email: '', password: '' }}
validationSchema={LoginSchema}
onSubmit={handleSubmit}
>
{({ isSubmitting }) => (
<Form>
<div className="form-group">
<label htmlFor="email">Email</label>
<Field
type="email"
name="email"
id="email"
className="form-control"
placeholder="Enter your email"
/>
<ErrorMessage name="email" component="div" className="error-message" />
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<Field
type="password"
name="password"
id="password"
className="form-control"
placeholder="Enter your password"
/>
<ErrorMessage name="password" component="div" className="error-message" />
</div>
<button
type="submit"
className="btn btn-primary btn-block"
disabled={isSubmitting}
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</button>
</Form>
)}
</Formik>
<p className="auth-redirect">
Don't have an account? <Link to="/register">Register</Link>
</p>
</div>
</div>
);
};
export default Login;
Task Filter Component (client/src/components/tasks/TaskFilter.js)
import React from 'react';
const TaskFilter = ({ filters, onFilterChange }) => {
const handleChange = (e) => {
const { name, value } = e.target;
onFilterChange({ [name]: value });
};
return (
<div className="task-filter">
<div className="filter-group">
<label htmlFor="status">Status:</label>
<select
name="status"
id="status"
value={filters.status}
onChange={handleChange}
>
<option value="">All</option>
<option value="pending">Pending</option>
<option value="in-progress">In Progress</option>
<option value="completed">Completed</option>
</select>
</div>
<div className="filter-group">
<label htmlFor="priority">Priority:</label>
<select
name="priority"
id="priority"
value={filters.priority}
onChange={handleChange}
>
<option value="">All</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="filter-group">
<label htmlFor="sortBy">Sort By:</label>
<select
name="sortBy"
id="sortBy"
value={filters.sortBy}
onChange={handleChange}
>
<option value="createdAt_desc">Newest First</option>
<option value="createdAt_asc">Oldest First</option>
<option value="dueDate_asc">Due Date (Ascending)</option>
<option value="dueDate_desc">Due Date (Descending)</option>
<option value="priority_desc">Priority (High to Low)</option>
<option value="priority_asc">Priority (Low to High)</option>
</select>
</div>
</div>
);
};
export default TaskFilter;
Basic Styling (client/src/App.css)
/* Global Styles */
:root {
--primary-color: #3498db;
--primary-hover: #2980b9;
--secondary-color: #e74c3c;
--dark-color: #34495e;
--light-color: #f4f4f4;
--danger-color: #e74c3c;
--success-color: #2ecc71;
--warning-color: #f1c40f;
--border-color: #ddd;
--text-color: #333;
--bg-color: #fff;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 1rem;
line-height: 1.6;
background-color: #f8f9fa;
color: var(--text-color);
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
ul {
list-style: none;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 2rem;
overflow: hidden;
}
/* Utility Classes */
.btn {
display: inline-block;
background: var(--light-color);
color: #333;
padding: 0.5rem 1.2rem;
border: none;
cursor: pointer;
margin-right: 0.5rem;
transition: opacity 0.2s ease-in;
outline: none;
border-radius: 4px;
font-size: 1rem;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-dark {
background: var(--dark-color);
color: white;
}
.btn-dark:hover {
background: #23303b;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #bd2a1c;
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: #25a25a;
}
.btn-block {
display: block;
width: 100%;
text-align: center;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loader {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
font-size: 1.2rem;
color: var(--primary-color);
}
/* Form Styles */
.form-group {
margin-bottom: 1.2rem;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 0.3rem;
font-weight: 500;
}
.form-control {
display: block;
width: 100%;
padding: 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
}
.error-message {
color: var(--danger-color);
font-size: 0.8rem;
margin-top: 0.2rem;
}
/* Auth Styles */
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.auth-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 2rem;
width: 100%;
max-width: 400px;
}
.auth-card h2 {
text-align: center;
margin-bottom: 0.5rem;
}
.auth-subtitle {
text-align: center;
margin-bottom: 1.5rem;
color: #666;
}
.auth-redirect {
margin-top: 1.5rem;
text-align: center;
font-size: 0.9rem;
}
/* Task Styles */
.dashboard {
padding: 2rem 0;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.task-filter {
display: flex;
gap: 1rem;
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-count {
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.task-list {
display: grid;
gap: 1rem;
}
.task-item {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.task-title {
display: flex;
align-items: center;
gap: 0.8rem;
}
.task-title h3 {
font-size: 1.2rem;
margin: 0;
}
.task-priority {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-weight: 500;
}
.priority-high {
background-color: #fceaea;
color: var(--danger-color);
}
.priority-medium {
background-color: #fff4de;
color: var(--warning-color);
}
.priority-low {
background-color: #e5f5eb;
color: var(--success-color);
}
.task-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.btn-edit {
background-color: #eef2ff;
color: #4f46e5;
}
.btn-delete {
background-color: #fef2f2;
color: #ef4444;
}
.task-description {
color: #555;
font-size: 0.95rem;
}
.task-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.task-dates {
display: flex;
flex-direction: column;
gap: 0.3rem;
color: #666;
}
.task-status select {
padding: 0.3rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--border-color);
font-size: 0.85rem;
}
.status-pending {
color: var(--warning-color);
}
.status-in-progress {
color: var(--primary-color);
}
.status-completed {
color: var(--success-color);
}
.task-completed {
opacity: 0.8;
}
.task-completed .task-title h3 {
text-decoration: line-through;
}
.no-tasks {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 2rem;
text-align: center;
}
.task-form-container {
max-width: 700px;
margin: 2rem auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
.task-form-container h1 {
margin-bottom: 1.5rem;
}
.form-buttons {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
}
Step 4: Look Back and Reflect
The final step in Polya's problem-solving process is to look back and reflect on our solution. This step is crucial for learning and improving our development process for future projects.
Evaluating Our Solution
Let's evaluate our task management application against our initial requirements:
- User Authentication: We've implemented a robust JWT-based authentication system with refresh tokens, secure password storage, and protected routes.
- Task Management: Our application allows users to create, view, update, and delete tasks with attributes like title, description, status, priority, and due date.
- Filtering and Sorting: Users can filter tasks by status and priority, and sort them by various criteria.
- Responsive Design: Our UI is designed to be responsive and user-friendly.
- Security: We've implemented proper authentication, authorization, and data validation.
Potential Improvements
While our application meets the core requirements, several improvements could be made in a real-world scenario:
- Testing: Add comprehensive unit and integration tests for both frontend and backend components.
- Advanced Features: Implement additional features like task categories, subtasks, file attachments, and sharing.
- Performance Optimization: Implement pagination, caching, and other optimizations for improved performance.
- Security Enhancements: Add rate limiting, CSRF protection, and additional security measures.
- Accessibility: Ensure the application is fully accessible by following WCAG guidelines.
- Offline Support: Implement service workers for offline functionality.
- Notifications: Add email or push notifications for task reminders.
Learning Points
Through this project, we've learned several key concepts and best practices:
- Structuring a full-stack MERN application with clear separation of concerns
- Implementing JWT authentication with refresh tokens for improved security
- Using React Context API for global state management
- Creating reusable components and custom hooks
- Handling form validation with Formik and Yup
- Building a RESTful API with Express.js
- Working with MongoDB and Mongoose for data storage
- Implementing error handling and validation
Applying Polya's Process to Future Projects
George Polya's problem-solving process provides a structured approach that can be applied to any development project:
- Understand the Problem: Define clear requirements, identify user needs, and understand the constraints.
- Devise a Plan: Create a project structure, define the component hierarchy, and map out the development workflow.
- Execute the Plan: Implement the solution, breaking it down into manageable tasks and following best practices.
- Look Back and Reflect: Evaluate the solution, identify improvements, and learn from the process.
This systematic approach helps ensure that your development process is well-structured, efficient, and leads to high-quality solutions.
Practice Activities
Activity 1: Basic Task Manager
Implement a simplified version of the task management application with basic functionality:
- User registration and login
- Create, read, update, and delete tasks
- Mark tasks as complete/incomplete
Focus on applying Polya's 4 steps to structure your development process.
Activity 2: Enhanced Task Manager
Extend the basic task manager with additional features:
- Task categories or tags
- Search functionality
- Task reminders with date/time selection
- User profile management
Document how you applied each of Polya's problem-solving steps in your implementation.
Activity 3: Collaborative Task Manager
Build a collaborative task management system where users can:
- Create team workspaces
- Invite other users to collaborate
- Assign tasks to team members
- Comment on tasks
- Track task activity history
Implement real-time updates using Socket.io or a similar technology.
Activity 4: Enterprise Task Manager
Develop a comprehensive task management system suitable for enterprise use:
- Role-based access control (admin, manager, member)
- Task dependencies and subtasks
- File attachments and document management
- Reporting and analytics dashboard
- Integration with third-party services (email, calendar)
Focus on scalability, security, and performance optimization.
Summary
In this weekend project, we've applied George Polya's 4-step problem-solving procedure to develop a complete MERN stack application with authentication:
- Understand the Problem: We defined clear requirements for our task management application, identifying key features, data models, and potential challenges.
- Devise a Plan: We created a comprehensive project structure, designed the component hierarchy, defined API endpoints, and mapped out our development workflow.
- Execute the Plan: We implemented the solution, setting up the backend with Express.js and MongoDB, building the frontend with React, and connecting them through a RESTful API.
- Look Back and Reflect: We evaluated our solution against the requirements, identified potential improvements, and reflected on the learning points and best practices.
Through this process, we've built a functional task management application with user authentication, CRUD operations, and a responsive UI. We've learned valuable lessons about full-stack development with the MERN stack, including:
- JWT-based authentication with refresh tokens
- State management with React Context API
- RESTful API design and implementation
- MongoDB data modeling with Mongoose
- Form handling and validation in React
- Error handling and security best practices
Most importantly, we've learned how to apply a structured problem-solving approach to software development, breaking down complex tasks into manageable steps and systematically working through them.