A role-based access control and user activity tracking platform, built as a clean Node + React monorepo.
Live app: https://avidus-interactive-three.vercel.app
API health: https://avidus-interactive-server.onrender.com/api/health
Default admin login: admin@avidus.com / Admin@12345 (set via env vars; change in production)
Avidus interactive is a full-stack reference application that demonstrates
how to ship a real-world JWT-authenticated product with two roles
(admin, user), an auditable activity log, and a small but useful
task-tracking domain. The backend is a stateless Express API on top of
MongoDB; the frontend is a React 18 SPA built with Vite, Tailwind, and
shadcn/ui. It is designed to be easy to read, easy to extend, and easy
to deploy.
- ✅ Self-service registration with email + strong password
- ✅ Email + password login, with secure refresh-token rotation
- ✅ "Remember me" via long-lived refresh token (7 days)
- ✅ Personal profile — change name and password from one screen
- ✅ Personal dashboard with task statistics (pending / in-progress / completed)
- ✅ Full CRUD on your own tasks (create, list, view, update, change status, delete)
- ✅ Filter and search your tasks by status or free-text
- ✅ Everything users can do, plus a separate admin view
- ✅ User management — list, search, activate / deactivate, delete
- ✅ Global task oversight — see and delete any user's task
- ✅ Paginated activity log, filterable by action, user, or date range
- ✅ Analytics dashboard — user counts, task counts, 14-day task trend
- ✅ Safety rails — admins cannot delete or self-deactivate their own account
- ✅ Orphaned tasks (after user deletion) are kept for audit, with
owner: null
| Layer | Technology |
|---|---|
| Runtime | Node.js 24 |
| API framework | Express 4 |
| Database / ODM | MongoDB 6+ with Mongoose 8 |
| Auth | JWT (access 15m / refresh 7d, hashed at rest) + bcrypt |
| Validation | Joi |
| Security middleware | helmet, express-rate-limit, CORS allow-list |
| Frontend framework | React 18 + React Router 6 |
| Build tool | Vite 5 |
| Styling | Tailwind CSS 3 + shadcn/ui primitives |
| Charts | Recharts |
| HTTP client | Axios (with auth interceptor) |
| Toasts | Sonner |
| Linting (server) | ESLint 8 |
| Dev runner (server) | nodemon |
+-------------------+ Authorization: Bearer <accessToken> +-------------------+
| | ------------------------------------------->| |
| React 18 SPA | <------------------------------------------- | Express API |
| (Vite, Tailwind,| { success, message, data, … } | (Node 24) |
| shadcn/ui) | | Port 5000 |
| Port 5173 | POST /api/auth/refresh | |
| | ------------------------------------------->| helmet, cors, |
| Axios client | { accessToken, refreshToken } | rate-limit, |
| - accessToken | | Joi validate, |
| in | | role middleware |
| localStorage | | |
| - refreshToken | +---------+---------+
| in body or | |
| httpOnly | | Mongoose driver
| cookie | v
+-------------------+ +-------------------+
| MongoDB 6+ |
| - users |
| - tasks |
| - activitylogs |
+-------------------+
Request flow:
- The browser Axios client attaches the access token in the
Authorizationheader. - Express validates the JWT, runs Joi input validation, and (for state
changes) records an
ActivityLogentry through a fire-and-forget helper so logging never blocks the response. - Mongoose talks to MongoDB. Responses are wrapped in the standard
envelope
{ success, message, data }(ordata: { items, pagination }). - When the access token expires, the client posts the refresh token to
/api/auth/refreshand gets a brand new pair back.
For the full version — data model, request lifecycle diagram, error
shape, security boundaries — see docs/architecture.md.
Avidus interactive/
├── README.md ← you are here
├── LICENSE ← MIT
├── CONTRIBUTING.md ← contribution guide
├── .editorconfig ← cross-OS editor config
├── .gitattributes
├── .gitignore
├── postman_collection.json ← importable Postman v2.1 collection
├── docs/
│ ├── architecture.md ← longer architecture write-up
│ └── screenshots/ ← (empty; populate with app screenshots)
│ └── .gitkeep
├── server/ ← Node 24 + Express + MongoDB
│ ├── .env.example
│ ├── package.json
│ ├── scripts/
│ │ └── seedAdmin.js ← `npm run seed:admin`
│ └── src/
│ ├── app.js ← Express app, CORS, helmet, rate limit, mount routers
│ ├── server.js ← Entrypoint (connect DB, listen)
│ ├── config/
│ │ ├── db.js
│ │ └── env.js
│ ├── models/
│ │ ├── User.js
│ │ ├── Task.js
│ │ └── ActivityLog.js
│ ├── routes/
│ │ ├── auth.routes.js
│ │ ├── user.routes.js
│ │ ├── task.routes.js
│ │ └── admin.routes.js
│ ├── controllers/
│ │ ├── auth.controller.js
│ │ ├── user.controller.js
│ │ ├── task.controller.js
│ │ ├── admin.controller.js
│ │ └── activity.controller.js
│ ├── middlewares/
│ │ ├── auth.middleware.js
│ │ ├── admin.middleware.js
│ │ ├── activity.middleware.js
│ │ └── error.middleware.js
│ ├── validators/
│ │ ├── auth.validator.js
│ │ └── task.validator.js
│ ├── services/
│ │ ├── token.service.js
│ │ └── activity.service.js
│ └── utils/
│ ├── ApiError.js
│ ├── ApiResponse.js
│ ├── asyncHandler.js
│ └── logger.js
└── client/ ← React 18 + Vite + Tailwind + shadcn/ui
├── .env.example
├── package.json
└── (src/, public/, index.html — populated by Vite scaffold)
You need Node.js 24+, npm 10+, and a running MongoDB 6+ instance
(default connection string in the example is mongodb://127.0.0.1:27017/avidus_interactive).
The two apps run as siblings — open two terminals, one for each.
-
Clone the repo
git clone <your-fork-url> avidus-interactive cd avidus-interactive
-
Backend —
server/cd server cp .env.example .env # open .env and set at least: # MONGODB_URI, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, # ADMIN_EMAIL, ADMIN_PASSWORD npm install npm run seed:admin # creates / promotes the admin from .env npm run dev # http://localhost:5000
-
Frontend —
client/cd client cp .env.example .env # contains VITE_API_BASE_URL=http://localhost:5000/api npm install npm run dev # http://localhost:5173
-
Open the app at http://localhost:5173, sign in with the seeded admin credentials (see below), and you're in.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
no | 5000 |
HTTP port the API listens on |
NODE_ENV |
no | development |
development / production / test; controls CORS cookie secure flag |
MONGODB_URI |
yes | — | Full MongoDB connection string, e.g. mongodb://127.0.0.1:27017/avidus_interactive |
CLIENT_ORIGIN |
no | http://localhost:5173 |
Comma-separated list of allowed CORS origins |
JWT_ACCESS_SECRET |
yes | — | Long random string used to sign access tokens |
JWT_REFRESH_SECRET |
yes | — | Long random string used to sign refresh tokens |
JWT_ACCESS_EXPIRY |
no | 15m |
Access-token lifetime (ms, s, m, h, d per jsonwebtoken) |
JWT_REFRESH_EXPIRY |
no | 7d |
Refresh-token lifetime |
ADMIN_NAME |
no | Super Admin |
Display name used by npm run seed:admin |
ADMIN_EMAIL |
yes (for seeding) | — | Email of the seeded admin |
ADMIN_PASSWORD |
yes (for seeding) | — | Password of the seeded admin (min 8 chars) |
| Variable | Required | Default | Description |
|---|---|---|---|
VITE_API_BASE_URL |
yes | http://localhost:5000/api |
Base URL the Axios client prefixes on calls |
Vite only exposes variables prefixed with
VITE_to the browser bundle. Don't put secrets in the client.env.
After running npm run seed:admin, the following account exists in
your database — taken straight from server/.env.example:
| Field | Value |
|---|---|
admin@avidus.com |
|
| Password | Admin@12345 |
⚠️ Change these immediately in any non-local environment. The example values exist purely to make first-time setup friction-free. In production, set strong unique values forADMIN_PASSWORDand the twoJWT_*_SECRETs, and rotate them regularly.
Base URL: http://localhost:5000/api
All requests and responses use JSON. The response envelope is:
{ "success": true, "message": "OK", "data": { /* … */ } }For paginated lists, data is shaped as:
{
"items": [ /* … */ ],
"pagination": { "page": 1, "limit": 25, "total": 42, "totalPages": 2 }
}Errors use the same envelope with success: false and an optional
details object containing per-field validation messages.
Looking for an importable Postman v2.1 collection? See
postman_collection.jsonat the repo root.
| Property | Value |
|---|---|
| Auth | None |
| Role | Public |
| Rate limit | 20 / hour / IP |
Request body:
{
"name": "Jane Doe",
"email": "jane.doe@example.com",
"password": "S3cret!Pass"
}Response 201 Created:
{
"success": true,
"message": "Registration successful",
"data": {
"user": {
"_id": "65f0a1b2c3d4e5f6a7b8c9d0",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"role": "user",
"status": "active",
"lastLoginAt": null,
"createdAt": "2026-06-04T10:00:00.000Z",
"updatedAt": "2026-06-04T10:00:00.000Z"
},
"accessToken": "eyJhbGciOi…",
"refreshToken": "eyJhbGciOi…"
}
}| Property | Value |
|---|---|
| Auth | None |
| Role | Public |
| Rate limit | 10 / 15 min / IP |
Request body:
{
"email": "admin@avidus.com",
"password": "Admin@12345"
}Response 200 OK:
{
"success": true,
"message": "Login successful",
"data": {
"user": { "…": "same shape as register" },
"accessToken": "eyJhbGciOi…",
"refreshToken": "eyJhbGciOi…"
}
}Inactive accounts return 403 Account is inactive.
| Property | Value |
|---|---|
| Auth | None |
| Role | Public (valid refresh token) |
Request body:
{ "refreshToken": "eyJhbGciOi…" }Response 200 OK:
{
"success": true,
"message": "Token refreshed",
"data": {
"accessToken": "eyJhbGciOi…",
"refreshToken": "eyJhbGciOi…"
}
}The server rotates the refresh token: the old hash is replaced with a new bcrypt hash, so a previously-leaked refresh token is invalidated.
| Property | Value |
|---|---|
| Auth | Bearer |
| Role | Any authenticated |
No request body.
Response 200 OK:
{ "success": true, "message": "Logged out", "data": null }Clears the stored refreshTokenHash on the user and the
httpOnly refreshToken cookie (if the cookie is in use).
| Property | Value |
|---|---|
| Auth | Bearer |
| Role | Any authenticated |
Response 200 OK:
{
"success": true,
"message": "Current user",
"data": {
"user": {
"_id": "…",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"role": "user",
"status": "active",
"lastLoginAt": "2026-06-04T10:15:00.000Z",
"createdAt": "2026-05-01T08:00:00.000Z",
"updatedAt": "2026-06-04T10:15:00.000Z"
}
}
}All endpoints in this section require a Bearer access token. None
require the admin role — any authenticated user can call them.
Returns the current user's profile.
Response 200 OK:
{
"success": true,
"message": "Profile",
"data": {
"user": {
"_id": "…", "name": "…", "email": "…",
"role": "user", "status": "active",
"lastLoginAt": "…", "createdAt": "…", "updatedAt": "…"
}
}
}Update the current user's name and/or password. To change the
password, both currentPassword and newPassword must be supplied.
Request body (at least one of name or currentPassword required):
{
"name": "Jane M. Doe",
"currentPassword": "S3cret!Pass",
"newPassword": "N3wer!Pass"
}Response 200 OK:
{
"success": true,
"message": "Profile updated",
"data": { "user": { "…": "same shape as GET profile" } }
}Returns task counts for the current user.
Response 200 OK:
{
"success": true,
"message": "My task stats",
"data": {
"total": 12,
"pending": 5,
"inProgress": 4,
"completed": 3
}
}Paginated list of the current user's tasks. Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
page |
int | 1 |
1-indexed |
limit |
int | 25 |
capped at 100 |
status |
string | — | One of pending, in-progress, completed |
q |
string | — | Free-text match on title or description |
Response 200 OK:
{
"success": true,
"message": "My tasks",
"data": {
"items": [
{
"_id": "…",
"title": "Ship Q2 release",
"description": "…",
"status": "in-progress",
"priority": "high",
"owner": "…",
"createdAt": "…", "updatedAt": "…"
}
],
"pagination": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}
}All endpoints require a Bearer access token. Users may only mutate their own tasks; admins may view/delete any task.
Create a new task owned by the current user.
Request body:
{
"title": "Ship Q2 release",
"description": "Cut the Q2 release branch and publish the changelog.",
"status": "pending",
"priority": "high"
}status defaults to pending; priority defaults to medium. Allowed
statuses: pending | in-progress | completed. Allowed priorities:
low | medium | high.
Response 201 Created:
{
"success": true,
"message": "Task created",
"data": {
"task": {
"_id": "…",
"title": "Ship Q2 release",
"description": "…",
"status": "pending",
"priority": "high",
"owner": "65f0a1b2…",
"createdAt": "…", "updatedAt": "…"
}
}
}Paginated list of the current user's tasks. Same query params as
GET /api/users/me/tasks.
Response 200 OK: same paginated envelope shape as above.
Fetch a single task by id. Owners and admins can read; everyone else
gets 403.
Response 200 OK:
{
"success": true,
"message": "Task",
"data": { "task": { "…": "see POST /api/tasks shape" } }
}Replace mutable fields on a task. Owner-only (403 otherwise).
Request body (any subset — at least one field required):
{
"title": "Ship Q2 release (revised)",
"description": "Cut the branch, publish notes, announce on Slack.",
"status": "in-progress",
"priority": "high"
}Response 200 OK:
{
"success": true,
"message": "Task updated",
"data": { "task": { "…": "updated task" } }
}Slim status-only update. Owner-only.
Request body:
{ "status": "completed" }Response 200 OK:
{
"success": true,
"message": "Task status updated",
"data": { "task": { "…": "task with new status" } }
}Delete a task. Owners and admins can delete.
Response 200 OK:
{ "success": true, "message": "Task deleted", "data": null }All endpoints in this section require a Bearer access token with
role: "admin". Non-admins get 403 Admin privileges required.
Paginated list of users. Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
page |
int | 1 |
1-indexed |
limit |
int | 25 |
capped at 100 |
role |
string | — | admin or user |
status |
string | — | active or inactive |
q |
string | — | Free-text match on name or email |
Response 200 OK:
{
"success": true,
"message": "Users",
"data": {
"items": [ { "_id": "…", "name": "…", "email": "…", "role": "user", "status": "active", "…": "…" } ],
"pagination": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}
}Permanently delete a user other than the calling admin
(400 if you target yourself). All of the user's tasks are
orphaned (owner: null) for audit — they are not cascade-deleted.
Response 200 OK:
{ "success": true, "message": "User deleted", "data": null }Activate or deactivate any user other than yourself with status: "inactive".
Request body:
{ "status": "inactive" }Response 200 OK:
{
"success": true,
"message": "User status updated",
"data": { "user": { "…": "user with new status" } }
}Paginated list of all tasks across the system. Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
page |
int | 1 |
1-indexed |
limit |
int | 25 |
capped at 100 |
status |
string | — | pending, in-progress, completed |
priority |
string | — | low, medium, high |
owner |
string | — | Filter by owner _id (or null for orphaned) |
q |
string | — | Free-text match on title or description |
Each item in data.items is populated with owner: { _id, name, email, role }.
Admin-only hard delete. Logs TASK_DELETED with description
Admin deleted task: "<title>".
Response 200 OK:
{ "success": true, "message": "Task deleted", "data": null }Paginated, filterable view of the activitylogs collection. Query params:
| Param | Type | Notes |
|---|---|---|
page |
int | 1-indexed |
limit |
int | capped at 100 |
action |
string | One of LOGIN, LOGOUT, REGISTER, TASK_CREATED, TASK_UPDATED, TASK_DELETED, USER_DELETED, USER_STATUS_UPDATED |
user |
string | User _id |
from |
ISO date | Lower bound for createdAt |
to |
ISO date | Upper bound for createdAt |
Response 200 OK:
{
"success": true,
"message": "Activity logs",
"data": {
"items": [
{
"_id": "…",
"user": { "_id": "…", "name": "Jane Doe", "email": "jane@example.com", "role": "user" },
"userEmail": "jane@example.com",
"action": "TASK_CREATED",
"targetType": "Task",
"targetId": "…",
"description": "Task created: \"Ship Q2 release\"",
"ip": "127.0.0.1",
"userAgent": "Mozilla/5.0 …",
"createdAt": "2026-06-04T10:00:00.000Z"
}
],
"pagination": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}
}Aggregate metrics for the admin dashboard. No query params.
Response 200 OK:
{
"success": true,
"message": "Analytics",
"data": {
"users": {
"total": 42, "active": 37, "inactive": 5,
"admins": 2, "regular": 40
},
"tasks": {
"total": 318, "pending": 121, "inProgress": 84, "completed": 113
},
"tasksByDay": [
{ "_id": "2026-05-23", "count": 9 },
{ "_id": "2026-05-24", "count": 14 }
],
"recentActivity": [ { "…": "same shape as a log entry" } ]
}
}✅ = allowed · ❌ = forbidden ·
| Action | user |
admin |
|---|---|---|
Register / login / refresh / logout / me |
✅ | ✅ |
| Read & update own profile | ✅ | ✅ |
| Read own stats and task list | ✅ | ✅ |
| Create / read / update / delete own task | ✅ | ✅ |
| Read another user's task | ❌ | ✅ |
| Delete another user's task | ❌ | ✅ |
| List all users | ❌ | ✅ |
| Delete a user | ❌ | |
| Activate / deactivate a user | ❌ | |
| List all tasks across the system | ❌ | ✅ |
| View activity logs | ❌ | ✅ |
| View analytics | ❌ | ✅ |
Every state-mutating endpoint passes through the activity middleware,
which exposes a req.log(action, targetType, targetId, description)
helper. The log is written after the response is prepared, so it
never blocks the user.
| Action | Fired by |
|---|---|
REGISTER |
POST /api/auth/register — new user created |
LOGIN |
POST /api/auth/login — successful login (success and failure paths) |
LOGOUT |
POST /api/auth/logout — refresh-token hash cleared |
TASK_CREATED |
POST /api/tasks |
TASK_UPDATED |
PUT /api/tasks/:id and PATCH /api/tasks/:id/status |
TASK_DELETED |
DELETE /api/tasks/:id (owner) and DELETE /api/admin/tasks/:id (admin) |
USER_DELETED |
DELETE /api/admin/users/:id |
USER_STATUS_UPDATED |
PATCH /api/admin/users/:id/status |
Each log row stores user, userEmail, action, targetType,
targetId, free-text description, request ip and userAgent,
and a createdAt timestamp.
Coming soon — drop exported PNGs into
docs/screenshots/and the placeholders below will pick them up automatically.
- JWT over server-side sessions — keeps the API stateless and easy
to scale horizontally. Refresh tokens are bcrypt-hashed at rest
(
User.refreshTokenHash) so a database leak does not immediately leak live sessions. - 15-minute access tokens, 7-day refresh tokens — short enough to limit the blast radius of a stolen access token, long enough that active users are not constantly re-authenticating.
localStoragefor the access token by default, with the refresh token also exposed as anhttpOnlycookie — the cookie path is available for frontends that prefer stricter XSS isolation. The body is always returned so the SPA can pick either storage strategy without server changes.- Role-based middleware (
auth+adminOnly) — declarative, composable, and easy to apply to a new route in one line. Avoids sprinklingif (user.role !== 'admin')checks in controllers. - Joi for input validation — declarative schemas per endpoint,
with
stripUnknown: trueso extra fields are dropped silently instead of leaking400s on harmless clients. { success, message, data }envelope — single, predictable response shape for the whole API. Errors add an optionaldetailsobject with per-field validation messages.- Activity logging is fire-and-forget — the service swallows DB errors and never awaits a write on the hot path, so a logging failure cannot bring down the API.
- Orphaned tasks (not cascade delete) when a user is removed —
preserves the audit trail in the activity log and in the
admin/taskslist. - shadcn/ui + Tailwind on the frontend — copy-in components, full ownership of the source, no locked-in dependency upgrade treadmill.
- Recharts for analytics — declarative React-friendly chart library; small bundle, no D3 wrangling.
The app is wired for one-click deploys to MongoDB Atlas (database) + Render (backend) + Vercel (frontend). All three have generous free tiers.
- Create a free cluster at https://www.mongodb.com/cloud/atlas.
- Add a database user (username + password).
- In Network Access, add
0.0.0.0/0(or your deployment IP). - Grab the connection string from Database → Connect → Drivers → Node.js,
with the database name
avidus_interactiveinserted before the?:mongodb+srv://<user>:<password>@cluster0.xxxxx.mongodb.net/avidus_interactive?retryWrites=true&w=majority
- Sign in to https://render.com with GitHub.
- New + → Blueprint → connect the
avidus-interactiverepo. - Render auto-detects
server/render.yamland shows the planned Web Service. - Before deploying, open the service and set these env vars (the rest
are pre-filled by the Blueprint):
MONGODB_URI— your Atlas connection stringADMIN_EMAIL— e.g.admin@avidus.comADMIN_PASSWORD— a strong passwordJWT_ACCESS_SECRETandJWT_REFRESH_SECRETcan be auto-generated (Render will fill these) or paste your own long random strings.
- Click Apply. Render builds, deploys, and starts the server. The
start command is
npm run seed:admin && node src/server.js, so the first admin is created/refreshed on every start — idempotent, safe to re-run. (Render's free tier doesn't support a separatereleaseCommand, so we chain the seed into the start command instead.) - Copy the service URL — looks like
https://avidus-interactive-server.onrender.com.
- Sign in to https://vercel.com with GitHub.
- Add New → Project → import
avidus-interactive. - Configure:
- Root Directory:
client - Framework Preset: Vite (auto-detected)
- Build Command:
npm run build - Output Directory:
dist - Environment Variables → add
VITE_API_BASE_URL=https://avidus-interactive-server.onrender.com/api
- Root Directory:
- Deploy. You'll get a URL like
https://avidus-interactive.vercel.app— this is your deployment link.
Go back to Render → avidus-interactive-server → Environment, set
CLIENT_ORIGIN to the Vercel URL (no trailing slash), and save. Render
auto-redeploys.
Open the Vercel URL. Log in with the admin email/password you set in step 1. You should land on the admin dashboard backed by Atlas.
Render's free tier sleeps the service after 15 min of inactivity. The first request after sleep takes ~30 s. For a submission demo, ping it once before sharing the link.
- 🔁 Refresh-token rotation via httpOnly cookies — move refresh
tokens entirely out of
localStorageto close the XSS-read window. - 📧 Email verification on registration (signed token + 24h expiry).
- 🔑 Password reset flow over email (one-time signed token).
- 🛡️ Account lockout after N failed logins (in addition to the current IP-based rate limit).
- 📜 Per-user audit trail — expose a
GET /api/users/me/activityendpoint that returns only the current user's log rows. - 🧑🤝🧑 Team / organization model so users can share tasks and admins can be scoped to a single tenant.
- 🧪 Automated test suite — Jest + Supertest for the API, Vitest
- React Testing Library for the client.
- 🐳 Docker Compose for one-command local dev (Mongo + API + client).
- 🚀 CI pipeline (lint, test, build) on every PR.
- 📦 OpenAPI 3.1 spec generated from Joi schemas, served at
/api/docs. - 🌍 i18n in the React app.
- 🔔 Real-time updates over Server-Sent Events for the admin activity log and the user's own task list.
This project is released under the MIT License — see
LICENSE for the full text.
Maintained by the Avidus interactive team. Drop a line at
hello@avidus.com (placeholder) or open an issue on the repo.