Skip to content

Add TeamExpense application code#5

Open
reeder32 wants to merge 1 commit intomainfrom
feature/teamexpense-app
Open

Add TeamExpense application code#5
reeder32 wants to merge 1 commit intomainfrom
feature/teamexpense-app

Conversation

@reeder32
Copy link
Copy Markdown
Collaborator

@reeder32 reeder32 commented Feb 24, 2026

Summary by CodeRabbit

Release Notes

New Features

  • User authentication with login and registration
  • Expense management: create, view, list, search, filter, and delete expenses
  • Manager approval workflow for expense review
  • Budget limit enforcement per transaction
  • Personal spending dashboard with category summaries
  • Team spending reports and data export (admin users only)

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add complete TeamExpense application with backend and frontend

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Complete TeamExpense application with backend and frontend
• Backend: Express server with SQLite database, authentication, and expense management
• Frontend: React app with login, expense tracking, and reporting features
• User roles (member/admin) with budget limits and expense approval workflow
Diagram
flowchart LR
  A["Express Backend<br/>Port 3001"] --> B["SQLite Database<br/>Users & Expenses"]
  A --> C["Auth Routes<br/>Register/Login"]
  A --> D["Expense Routes<br/>CRUD Operations"]
  A --> E["Reports Routes<br/>Summaries & Export"]
  F["React Frontend<br/>Port 3000"] --> G["Login Page"]
  F --> H["Dashboard<br/>Spending Summary"]
  F --> I["Expense Management<br/>List/Create/Delete"]
  F --> J["Reports Page<br/>Team Analytics"]
  G --> A
  H --> A
  I --> A
  J --> A
Loading

Grey Divider

File Changes

1. backend/src/db.ts ⚙️ Configuration changes +29/-0

Database initialization with users and expenses tables

backend/src/db.ts


2. backend/src/index.ts ✨ Enhancement +23/-0

Express server setup with route mounting

backend/src/index.ts


3. backend/src/middleware/auth.ts ✨ Enhancement +32/-0

JWT authentication middleware and token verification

backend/src/middleware/auth.ts


View more (14)
4. backend/src/routes/auth.ts ✨ Enhancement +66/-0

User registration and login endpoints with password hashing

backend/src/routes/auth.ts


5. backend/src/routes/expenses.ts ✨ Enhancement +152/-0

Expense CRUD operations with budget limits and filtering

backend/src/routes/expenses.ts


6. backend/src/routes/reports.ts ✨ Enhancement +56/-0

Spending summaries and expense export for admins

backend/src/routes/reports.ts


7. backend/src/utils/validate.ts ✨ Enhancement +22/-0

Input validation utilities and category definitions

backend/src/utils/validate.ts


8. frontend/src/App.tsx ✨ Enhancement +28/-0

Main app component with routing and private route protection

frontend/src/App.tsx


9. frontend/src/api/client.ts ✨ Enhancement +15/-0

Axios client with automatic token injection

frontend/src/api/client.ts


10. frontend/src/components/ExpenseCard.tsx ✨ Enhancement +38/-0

Reusable expense card component with status display

frontend/src/components/ExpenseCard.tsx


11. frontend/src/components/StatusBadge.tsx ✨ Enhancement +33/-0

Status badge component with color-coded styling

frontend/src/components/StatusBadge.tsx


12. frontend/src/index.tsx ✨ Enhancement +10/-0

React app entry point and root rendering

frontend/src/index.tsx


13. frontend/src/pages/Dashboard.tsx ✨ Enhancement +56/-0

Dashboard page with spending summary and navigation

frontend/src/pages/Dashboard.tsx


14. frontend/src/pages/ExpenseForm.tsx ✨ Enhancement +69/-0

Form for creating new expenses with validation

frontend/src/pages/ExpenseForm.tsx


15. frontend/src/pages/ExpenseList.tsx ✨ Enhancement +67/-0

Paginated expense list with delete functionality

frontend/src/pages/ExpenseList.tsx


16. frontend/src/pages/Login.tsx ✨ Enhancement +52/-0

Login page with email and password authentication

frontend/src/pages/Login.tsx


17. frontend/src/pages/Reports.tsx ✨ Enhancement +70/-0

Admin reports page with team summary and export

frontend/src/pages/Reports.tsx


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review bot commented Feb 24, 2026

Code Review by Qodo

🐞 Bugs (10) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. SQL injection search 🐞 Bug ⛨ Security
Description
The expenses search endpoint builds SQL with unescaped user input, enabling SQL injection and
potential cross-user data access/modification. This is exploitable via the q query parameter.
Code

backend/src/routes/expenses.ts[R69-78]

+router.get("/search", authenticate, (req: AuthRequest, res: Response) => {
+  const query = req.query.q as string;
+
+  if (!query) {
+    return res.status(400).json({ error: "Search query is required" });
+  }
+
+  const results = db.prepare(
+    `SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
+  ).all();
Evidence
q is taken directly from the request and interpolated into the SQL string instead of using
placeholders, which enables attackers to break out of the LIKE string and inject SQL.

backend/src/routes/expenses.ts[69-79]
backend/src/routes/expenses.ts[70-74]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`GET /api/expenses/search` is vulnerable to SQL injection because it interpolates `req.query.q` directly into the SQL statement.

### Issue Context
The current implementation uses a template literal to build SQL, which allows crafted `q` values to alter the query.

### Fix Focus Areas
- backend/src/routes/expenses.ts[69-80]
 - Replace the template literal with a parameterized query, e.g.:
   - `SELECT * FROM expenses WHERE user_id = ? AND description LIKE ? ORDER BY created_at DESC`
   - params: `[req.user!.id, `%${query}%`]`
 - Consider trimming/length-limiting `query` similarly to `sanitizeString`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Routes shadowed by :id 🐞 Bug ✓ Correctness
Description
GET /:id is registered before /search and /filter, so requests to /search and /filter will
match the /:id handler first and never reach the intended endpoints. This breaks search and
filtering.
Code

backend/src/routes/expenses.ts[R40-85]

+// Get single expense
+router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
+  const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);
+
+  if (!expense) {
+    return res.status(404).json({ error: "Expense not found" });
+  }
+
+  res.json(expense);
+});
+
+// List expenses with pagination
+router.get("/", authenticate, (req: AuthRequest, res: Response) => {
+  const page = parseInt(req.query.page as string) || 1;
+  const limit = parseInt(req.query.limit as string) || 20;
+  const userId = req.user!.id;
+
+  const expenses = db.prepare(
+    "SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
+  ).all(userId, limit, page * limit);
+
+  const count = db.prepare(
+    "SELECT COUNT(*) as total FROM expenses WHERE user_id = ?"
+  ).get(userId) as any;
+
+  res.json({ data: expenses, total: count.total, page, limit });
+});
+
+// Search expenses
+router.get("/search", authenticate, (req: AuthRequest, res: Response) => {
+  const query = req.query.q as string;
+
+  if (!query) {
+    return res.status(400).json({ error: "Search query is required" });
+  }
+
+  const results = db.prepare(
+    `SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
+  ).all();
+
+  res.json(results);
+});
+
+// Filter expenses by date range
+router.get("/filter", authenticate, (req: AuthRequest, res: Response) => {
+  const { startDate, endDate, category } = req.query;
Evidence
Express routes are evaluated in registration order. Since /:id appears before /search and
/filter in the same router, /search and /filter will be interpreted as an id path parameter.

backend/src/routes/expenses.ts[40-49]
backend/src/routes/expenses.ts[68-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/:id` is defined before `/search` and `/filter`, causing those literal routes to be shadowed.

### Issue Context
This prevents search and filtering endpoints from being reachable.

### Fix Focus Areas
- backend/src/routes/expenses.ts[40-110]
 - Register `/search` and `/filter` before `/:id`.
 - Optionally constrain `/:id` to numeric IDs only: `router.get(&#x27;/:id(\\d+)&#x27;, ...)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Expense IDOR read 🐞 Bug ⛨ Security
Description
GET /api/expenses/:id returns an expense solely by ID without checking ownership, allowing any
authenticated user to fetch other users’ expenses by guessing IDs.
Code

backend/src/routes/expenses.ts[R41-48]

+router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
+  const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);
+
+  if (!expense) {
+    return res.status(404).json({ error: "Expense not found" });
+  }
+
+  res.json(expense);
Evidence
The read path uses WHERE id = ? only. In contrast, delete correctly scopes by user_id,
demonstrating missing authorization on read.

backend/src/routes/expenses.ts[41-43]
backend/src/routes/expenses.ts[134-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`GET /expenses/:id` does not enforce that the requested expense belongs to the authenticated user.

### Issue Context
Delete already enforces `id` + `user_id`, but GET does not.

### Fix Focus Areas
- backend/src/routes/expenses.ts[40-49]
 - Change query to `SELECT * FROM expenses WHERE id = ? AND user_id = ?`.
 - If admins should read any expense, implement explicit role-based branching (admin: id only; member: id+user_id).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Pagination offset wrong 🐞 Bug ✓ Correctness
Description
The list endpoint uses OFFSET page * limit which skips the first page (page=1 starts at
offset=20). Users will see missing/empty results on early pages.
Code

backend/src/routes/expenses.ts[R52-60]

+router.get("/", authenticate, (req: AuthRequest, res: Response) => {
+  const page = parseInt(req.query.page as string) || 1;
+  const limit = parseInt(req.query.limit as string) || 20;
+  const userId = req.user!.id;
+
+  const expenses = db.prepare(
+    "SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
+  ).all(userId, limit, page * limit);
+
Evidence
With page defaulting to 1, the computed offset is 1 * limit, not 0. This is inconsistent with
expected pagination behavior.

backend/src/routes/expenses.ts[52-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Pagination offset is incorrect (`page * limit`).

### Issue Context
Default `page=1` should return the first `limit` rows with offset 0.

### Fix Focus Areas
- backend/src/routes/expenses.ts[52-66]
 - Change offset to `(page - 1) * limit`.
 - Add validation: clamp/reject negative or extremely large `limit` values.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Public team summary 🐞 Bug ⛨ Security
Description
GET /api/reports/team-summary is labeled “admin only” but has no authentication/authorization
middleware, leaking organization-wide spending totals to unauthenticated callers.
Code

backend/src/routes/reports.ts[R26-33]

+// Team summary — admin only
+router.get("/team-summary", (req: Request, res: Response) => {
+  const expenses = db.prepare(
+    "SELECT * FROM expenses WHERE status = 'approved'"
+  ).all() as any[];
+
+  const summary: Record<string, { name: string; total: number; count: number }> = {};
+
Evidence
Unlike /my-summary and /export, team-summary does not use authenticate and does not check
user role.

backend/src/routes/reports.ts[7-10]
backend/src/routes/reports.ts[26-30]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/reports/team-summary` is publicly accessible despite being intended for admins.

### Issue Context
Other report routes correctly use `authenticate` and role checks.

### Fix Focus Areas
- backend/src/routes/reports.ts[26-44]
 - Add `authenticate` middleware.
 - Add an admin role check and return 403 when unauthorized.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Hardcoded JWT secret 🐞 Bug ⛨ Security
Description
JWT signing/verification uses a hardcoded secret (secret123). If the code leaks or is guessable,
attackers can forge tokens and become any user/admin.
Code

backend/src/middleware/auth.ts[4]

+const JWT_SECRET = "secret123";
Evidence
The secret is embedded in source and used for token verification, making token forgery feasible and
preventing safe rotation via deployment configuration.

backend/src/middleware/auth.ts[4-5]
backend/src/middleware/auth.ts[19-26]
backend/src/routes/auth.ts[31-35]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
JWT secret is hardcoded, enabling token forgery and blocking secret rotation.

### Issue Context
Both signing and verification depend on `JWT_SECRET`.

### Fix Focus Areas
- backend/src/middleware/auth.ts[1-32]
 - Replace hardcoded constant with `process.env.JWT_SECRET`.
 - Throw on startup if missing.
 - Avoid exporting the secret; instead export a helper (e.g., `signToken(payload)`), or keep secret private to the module.
- backend/src/routes/auth.ts[1-65]
 - Update token signing to use the centralized helper or imported config.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Dashboard interval leak 🐞 Bug ⛯ Reliability
Description
Dashboard sets a polling interval without cleanup, causing leaked timers and duplicate
polling—especially in React 18 StrictMode where effects are invoked twice in development.
Code

frontend/src/pages/Dashboard.tsx[R15-27]

+  useEffect(() => {
+    const fetchSummary = async () => {
+      try {
+        const { data } = await api.get("/reports/my-summary");
+        setSummary(data);
+      } catch (err) {
+        console.error("Failed to fetch summary", err);
+      }
+    };
+
+    fetchSummary();
+    setInterval(fetchSummary, 30000);
+  }, []);
Evidence
setInterval is called but its handle is not cleared on unmount. The app renders inside
React.StrictMode, increasing the chance of duplicate intervals during development.

frontend/src/pages/Dashboard.tsx[15-27]
frontend/src/index.tsx[5-10]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Polling interval is never cleaned up.

### Issue Context
React 18 StrictMode can run effects twice in dev; unmount/remount will also stack intervals.

### Fix Focus Areas
- frontend/src/pages/Dashboard.tsx[15-27]
 - Capture interval id: `const id = window.setInterval(...)`.
 - Return cleanup: `return () =&gt; clearInterval(id);`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. N+1 queries report 🐞 Bug ➹ Performance
Description
Team summary loops through expenses and performs a user lookup query per user, which scales poorly
and can slow down significantly with more records.
Code

backend/src/routes/reports.ts[R28-41]

+  const expenses = db.prepare(
+    "SELECT * FROM expenses WHERE status = 'approved'"
+  ).all() as any[];
+
+  const summary: Record<string, { name: string; total: number; count: number }> = {};
+
+  for (const exp of expenses) {
+    if (!summary[exp.user_id]) {
+      const user = db.prepare("SELECT name FROM users WHERE id = ?").get(exp.user_id) as any;
+      summary[exp.user_id] = { name: user.name, total: 0, count: 0 };
+    }
+    summary[exp.user_id].total += exp.amount;
+    summary[exp.user_id].count += 1;
+  }
Evidence
Inside the loop over expenses, the code performs an additional SELECT name FROM users ... query
when encountering a new user_id, creating many DB round-trips rather than a single aggregate
query.

backend/src/routes/reports.ts[28-41]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Team summary performs N+1 database queries.

### Issue Context
This can be replaced by a single SQL aggregation.

### Fix Focus Areas
- backend/src/routes/reports.ts[26-44]
 - Implement a query like:
   - `SELECT u.name, SUM(e.amount) AS total, COUNT(*) AS count FROM expenses e JOIN users u ON u.id = e.user_id WHERE e.status=&#x27;approved&#x27; GROUP BY e.user_id ORDER BY total DESC;`
 - Return rows directly without building an in-memory map.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. SQLite WAL artifacts tracked 🐞 Bug ⛯ Reliability
Description
SQLite WAL/SHM artifacts are present in the repo and not ignored by .gitignore, risking accidental
data leakage and noisy diffs. WAL mode is explicitly enabled, which will keep generating these files
during development.
Code

backend/src/db.ts[R4-6]

+const db = new Database(path.join(__dirname, "..", "teamexpense.db"));
+
+db.pragma("journal_mode = WAL");
Evidence
The DB is configured to use WAL journaling, which creates *-wal and *-shm sidecar files. The
repo’s .gitignore ignores *.db but not *.db-wal/*.db-shm, and the artifacts exist in the
backend directory.

backend/src/db.ts[4-6]
.gitignore[1-4]
backend/teamexpense.db-wal[1-1]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
SQLite WAL/SHM artifacts are being tracked, and `.gitignore` doesn’t exclude them.

### Issue Context
WAL mode is enabled, so these files will be repeatedly generated.

### Fix Focus Areas
- .gitignore[1-4]
 - Add ignore patterns: `*.db-wal`, `*.db-shm` (and optionally `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`).
- backend/src/db.ts[4-6]
 - No change required, but be aware WAL mode will keep generating sidecars.
- backend/teamexpense.db-wal[1-1]
- backend/teamexpense.db-shm[1-1]
 - Remove these artifacts from the repo (and ensure they don’t get re-added).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
10. Stale delete state 🐞 Bug ⛯ Reliability
Description
After deleting an expense, the UI updates state using a captured expenses array, which can be
stale if state changed between render and deletion completion. This can reintroduce deleted items or
drop newer ones.
Code

frontend/src/pages/ExpenseList.tsx[R27-31]

+  const handleDelete = async (id: number) => {
+    try {
+      await api.delete(`/expenses/${id}`);
+      setExpenses(expenses.filter((e) => e.id !== id));
+    } catch (err: any) {
Evidence
setExpenses(expenses.filter(...)) uses the closure value rather than the latest state. Using a
functional state update avoids stale closures.

frontend/src/pages/ExpenseList.tsx[27-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
State update after deletion uses a potentially stale `expenses` closure.

### Issue Context
Async handlers can complete after state has changed for other reasons (pagination, refresh, etc.).

### Fix Focus Areas
- frontend/src/pages/ExpenseList.tsx[27-34]
 - Replace `setExpenses(expenses.filter(...))` with `setExpenses((prev) =&gt; prev.filter(...))`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 24, 2026

Claude finished @reeder32's task —— View job


Comprehensive Security & Quality Review

🔴 CRITICAL SECURITY VULNERABILITIES

1. SQL Injection - backend/src/routes/expenses.ts:77-78 - SEVERITY: CRITICAL

const results = db.prepare(
  `SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
).all();

Issue: Direct string interpolation allows SQL injection through query parameter.
Impact: Full database compromise, data theft, data manipulation.

2. Hardcoded JWT Secret - backend/src/middleware/auth.ts:4 - SEVERITY: CRITICAL

const JWT_SECRET = "secret123";

Issue: Weak, hardcoded JWT secret in source code.
Impact: Token forgery, authentication bypass, privilege escalation.

🟠 HIGH SEVERITY ISSUES

3. Missing Authentication - backend/src/routes/reports.ts:27 - SEVERITY: HIGH

router.get("/team-summary", (req: Request, res: Response) => {

Issue: Team summary endpoint lacks authentication middleware - anyone can access team spending data.
Impact: Data exposure, privacy violation.

4. Authorization Bypass - backend/src/routes/expenses.ts:41-48 - SEVERITY: HIGH

router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
  const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);
  // No user ownership check

Issue: Users can view any expense by ID, not just their own.
Impact: Information disclosure, privacy violation.

5. Client-Side Security - frontend/src/App.tsx:10-11 - SEVERITY: HIGH

function PrivateRoute({ children }: { children: React.ReactNode }) {
  const token = localStorage.getItem("token");
  return token ? <>{children}</> : <Navigate to="/login" />;

Issue: Client-side only authentication check - easily bypassable.
Impact: Unauthorized access to protected routes.

🟡 MEDIUM SEVERITY ISSUES

6. Logic Error - backend/src/routes/expenses.ts:59 - SEVERITY: MEDIUM

const expenses = db.prepare(
  "SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
).all(userId, limit, page * limit);

Issue: Incorrect pagination offset calculation - should be (page - 1) * limit.
Impact: Wrong results returned, pagination broken.

7. Memory Leak - frontend/src/pages/Dashboard.tsx:26 - SEVERITY: MEDIUM

useEffect(() => {
  // ...
  setInterval(fetchSummary, 30000);
}, []);

Issue: setInterval not cleaned up on component unmount.
Impact: Memory leak, performance degradation.

8. Insecure Token Storage - frontend/src/pages/Login.tsx:15 - SEVERITY: MEDIUM

localStorage.setItem("token", data.token);

Issue: JWT stored in localStorage is vulnerable to XSS attacks.
Impact: Token theft via XSS.

🔵 LOW SEVERITY ISSUES

9. Missing Input Validation - backend/src/routes/expenses.ts:53-54 - SEVERITY: LOW

const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;

Issue: No bounds checking on pagination parameters.
Impact: Potential performance issues with large limit values.

10. Error Information Leakage - backend/src/routes/auth.ts:47 - SEVERITY: LOW

const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;

Issue: Returns full user object including password hash (though not exposed in response).
Impact: Unnecessary data processing.

11. Resource Leak - backend/src/routes/reports.ts:36 - SEVERITY: LOW

const user = db.prepare("SELECT name FROM users WHERE id = ?").get(exp.user_id) as any;

Issue: Inefficient N+1 query pattern in loop.
Impact: Poor performance with many expenses.

📱 UI/UX & ACCESSIBILITY VIOLATIONS

12. Missing Labels - frontend/src/pages/Login.tsx:29-35 - SEVERITY: MEDIUM

<input
  type="email"
  // Missing aria-label or associated label
 

Issue: Form inputs lack proper labels for screen readers.
Impact: Accessibility violation (WCAG 2.1 Level A).

13. Missing Alt Text - frontend/src/components/ExpenseCard.tsx:27 - SEVERITY: LOW

<img src="/logo.png" width={24} height={24} />

Issue: Image missing alt attribute.
Impact: Accessibility violation for screen readers.

14. Poor Color Contrast - frontend/src/components/StatusBadge.tsx:5 - SEVERITY: LOW

pending: {
  color: "#F5E642", // Yellow on white background
  backgroundColor: "#FFFFFF",

Issue: Poor contrast ratio for yellow text on white background.
Impact: Accessibility violation (WCAG 2.1 Level AA).

15. No Focus Management - frontend/src/pages/ExpenseList.tsx:46-55 - SEVERITY: LOW

<div
  onClick={() => handleDelete(exp.id)}
  style={{ /* styled like button but not keyboard accessible */ }}
>
  Delete
</div>

Issue: Clickable element not keyboard accessible.
Impact: Accessibility violation for keyboard users.

📊 Summary

  • Critical: 2 issues
  • High: 3 issues
  • Medium: 3 issues
  • Low: 7 issues
  • Total: 15 security and quality issues found

Priority Fix Order

  1. Fix SQL injection vulnerability immediately
  2. Use environment variable for JWT secret
  3. Add authentication to team-summary endpoint
  4. Implement proper authorization checks
  5. Validate token server-side for protected routes

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

Implements a complete full-stack team expense management system with a Node.js/Express backend featuring SQLite database, JWT authentication, expense CRUD operations, budget enforcement, and admin reporting capabilities; includes a React frontend with login, dashboard, expense management, and admin dashboards.

Changes

Cohort / File(s) Summary
Backend Database & Initialization
backend/src/db.ts, backend/src/index.ts
Establishes SQLite database with WAL journaling, creates users and expenses tables with constraints and relationships, and initializes Express server with CORS and JSON middleware mounting auth, expenses, and reports route handlers.
Backend Authentication
backend/src/middleware/auth.ts, backend/src/routes/auth.ts
Implements token-based authentication via JWT middleware that validates Bearer tokens and populates request user; provides POST /register (email validation, bcrypt hashing, user insertion) and POST /login (credential verification, token issuance with 24h expiry).
Backend Expense Management
backend/src/routes/expenses.ts
Implements full expense workflow: POST / (create with budget check against 5000 limit), GET / (list with pagination), GET /:id (retrieve single), GET /search (description-based search), GET /filter (date/category filtering), PATCH /:id/status (admin-only approval/rejection), DELETE /:id (pending expense removal with ownership check).
Backend Reporting & Utilities
backend/src/routes/reports.ts, backend/src/utils/validate.ts
Adds reporting endpoints (GET /my-summary for user totals/categories, GET /team-summary for admin aggregation, GET /export for admin-only bulk export); provides validation helpers (email format, positive numbers, string sanitization) and constants (predefined expense categories, budget limit).
Frontend Routing & API Integration
frontend/src/App.tsx, frontend/src/api/client.ts, frontend/src/index.tsx
Sets up React app with BrowserRouter, private routes protecting authenticated pages, and Axios client with request interceptor that injects Bearer token from localStorage for all API calls.
Frontend Authentication
frontend/src/pages/Login.tsx
Renders login form accepting email and password, authenticates via POST /auth/login, stores token and user in localStorage on success, and navigates to dashboard.
Frontend Dashboard & Expense Pages
frontend/src/pages/Dashboard.tsx, frontend/src/pages/ExpenseList.tsx, frontend/src/pages/ExpenseForm.tsx
Dashboard fetches and displays user spending summary with category breakdown (polling every 30s); ExpenseList shows paginated expenses with delete actions; ExpenseForm provides form to create new expense with category selection.
Frontend Reports & Components
frontend/src/pages/Reports.tsx, frontend/src/components/ExpenseCard.tsx, frontend/src/components/StatusBadge.tsx
Reports page displays team spending table for admins and provides JSON export functionality; ExpenseCard renders individual expense details with status; StatusBadge displays styled status indicators (pending/approved/rejected).

Sequence Diagrams

sequenceDiagram
    participant Client as React Client
    participant Server as Express Server
    participant DB as SQLite Database
    
    rect rgba(100, 150, 200, 0.5)
    Note over Client,DB: User Registration Flow
    Client->>Server: POST /auth/register (email, password, name)
    Server->>Server: Validate email format & bcrypt hash password
    Server->>DB: INSERT user (email, hashed_password, name, role='member')
    DB-->>Server: User inserted
    Server->>Server: Generate JWT (id, email, role) with 24h expiry
    Server-->>Client: Return token + user details
    Client->>Client: Store token in localStorage
    end
Loading
sequenceDiagram
    participant Client as React Client
    participant Server as Express Server
    participant DB as SQLite Database
    
    rect rgba(100, 150, 200, 0.5)
    Note over Client,DB: Expense Creation with Budget Check
    Client->>Client: Read token from localStorage
    Client->>Server: POST /expenses (amount, description, category) + Bearer token
    Server->>Server: Verify JWT token & extract user id
    Server->>Server: Validate amount > 0, category in predefined list
    Server->>DB: SELECT SUM(amount) for user where status='pending'
    DB-->>Server: Current pending total
    Server->>Server: Check: pending_total + new_amount <= 5000 limit
    alt Budget allows
        Server->>DB: INSERT expense (user_id, amount, description, category, status='pending')
        DB-->>Server: Expense created
        Server-->>Client: Return 201 + expense details
    else Budget exceeded
        Server-->>Client: Return 400 (budget limit exceeded)
    end
    end
Loading
sequenceDiagram
    participant Client as React Client
    participant Server as Express Server
    participant DB as SQLite Database
    
    rect rgba(100, 150, 200, 0.5)
    Note over Client,DB: Admin Team Summary & Export
    Client->>Client: Read user.role from localStorage
    Client->>Server: GET /reports/team-summary + Bearer token (admin only)
    Server->>Server: Verify JWT & check role='admin'
    Server->>DB: SELECT * from expenses WHERE status='approved'
    DB-->>Server: Approved expenses list
    Server->>DB: SELECT id, name from users
    DB-->>Server: All users
    Server->>Server: Aggregate: sum amounts & count per user
    Server-->>Client: Return { name, total, count }[] array
    Client->>Client: Render team spending table
    
    Client->>Server: GET /reports/export + Bearer token (admin only)
    Server->>Server: Verify role='admin'
    Server->>DB: SELECT * from expenses
    DB-->>Server: All expenses
    Server-->>Client: Return expenses array
    Client->>Client: Convert to JSON blob & trigger download
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 Whiskers twitching with glee:

A team's expenses now dance in rows,
From login form to budget flows,
JWT tokens hop through the air,
Admin reports with numbers rare,
Full-stack burrows, complete and bright! 🌟

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Add TeamExpense application code' is vague and generic, using broad terms that don't convey the specific nature of the changes. Consider a more descriptive title that highlights the main architectural components, such as 'Implement backend and frontend for TeamExpense app' or 'Add auth, expense management, and reporting features'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/teamexpense-app

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

🤖 OpenAI Code Review

backend/src/db.ts

  • Line 4: Low
    • Using SQLite with better-sqlite3 is generally safe, but keep in mind that AUTOINCREMENT on the id column is not always efficient in SQLite, as it enforces a unique constraint that is otherwise provided by PRIMARY KEY.

backend/src/middleware/auth.ts

  • Line 4: High
    • Hardcoded JWT secrets like JWT_SECRET = "secret123"; should be stored in environment variables to avoid exposure in code repositories.

backend/src/routes/auth.ts

  • Line 53: Medium

    • The bcrypt.hash and bcrypt.compare functions are asynchronous and should ideally be wrapped in try-catch blocks to handle potential errors.
  • Line 65: Medium

    • While the user password verification tells if credentials are valid, a malicious actor could infer if an account exists. Consider a consistent response for invalid credentials to prevent user enumeration.

backend/src/routes/expenses.ts

  • Line 68: Critical

    • SQL Injection vulnerability in "SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC". Variables like ${req.user!.id} and ${query} should use parameterized queries to prevent injection attacks.
  • Line 108: Medium

    • Direct user role comparison (req.user!.role !== "admin") may expose the application to privilege escalation if roles are improperly assigned or managed outside trusted authentication services.
  • Line 132: Medium

    • Excessive use of let variables. SQL statements should be handled with parameterized queries to prevent SQL injections in complex queries.

backend/src/routes/reports.ts

  • Line 33: Medium
    • When an expense summary is obtained, multiple queries are executed sequentially; consider using transactions or batch fetching strategies for increased performance.

backend/src/utils/validate.ts

  • Line 12: Low
    • Truncating strings to 500 characters with .slice(0, 500) is a simplistic approach to sanitization. Consider using libraries like validator for robust XSS protection.

frontend/src/App.tsx

  • Line 16: Low
    • While using <Navigate /> for redirection is useful, ensure alternative text for routes not found (404) or unauthorized access is provided for better UX.

frontend/src/components/ExpenseCard.tsx

  • Line 30: Low
    • The use of img without alt attribute creates an accessibility issue for screen readers. Consider adding an alt prop to all images.

frontend/src/pages/Dashboard.tsx

  • Line 23: Medium
    • setInterval manages repetitive tasks but can cause memory leaks if not cleared, especially on page navigation. Consider using setTimeout with clear interval on component unmount (clearInterval).

frontend/src/pages/ExpenseForm.tsx

  • Line 30: Medium
    • parseFloat(amount) can lead to NaN or inappropriate type handling. Inputs should be validated or cleaned before submission.

The above points identify several areas for improvement around security, potential SQL injection vulnerabilities, logical errors, and minor accessibility issues. Critical areas such as SQL injection should be prioritized to safeguard user data and application integrity.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Feb 24, 2026

Greptile Summary

This PR adds the complete TeamExpense application with frontend React components and backend Express API for managing team expenses. However, it contains multiple critical security vulnerabilities that must be fixed before merging.

Critical Security Issues:

  • SQL injection vulnerability in search endpoint allows arbitrary database access
  • Hardcoded JWT secret exposes authentication system
  • Missing authentication on team summary endpoint allows unauthorized access
  • CORS misconfiguration accepts requests from any origin
  • Missing authorization checks allow users to view others' expenses
  • Pagination offset calculation error causes incorrect results
  • Memory leak from uncleaned interval in Dashboard component

Confidence Score: 0/5

  • This PR has critical security vulnerabilities that make it unsafe to merge
  • Multiple critical security issues including SQL injection, hardcoded secrets, missing authentication, and broken authorization checks
  • Critical attention needed: backend/src/middleware/auth.ts, backend/src/routes/expenses.ts, backend/src/routes/reports.ts, backend/src/index.ts

Important Files Changed

Filename Overview
backend/src/index.ts Express server setup with CORS misconfiguration allowing all origins
backend/src/middleware/auth.ts JWT authentication with hardcoded secret - critical security issue
backend/src/routes/expenses.ts CRUD operations with SQL injection vulnerability, broken pagination, and missing authorization checks
backend/src/routes/reports.ts Reporting endpoints with missing authentication on team summary endpoint
frontend/src/pages/Dashboard.tsx User dashboard with memory leak from uncleaned interval

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend
    participant ExpressAPI
    participant SQLite
    
    User->>Frontend: Login
    Frontend->>ExpressAPI: POST /api/auth/login
    ExpressAPI->>SQLite: Query user credentials
    SQLite-->>ExpressAPI: User data
    ExpressAPI-->>Frontend: JWT token
    Frontend->>Frontend: Store token in localStorage
    
    User->>Frontend: Create expense
    Frontend->>ExpressAPI: POST /api/expenses (with JWT)
    ExpressAPI->>ExpressAPI: Verify JWT
    ExpressAPI->>SQLite: Check budget limit
    SQLite-->>ExpressAPI: Total expenses
    ExpressAPI->>SQLite: Insert expense
    SQLite-->>ExpressAPI: Created expense
    ExpressAPI-->>Frontend: Expense data
    
    User->>Frontend: View dashboard
    Frontend->>ExpressAPI: GET /api/reports/my-summary (with JWT)
    ExpressAPI->>SQLite: Query approved expenses
    SQLite-->>ExpressAPI: Expense data
    ExpressAPI-->>Frontend: Summary by category
    
    User->>Frontend: View reports (admin)
    Frontend->>ExpressAPI: GET /api/reports/team-summary
    Note over ExpressAPI: Missing auth check!
    ExpressAPI->>SQLite: Query all expenses
    SQLite-->>ExpressAPI: Team data
    ExpressAPI-->>Frontend: Team summary
Loading

Last reviewed commit: d7d7dd0

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

17 files reviewed, 7 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +77 to +78
`SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
).all();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical SQL injection vulnerability - user input query is directly interpolated into SQL

Suggested change
`SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
).all();
const results = db.prepare(
"SELECT * FROM expenses WHERE user_id = ? AND description LIKE ? ORDER BY created_at DESC"
).all(req.user!.id, `%${query}%`);

Comment on lines +27 to +43
router.get("/team-summary", (req: Request, res: Response) => {
const expenses = db.prepare(
"SELECT * FROM expenses WHERE status = 'approved'"
).all() as any[];

const summary: Record<string, { name: string; total: number; count: number }> = {};

for (const exp of expenses) {
if (!summary[exp.user_id]) {
const user = db.prepare("SELECT name FROM users WHERE id = ?").get(exp.user_id) as any;
summary[exp.user_id] = { name: user.name, total: 0, count: 0 };
}
summary[exp.user_id].total += exp.amount;
summary[exp.user_id].count += 1;
}

res.json(Object.values(summary));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing authentication - anyone can access team summary without being logged in or having admin privileges

const app = express();
const PORT = 3001;

app.use(cors());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CORS allows all origins - attackers can make requests from malicious sites


const expenses = db.prepare(
"SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
).all(userId, limit, page * limit);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pagination offset calculation is wrong - should be (page - 1) * limit

Suggested change
).all(userId, limit, page * limit);
).all(userId, limit, (page - 1) * limit);

Comment on lines +41 to +48
router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);

if (!expense) {
return res.status(404).json({ error: "Expense not found" });
}

res.json(expense);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users can view any expense by ID without checking ownership - should verify user_id matches req.user.id

};

fetchSummary();
setInterval(fetchSummary, 30000);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak - setInterval is never cleared when component unmounts

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const JWT_SECRET = "secret123";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded JWT secret exposes all tokens to compromise - must use environment variable

Comment on lines +69 to +78
router.get("/search", authenticate, (req: AuthRequest, res: Response) => {
const query = req.query.q as string;

if (!query) {
return res.status(400).json({ error: "Search query is required" });
}

const results = db.prepare(
`SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
).all();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Sql injection search 🐞 Bug ⛨ Security

The expenses search endpoint builds SQL with unescaped user input, enabling SQL injection and
potential cross-user data access/modification. This is exploitable via the q query parameter.
Agent Prompt
### Issue description
`GET /api/expenses/search` is vulnerable to SQL injection because it interpolates `req.query.q` directly into the SQL statement.

### Issue Context
The current implementation uses a template literal to build SQL, which allows crafted `q` values to alter the query.

### Fix Focus Areas
- backend/src/routes/expenses.ts[69-80]
  - Replace the template literal with a parameterized query, e.g.:
    - `SELECT * FROM expenses WHERE user_id = ? AND description LIKE ? ORDER BY created_at DESC`
    - params: `[req.user!.id, `%${query}%`]`
  - Consider trimming/length-limiting `query` similarly to `sanitizeString`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +40 to +85
// Get single expense
router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);

if (!expense) {
return res.status(404).json({ error: "Expense not found" });
}

res.json(expense);
});

// List expenses with pagination
router.get("/", authenticate, (req: AuthRequest, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const userId = req.user!.id;

const expenses = db.prepare(
"SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
).all(userId, limit, page * limit);

const count = db.prepare(
"SELECT COUNT(*) as total FROM expenses WHERE user_id = ?"
).get(userId) as any;

res.json({ data: expenses, total: count.total, page, limit });
});

// Search expenses
router.get("/search", authenticate, (req: AuthRequest, res: Response) => {
const query = req.query.q as string;

if (!query) {
return res.status(400).json({ error: "Search query is required" });
}

const results = db.prepare(
`SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
).all();

res.json(results);
});

// Filter expenses by date range
router.get("/filter", authenticate, (req: AuthRequest, res: Response) => {
const { startDate, endDate, category } = req.query;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Routes shadowed by :id 🐞 Bug ✓ Correctness

GET /:id is registered before /search and /filter, so requests to /search and /filter will
match the /:id handler first and never reach the intended endpoints. This breaks search and
filtering.
Agent Prompt
### Issue description
`/:id` is defined before `/search` and `/filter`, causing those literal routes to be shadowed.

### Issue Context
This prevents search and filtering endpoints from being reachable.

### Fix Focus Areas
- backend/src/routes/expenses.ts[40-110]
  - Register `/search` and `/filter` before `/:id`.
  - Optionally constrain `/:id` to numeric IDs only: `router.get('/:id(\\d+)', ...)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +41 to +48
router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);

if (!expense) {
return res.status(404).json({ error: "Expense not found" });
}

res.json(expense);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Expense idor read 🐞 Bug ⛨ Security

GET /api/expenses/:id returns an expense solely by ID without checking ownership, allowing any
authenticated user to fetch other users’ expenses by guessing IDs.
Agent Prompt
### Issue description
`GET /expenses/:id` does not enforce that the requested expense belongs to the authenticated user.

### Issue Context
Delete already enforces `id` + `user_id`, but GET does not.

### Fix Focus Areas
- backend/src/routes/expenses.ts[40-49]
  - Change query to `SELECT * FROM expenses WHERE id = ? AND user_id = ?`.
  - If admins should read any expense, implement explicit role-based branching (admin: id only; member: id+user_id).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +52 to +60
router.get("/", authenticate, (req: AuthRequest, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const userId = req.user!.id;

const expenses = db.prepare(
"SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
).all(userId, limit, page * limit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Pagination offset wrong 🐞 Bug ✓ Correctness

The list endpoint uses OFFSET page * limit which skips the first page (page=1 starts at
offset=20). Users will see missing/empty results on early pages.
Agent Prompt
### Issue description
Pagination offset is incorrect (`page * limit`).

### Issue Context
Default `page=1` should return the first `limit` rows with offset 0.

### Fix Focus Areas
- backend/src/routes/expenses.ts[52-66]
  - Change offset to `(page - 1) * limit`.
  - Add validation: clamp/reject negative or extremely large `limit` values.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +26 to +33
// Team summary — admin only
router.get("/team-summary", (req: Request, res: Response) => {
const expenses = db.prepare(
"SELECT * FROM expenses WHERE status = 'approved'"
).all() as any[];

const summary: Record<string, { name: string; total: number; count: number }> = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

5. Public team summary 🐞 Bug ⛨ Security

GET /api/reports/team-summary is labeled “admin only” but has no authentication/authorization
middleware, leaking organization-wide spending totals to unauthenticated callers.
Agent Prompt
### Issue description
`/reports/team-summary` is publicly accessible despite being intended for admins.

### Issue Context
Other report routes correctly use `authenticate` and role checks.

### Fix Focus Areas
- backend/src/routes/reports.ts[26-44]
  - Add `authenticate` middleware.
  - Add an admin role check and return 403 when unauthorized.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const JWT_SECRET = "secret123";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

6. Hardcoded jwt secret 🐞 Bug ⛨ Security

JWT signing/verification uses a hardcoded secret (secret123). If the code leaks or is guessable,
attackers can forge tokens and become any user/admin.
Agent Prompt
### Issue description
JWT secret is hardcoded, enabling token forgery and blocking secret rotation.

### Issue Context
Both signing and verification depend on `JWT_SECRET`.

### Fix Focus Areas
- backend/src/middleware/auth.ts[1-32]
  - Replace hardcoded constant with `process.env.JWT_SECRET`.
  - Throw on startup if missing.
  - Avoid exporting the secret; instead export a helper (e.g., `signToken(payload)`), or keep secret private to the module.
- backend/src/routes/auth.ts[1-65]
  - Update token signing to use the centralized helper or imported config.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (7)
frontend/src/pages/ExpenseList.tsx-63-63 (1)

63-63: ⚠️ Potential issue | 🟡 Minor

"Next" page button is unbounded — navigating past the last page yields an empty silent list.

Consider disabling "Next" when the returned page has fewer items than the page size, or track a hasMore flag from the API response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/ExpenseList.tsx` at line 63, The "Next" button in the
ExpenseList component is unbounded; update the Next button (the element using
onClick={() => setPage((p) => p + 1)}) to be disabled when there are no more
pages — either by checking if the currently displayed items length is less than
the pageSize (disable when items.length < pageSize) or by wiring a hasMore flag
from the API response and disabling when hasMore is false; ensure the button's
disabled prop and any page increment logic (setPage) respect this guard so users
cannot advance past the last page.
frontend/src/pages/ExpenseForm.tsx-14-26 (1)

14-26: ⚠️ Potential issue | 🟡 Minor

parseFloat(amount) can silently produce NaN or a negative number — add client-side validation.

When the amount field is empty, parseFloat("") returns NaN. A negative or zero value passes parseFloat but fails backend validation. The user receives a generic error with no field-level guidance.

🔧 Proposed fix
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
+    const parsedAmount = parseFloat(amount);
+    if (!amount || isNaN(parsedAmount) || parsedAmount <= 0) {
+      setError("Please enter a valid positive amount.");
+      return;
+    }
     try {
       await api.post("/expenses", {
-        amount: parseFloat(amount),
+        amount: parsedAmount,
         description,
         category,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/ExpenseForm.tsx` around lines 14 - 26, In handleSubmit,
add client-side validation for the amount field before calling api.post: check
that amount is not empty, that parseFloat(amount) yields a finite number and
that the value is > 0; if validation fails, set a user-friendly field-level
error (use setError or introduce setAmountError) and return early so api.post is
not called; ensure you still pass the parsed numeric value (e.g., parsedAmount)
into the payload instead of calling parseFloat inline, and keep existing error
handling for server responses in the catch block.
frontend/src/pages/Login.tsx-11-21 (1)

11-21: ⚠️ Potential issue | 🟡 Minor

No loading state — double-submit is possible.

Rapid clicks will fire multiple concurrent POST requests. Disable the button while the request is in flight.

🔧 Proposed fix
-  const [error, setError] = useState("");
+  const [error, setError] = useState("");
+  const [loading, setLoading] = useState(false);
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
+    setLoading(true);
+    setError("");
     try {
       const { data } = await api.post("/auth/login", { email, password });
       localStorage.setItem("token", data.token);
       localStorage.setItem("user", JSON.stringify(data.user));
       navigate("/");
     } catch (err: any) {
       setError(err.response?.data?.error || "Login failed");
+    } finally {
+      setLoading(false);
     }
   };

Then on the button:

-        <button type="submit" style={{ width: "100%", padding: 10 }}>
-          Log In
+        <button type="submit" disabled={loading} style={{ width: "100%", padding: 10 }}>
+          {loading ? "Logging in…" : "Log In"}
         </button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Login.tsx` around lines 11 - 21, Add a loading state to
prevent double-submit: create a const [loading, setLoading] = useState(false) in
the component, then in handleSubmit setLoading(true) immediately after
e.preventDefault() (and optionally return early if loading is true), perform the
api.post("/auth/login", { email, password }), and in a finally block call
setLoading(false); update the submit button to use the loading state
(disabled={loading}) so it cannot be clicked while the request is in flight;
keep existing error handling (setError) and navigation (navigate("/")) intact.
frontend/src/App.tsx-9-12 (1)

9-12: ⚠️ Potential issue | 🟡 Minor

PrivateRoute doesn't detect expired tokens.

localStorage.getItem("token") returns a truthy string even after the token has expired. The user will pass the guard, land on a protected page, and immediately get 401s from every API call, with no automatic redirect to /login.

Consider decoding the token client-side to check the exp claim before rendering children, or clearing the token and redirecting when the API client receives a 401.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/App.tsx` around lines 9 - 12, PrivateRoute currently only checks
for existence of localStorage token string; update PrivateRoute to decode the
JWT (e.g., using jwt-decode or manual base64 JSON parse) and validate the exp
claim before rendering children: if token missing or exp is in the past, clear
the token from storage and redirect to /login; otherwise render children. Use
the existing PrivateRoute function and the localStorage key "token" when
implementing this check.
backend/src/routes/auth.ts-13-15 (1)

13-15: ⚠️ Potential issue | 🟡 Minor

No password length/strength validation.

!password is falsy for an empty string, but a single-character password passes. Add a minimum length check.

🛡️ Proposed fix
 if (!email || !password || !name) {
   return res.status(400).json({ error: "All fields are required" });
 }
+
+if (password.length < 8) {
+  return res.status(400).json({ error: "Password must be at least 8 characters" });
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/routes/auth.ts` around lines 13 - 15, The current validation only
checks for existence of email, password and name but allows very short
passwords; update the validation in the registration handler to enforce a
minimum password length (e.g., password.length >= 8) and return a 400 with a
clear error when the password is too short; modify the if that checks email,
password, name (using the password variable) to include the length check and
ensure the response message explicitly states the minimum password requirement
so callers know why it failed.
frontend/src/pages/Reports.tsx-23-35 (1)

23-35: ⚠️ Potential issue | 🟡 Minor

URL.createObjectURL is never revoked and the anchor isn't appended to the DOM.

URL.createObjectURL allocates a memory-backed URL that persists until the document is unloaded unless explicitly released. Additionally, triggering .click() on a detached element is unreliable in some browsers (Firefox in particular requires the element to be in the DOM).

🛡️ Proposed fix
 const a = document.createElement("a");
 a.href = url;
 a.download = "expenses-export.json";
+document.body.appendChild(a);
 a.click();
+document.body.removeChild(a);
+URL.revokeObjectURL(url);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Reports.tsx` around lines 23 - 35, In handleExport, append
the created anchor element to the DOM before calling a.click() (e.g.,
document.body.appendChild(a)), and ensure you revoke the blob URL and remove the
anchor afterward to avoid memory leaks; modify handleExport to create the blob
and URL via URL.createObjectURL, append the <a> to document.body, trigger
a.click(), then call URL.revokeObjectURL(url) and remove the anchor (and use a
try/catch/finally so revoke/remove always run) to ensure proper cleanup.
backend/src/routes/expenses.ts-9-37 (1)

9-37: ⚠️ Potential issue | 🟡 Minor

Validate description type and return the sanitized value.

If description is not a string, sanitizeString will throw and return a 500. Also, the response currently echoes the unsanitized value, which can drift from what’s stored.

✅ Suggested fix
-  if (!description || !category) {
+  if (typeof description !== "string" || typeof category !== "string" || !description.trim()) {
     return res.status(400).json({ error: "Description and category are required" });
   }
@@
-  const result = db.prepare(
+  const sanitizedDescription = sanitizeString(description);
+  const result = db.prepare(
     "INSERT INTO expenses (user_id, amount, description, category) VALUES (?, ?, ?, ?)"
-  ).run(userId, amount, sanitizeString(description), category);
+  ).run(userId, amount, sanitizedDescription, category);
 
-  res.status(201).json({ id: result.lastInsertRowid, amount, description, category, status: "pending" });
+  res.status(201).json({ id: result.lastInsertRowid, amount, description: sanitizedDescription, category, status: "pending" });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/routes/expenses.ts` around lines 9 - 37, The handler registered
via router.post(...) must validate that req.body.description is a string before
calling sanitizeString to avoid throws and 500s; if it's not a string return
res.status(400).json({ error: "Description must be a string" }). Then call const
cleanDescription = sanitizeString(description) and use cleanDescription both in
the DB insert (the run call that uses sanitizeString) and in the response body
(replace the echoed description with cleanDescription) so the client sees the
stored, sanitized value; keep all other validations (amount, category, budget)
unchanged.
🧹 Nitpick comments (4)
backend/src/utils/validate.ts (1)

22-22: BUDGET_LIMIT is a hardcoded magic number — consider making it configurable.

A fixed 5000 cannot accommodate different teams or user tiers without a code change. At minimum, move it to an environment variable so it can vary across environments.

♻️ Proposed change
-export const BUDGET_LIMIT = 5000;
+export const BUDGET_LIMIT = Number(process.env.BUDGET_LIMIT) || 5000;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/utils/validate.ts` at line 22, BUDGET_LIMIT is a hardcoded magic
number; change it to read from an environment variable (e.g.,
process.env.BUDGET_LIMIT) with a sensible fallback (5000) and parse/validate it
as an integer; update the export in validate.ts (BUDGET_LIMIT) to compute
Number.parseInt(process.env.BUDGET_LIMIT, 10) || 5000 and throw or log a clear
error if the env value is invalid (NaN or <=0), and ensure any code that imports
BUDGET_LIMIT continues to work with the new exported constant name.
frontend/src/api/client.ts (1)

7-13: Add a 401 response interceptor for centralised auth-failure handling.

Without one, every consumer must handle token expiry individually. A single interceptor here can redirect to /login and clear storage in one place.

♻️ Proposed addition
 api.interceptors.request.use((config) => {
   const token = localStorage.getItem("token");
   if (token) {
     config.headers.Authorization = `Bearer ${token}`;
   }
   return config;
 });
+
+api.interceptors.response.use(
+  (response) => response,
+  (error) => {
+    if (error.response?.status === 401) {
+      localStorage.removeItem("token");
+      localStorage.removeItem("user");
+      window.location.href = "/login";
+    }
+    return Promise.reject(error);
+  }
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/api/client.ts` around lines 7 - 13, Add a response interceptor
to the existing axios setup so 401 auth failures are handled centrally: in the
same module where api.interceptors.request.use is defined, register
api.interceptors.response.use and in the error handler detect
error.response?.status === 401, then clear auth state
(localStorage.removeItem("token") and any other storage) and redirect to
"/login" (e.g., window.location.assign("/login")); ensure the interceptor
re-throws or returns a rejected promise after handling so callers still receive
the error when needed.
frontend/src/pages/ExpenseForm.tsx (1)

5-5: CATEGORIES is duplicated from backend/src/utils/validate.ts — maintenance risk.

If the backend list changes, the frontend dropdown silently goes out of sync. Consider fetching allowed categories from the API, or sharing a types/constants package.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/ExpenseForm.tsx` at line 5, The CATEGORIES constant in
ExpenseForm.tsx duplicates the backend list (validate.ts) and can drift; replace
the hardcoded CATEGORIES with a dynamic source: either import a shared
constants/types package if available or fetch the allowed categories from the
backend API at component mount (e.g., useExpenseForm or ExpenseForm component
initialization) and store them in state used by the dropdown; ensure the
dropdown uses that state and handle loading/error states so the frontend always
reflects backend-validated categories.
backend/src/index.ts (1)

8-8: Read PORT from the environment.

Hardcoding 3001 prevents overriding the port without a code change.

♻️ Proposed fix
-const PORT = 3001;
+const PORT = Number(process.env.PORT) || 3001;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/index.ts` at line 8, Replace the hardcoded port constant PORT
with a value read from the environment; change the declaration of PORT so it
reads process.env.PORT (falling back to 3001 when not provided) and ensure the
value is converted to a number before usage; update any code that references the
constant PORT accordingly (look for the PORT constant in backend/src/index.ts).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/db.ts`:
- Line 6: The database connection enables WAL but not foreign key enforcement,
so the FOREIGN KEY constraints are ignored; update the connection initialization
where the sqlite connection object db is created (the same place calling
db.pragma("journal_mode = WAL")) to also run db.pragma("foreign_keys = ON") so
foreign key checks are enabled for this connection (remember this pragma must be
set on each connection open).
- Line 4: The repository currently ignores *.db but not SQLite WAL files;
because backend/src/db.ts creates the Database instance (const db = new
Database(...)) and enables WAL with db.pragma("journal_mode = WAL"), add
patterns to the root .gitignore to exclude environment-specific WAL files by
adding entries for *.db-shm and *.db-wal so backend/teamexpense.db-shm and
backend/teamexpense.db-wal are not tracked.

In `@backend/src/index.ts`:
- Line 10: The current call app.use(cors()) allows all origins; restrict CORS by
replacing the wildcard with a configured allowlist: define an array of
allowedOrigins and pass a cors options object (or origin callback) to cors()
that checks the incoming Origin against that list and either allows or rejects
it, and set credentials: true if you need to support cookies/auth; update the
middleware instantiation where app.use(cors()) appears to use this
allowlist-based options so only known origins can access authenticated
endpoints.

In `@backend/src/middleware/auth.ts`:
- Line 4: Replace the hardcoded JWT_SECRET with a value loaded from process.env
and fail fast if missing: read process.env.JWT_SECRET into the exported
JWT_SECRET constant (export const JWT_SECRET: string) and throw or exit during
module initialization when it's undefined or empty so the app won't start with
an insecure default; update any code that imported the previous value to use the
new exported JWT_SECRET string.

In `@backend/src/routes/auth.ts`:
- Around line 40-64: Add a per-IP or per-email rate limiter middleware (using
express-rate-limit) to the login endpoint to prevent brute-force attempts:
create a limiter (e.g., loginLimiter) with sensible windowMs and max attempts
and a custom keyGenerator that uses req.ip or req.body.email (or both) and a
custom handler for blocked requests, then apply that middleware to
router.post("/login", ...) so the limiter runs before the authentication logic
in the login route; ensure the limiter is exported/initialized where router is
defined and tailored to block/slowdown repeated requests while returning a clear
429 response.
- Around line 27-37: result.lastInsertRowid can be number | bigint which will
break JSON/JWT serialization; before using it in jwt.sign and the JSON response
convert/narrow it to a safe number type (e.g., coerce with
Number(result.lastInsertRowid) or validate and toString() for stable string ids)
and use that converted value in jwt.sign(...) and in the user object returned by
res.status(201).json(...); update uses of result.lastInsertRowid in this block
to reference the new narrowed variable.

In `@backend/src/routes/expenses.ts`:
- Around line 68-78: The SQL is built with string interpolation in the
router.get("/search") handler (db.prepare(...).all()), which allows SQL
injection via req.query.q; change it to use a parameterized query and pass the
sanitized LIKE pattern as a parameter (e.g., "SELECT * FROM expenses WHERE
user_id = ? AND description LIKE ? ORDER BY created_at DESC") and supply
[req.user!.id, `%${query}%`] (or named params) to db.prepare().all(...) after
validating/normalizing query to a string; ensure you still return the results as
before.
- Around line 42-48: The GET /:id handler currently fetches an expense by id
without checking ownership; update the DB lookup to include the authenticated
user's id so only the owner can fetch it: ensure the auth middleware populates
req.user.id, change the SELECT in the db.prepare call that currently uses
"SELECT * FROM expenses WHERE id = ?" to also filter by the owner column (e.g.
"AND user_id = ?"), pass req.params.id and req.user.id in the proper order to
.get(), and keep the existing 404 response when no row is returned (operate on
the existing expense variable).
- Line 41: The dynamic route router.get("/:id", authenticate, ...) is currently
too greedy and captures requests intended for the later /search and /filter
handlers; change the route path to constrain id to numeric values (e.g., use a
regex path like "/:id(\\d+)" or an equivalent route parameter constraint) so
only numeric IDs match, leaving the /search and /filter routes to be reached;
update the declaration that registers the route (router.get("/:id", ...)) to the
constrained form and keep the same handler and middleware (authenticate,
AuthRequest, Response) otherwise unchanged.
- Around line 52-60: The pagination offset logic in the router.get handler is
wrong: it uses page * limit (which skips the first page) and can produce
negative/invalid offsets for page < 1; change the calculation to compute offset
= (Math.max(page, 1) - 1) * Math.max(limit, 1) (or otherwise ensure page and
limit are clamped to minimums) and pass that offset to the prepared statement
instead of page * limit; update the variables used in the
db.prepare(...).all(...) call in the router.get handler so the query uses limit
and the corrected offset and guard against non-positive page/limit values.

In `@backend/src/routes/reports.ts`:
- Around line 34-41: Current loop over expenses causes N+1 queries via
db.prepare("SELECT name FROM users WHERE id = ?") for each distinct exp.user_id
and will throw if a user is missing; replace this by querying expenses joined to
users once (e.g., SELECT e.user_id, u.name, e.amount FROM expenses e LEFT JOIN
users u ON e.user_id = u.id) to return name (nullable) and amount per row, then
aggregate into the summary object (the existing summary variable) in memory;
ensure you handle null u.name by using a fallback like "Unknown" or empty string
before assigning summary[exp.user_id].name so no runtime error occurs when a
user row is missing.
- Around line 27-44: The /team-summary route (router.get("/team-summary")) is
missing authentication and admin authorization; update the route to apply the
authenticate middleware and then enforce an admin role check (use req.user.role
or the same pattern used in the /export handler) before querying the database so
only authenticated admins can access the aggregated data; return a 401/403 on
missing auth or non-admin and keep the existing summary logic intact.

In `@frontend/src/api/client.ts`:
- Around line 3-5: The axios instance creation in frontend/src/api/client.ts
currently hardcodes baseURL to "http://localhost:3001/api" which breaks
non-local deployments; change the axios.create call that initializes api to read
the base URL from an environment variable (e.g., VITE_API_BASE_URL or
REACT_APP_API_BASE_URL depending on your build system) and fall back to a safe
default such as a relative "/api" if the env var is missing, ensuring the api
symbol uses that computed value instead of the literal localhost string.

In `@frontend/src/components/ExpenseCard.tsx`:
- Line 27: In ExpenseCard (component: ExpenseCard.tsx) the decorative logo <img
src="/logo.png" width={24} height={24} /> is missing an alt attribute; update
the <img> element inside the ExpenseCard render to include an appropriate alt
(use alt="" if decorative or a short descriptive string like "App logo" if
meaningful) so screen readers get correct semantics.

In `@frontend/src/components/StatusBadge.tsx`:
- Around line 4-12: The "pending" style in StatusBadge.tsx currently uses color
"#F5E642" on a white background, which fails WCAG contrast; update the pending
object so the background is the yellow ("#F5E642") and the text color is a dark
high-contrast color (e.g., "#111111" or "#222222") and adjust the border to a
darker yellow (e.g., "#E0C800") if needed to preserve visible outline; modify
the pending style object (pending: { ... }) to swap backgroundColor and color
values to ensure a contrast ratio >= 4.5:1 while keeping padding, borderRadius,
fontSize, and fontWeight unchanged.

In `@frontend/src/pages/Dashboard.tsx`:
- Around line 25-27: The effect starts a recurring timer with
setInterval(fetchSummary, 30000) but never clears it, causing fetchSummary to
continue running after unmount and potentially calling setSummary on an
unmounted component; update the useEffect in Dashboard.tsx to store the interval
ID returned by setInterval, return a cleanup function that calls clearInterval
on that ID, and optionally guard setSummary in fetchSummary (or use an
AbortController) to avoid state updates after unmount.

In `@frontend/src/pages/ExpenseList.tsx`:
- Around line 45-55: Replace the non-focusable clickable <div> with an
accessible <button> in ExpenseList.tsx so keyboard and assistive tech can
activate it; use the existing onClick handler (handleDelete(exp.id)), add
type="button" and an explicit aria-label (e.g., `aria-label={`Delete expense
${exp.id}`}`), and preserve visual styling by moving the inline styles
(marginLeft, color: "red", cursor, padding) to the button (or to CSS) while
removing default button chrome (e.g., border: "none", background: "transparent")
as needed.
- Around line 19-25: The useEffect's inner async fetch (the anonymous const
fetch) swallows API failures so the UI shows a blank list; wrap the
api.get(`/expenses?page=${page}`) call in a try/catch, call setExpenses only on
success and set an error state (e.g., add a useState like const [error,
setError]) in the catch to store a human-friendly message or the error detail,
and update the JSX to render that error when error is non-empty; reference the
existing useEffect, the inner fetch function, api.get and setExpenses when
making these changes.

In `@frontend/src/pages/Login.tsx`:
- Around line 15-16: Remove persistence of the full user object to localStorage:
stop calling localStorage.setItem("user", JSON.stringify(data.user)) and instead
store only non-PII attributes you truly need (e.g., role, displayName) or derive
claims from the JWT token saved as "token" via localStorage.setItem("token",
data.token); update the Login component's success handler to extract allowed
fields from data.user (or decode the JWT) and persist only those minimal safe
values, and ensure any code reading localStorage uses the new keys and does not
expect the full user object.
- Around line 27-49: Add accessible labels, required attributes, and an alert
role for errors: give the email and password inputs associated <label> elements
(use htmlFor/id pairs like id="email" and id="password" referenced by labels) or
visually-hidden labels, add required to both inputs, and ensure the error
display (the component/element rendered when validation/auth fails near
handleSubmit) includes role="alert" so screen readers announce it; update the
JSX around handleSubmit, the email/password stateful inputs, and the error
paragraph/element accordingly.

---

Minor comments:
In `@backend/src/routes/auth.ts`:
- Around line 13-15: The current validation only checks for existence of email,
password and name but allows very short passwords; update the validation in the
registration handler to enforce a minimum password length (e.g., password.length
>= 8) and return a 400 with a clear error when the password is too short; modify
the if that checks email, password, name (using the password variable) to
include the length check and ensure the response message explicitly states the
minimum password requirement so callers know why it failed.

In `@backend/src/routes/expenses.ts`:
- Around line 9-37: The handler registered via router.post(...) must validate
that req.body.description is a string before calling sanitizeString to avoid
throws and 500s; if it's not a string return res.status(400).json({ error:
"Description must be a string" }). Then call const cleanDescription =
sanitizeString(description) and use cleanDescription both in the DB insert (the
run call that uses sanitizeString) and in the response body (replace the echoed
description with cleanDescription) so the client sees the stored, sanitized
value; keep all other validations (amount, category, budget) unchanged.

In `@frontend/src/App.tsx`:
- Around line 9-12: PrivateRoute currently only checks for existence of
localStorage token string; update PrivateRoute to decode the JWT (e.g., using
jwt-decode or manual base64 JSON parse) and validate the exp claim before
rendering children: if token missing or exp is in the past, clear the token from
storage and redirect to /login; otherwise render children. Use the existing
PrivateRoute function and the localStorage key "token" when implementing this
check.

In `@frontend/src/pages/ExpenseForm.tsx`:
- Around line 14-26: In handleSubmit, add client-side validation for the amount
field before calling api.post: check that amount is not empty, that
parseFloat(amount) yields a finite number and that the value is > 0; if
validation fails, set a user-friendly field-level error (use setError or
introduce setAmountError) and return early so api.post is not called; ensure you
still pass the parsed numeric value (e.g., parsedAmount) into the payload
instead of calling parseFloat inline, and keep existing error handling for
server responses in the catch block.

In `@frontend/src/pages/ExpenseList.tsx`:
- Line 63: The "Next" button in the ExpenseList component is unbounded; update
the Next button (the element using onClick={() => setPage((p) => p + 1)}) to be
disabled when there are no more pages — either by checking if the currently
displayed items length is less than the pageSize (disable when items.length <
pageSize) or by wiring a hasMore flag from the API response and disabling when
hasMore is false; ensure the button's disabled prop and any page increment logic
(setPage) respect this guard so users cannot advance past the last page.

In `@frontend/src/pages/Login.tsx`:
- Around line 11-21: Add a loading state to prevent double-submit: create a
const [loading, setLoading] = useState(false) in the component, then in
handleSubmit setLoading(true) immediately after e.preventDefault() (and
optionally return early if loading is true), perform the api.post("/auth/login",
{ email, password }), and in a finally block call setLoading(false); update the
submit button to use the loading state (disabled={loading}) so it cannot be
clicked while the request is in flight; keep existing error handling (setError)
and navigation (navigate("/")) intact.

In `@frontend/src/pages/Reports.tsx`:
- Around line 23-35: In handleExport, append the created anchor element to the
DOM before calling a.click() (e.g., document.body.appendChild(a)), and ensure
you revoke the blob URL and remove the anchor afterward to avoid memory leaks;
modify handleExport to create the blob and URL via URL.createObjectURL, append
the <a> to document.body, trigger a.click(), then call URL.revokeObjectURL(url)
and remove the anchor (and use a try/catch/finally so revoke/remove always run)
to ensure proper cleanup.

---

Nitpick comments:
In `@backend/src/index.ts`:
- Line 8: Replace the hardcoded port constant PORT with a value read from the
environment; change the declaration of PORT so it reads process.env.PORT
(falling back to 3001 when not provided) and ensure the value is converted to a
number before usage; update any code that references the constant PORT
accordingly (look for the PORT constant in backend/src/index.ts).

In `@backend/src/utils/validate.ts`:
- Line 22: BUDGET_LIMIT is a hardcoded magic number; change it to read from an
environment variable (e.g., process.env.BUDGET_LIMIT) with a sensible fallback
(5000) and parse/validate it as an integer; update the export in validate.ts
(BUDGET_LIMIT) to compute Number.parseInt(process.env.BUDGET_LIMIT, 10) || 5000
and throw or log a clear error if the env value is invalid (NaN or <=0), and
ensure any code that imports BUDGET_LIMIT continues to work with the new
exported constant name.

In `@frontend/src/api/client.ts`:
- Around line 7-13: Add a response interceptor to the existing axios setup so
401 auth failures are handled centrally: in the same module where
api.interceptors.request.use is defined, register api.interceptors.response.use
and in the error handler detect error.response?.status === 401, then clear auth
state (localStorage.removeItem("token") and any other storage) and redirect to
"/login" (e.g., window.location.assign("/login")); ensure the interceptor
re-throws or returns a rejected promise after handling so callers still receive
the error when needed.

In `@frontend/src/pages/ExpenseForm.tsx`:
- Line 5: The CATEGORIES constant in ExpenseForm.tsx duplicates the backend list
(validate.ts) and can drift; replace the hardcoded CATEGORIES with a dynamic
source: either import a shared constants/types package if available or fetch the
allowed categories from the backend API at component mount (e.g., useExpenseForm
or ExpenseForm component initialization) and store them in state used by the
dropdown; ensure the dropdown uses that state and handle loading/error states so
the frontend always reflects backend-validated categories.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3c8ccf6 and d7d7dd0.

📒 Files selected for processing (17)
  • backend/src/db.ts
  • backend/src/index.ts
  • backend/src/middleware/auth.ts
  • backend/src/routes/auth.ts
  • backend/src/routes/expenses.ts
  • backend/src/routes/reports.ts
  • backend/src/utils/validate.ts
  • frontend/src/App.tsx
  • frontend/src/api/client.ts
  • frontend/src/components/ExpenseCard.tsx
  • frontend/src/components/StatusBadge.tsx
  • frontend/src/index.tsx
  • frontend/src/pages/Dashboard.tsx
  • frontend/src/pages/ExpenseForm.tsx
  • frontend/src/pages/ExpenseList.tsx
  • frontend/src/pages/Login.tsx
  • frontend/src/pages/Reports.tsx

import Database from "better-sqlite3";
import path from "path";

const db = new Database(path.join(__dirname, "..", "teamexpense.db"));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the DB file or *.db pattern is excluded in .gitignore
fd -g ".gitignore" --exec grep -n "\.db\|teamexpense" {} +

Repository: reeder32/teamexpense

Length of output: 271


🏁 Script executed:

#!/bin/bash
# Check if teamexpense.db file exists in the repository and if it's tracked by git
echo "=== Checking for database files in repository ==="
find . -name "teamexpense.db" -o -name "*.db" 2>/dev/null | head -20

echo -e "\n=== Checking git status for tracked db files ==="
git ls-files | grep -i "\.db\|teamexpense" || echo "No .db files found in git index"

echo -e "\n=== Check .gitignore at repository root ==="
if [ -f ".gitignore" ]; then
  cat .gitignore | grep -n "\.db\|teamexpense" || echo "No db patterns found in root .gitignore"
else
  echo ".gitignore not found at repository root"
fi

echo -e "\n=== Check for backend/.gitignore ==="
if [ -f "backend/.gitignore" ]; then
  cat backend/.gitignore | grep -n "\.db\|teamexpense" || echo "No db patterns found in backend/.gitignore"
else
  echo "backend/.gitignore not found"
fi

Repository: reeder32/teamexpense

Length of output: 336


🏁 Script executed:

#!/bin/bash
# Verify the actual file structure and path resolution context
echo "=== Backend directory structure ==="
ls -la backend/ 2>/dev/null | grep -E "\.db|\.gitignore|src" | head -20

echo -e "\n=== Check if backend/src/db.ts exists ==="
if [ -f "backend/src/db.ts" ]; then
  echo "File found. Content around line 4:"
  sed -n '1,10p' backend/src/db.ts
else
  echo "File not found"
fi

Repository: reeder32/teamexpense

Length of output: 629


Add .db-shm and .db-wal patterns to .gitignore.

The root .gitignore already excludes *.db, but SQLite's WAL (Write-Ahead Logging) support—enabled on line 6 (db.pragma("journal_mode = WAL"))—creates .db-shm and .db-wal files that are currently tracked in git (backend/teamexpense.db-shm and backend/teamexpense.db-wal). These are environment-specific database state files and should be excluded. Update .gitignore to also exclude *.db-shm and *.db-wal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/db.ts` at line 4, The repository currently ignores *.db but not
SQLite WAL files; because backend/src/db.ts creates the Database instance (const
db = new Database(...)) and enables WAL with db.pragma("journal_mode = WAL"),
add patterns to the root .gitignore to exclude environment-specific WAL files by
adding entries for *.db-shm and *.db-wal so backend/teamexpense.db-shm and
backend/teamexpense.db-wal are not tracked.


const db = new Database(path.join(__dirname, "..", "teamexpense.db"));

db.pragma("journal_mode = WAL");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Foreign key enforcement is disabled — the REFERENCES users(id) constraint is silently ignored.

SQLite disables FK enforcement by default. Without PRAGMA foreign_keys = ON, the FOREIGN KEY (user_id) REFERENCES users(id) declaration has no effect: rows with dangling user_id values can be inserted and users can be deleted leaving orphaned expense rows.

🔧 Proposed fix
 db.pragma("journal_mode = WAL");
+db.pragma("foreign_keys = ON");

Note: this pragma must be set on every connection opening (it is not persisted). Since better-sqlite3 uses a single synchronous connection, adding it here is sufficient.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
db.pragma("journal_mode = WAL");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/db.ts` at line 6, The database connection enables WAL but not
foreign key enforcement, so the FOREIGN KEY constraints are ignored; update the
connection initialization where the sqlite connection object db is created (the
same place calling db.pragma("journal_mode = WAL")) to also run
db.pragma("foreign_keys = ON") so foreign key checks are enabled for this
connection (remember this pragma must be set on each connection open).

const app = express();
const PORT = 3001;

app.use(cors());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict CORS to known origins.

cors() with no configuration emits Access-Control-Allow-Origin: *, allowing any website to call your authenticated API endpoints. To avoid exposing sensitive data to unintended origins, specify origins in an allowlist rather than using the allow-all wildcard. Even though the wildcard blocks credentialed requests by spec, it still permits reading any unauthenticated response body from any origin.

🛡️ Proposed fix
-app.use(cors());
+app.use(cors({
+  origin: process.env.CORS_ORIGIN || "http://localhost:3000",
+  methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
+}));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.use(cors());
app.use(cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
}));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/index.ts` at line 10, The current call app.use(cors()) allows all
origins; restrict CORS by replacing the wildcard with a configured allowlist:
define an array of allowedOrigins and pass a cors options object (or origin
callback) to cors() that checks the incoming Origin against that list and either
allows or rejects it, and set credentials: true if you need to support
cookies/auth; update the middleware instantiation where app.use(cors()) appears
to use this allowlist-based options so only known origins can access
authenticated endpoints.

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const JWT_SECRET = "secret123";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Hardcoded JWT_SECRET is a critical security vulnerability.

"secret123" is committed to source control and trivially guessable. Any attacker who knows this value can forge arbitrary tokens for any user ID and role (including admin), bypassing all authentication.

Load it from an environment variable and fail fast at startup if it is absent:

🛡️ Proposed fix
-const JWT_SECRET = "secret123";
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET) {
+  throw new Error("JWT_SECRET environment variable is not set");
+}

Update the export type accordingly (export const JWT_SECRET: string), since after the guard TypeScript will narrow it to string.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const JWT_SECRET = "secret123";
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is not set");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/middleware/auth.ts` at line 4, Replace the hardcoded JWT_SECRET
with a value loaded from process.env and fail fast if missing: read
process.env.JWT_SECRET into the exported JWT_SECRET constant (export const
JWT_SECRET: string) and throw or exit during module initialization when it's
undefined or empty so the app won't start with an insecure default; update any
code that imported the previous value to use the new exported JWT_SECRET string.

Comment on lines +27 to +37
const result = db.prepare(
"INSERT INTO users (email, password, name) VALUES (?, ?, ?)"
).run(email, hash, name);

const token = jwt.sign(
{ id: result.lastInsertRowid, email, role: "member" },
JWT_SECRET,
{ expiresIn: "24h" }
);

res.status(201).json({ token, user: { id: result.lastInsertRowid, email, name, role: "member" } });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's find and examine the auth.ts file to see the code context
if [ -f "backend/src/routes/auth.ts" ]; then
  echo "=== Auth.ts file (lines 20-40) ==="
  sed -n '20,40p' backend/src/routes/auth.ts
fi

# Check what version of better-sqlite3 is installed
echo -e "\n=== Package.json dependencies ==="
if [ -f "package.json" ]; then
  grep -A 10 '"dependencies"' package.json | grep -E '(better-sqlite3|@types/better-sqlite3)'
fi

if [ -f "backend/package.json" ]; then
  grep -A 10 '"dependencies"' backend/package.json | grep -E '(better-sqlite3|@types/better-sqlite3)'
fi

# Search for lastInsertRowid usage in the codebase
echo -e "\n=== lastInsertRowid usage ==="
rg -n "lastInsertRowid" --type ts --type js

Repository: reeder32/teamexpense

Length of output: 1249


🏁 Script executed:

#!/bin/bash
# Check if node_modules has the type definitions
echo "=== Checking for better-sqlite3 types in node_modules ==="
if [ -d "node_modules/@types/better-sqlite3" ]; then
  find node_modules/@types/better-sqlite3 -name "*.d.ts" -type f | head -5
  echo ""
  echo "=== Type definition for RunResult ==="
  grep -A 5 "RunResult" node_modules/@types/better-sqlite3/index.d.ts 2>/dev/null || echo "File not found in expected location"
fi

# Also try to find the declaration file another way
echo -e "\n=== Searching for RunResult type definition ==="
find . -path ./node_modules -prune -o -name "*.d.ts" -type f -print 2>/dev/null | xargs grep -l "RunResult" 2>/dev/null || echo "No RunResult definitions found"

# Check if the type definitions are available
echo -e "\n=== Checking available package.json in node_modules ==="
if [ -f "node_modules/better-sqlite3/package.json" ]; then
  grep -E '"(types|typings)"' node_modules/better-sqlite3/package.json
fi

Repository: reeder32/teamexpense

Length of output: 258


🌐 Web query:

better-sqlite3 RunResult lastInsertRowid type definition

💡 Result:

In better-sqlite3, Statement#run() returns a RunResult (often called the “info” object) whose lastInsertRowid is typed as:

interface RunResult {
  changes: number;
  lastInsertRowid: number | bigint;
}

That number | bigint comes from the TypeScript definitions (@types/better-sqlite3) and reflects whether the statement/database is configured to return “safe integers” (BigInt) or normal JS numbers. [1], [2]

Note: the API docs describe the same field (spelled lastInsertROWID in the docs) as “the rowid of the last row inserted,” and say it should be ignored if the statement didn’t insert rows. [2]

Sources: [1] [2]


🏁 Script executed:

#!/bin/bash
# Check TypeScript configuration
echo "=== TypeScript Config ==="
if [ -f "tsconfig.json" ]; then
  jq '.' tsconfig.json 2>/dev/null || cat tsconfig.json
fi

if [ -f "backend/tsconfig.json" ]; then
  jq '.' backend/tsconfig.json 2>/dev/null || cat backend/tsconfig.json
fi

# Check if there's any type casting or narrowing around lastInsertRowid
echo -e "\n=== Context around lastInsertRowid usage in auth.ts ==="
sed -n '25,40p' backend/src/routes/auth.ts

# Check if jwt.sign has any special handling or type requirements
echo -e "\n=== Checking jwt import and type ==="
grep -n "import.*jwt" backend/src/routes/auth.ts

# Verify the actual behavior of JSON.stringify with bigint
echo -e "\n=== Testing JSON.stringify with bigint behavior ==="
node <<'EOF'
try {
  const result = JSON.stringify({ id: BigInt(1), email: "test" });
  console.log("Result:", result);
} catch (e) {
  console.log("Error:", e.message);
}
EOF

Repository: reeder32/teamexpense

Length of output: 1140


Handle the number | bigint union type for lastInsertRowid.

better-sqlite3's RunResult.lastInsertRowid is typed as number | bigint depending on database configuration. Using it directly in JWT payloads and JSON responses without type narrowing is unsafe—JSON.stringify() will throw "Do not know how to serialize a BigInt" if the value is a bigint. Either assert the type at the database initialization level, explicitly narrow the type before use (e.g., Number(result.lastInsertRowid)), or ensure your database configuration consistently returns number values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/routes/auth.ts` around lines 27 - 37, result.lastInsertRowid can
be number | bigint which will break JSON/JWT serialization; before using it in
jwt.sign and the JSON response convert/narrow it to a safe number type (e.g.,
coerce with Number(result.lastInsertRowid) or validate and toString() for stable
string ids) and use that converted value in jwt.sign(...) and in the user object
returned by res.status(201).json(...); update uses of result.lastInsertRowid in
this block to reference the new narrowed variable.

Comment on lines +25 to +27
fetchSummary();
setInterval(fetchSummary, 30000);
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

setInterval is never cleared — causes timer leak and post-unmount state updates.

The interval ID is discarded, so the timer runs forever. After the component unmounts, fetchSummary will still fire every 30 seconds, trigger API requests, and call setSummary on the unmounted component, producing React warnings.

🐛 Proposed fix
-    fetchSummary();
-    setInterval(fetchSummary, 30000);
-  }, []);
+    fetchSummary();
+    const id = setInterval(fetchSummary, 30000);
+    return () => clearInterval(id);
+  }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fetchSummary();
setInterval(fetchSummary, 30000);
}, []);
fetchSummary();
const id = setInterval(fetchSummary, 30000);
return () => clearInterval(id);
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Dashboard.tsx` around lines 25 - 27, The effect starts a
recurring timer with setInterval(fetchSummary, 30000) but never clears it,
causing fetchSummary to continue running after unmount and potentially calling
setSummary on an unmounted component; update the useEffect in Dashboard.tsx to
store the interval ID returned by setInterval, return a cleanup function that
calls clearInterval on that ID, and optionally guard setSummary in fetchSummary
(or use an AbortController) to avoid state updates after unmount.

Comment on lines +19 to +25
useEffect(() => {
const fetch = async () => {
const { data } = await api.get(`/expenses?page=${page}`);
setExpenses(data.data);
};
fetch();
}, [page]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fetch errors are silently swallowed — the list goes blank with no user feedback.

The async call inside useEffect has no catch. An API failure leaves expenses empty and the user sees no indication of what went wrong.

🔧 Proposed fix
+  const [error, setError] = useState<string | null>(null);
+
   useEffect(() => {
-    const fetch = async () => {
-      const { data } = await api.get(`/expenses?page=${page}`);
-      setExpenses(data.data);
+    const loadExpenses = async () => {
+      try {
+        const { data } = await api.get(`/expenses?page=${page}`);
+        setExpenses(data.data);
+      } catch (err: any) {
+        setError(err.response?.data?.error || "Failed to load expenses");
+      }
     };
-    fetch();
+    loadExpenses();
   }, [page]);

Then render the error in the JSX:

+      {error && <p role="alert" style={{ color: "red" }}>{error}</p>}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const fetch = async () => {
const { data } = await api.get(`/expenses?page=${page}`);
setExpenses(data.data);
};
fetch();
}, [page]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadExpenses = async () => {
try {
const { data } = await api.get(`/expenses?page=${page}`);
setExpenses(data.data);
} catch (err: any) {
setError(err.response?.data?.error || "Failed to load expenses");
}
};
loadExpenses();
}, [page]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/ExpenseList.tsx` around lines 19 - 25, The useEffect's
inner async fetch (the anonymous const fetch) swallows API failures so the UI
shows a blank list; wrap the api.get(`/expenses?page=${page}`) call in a
try/catch, call setExpenses only on success and set an error state (e.g., add a
useState like const [error, setError]) in the catch to store a human-friendly
message or the error detail, and update the JSX to render that error when error
is non-empty; reference the existing useEffect, the inner fetch function,
api.get and setExpenses when making these changes.

Comment on lines +45 to +55
<div
onClick={() => handleDelete(exp.id)}
style={{
marginLeft: 12,
color: "red",
cursor: "pointer",
padding: "4px 8px",
}}
>
Delete
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Delete trigger is a <div> — keyboard users cannot reach or activate it.

A <div onClick> is not a focusable element; keyboard-only users and assistive technologies cannot interact with it.

♿ Proposed fix
-            <div
-              onClick={() => handleDelete(exp.id)}
-              style={{
-                marginLeft: 12,
-                color: "red",
-                cursor: "pointer",
-                padding: "4px 8px",
-              }}
-            >
-              Delete
-            </div>
+            <button
+              onClick={() => handleDelete(exp.id)}
+              aria-label={`Delete expense ${exp.id}`}
+              style={{
+                marginLeft: 12,
+                color: "red",
+                cursor: "pointer",
+                padding: "4px 8px",
+                background: "none",
+                border: "none",
+              }}
+            >
+              Delete
+            </button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/ExpenseList.tsx` around lines 45 - 55, Replace the
non-focusable clickable <div> with an accessible <button> in ExpenseList.tsx so
keyboard and assistive tech can activate it; use the existing onClick handler
(handleDelete(exp.id)), add type="button" and an explicit aria-label (e.g.,
`aria-label={`Delete expense ${exp.id}`}`), and preserve visual styling by
moving the inline styles (marginLeft, color: "red", cursor, padding) to the
button (or to CSS) while removing default button chrome (e.g., border: "none",
background: "transparent") as needed.

Comment on lines +15 to +16
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

PII (user object including email) persisted in localStorage.

localStorage is accessible to any JS on the page and is not cleared on tab/window close. Store only the minimum needed (e.g., role, name) — not the full user object with email — or derive what you need from the JWT payload instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Login.tsx` around lines 15 - 16, Remove persistence of the
full user object to localStorage: stop calling localStorage.setItem("user",
JSON.stringify(data.user)) and instead store only non-PII attributes you truly
need (e.g., role, displayName) or derive claims from the JWT token saved as
"token" via localStorage.setItem("token", data.token); update the Login
component's success handler to extract allowed fields from data.user (or decode
the JWT) and persist only those minimal safe values, and ensure any code reading
localStorage uses the new keys and does not expect the full user object.

Comment on lines +27 to +49
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
style={{ width: "100%", padding: 8 }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
style={{ width: "100%", padding: 8 }}
/>
</div>
<button type="submit" style={{ width: "100%", padding: 10 }}>
Log In
</button>
</form>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Form inputs are inaccessible: missing <label> elements, required attributes, and an role="alert" on the error.

Screen readers cannot identify the inputs without visible or visually-hidden labels. The error paragraph is also silent to assistive technology without role="alert".

♿ Proposed fix
-      {error && <p style={{ color: "red" }}>{error}</p>}
+      {error && <p role="alert" style={{ color: "red" }}>{error}</p>}
       <form onSubmit={handleSubmit}>
         <div style={{ marginBottom: 12 }}>
+          <label htmlFor="email" style={{ display: "block", marginBottom: 4 }}>Email</label>
           <input
+            id="email"
             type="email"
             value={email}
             onChange={(e) => setEmail(e.target.value)}
             placeholder="Email"
+            required
             style={{ width: "100%", padding: 8 }}
           />
         </div>
         <div style={{ marginBottom: 12 }}>
+          <label htmlFor="password" style={{ display: "block", marginBottom: 4 }}>Password</label>
           <input
+            id="password"
             type="password"
             value={password}
             onChange={(e) => setPassword(e.target.value)}
             placeholder="Password"
+            required
             style={{ width: "100%", padding: 8 }}
           />
         </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Login.tsx` around lines 27 - 49, Add accessible labels,
required attributes, and an alert role for errors: give the email and password
inputs associated <label> elements (use htmlFor/id pairs like id="email" and
id="password" referenced by labels) or visually-hidden labels, add required to
both inputs, and ensure the error display (the component/element rendered when
validation/auth fails near handleSubmit) includes role="alert" so screen readers
announce it; update the JSX around handleSubmit, the email/password stateful
inputs, and the error paragraph/element accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant