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:
handleLoginAsUserappends the impersonated user's live auth token directly to thewindow.openURL 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 HTTPRefererheader 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),
handleLoginAsUsersends 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
/webauthenticatewith 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: trueflag is already sent in the request body. The backend should branch onsocial === trueand 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, 178andsrc/pages/MyProfile.tsx:78, 104, 107, 124, 143, 237, 251-293(25 logs in MyProfile alone), plussrc/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 aJSON.stringifyof the complete authentication response — to the browser console. Theconsole.log("login - Full response structure:", JSON.stringify(data, null, 2))atAuthContext.tsx:105prints 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 loglines before merging to any environment. Add an ESLint rule (no-console) withwarnlevel 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:
ProtectedRoutecontains auseEffectthat always callscheckAuth(). On mount,isLoadingisfalseandisAuthenticatedreflects whatever theAuthContexthas from its own startup check. The component renders the spinner only ifisLoadingis true. ButAuthContext.checkAuthdoes not setisLoading = trueat the start of the verification — onlyfalseat the end. If a secondProtectedRoutefires a redundantcheckAuth()call (e.g., by navigating between protected pages),isLoadingis alreadyfalse, the route renders children immediately, then fires a silent re-verification that on failure would callremoveAuthToken()and setuser = null— which flipsisAuthenticatedtofalsemid-session and triggers a logout. - Fix: Remove the
useEffectfromProtectedRouteentirely. The top-levelAuthProvideralready callscheckAuth()on mount.ProtectedRouteshould only inspectisLoadingandisAuthenticatedfrom context — not re-trigger verification.
H4. fetchPersonnel fires one API request per keypress — no debounce¶
- File:
src/pages/Personnel.tsx:194-253and284-287 - What:
handleSearchChangesetssearchTermdirectly, which is in theuseCallbackdependency array offetchPersonnel(line 252).fetchPersonnelis in the dependency array of theuseEffectat line 268-271. Result: every character typed triggers a full API call to/getPersonnel/{page}. - Compare:
Accounts.tsxandAffiliateMarketing.tsxcorrectly use auseRef(debounce(...))pattern with a separatedebouncedSearchTermstate.Personnel.tsxskipped this pattern entirely. - Fix: Replicate the
debouncedSearchFn = useRef(debounce(...)).currentpattern fromAccounts.tsx. KeepsearchTermfor the controlled input and a separatedebouncedSearchTerminfetchPersonnel'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:
exportToCSVbuilds its data array using.map()on accounts. For the "Landing Page" column (index 4), the code conditionally returns a<Badge ...>JSX element whenlanding_Page === "Incomplete". WhenString(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 callsfetch(fullUrl, options)with noAbortSignalor 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
AbortControllerwith a timeout:const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 30_000); options.signal = controller.signal;and clear the timeout in afinallyblock.
M3. deletedBy hardcoded to 1 instead of using the logged-in admin's ID¶
- Files:
src/pages/Personnel.tsx:308andsrc/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. Theuserobject is already available viauseAuth()inAccounts.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-105andsrc/services/api.ts:124-125 - What:
request('/getAffMarketingDetails${page}', "GET")andrequest('/getAlltAccountManagers${page}', "GET")concatenate the page number directly into the path string. SimilarlygetUserDetail(line 154) andgetCities(line 183). These also have a double typo ingetAlltAccountManagers(extrat). - Why it matters: If
pageorcountryIdwere ever user-supplied (they aren't currently, but the pattern is risky), path traversal would be trivial. Even as-is, aNaNpage 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.idis missing from the auth context, the component callsapi.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()setsuser.idtouserId || ""(empty string) when the/meresponse doesn't match any of the six field names tried (id,user_id,memberId,userId). An empty string inuser.idis truthy only in the wrong direction —!!useristruebutuser.idis 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/loginwith the actual/meresponse shape. Onceuser.idis 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 onAffiliatedUserDisplayafter the mapping step (the originalmemberIdis discarded). Using index as key means React cannot track row identity on re-renders if the list is reordered or partially updated. - Fix: Retain
memberIdinAffiliatedUserDisplayand 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.