Case study / Vol. 01
WorkShield is an employee management system with role-based access control. The constraint was speed: design, build, test, and deploy in one focused session.
The problem
Internal tools accumulate role complexity faster than they accumulate features. An HR manager needs to approve leaves, but not delete employees. An employee needs to see their pay, but not their colleague's. An admin needs to do everything — without accidentally exposing salary data through a leaky endpoint.
The wrong shape is to scatter if (user.role === "admin") across handlers. The right shape is two composed middlewares — one that asserts the role, one that asserts ownership — and to make them impossible to bypass.
Decisions
Hiding routes and buttons feels safe. It isn't. Every protected endpoint runs requireAuth + requireRole, plus an ownership check that rejects access to other people's records — even for an Admin reading sensitive HR fields.
Why: Frontend hiding is cosmetic. A motivated user can hit /api/employees with curl. Backend is the only honest gate.
Stateless bearer tokens via jose, signed with a 32+ char secret, 7-day expiry. No session table. No sticky sessions. Trades server-side revocation for horizontal scale and operational simplicity.
Why: For a demo with a single admin tier, revocation hooks aren't worth the storage and join cost. If we needed to invalidate, a denylist in Redis would be the next step.
Mongoose models map cleanly to the four entities (User, Employee, Leave, Salary). No relational joins, no migrations, no schema drift on demos. Mongo's flexible schema absorbs feature additions without a migration step.
Why: Postgres wins for analytics and constraints. Mongo wins for velocity on schemas that change weekly. This system trades that flexibility on purpose.
Bun.password.hash replaces bcrypt. Bun.serve replaces express. Native TypeScript. Fast install. Single binary for dev + prod. The whole runtime story collapses into one tool.
Why: On a small team, the productivity wins (no node-gyp, no ts-node) outweigh the smaller ecosystem and missing edge-case packages.
Just useState + a tiny AuthProvider context. Zustand and Redux are overkill for four pages. Server state (employees, leaves, salary) refetches per-page. Optimistic UI on critical writes.
Why: State libraries pay off when you share state across many components. This dashboard has none of that. Adding a library would be ceremony tax.
Architecture
Build phases
Mongoose models for User/Employee/Leave/Salary.
JWT signing, password hashing, requireAuth, requireRole, ownership checks.
Hono routes for auth, employees, leaves, salary. Zod validation on every body.
Vite + React 19, react-router, sidebar layout, modal CRUD, role-aware nav.
Playwright covering login, RBAC matrix, employee CRUD, leave flow, direct API blocks.
Idempotent seed on first boot. Docker compose for local. Render staging blueprint.
Outcomes
100%
Type-safe routesAPI layer
RBAC enforcement5
E2E flows covered<800ms
First contentful paint~260KB
Bundle size (dashboard)Render + VPS
Deploy targetsIf we kept going
Tenant-scoped collections + tenant id on every JWT claim.
Every write logged with actor, target, timestamp.
Hide salary fields from non-managers via projection in queries.
Notify Slack on leave approvals, payroll runs.
Responsive table → cards. Bottom sheet for filters.
Sentry + structured logs + p95 latency tracking.
The system is live with seed data. Three roles preloaded — sign in to feel the difference between Admin, HR, and Employee.