A production-shaped REST API with JWT authentication, role-based access control (RBAC), and full CRUD for a task entity, plus a React console to exercise every endpoint.
Built for the Backend Developer (Intern) assignment. Backend is the primary focus; the frontend is a supporting client.
- JWT Authentication (Access + Refresh Tokens)
- Role-Based Access Control (RBAC)
- Task CRUD Operations
- Pagination & Filtering
- Rate Limiting & Security Hardening
- Swagger/OpenAPI Documentation
- Dockerized Deployment
- Centralized Error Handling
| Layer | Choice |
|---|---|
| Backend | Node.js, Express, MongoDB (Mongoose) |
| Auth | JWT (access + refresh), bcrypt password hashing |
| Security | helmet, CORS, express-rate-limit, express-mongo-sanitize, input validation |
| Docs | Swagger / OpenAPI 3 (/docs) + Postman collection |
| Frontend | React + Vite + React Router |
| Deploy | Dockerfile + docker-compose |
docker compose up --build
# API: http://localhost:5000
# Docs: http://localhost:5000/docsBackend
cd backend
cp .env.example .env # then set a strong JWT_SECRET
npm install
npm run seed # creates demo admin + user (optional)
npm run dev # http://localhost:5000Requires a running MongoDB. Either install it locally or run only the DB:
docker run -p 27017:27017 mongo:7.
Frontend
cd frontend
npm install
npm run dev # http://localhost:5173The Vite dev server proxies /api to the backend, so no extra config is needed.
| Role | Password | |
|---|---|---|
| Admin | [email protected] |
Admin@123 |
| User | [email protected] |
User@1234 |
Base path: /api/v1. Full interactive docs at /docs; importable spec at /docs.json.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/register |
— | Register a user (always role user) |
| POST | /auth/login |
— | Get access + refresh tokens |
| POST | /auth/refresh |
— | Exchange a refresh token |
| GET | /auth/me |
Bearer | Current authenticated user |
| Method | Endpoint | Description |
|---|---|---|
| GET | /tasks |
List own tasks (admins see all); paginated, filterable |
| POST | /tasks |
Create a task |
| GET | /tasks/:id |
Get one task |
| PATCH | /tasks/:id |
Update a task |
| DELETE | /tasks/:id |
Delete a task |
Query params on list: page, limit, status (todo|in_progress|done), priority (low|medium|high).
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/users |
List all users |
| PATCH | /admin/users/:id/role |
Change a user's role |
Every response uses a consistent envelope:
# Register
curl -X POST http://localhost:5000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Ada","email":"[email protected]","password":"Passw0rd!"}'
# Create a task (use the accessToken from the response above)
curl -X POST http://localhost:5000/api/v1/tasks \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"Write docs","priority":"high"}'taskflow-api/
├── backend/
│ └── src/
│ ├── config/ # env + db connection
│ ├── models/ # User, Task (Mongoose schemas)
│ ├── controllers/ # request handlers
│ ├── routes/v1/ # versioned routers (auth, tasks, admin)
│ ├── middleware/ # authenticate, authorize, validate, error, rateLimit
│ ├── validators/ # express-validator rule chains
│ ├── utils/ # token, logger, apiResponse, asyncHandler, seed
│ ├── docs/ # swagger spec
│ ├── app.js # express app assembly
│ └── server.js # boot + graceful shutdown
├── frontend/ # React + Vite console
├── postman_collection.json
├── docker-compose.yml
├── SCALABILITY.md
└── README.md
- Passwords hashed with bcrypt (cost 12); never returned in responses (
select: false). - Short-lived access tokens (15m) + refresh tokens (7d); refresh tokens can't access resources.
- Users cannot self-assign the admin role at registration.
- Ownership-scoped queries — a user can never read or mutate another user's tasks.
- helmet headers, CORS allow-list, mongo-sanitize (NoSQL injection), body-size cap, and rate limiting (stricter on auth routes).
- Centralized error handler that hides stack traces in production.
See SCALABILITY.md for how this scales to production.
MIT
All diagrams below are Mermaid and render natively on GitHub.
graph TB
subgraph client["Client tier"]
UI["React + Vite Console<br/>(login · dashboard · admin)"]
end
subgraph api["API tier — Express (stateless, horizontally scalable)"]
direction TB
MW["Middleware pipeline<br/>helmet · CORS · rate-limit · sanitize · JSON"]
RT["Versioned router /api/v1"]
subgraph routers[" "]
AR["Auth routes"]
TR["Task routes"]
ADR["Admin routes"]
end
AUTH["authenticate (JWT)"]
AUTHZ["authorize (RBAC)"]
CTRL["Controllers"]
ERR["Central error handler"]
DOCS["Swagger /docs"]
end
subgraph data["Data tier"]
MODELS["Mongoose models<br/>User · Task"]
DB[("MongoDB<br/>indexed + pooled")]
end
UI -->|HTTPS + Bearer JWT| MW
MW --> RT
RT --> AR & TR & ADR
AR --> CTRL
TR --> AUTH --> CTRL
ADR --> AUTH --> AUTHZ --> CTRL
CTRL --> MODELS --> DB
CTRL -. throws .-> ERR
UI -.reads.-> DOCS
The API holds no session state — auth is JWT-based, so any instance can serve any request, which is what makes the API tier horizontally scalable.
sequenceDiagram
actor U as User
participant FE as React Console
participant API as Express API
participant DB as MongoDB
U->>FE: Enter credentials
FE->>API: POST /auth/login
API->>DB: find user (+password)
DB-->>API: user doc
API->>API: bcrypt.compare(password)
API-->>FE: 200 { accessToken (15m), refreshToken (7d) }
FE->>FE: store tokens
Note over FE,API: Subsequent protected calls
FE->>API: GET /tasks (Authorization: Bearer access)
API->>API: verify access token
API-->>FE: 200 data
Note over FE,API: Access token expires
FE->>API: GET /tasks (expired access)
API-->>FE: 401 Token expired
FE->>API: POST /auth/refresh { refreshToken }
API-->>FE: 200 { new accessToken }
FE->>API: retry GET /tasks
API-->>FE: 200 data
The console refreshes silently and once per failure, then retries the original request — the user never sees the expiry.
flowchart LR
REQ([Incoming request]) --> SEC[helmet / CORS]
SEC --> RL{Rate limit OK?}
RL -- no --> R429[429 Too Many Requests]
RL -- yes --> BODY[Parse + sanitize body]
BODY --> ROUTE{Route match?}
ROUTE -- no --> R404[404 Not Found]
ROUTE -- yes --> AUTHN{Auth required?}
AUTHN -- yes --> JWT{Valid JWT?}
JWT -- no --> R401[401 Unauthorized]
JWT -- yes --> RBAC{Role allowed?}
AUTHN -- no --> VAL
RBAC -- no --> R403[403 Forbidden]
RBAC -- yes --> VAL{Input valid?}
VAL -- no --> R400[400 + field errors]
VAL -- yes --> CTRL[Controller -> DB]
CTRL --> OK[2xx success envelope]
CTRL -. error .-> EH[Central error handler]
EH --> ERESP[Consistent error envelope]
Every failure path produces the same JSON envelope ({ success, message, errors? }), so the client handles all of them uniformly.
flowchart TD
START([Authenticated request]) --> ROLE{User role?}
ROLE -- admin --> ALL[Full scope:<br/>all tasks + user management]
ROLE -- user --> OWN[Scoped to owner == self]
OWN --> Q[Query filtered by owner: userId]
ALL --> Q2[Query unfiltered<br/>admin routes gated by authorize#40;admin#41;]
Q --> RESP([Only own resources returned])
Q2 --> RESP2([All resources returned])
Ownership scoping is applied in the query itself, not after fetching — a user can never read or mutate another user's task, even by guessing an ID.
erDiagram
USER ||--o{ TASK : owns
USER {
ObjectId _id
string name
string email "unique, indexed"
string password "bcrypt hash, never returned"
string role "user | admin"
date createdAt
date updatedAt
}
TASK {
ObjectId _id
string title
string description
string status "todo | in_progress | done"
string priority "low | medium | high"
date dueDate
ObjectId owner "FK -> USER, indexed"
date createdAt
date updatedAt
}
A compound index on { owner, createdAt } backs the dominant query: this user's tasks, newest first.
graph LR
subgraph internet[Internet]
BROWSER[Browser]
end
subgraph edge[Edge]
LB[Load balancer / reverse proxy]
end
subgraph compute["Compute (autoscaled)"]
A1[API container 1]
A2[API container 2]
AN[API container N]
end
subgraph stateful[Stateful]
REDIS[(Redis<br/>cache + rate limits — next step)]
MONGO[(MongoDB<br/>replica set)]
end
BROWSER --> LB --> A1 & A2 & AN
A1 & A2 & AN --> MONGO
A1 & A2 & AN -.planned.-> REDIS
Because instances are stateless, scaling out is "add another container." docker-compose.yml runs the single-node version of this locally; the dashed Redis layer is the documented next step in SCALABILITY.md.
