Curated Work / employee-management-system
Employee Management System
Role-based teams with multi-level access and task workflows.


Overview
A multi-tenant employee operations platform built on the MERN stack, where five distinct actor types — Admin, Manager, Operator, Client, and multi-level Employees — each see a completely different slice of the same database. The hard problem was never the CRUD; it was guaranteeing that a Manager can never read another Manager's team, that an Operator can only touch tasks routed to them, and that every state transition on a task passes through a review-and-verify gate before it counts. I treated authorization as a first-class backend concern rather than a UI convenience, so every query is scoped to the caller's position in the org hierarchy at the middleware layer. The result is a system where role separation is enforced by the API, not painted on by the frontend.
A full-stack, role-based employee management platform designed to handle multi-level access, task assignments, and secure data separation across teams through a structured, scalable dashboard system.
Key Features
- Multi-role dashboards: Admin, Manager, Operator, Client, and multi-level Employees.
- Granular role-based access control ensuring protected data for each user level.
- Task assignment, tracking, and approval workflows across hierarchical roles.
- Dedicated review and verification layers for monitoring work progress.
- Secure authentication and authorization across all dashboards.
Why I Built This
I kept seeing internal tools where "roles" meant hiding a button in React while the underlying API happily returned everyone's data to anyone with a valid token. I wanted to build the opposite: a system where the frontend role is purely cosmetic and the real boundary lives in the backend, so even a hand-crafted request from a logged-in Operator can't reach a Manager's records.
The domain — task assignment across a management hierarchy with client-facing deliverables — was a good forcing function because it has genuinely conflicting visibility rules. A Client should see the status of their own work and nothing about who's doing it; a Manager should see their whole subtree but not sibling teams; an Admin sees everything but every action they take still gets logged. Modeling those rules correctly, and proving they hold, was the actual project.
Architecture Overview
It's a conventional MERN split with a deliberately fat middleware layer. The React client (Vite, Material UI for the data-dense tables, Tailwind for layout scaffolding) is a thin rendering shell — it decodes the JWT claims into an AuthContext and picks which per-role dashboard to mount, but it never makes an authorization decision that the server trusts. Every protected request flows through the same three-stage chain on the Express side: authenticate, then authorize by role, then load-and-check ownership of the specific resource before any controller code runs.
employee-management/
├── client/ # React + Vite + MUI + Tailwind
│ ├── src/
│ │ ├── context/AuthContext.jsx # decoded JWT claims, role
│ │ ├── routes/ProtectedRoute.jsx # cosmetic role gate
│ │ ├── api/axios.js # interceptor attaches Bearer token
│ │ └── dashboards/ # one view per actor type
│ │ ├── AdminDashboard.jsx
│ │ ├── ManagerDashboard.jsx
│ │ ├── OperatorDashboard.jsx
│ │ └── ClientDashboard.jsx
└── server/
├── models/
│ ├── User.js # Mongoose discriminator base
│ │ ├── Manager / Operator / Employee (discriminators)
│ ├── Task.js # state + assignment refs
│ └── AuditLog.js # append-only
├── middleware/
│ ├── authenticate.js # verify JWT, load req.user
│ ├── authorize.js # role + permission matrix
│ └── scope.js # inject subtree filter into query
├── services/taskWorkflow.js # state machine + transitions
├── controllers/
└── routes/- Frontend — React + Vite with Material UI DataGrids for the operational tables and Tailwind for page-level layout; role only decides which dashboard renders, never what data is authorized.
- Data layer — MongoDB via Mongoose, using discriminators on a single User collection so Manager/Operator/Employee share one identity model while carrying role-specific fields.
- Auth — Short-lived JWT access tokens carrying role and org-path claims, verified on every request; bcrypt-hashed credentials, refresh handled server-side.
- Enforcement — A three-stage Express middleware chain (authenticate → authorize → scope) that every route composes, so no controller is reachable without passing the guards.
Access Control (RBAC)
Authorization is split into two questions that I deliberately keep separate: can this role perform this action, and is this specific record inside the caller's allowed scope. The first is a static permission matrix — a plain object keyed by role and action (task:assign, task:verify, user:create) that the authorize middleware checks before the handler runs. The second is dynamic and is where most of the interesting work lives, because "scope" for a Manager means their entire org subtree, not a flat list.
To make subtree checks cheap I store a materialized path (an ancestors array of user IDs) on every User document. The scope middleware reads the caller's own node and injects a filter — { path: caller._id } for managers, { assignee: caller._id } for operators, { client: caller._id } for clients — directly into the Mongoose query before it executes. That means a Manager physically cannot query outside their subtree even by ID-guessing, because the ownership predicate is ANDed onto the database read, not evaluated after the fact.
- Permission matrix — Role-to-action mapping evaluated in middleware; adding a capability is a one-line matrix edit, not a scatter of if-checks across controllers.
- Scope-as-query-filter — Ownership is enforced by mutating the DB query itself, so a valid token for the wrong subtree returns an empty set rather than leaking a 403 that confirms the record exists.
- Materialized path — An ancestors array on each user turns 'is X under manager Y' into an indexed array-contains lookup instead of a recursive graph walk on every request.
Hierarchical Task Engine & Verification
Tasks are not simple status strings — they move through an explicit state machine owned by a taskWorkflow service, and every transition is authorized against both the caller's role and their position relative to the task. The lifecycle is Draft → Assigned → In Progress → Submitted → Under Review → (Verified | Rejected → back to In Progress). Assignment flows down the hierarchy (Manager assigns to Operator/Employee) and approval flows back up: the person who does the work can never be the person who verifies it.
The verification layer is the part I'm most careful about. When an Operator marks a task Submitted, it enters Under Review and becomes read-only for them; only a user strictly above them in the path with the task:verify permission can move it to Verified or bounce it back with a rejection reason. Every one of these transitions writes an immutable AuditLog entry — actor, action, before/after state, timestamp — so the review trail is reconstructable and no state change is anonymous.
- Explicit state machine — Illegal transitions (e.g. Operator self-verifying, or editing a Submitted task) are rejected by the service, not just discouraged by the UI.
- Separation of duties — Doer and verifier must be different users, and the verifier must be an ancestor in the org path — enforced server-side on the transition.
- Append-only audit — AuditLog documents are never updated or deleted, giving a tamper-evident history for every task and every privileged action.
Backend Systems
The Express API is organized so that data access, identity, and workflow logic never bleed into each other. Controllers stay thin — they orchestrate; the taskWorkflow service owns transition rules; and the middleware chain owns the security perimeter. Dashboards are powered by MongoDB aggregation pipelines rather than N+1 controller loops, so a Manager's overview (open tasks by operator, pending verifications, rejection rate) is a single scoped aggregation.
- Data — Mongoose schemas with discriminators for the user hierarchy and reference fields (assignee, client, path) on Task; compound indexes on { path, state } and { assignee, state } keep the dashboard aggregations fast.
- Auth — JWT verification loads a trimmed req.user (id, role, path) on every request; passwords bcrypt-hashed with per-user salt, and tokens carry the minimum claims needed to build the scope filter.
- API — RESTful resource routes that each compose authenticate → authorize → scope, so security is declarative at the route definition instead of re-implemented per handler.
- Aggregation dashboards — Per-role summary views are computed in MongoDB via pipelines with the same subtree filter applied at the $match stage, keeping read-scoping consistent between list endpoints and analytics.
Key Decisions
- Enforce RBAC at the API, not the UI — The React role gate is purely for UX; every authorization decision is re-made on the server, so bypassing the frontend gains an attacker nothing.
- Mongoose discriminators over separate collections — Manager/Operator/Employee share one User collection and one identity model, which made auth, references, and the org path uniform instead of forcing polymorphic joins.
- Materialized path over recursive lookups — Storing an ancestors array trades a little write-time bookkeeping for O(1) indexed subtree checks on every read — the right call for a read-heavy dashboard app.
- Scope as a query filter, not a post-check — ANDing the ownership predicate onto the DB query prevents existence leaks and removes the whole class of 'forgot to check owner' controller bugs.
- Append-only AuditLog over mutable status fields — Chose an immutable event log so the verification history can't be quietly rewritten, at the cost of more storage and one extra write per transition.
- Material UI for tables, Tailwind for layout — Leaned on MUI DataGrid for the dense, sortable operational tables where building from scratch wasn't worth it, and kept Tailwind for fast, consistent page structure.
What I Learned
- Authorization is a data-modeling problem before it's a middleware problem — the materialized path was what made correct scoping cheap; without it every guard would have been a recursive query.
- Separating 'can this role do X' from 'does this record belong to the caller' kept the permission logic readable and let me reason about each failure mode independently.
- An explicit state machine for tasks eliminated a surprising amount of defensive code — once illegal transitions are impossible at the service layer, controllers and UI both get simpler.
- Deciding early that the frontend is untrusted changed how I built everything downstream, and it's the single practice I now carry into every role-based system.
Tech Stack
Like what you see?
Explore more work or get in touch.
