Skip to content

admin_console_R — code inspection

Audit date: 2026-05-17. Lens: concrete code-level issues not already covered by someli-doc/audit/admin_console_R/security.md and friends.

Summary

Eleven findings: 1 Critical, 4 High, 3 Medium, 3 Low. The Critical is a live customer session token appended as a plain URL query parameter in the "Login as User" impersonation flow — it leaks into browser history, server access logs, and any subsequent Referer header. High findings cover: a hardcoded bcrypt hash used as a fallback password in that same impersonation call; 68 console.log calls in production code printing full API responses with PII; a ProtectedRoute that calls checkAuth() on every protected-page mount causing a visible flash-of-unauthenticated-content on fast tab switches; and a fetchPersonnel callback that fires one API request per keypress (no debounce, only searchTerm in state). Medium findings include: JSX element placed in a CSV data array that stringifies to [object Object]; no request timeout on any fetch call; and deletedBy: 1 hardcoded instead of using the logged-in admin's ID. No role-based route gating exists anywhere in the frontend.

Critical

C1. Customer session token exposed in URL query parameter

  • File: src/pages/Accounts.tsx:543-554
  • What: handleLoginAsUser appends the impersonated user's live auth token directly to the window.open URL as a plain query parameter (?token=...).
  • Why it matters: The token lands in: (1) the browser's address bar and navigation history, (2) every web server / CDN / reverse-proxy access log for the destination host (ENV.APP_URL), (3) the HTTP Referer header on any link or resource the impersonated session subsequently loads, and (4) browser extensions running on the opened tab. Because tokens have no server-side expiry (documented platform-wide), a leaked token gives indefinitely long access to the customer account. Any admin staff member's browser history or any proxy log = full list of customer sessions ever impersonated.
  • Fix: Pass the token in a short-lived, server-generated one-time-code instead. The admin console calls /webauthenticate, receives a short-lived "handoff token" valid for ~30 seconds, passes only that code in the URL. The target app exchanges the code for a real session. Alternatively, POST the token into a server-side session store and pass only the session ID. Never put a bearer token in a URL.

High

H1. Hardcoded bcrypt hash as fallback password in impersonation request

  • File: src/pages/Accounts.tsx:528
  • What: When impersonating a social-login user (who has no password stored), handleLoginAsUser sends a hardcoded bcrypt hash "$2a$10$C3JlPSbVkMLNOOafNQWedaeOiS" as the password to /webauthenticate.
  • Why it matters: This is a partial bcrypt hash (22-char base-64 salt only, no actual digest, so it is not a valid bcrypt string). The back-end either ignores it or matches it loosely. Either way, the FE is constructing a fake credential and sending it to an unauthenticated endpoint. If the backend validates this hash against anything, any attacker who knows the hash can call /webauthenticate with any social-login account's email and this fixed hash to obtain a valid session token — without admin access. The hash is now public via this repo.
  • Fix: The social: true flag is already sent in the request body. The backend should branch on social === true and skip password validation entirely. Remove the hardcoded hash from the frontend.

H2. 68 console.log calls ship to production, several printing full API responses

  • Files: src/context/AuthContext.tsx:56, 59, 104, 105, 144, 155, 162, 176, 178 and src/pages/MyProfile.tsx:78, 104, 107, 124, 143, 237, 251-293 (25 logs in MyProfile alone), plus src/pages/Accounts.tsx:769, 784, 795, 812
  • What: Every login, checkAuth, and profile load prints the full API response — including the raw JWT token, user ID, email, and a JSON.stringify of the complete authentication response — to the browser console. The console.log("login - Full response structure:", JSON.stringify(data, null, 2)) at AuthContext.tsx:105 prints the entire login response including the token.
  • Why it matters: Browser extensions, browser-based dev tools shared in screenshares, and any XSS payload can trivially harvest these logs. This is the admin console — every session is a high-value internal staff account. On MyProfile, console.log("All localStorage keys:", Object.keys(localStorage)) at line 293 maps the entire storage surface to an attacker.
  • Fix: Remove all // Debug log lines before merging to any environment. Add an ESLint rule (no-console) with warn level so future additions are caught in CI.

H3. ProtectedRoute calls checkAuth() on every mount, creating a flash-of-content window

  • File: src/components/auth/ProtectedRoute.tsx:13-19
  • What: ProtectedRoute contains a useEffect that always calls checkAuth(). On mount, isLoading is false and isAuthenticated reflects whatever the AuthContext has from its own startup check. The component renders the spinner only if isLoading is true. But AuthContext.checkAuth does not set isLoading = true at the start of the verification — only false at the end. If a second ProtectedRoute fires a redundant checkAuth() call (e.g., by navigating between protected pages), isLoading is already false, the route renders children immediately, then fires a silent re-verification that on failure would call removeAuthToken() and set user = null — which flips isAuthenticated to false mid-session and triggers a logout.
  • Fix: Remove the useEffect from ProtectedRoute entirely. The top-level AuthProvider already calls checkAuth() on mount. ProtectedRoute should only inspect isLoading and isAuthenticated from context — not re-trigger verification.

H4. fetchPersonnel fires one API request per keypress — no debounce

  • File: src/pages/Personnel.tsx:194-253 and 284-287
  • What: handleSearchChange sets searchTerm directly, which is in the useCallback dependency array of fetchPersonnel (line 252). fetchPersonnel is in the dependency array of the useEffect at line 268-271. Result: every character typed triggers a full API call to /getPersonnel/{page}.
  • Compare: Accounts.tsx and AffiliateMarketing.tsx correctly use a useRef(debounce(...)) pattern with a separate debouncedSearchTerm state. Personnel.tsx skipped this pattern entirely.
  • Fix: Replicate the debouncedSearchFn = useRef(debounce(...)).current pattern from Accounts.tsx. Keep searchTerm for the controlled input and a separate debouncedSearchTerm in fetchPersonnel's dep array.

Medium

M1. JSX element placed in CSV data array — renders as [object Object] in exported file

  • File: src/pages/Accounts.tsx:695-701
  • What: exportToCSV builds its data array using .map() on accounts. For the "Landing Page" column (index 4), the code conditionally returns a <Badge ...> JSX element when landing_Page === "Incomplete". When String(cell) is called on a React element at line 710, it produces "[object Object]" in the CSV.
  • Why it matters: Every admin who exports an account list with any incomplete-onboarding accounts gets silently corrupted data in that column.
  • Fix: Replace the JSX branch with a plain string: account.landing_Page === "Incomplete" ? "Incomplete (onboarding)" : (account.landing_Page || "").

M2. No request timeout on any fetch call

  • File: src/services/api.ts:49-72
  • What: The request() function calls fetch(fullUrl, options) with no AbortSignal or timeout. An unresponsive backend will hang the request indefinitely, blocking the UI spinner forever.
  • Why it matters: In an admin tool used by support staff, a hung request means a table that never stops loading and cannot be retried without a full page refresh. This is especially impactful for the accounts page which loads on every navigation.
  • Fix: Add an AbortController with a timeout: const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 30_000); options.signal = controller.signal; and clear the timeout in a finally block.

M3. deletedBy hardcoded to 1 instead of using the logged-in admin's ID

  • Files: src/pages/Personnel.tsx:308 and src/pages/Accounts.tsx:595
  • What: Both delete endpoints are called with deletedBy: 1, which is a fixed member ID (presumably a seed admin) rather than the ID of the staff member performing the deletion.
  • Why it matters: The backend audit log for every deletion will attribute the action to member ID 1 rather than the actual operator. Forensic investigation of destructive actions becomes impossible. This is especially significant in an admin console where deletions are high-value events.
  • Fix: Use the logged-in admin's ID: deletedBy: Number(user?.id) || Number(localStorage.getItem("useid")) || 0. The user object is already available via useAuth() in Accounts.tsx (just not imported yet in that component — add it).

Low / nits

L1. getAffMarketingDetails and getAllAccountManagers build URLs by string concatenation, not path segments

  • File: src/services/api.ts:104-105 and src/services/api.ts:124-125
  • What: request('/getAffMarketingDetails${page}', "GET") and request('/getAlltAccountManagers${page}', "GET") concatenate the page number directly into the path string. Similarly getUserDetail (line 154) and getCities (line 183). These also have a double typo in getAlltAccountManagers (extra t).
  • Why it matters: If page or countryId were ever user-supplied (they aren't currently, but the pattern is risky), path traversal would be trivial. Even as-is, a NaN page produces a nonsensical URL that silently returns unexpected data.
  • Fix: Pass numeric identifiers as path segments with explicit /: \/getAffMarketingDetails/${page}`(already done inconsistently forgetAffiliatedUsersat line 118). Fix thegetAlltAccountManagers` typo.

L2. MyProfile searches for the logged-in user by email via a user-search endpoint

  • File: src/pages/MyProfile.tsx:258-285
  • What: If user.id is missing from the auth context, the component calls api.searchUser(userEmail, "email") — a general user-search endpoint — to find the logged-in user's own ID. This is a workaround for a broken auth flow and exposes internal user-search to be used as a profile-ID lookup.
  • Why it matters: The root cause is that checkAuth() sets user.id to userId || "" (empty string) when the /me response doesn't match any of the six field names tried (id, user_id, memberId, userId). An empty string in user.id is truthy only in the wrong direction — !!user is true but user.id is falsy, making the component fall back to the search workaround on every load until the auth response shape is fixed.
  • Fix: Align the field names in checkAuth/login with the actual /me response shape. Once user.id is reliably populated, delete the search-by-email fallback.

L3. AffiliateMarketing table: affiliated-users list uses array index as key

  • File: src/pages/AffiliateMarketing.tsx:681
  • What: affiliatedUsers.map((user, index) => (<TableRow key={index}>...)). There is no unique ID on AffiliatedUserDisplay after the mapping step (the original memberId is discarded). Using index as key means React cannot track row identity on re-renders if the list is reordered or partially updated.
  • Fix: Retain memberId in AffiliatedUserDisplay and use it as the key: key={user.memberId}.

Cross-cutting observations

No frontend role-based access control. ProtectedRoute checks only isAuthenticated — it does not inspect user.role. All five protected routes are equally accessible to any authenticated user regardless of role. ENV.SUPER_ADMIN, ENV.ADMIN, ENV.ACCOUNT_MANAGER, ENV.DEVELOPER, and ENV.DESIGNER are defined in src/config/env.ts but never referenced in any component or route guard. Whether the backend enforces role restrictions on its endpoints is not visible from this repo, but the frontend provides no layered defense.

No abort on async effects anywhere. None of the useEffect-triggered fetch calls in Accounts.tsx, AffiliateMarketing.tsx, CustomerService.tsx, or MyProfile.tsx use AbortController with a cleanup return. Rapid navigation (e.g., open Accounts, immediately switch to Personnel) leaves inflight requests that will call setState on unmounted components in older React versions, and silently overwrite state in React 18's concurrent mode if a second render cycle resolves in a different order.

api.ts generics are decoration only. Every named method casts to Promise<T> via as Promise<T>, not by returning a typed response. The underlying request() returns Promise<any>. TypeScript does not catch mismatched response shapes at call sites because the cast suppresses inference. Switching to proper typed handleResponse<T>() would make the type system actionable.

Source maps. vite.config.ts has no build.sourcemap setting. Vite defaults to false for production builds, which is correct. No action needed, but worth verifying the deploy artifact confirms this.