Source plan:
EMS RBAC Plan. This file is the executable, per-step breakdown. Each step is scoped to < 200 LOC of net new code. Implementation order matters: every step lists the prior steps it depends on under Blockers.
- Repo layout (npm workspaces):
/ ├── package.json # workspace root ├── feature-x.md ├── README.md ├── docker-compose.yml # added in Step 25 ├── backend/ # Node.js + Express + Mongoose └── frontend/ # Next.js (App Router) - Languages: TypeScript on both sides (
strict: true). - Branching: one feature branch per phase (e.g.
phase-2-auth), one commit per step. - LOC counting rule: net new lines of source code (
.ts,.tsx,.js). Generated files (lockfiles,next-env.d.ts, shadcn primitives) and config JSON do not count toward the 200-LOC budget. - Roles (single source of truth, frozen in Step 5):
ADMIN— full power.HR— manage employees (cannot escalate roles), payroll, onboarding, leave.MANAGER— view team, approve team leaves, conduct team reviews.EMPLOYEE— self-service only.
- Env vars (consolidated):
backend/.env:PORT,MONGO_URI,JWT_ACCESS_SECRET,JWT_REFRESH_SECRET,ACCESS_TOKEN_TTL=15m,REFRESH_TOKEN_TTL=7d,CORS_ORIGIN,NODE_ENV.frontend/.env.local:NEXT_PUBLIC_API_URL.
Goal: Establish the workspace so subsequent steps can install into backend/ and frontend/ independently.
Files to create / change:
package.json(root) —"workspaces": ["backend", "frontend"], root scripts (dev,lint,test)..gitignore— node_modules, .env, .next, dist, coverage..editorconfig— 2-space indent, LF, final newline.README.md— quickstart (clone,npm install,npm run dev)..nvmrc— pin Node 20 LTS.
Blockers: none.
Tests:
npm installat root completes with no errors.npm run -w backend --silentandnpm run -w frontend --silentboth list no scripts (placeholder dirs OK).
Acceptance: Workspace bootstraps cleanly, root node_modules exists, no app code yet.
LOC budget: ~40.
Goal: Bootable Express server with MongoDB connection and a health endpoint.
Files:
backend/package.json— deps:express,mongoose,cors,morgan,dotenv. Dev:typescript,tsx,@types/*,eslint.backend/tsconfig.json— strict, target ES2022,outDir: dist.backend/src/server.ts— boots app onPORT.backend/src/app.ts— Express instance, JSON/CORS/morgan, mounts/api/health.backend/src/config/db.ts—connectDb()with retry log.backend/src/config/env.ts— typed env loader (throws on missing).backend/.env.example— all keys with placeholder values.
Blockers: Step 1.
Tests:
npm run -w backend devstarts and logsMongo connectedagainst a local MongoDB.curl localhost:4000/api/health→{ "ok": true }.
Acceptance: Server hot-reloads via tsx watch, crashes loudly if envs are missing.
LOC budget: ~120.
Goal: Define User and Employee schemas; seed an admin so we can log in once auth lands.
Files:
backend/src/models/User.ts—email(unique),passwordHash,roleenum,employeeIdref, timestamps; pre-save bcrypt hook in Step 4 (placeholder here is just the schema).backend/src/models/Employee.ts— name, designation, department, managerId (self-ref), dateOfJoining, status (active|onboarding|terminated), contact, address (subdoc).backend/src/types/roles.ts—export const ROLES = ['ADMIN','HR','MANAGER','EMPLOYEE'] as const; export type Role = typeof ROLES[number];backend/src/scripts/seed.ts— idempotent: creates default admin from env, plus 3 demo employees (HR/Manager/Employee).backend/package.json— add"seed": "tsx src/scripts/seed.ts".
Blockers: Step 2.
Tests:
npm run -w backend seedtwice — second run is a no-op (no duplicates).- Mongo shell:
db.users.findOne({role:'ADMIN'})returns the seed admin.
Acceptance: Models compile under strict TS; seed produces a known fixture set used by later test plans.
LOC budget: ~160.
Goal: /auth/register (admin-gated, deferred enforcement to Step 5), /auth/login, /auth/refresh, /auth/logout, /auth/me.
Files:
backend/src/controllers/authController.ts— login (bcrypt compare), issue access token (15 m) + refresh token (7 d) as httpOnly Secure SameSite=Lax cookie.backend/src/routes/auth.ts— wires controller, mounted at/api/authinapp.ts.backend/src/utils/jwt.ts—signAccess,signRefresh,verify*helpers.backend/src/utils/password.ts—hash(),compare()thin wrappers around bcrypt.backend/src/models/User.ts— finalize pre-save bcrypt hook +comparePassword()instance method.backend/src/middleware/cookie.ts— sets/clears refresh cookie consistently.
Blockers: Step 3.
Tests (manual + supertest skeleton in Step 25):
- POST
/api/auth/loginwith seed admin → 200, body hasaccessToken,Set-Cookie: refresh=...; HttpOnly. - POST
/api/auth/refreshwith cookie → new access token; without cookie → 401. - Wrong password → 401, generic error message (no user enumeration).
Acceptance: Tokens are signed with separate secrets; refresh rotation invalidates old refresh on next refresh (jti tracked on user doc).
LOC budget: ~180.
Goal: Centralized authorization. Every protected route uses one of three guards.
Files:
backend/src/middleware/auth.ts—requireAuth(verifies access token, attachesreq.user).backend/src/middleware/rbac.ts—requireRole(...roles),requireSelfOrRole(getOwnerId, ...roles).backend/src/permissions.ts— declarative matrix:export const can = { employee: { create:['ADMIN'], readAny:['ADMIN','HR','MANAGER'], updateAny:['ADMIN','HR'], delete:['ADMIN'] }, leave: { approve:['HR','MANAGER'], applySelf:['*'] }, payroll: { generate:['ADMIN','HR'], readAny:['ADMIN','HR'] }, review: { conductTeam:['MANAGER','HR','ADMIN'], finalize:['HR','ADMIN'] }, } as const;
backend/src/routes/auth.ts— protect/auth/registerwithrequireRole('ADMIN')retroactively.
Blockers: Step 4.
Tests:
- Seed admin can hit
/api/auth/register; non-admin gets 403. - Missing/expired token → 401 with
WWW-Authenticate: Bearerheader. - Unit test the permission map covers every role × every resource action.
Acceptance: No route after this step uses inline role checks — only the guards from this file.
LOC budget: ~140.
Goal: Empty Next.js app boots, talks to backend through a typed client, has a base layout and theme.
Files:
frontend/package.json— Next 14+ App Router,tailwindcss,class-variance-authority,clsx,lucide-react,@tanstack/react-query,axios,sonner,zod,react-hook-form.frontend/tailwind.config.ts,postcss.config.js,app/globals.css— shadcn defaults.frontend/components.json— shadcn config (NOT counted toward LOC).frontend/src/app/layout.tsx— root layout, fonts,<ThemeProvider>,<QueryProvider>,<Toaster>.frontend/src/app/page.tsx— landing redirects to/loginor/dashboardbased on auth.frontend/src/lib/api.ts— axios instance, request interceptor injects access token, response interceptor calls/auth/refreshon 401 once.frontend/src/lib/query-provider.tsx— TanStack Query client.frontend/src/lib/theme-provider.tsx— next-themes.
Blockers: Step 2 (API URL exists), Step 5 nice-to-have for refresh-on-401 to actually work end-to-end.
Tests:
npm run -w frontend devopenslocalhost:3000with the landing redirect.- DevTools network tab: a fetch to
${NEXT_PUBLIC_API_URL}/healthsucceeds (CORS clean).
Acceptance: shadcn Button renders correctly with theme; dark mode toggle wires up but is hidden until Step 24.
LOC budget: ~150 (shadcn primitives generated by CLI do not count).
Goal: Login + register pages, persistent auth state, route protection.
Files:
frontend/src/app/(auth)/login/page.tsx— react-hook-form + zod, submits to/api/auth/login, stores access token in memory.frontend/src/app/(auth)/register/page.tsx— admin-only flow (gated client-side, enforced server-side).frontend/src/lib/auth-context.tsx—useAuth():user,accessToken,login(),logout(). Bootstraps via/auth/meon mount.frontend/src/middleware.ts— Next middleware: any path under/dashboardwithout a refresh cookie redirects to/login.frontend/src/components/ui/*— shadcninput,label,button,form,card(generated).
Blockers: Steps 4, 5, 6.
Tests:
- Bad credentials → inline error, no token stored.
- Successful login → redirect to
/dashboard,/auth/mereturns user. - Hard refresh keeps user logged in (refresh cookie + silent refresh on mount).
/dashboardwithout cookie → redirected to/login?next=/dashboard.
Acceptance: Access token never persisted to localStorage. Logout clears cookie via /auth/logout and resets context.
LOC budget: ~190.
Goal: Single dashboard layout that exposes different navigation per role.
Files:
frontend/src/app/(dashboard)/layout.tsx— sidebar + topbar +<main>{children}</main>.frontend/src/app/(dashboard)/page.tsx— role-aware home: ADMIN → KPIs, HR → pending approvals, MANAGER → team, EMPLOYEE → my snapshot.frontend/src/components/Sidebar.tsx— items declared withroles: Role[]; filters viauseAuth().frontend/src/components/RoleGate.tsx—<RoleGate allow={['ADMIN','HR']}>...children...</RoleGate>; rendersnullotherwise.frontend/src/lib/permissions.ts— mirror of backendpermissions.ts(manually kept in sync; called out as a tech-debt item to codegen later).
Blockers: Step 7.
Tests:
- Log in as each seeded role; sidebar items differ as expected.
- Manually visiting a hidden URL still works only if backend permits — verified by hitting an HR-only API as an EMPLOYEE and seeing 403.
Acceptance: No screen is visible to a role unless backend would also authorize it.
LOC budget: ~170.
Goal: REST endpoints scoped by role.
Files:
backend/src/routes/employees.ts—GET /,GET /:id,POST /,PATCH /:id,DELETE /:id(soft delete).backend/src/controllers/employeeController.ts— pagination (?page&limit), search (?q), filter by department/status.backend/src/services/employeeService.ts— business logic kept out of controller; applies role-scoped filters (EMPLOYEE → only self; MANAGER → only team).backend/src/validators/employee.ts— zod schemas for create/update.
Blockers: Steps 3, 5.
Tests (supertest in Step 25, manual now):
- ADMIN can list all, HR cannot promote anyone to ADMIN (validator strips/rejects role escalation), EMPLOYEE GET
/api/employees/:otherId→ 403. - Soft delete sets
status: 'terminated'and excludes from default list;?includeTerminated=truerequires ADMIN.
Acceptance: Every action goes through requireAuth + a permission guard. No raw find() without a role-scoped filter.
LOC budget: ~190.
Goal: Browsable, filterable employee directory for ADMIN/HR/MANAGER.
Files:
frontend/src/app/(dashboard)/employees/page.tsx— server component shell + client<EmployeeTable>.frontend/src/components/EmployeeTable.tsx— shadcn<Table>, debounced search, pagination, role-aware action menu.frontend/src/hooks/useEmployees.ts— TanStack Query hook (useQuery+keepPreviousData).frontend/src/types/employee.ts— TS type mirroring API response.
Blockers: Steps 8, 9.
Tests:
- Search "ali" filters in <300 ms perceived latency (debounce 250 ms).
- EMPLOYEE who reaches this URL by accident sees an empty state ("You don't have access") because backend returns 403 — handled gracefully.
- Pagination math: 25 employees, 10 per page → 3 pages.
Acceptance: No flashes of unauthorized content; loading state uses skeleton rows.
LOC budget: ~180.
Goal: Detail page with field-level edit permissions and a /profile self-service page.
Files:
frontend/src/app/(dashboard)/employees/[id]/page.tsx— header card + tabs (Profile / Leaves / Payslips / Attendance — tabs lazy-load in later phases).frontend/src/app/(dashboard)/profile/page.tsx— same component reused withid = me.frontend/src/components/EmployeeForm.tsx— react-hook-form, disables fields the role can't edit (e.g., EMPLOYEE can edit phone/address only).frontend/src/lib/fieldPermissions.ts— central map:{ field: roles[] }.
Blockers: Step 10.
Tests:
- Log in as EMPLOYEE →
/profileshows phone editable, designation read-only. - HR editing another employee can change designation but cannot change role (UI not rendered, server enforces).
- Optimistic update on save; on server 4xx the form re-syncs.
Acceptance: Field permissions defined in one place; both UI and a server-side test exist for at least 2 sensitive fields.
LOC budget: ~190.
Goal: Apply, list, approve, reject; balances per leave type.
Files:
backend/src/models/Leave.ts—employeeId,type(CL/SL/PL),from,to,days(computed),status(pending|approved|rejected|cancelled),reason,decidedBy,decisionNote.backend/src/models/LeaveBalance.ts—employeeId,year,balances: { CL:n, SL:n, PL:n }.backend/src/routes/leaves.ts—POST /,GET /me,GET /pending(HR/Manager),POST /:id/approve,POST /:id/reject,POST /:id/cancel.backend/src/services/leaveService.ts— business day calculator (excludes weekends; holidays out of scope), balance debit on approve, refund on cancel.
Blockers: Steps 5, 9.
Tests:
- Apply 3-day leave → balance unchanged until approval; on approve → balance decreases by 3.
- MANAGER can approve only own team (verified via
Employee.managerId). - Overlapping pending leave → 409 Conflict.
Acceptance: All state transitions are enforced server-side; client cannot fabricate status.
LOC budget: ~195.
Goal: Employee can apply and see their leaves.
Files:
frontend/src/app/(dashboard)/leaves/apply/page.tsx—<LeaveForm>+ balance summary card.frontend/src/app/(dashboard)/leaves/page.tsx— list of own leaves, filter by status.frontend/src/components/LeaveForm.tsx— date range picker (shadcn calendar), reason textarea, type select.frontend/src/hooks/useLeaves.ts—useMyLeaves,useApplyLeave.
Blockers: Steps 8, 12.
Tests:
- Apply with
to < from→ client-side validation error. - After successful apply, list page reflects new pending leave (cache invalidation).
- Cancelling a pending leave returns balance (visible after refetch).
Acceptance: Empty states for "no leaves yet"; toasts on success/error.
LOC budget: ~170.
Goal: HR/Manager queue for pending approvals with inline actions.
Files:
frontend/src/app/(dashboard)/leaves/approvals/page.tsx— table of pending leaves, inline Approve / Reject buttons.frontend/src/components/LeaveDecisionDialog.tsx— modal asks fordecisionNote, posts to backend.frontend/src/hooks/useLeaves.ts— extend withusePendingLeaves,useDecideLeave.
Blockers: Steps 8, 12, 13.
Tests:
- MANAGER queue lists only direct reports.
- HR queue lists everyone's pending (verified by counts).
- After Approve, row disappears, my-leaves view as that employee shows status=approved.
Acceptance: Optimistic removal from queue; rollback on server error.
LOC budget: ~150.
Goal: Define salary structure, generate monthly payslip records, stream PDF on demand.
Files:
backend/src/models/SalaryStructure.ts—employeeId,effectiveFrom,basic,hra,allowances[],deductions[].backend/src/models/Payslip.ts—employeeId,month(YYYY-MM),gross,deductions,net,lines[], generatedpdfPath(or stream-only).backend/src/routes/payroll.ts—POST /run(ADMIN/HR; idempotent per employee/month),GET /me?month=,GET /:id/pdf(streams).backend/src/services/payslipPdf.ts—pdfkitwriter: header, employee block, earnings table, deductions table, net, signature line.backend/package.json— addpdfkit,@types/pdfkit.
Blockers: Steps 5, 9.
Tests:
- Running for the same month twice produces no duplicate rows (upsert on
(employeeId, month)). - EMPLOYEE can
GET /me?month=2026-04only for self. GET /:id/pdfstreamsapplication/pdfwithContent-Disposition: attachment.
Acceptance: Net = gross − deductions, asserted in service unit test.
LOC budget: ~195.
Goal: Admin run + employee view/download.
Files:
frontend/src/app/(dashboard)/payroll/page.tsx— month picker, "Run payroll" button (ADMIN/HR only viaRoleGate), result table.frontend/src/app/(dashboard)/my-payslips/page.tsx— list of own payslips with Download button.frontend/src/components/PayslipPreview.tsx— embeds the streamed PDF in<iframe>for quick view.frontend/src/hooks/usePayroll.ts—useRunPayroll,useMyPayslips,useDownloadPayslip.
Blockers: Steps 8, 15.
Tests:
- Non-privileged user cannot see Run button (RoleGate) and gets 403 if calling API directly.
- Download triggers browser save dialog with correct filename
payslip-2026-04-<empCode>.pdf.
Acceptance: Loading + empty + error states all rendered; never blank screen.
LOC budget: ~160.
Goal: Daily check-in/out, monthly summary, regularization request.
Files:
backend/src/models/Attendance.ts—employeeId,date(YYYY-MM-DD),checkIn,checkOut,status(present|absent|half-day|leave),regularization?: { requestedAt, reason, decidedBy?, status }.backend/src/routes/attendance.ts—POST /check-in,POST /check-out,GET /me?month=,GET /summary?month=(HR/MANAGER),POST /:id/regularize,POST /:id/regularize/decide.backend/src/services/attendanceService.ts— derives status from times; integrates with leave (an approved leave on that date setsstatus: leave).
Blockers: Steps 5, 12.
Tests:
- Two check-ins on the same day → second is rejected (409).
- Check-out without check-in → 400.
- Approved leave on Friday auto-fills attendance row as
leave.
Acceptance: Date math is timezone-stable (server stores UTC, derives local day from configured TZ env, default Asia/Kolkata; document this).
LOC budget: ~190.
Goal: Dashboard widget + monthly calendar.
Files:
frontend/src/components/AttendanceWidget.tsx— shows today's status, primary action button (Check in/Check out/Done).frontend/src/app/(dashboard)/attendance/page.tsx— month grid (shadcn or custom div grid), color-coded cells, click to view detail and request regularization.frontend/src/hooks/useAttendance.ts—useToday,useMonth, mutations.
Blockers: Steps 8, 17.
Tests:
- Widget transitions Check in → Check out → Done within one session without reload.
- Calendar correctly merges leave days from leave system.
- Regularization request shows "pending decision" badge.
Acceptance: Widget is the only place mutations happen on the dashboard home; calendar is read+request only.
LOC budget: ~180.
Goal: Templates of tasks, auto-assigned on hire; employee marks complete.
Files:
backend/src/models/OnboardingTemplate.ts—name,tasks: [{ title, description, dueDays, assigneeRole?: 'EMPLOYEE'|'HR' }].backend/src/models/OnboardingTask.ts— instance per employee:templateRef,employeeId,title,dueDate,status(pending|done|skipped),completedAt.backend/src/routes/onboarding.ts— template CRUD (ADMIN/HR),POST /assign/:employeeId(auto-fires when employee is created withstatus: onboarding),GET /me,POST /tasks/:id/complete.backend/src/services/onboardingService.ts— assign-on-hire helper, called fromemployeeService.createEmployee.
Blockers: Steps 5, 9.
Tests:
- Creating an employee with
status: onboardingauto-creates tasks from the default template. - Employee can only complete own tasks; HR can complete any.
- Skipping a task is a separate action (audit-friendly).
Acceptance: At least one default template seeded in Step 3 update (acceptable retroactive change to seed script).
LOC budget: ~180.
Goal: Employee checklist + admin template editor.
Files:
frontend/src/app/(dashboard)/onboarding/page.tsx— for EMPLOYEE: checklist with progress bar; for HR/ADMIN: list of onboarding employees with progress.frontend/src/app/(dashboard)/onboarding/templates/page.tsx— ADMIN/HR template editor (CRUD + reorder tasks).frontend/src/components/OnboardingChecklist.tsx— checklist with optimistic toggle.frontend/src/hooks/useOnboarding.ts.
Blockers: Steps 8, 19.
Tests:
- Toggling a task immediately moves the progress bar; API failure rolls back.
- Template editor preserves task order on save (uses array indices).
- An employee with all tasks done: HR sees a "Complete onboarding" CTA which flips employee
statustoactive.
Acceptance: No template can be deleted while in use (server-side guard verified by UI error toast).
LOC budget: ~190.
Goal: Per-cycle reviews with a state machine: draft → self_submitted → manager_submitted → finalized.
Files:
backend/src/models/ReviewCycle.ts—name,period(e.g. H1 2026),openFrom,openTo,status.backend/src/models/Review.ts—cycleId,employeeId,managerId,selfRatings,selfComments,managerRatings,managerComments,finalRating,status.backend/src/routes/reviews.ts— cycle CRUD (HR/ADMIN),GET /me?cycleId=,POST /me/submit-self,GET /team?cycleId=(MANAGER),POST /:id/submit-manager,POST /:id/finalize(HR/ADMIN).backend/src/services/reviewService.ts— state-machine guard: each transition validates current status.
Blockers: Steps 5, 9.
Tests:
- A MANAGER cannot submit until employee submits self.
- HR cannot finalize before manager submits.
- Re-finalizing a finalized review returns 409.
Acceptance: Every state transition recorded with decidedBy + timestamp on the review doc.
LOC budget: ~195.
Goal: Self review form, manager view, finalization for HR.
Files:
frontend/src/app/(dashboard)/reviews/page.tsx— role-aware: EMPLOYEE sees self review for active cycle; MANAGER sees team list; HR sees all + finalize.frontend/src/components/ReviewForm.tsx— same form rendered with differentmodeprop (self|manager|finalize).frontend/src/hooks/useReviews.ts.
Blockers: Steps 8, 21.
Tests:
- Submitted self review becomes read-only for the employee.
- Manager view hides ratings until self is submitted (server returns 403 + UI shows waiting state).
- Finalize action shows confirmation dialog; success shows a final summary card.
Acceptance: One single <ReviewForm> for all three modes — no duplicated rating UI.
LOC budget: ~190.
Goal: Production-grade baseline: validation, rate limit, security headers, audit trail.
Files:
backend/src/middleware/validate.ts— generic zod request validator.backend/src/validators/*.ts— fill in any remaining (leave.ts,payroll.ts,attendance.ts,review.ts).backend/src/middleware/rateLimit.ts—express-rate-limitinstance (tighter on/auth/*).backend/src/middleware/audit.ts— wraps mutating routes; logsactor,action,targetType,targetId,before,after(diff) intoAuditLog.backend/src/models/AuditLog.ts.backend/src/app.ts— wirehelmet(),rateLimit,audit.
Blockers: All earlier backend steps.
Tests:
/api/auth/loginblocks after 10 attempts/min from same IP.- An HR editing an employee creates one AuditLog row with non-empty
before/afterdiff. - Helmet sets
X-Frame-Options,Strict-Transport-Security(in prod).
Acceptance: A privileged read-only route GET /api/audit?targetId= exists for ADMIN.
LOC budget: ~180.
Goal: Toasts, skeletons, error boundary, dark mode, empty states.
Files:
frontend/src/app/error.tsx,frontend/src/app/not-found.tsx.frontend/src/components/EmptyState.tsx,Skeletons.tsx.frontend/src/components/ThemeToggle.tsx— surface toggle in topbar.- Sweep existing pages (employees/leaves/payroll/attendance/onboarding/reviews) to use
<Skeletons>and<EmptyState>. Each touch is 5–15 LOC.
Blockers: All earlier frontend steps.
Tests:
- Throw an error in a page locally →
error.tsxrenders with retry. - Toggle dark mode → persists across reload (next-themes).
- All pages have a non-blank state for loading, empty, and error.
Acceptance: Lighthouse Accessibility ≥ 95 on /dashboard and /login.
LOC budget: ~190 (the per-page sweep is the bulk; if it exceeds budget, split into 24a / 24b).
Goal: Repeatable test suites and a one-command local stack.
Files:
backend/jest.config.js,backend/src/__tests__/auth.spec.ts,backend/src/__tests__/rbac.spec.ts,backend/src/__tests__/leaves.spec.ts,backend/src/__tests__/payroll.spec.ts.- Use
mongodb-memory-serverfor isolated DB. - Cover the role × action matrix from Step 5 with table-driven tests.
- Use
frontend/playwright.config.ts,frontend/e2e/auth.spec.ts,frontend/e2e/rbac.spec.ts— login flow + 1 unauthorized-access flow per role.docker-compose.yml—mongo,backend,frontendservices with health checks.backend/Dockerfile,frontend/Dockerfile.README.md(update) — quickstart, env vars,docker compose up, default seeded credentials, screenshots placeholder.
Blockers: All earlier steps.
Tests:
npm test -w backendpasses locally (CI optional).npm run e2e -w frontendpasses against the docker-compose stack.docker compose upbrings the app up and/api/healthis reachable from the frontend container.
Acceptance: Fresh clone → cp backend/.env.example backend/.env && docker compose up && npm run -w backend seed → can log in as admin/hr/employee from the UI.
LOC budget: ~190 (excluding generated Playwright config and Dockerfile boilerplate).
| Risk | Mitigation | Step |
|---|---|---|
| Permission drift between FE and BE | Single source permissions.ts on backend; FE imports a hand-mirrored copy with a TODO to codegen |
5, 8 |
| PDF generation memory pressure | pdfkit streams; no buffering whole doc |
15 |
| Token theft via XSS | Access token in memory only; refresh in httpOnly cookie | 4, 7 |
| Refresh-cookie CSRF | SameSite=Lax + only /auth/refresh reads it; consider double-submit token if SameSite=None ever needed |
4 |
| Timezone bugs in attendance | UTC storage + explicit TZ config; tests exercise day boundaries |
17 |
| Role escalation via PATCH | Validators strip role from non-ADMIN payloads |
5, 9 |
- All 25 steps merged; each commit independently boots/runs.
- Seed admin can perform every action defined in the permission matrix.
- A seeded EMPLOYEE can complete a full month: onboarding → check-in/out daily → apply leave → view payslip → submit self review.
npm test(backend) andnpm run e2e(frontend) both green.docker compose upfrom a fresh clone serves the app onlocalhost:3000.