Implement full stack admin dashboard#644
Conversation
📝 WalkthroughWalkthroughAdds a full admin dashboard: backend FastAPI router at /api/admin with role-aware Bearer auth (GitHub JWT or legacy API key), audit logging persisted to PostgreSQL, admin read/write endpoints for bounties, contributors, review pipeline, financials, payouts, system health, and audit log. Frontend adds an /admin route, AdminLayout, panels (overview, bounties, contributors, reviews, financial, health, audit-log), React Query hooks, a WebSocket hook for real-time events, TypeScript admin types, and end-to-end tests; an Alembic migration and SQLAlchemy model for admin audit logs were added. Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 30
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
backend/app/main.py (1)
375-390:⚠️ Potential issue | 🟠 MajorSecurity gap:
/api/syncendpoint lacks authentication despite triggering admin-level operations.The
/api/syncendpoint (lines 379-391) has no authentication protection, unlike the admin router routes which are guarded byADMIN_API_KEYBearer token validation. The endpoint is tagged["admin"]in OpenAPI but this provides only metadata documentation—no actual access control. The docstring itself acknowledges: "This endpoint should be protected by admin authentication in production."The endpoint directly triggers
sync_all(), which rehydrates all bounty and contributor data from the GitHub Issues API. Without authentication, an attacker can:
- Trigger repeated syncs to cause denial-of-service
- Race with legitimate operations during data rehydration
The endpoint should be moved into the admin router (which applies the
verify_admin_keydependency) or have that dependency applied directly to the function.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/main.py` around lines 375 - 390, The /api/sync endpoint is unprotected and directly calls sync_all(), so either move the trigger_sync function into the existing admin_router or apply the admin auth dependency to it; specifically, wrap or register trigger_sync with the same admin protection used elsewhere (the verify_admin_key dependency/Bearer token) or add the dependency parameter to the trigger_sync route decorator so it uses verify_admin_key, or relocate the function into admin_router so it inherits the router's ADMIN_API_KEY validation before calling sync_all().
🤖 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/app/api/admin.py`:
- Around line 35-36: The in-memory _audit_log deque (and its _log call sites in
admin router) is insufficient; persist every admin action (including read-only
endpoints like /overview, /contributors, /financial/*, /audit-log) to a durable,
queryable store instead of or in addition to the process-local deque. Add a new
Audit DB table/entity and update the functions that call _log (and
backend/app/core/audit.py’s logger.info sink) to write a normalized record
(timestamp, actor id, endpoint/action, resource identifiers, input parameters,
result/status) to that table; ensure all admin route handlers invoke the audit
routine (including read-only handlers) and change the /api/admin/audit-log
implementation to query the DB for paginated, filterable results so history
survives restarts and multi-worker deployments.
- Around line 356-385: Several admin mutation endpoints are returning the "ok"
field inconsistently (some as boolean True, others as the string "true"); update
them to always return a boolean. Specifically, in update_bounty_admin ensure the
response uses {"ok": True} (it already does), and find every admin mutation that
currently returns {"ok": "true"} and change those to {"ok": True} so the API
contract for the "ok" field is consistently a JSON boolean across all admin
mutation handlers.
- Around line 650-680: The health payload currently ignores redis_status and
ws_count when computing overall status and reports an irrelevant
webhook_events_processed from _audit_log; update the SystemHealthResponse
construction in the health endpoint so overall status is "healthy" only if
db_status, redis_status, and websocket (ws_count) are healthy/connected, include
redis and websocket states in the services dict (e.g., "redis": redis_status,
"websocket": "connected"/"disconnected" or ws_count), and stop using
len(_audit_log) as webhook_events_processed—either replace it with a real GitHub
webhook metric/source (e.g., a proper github webhook queue/counter) or
remove/rename the field to reflect admin audit size; adjust the code paths
around redis_status, ws_count, _audit_log and the SystemHealthResponse call
(reference symbols: redis_status, db_status, ws_manager/ws_count, _audit_log,
SystemHealthResponse, webhook_events_processed) so the reported health
accurately reflects realtime infrastructure state.
- Around line 32-74: The current shared-secret approach using ADMIN_API_KEY read
at import time and the require_admin dependency (plus _security) reduces every
caller to the synthetic actor "admin", preventing per-user roles, revocation,
rotation without restart, and meaningful audit entries in _audit_log; replace
this with GitHub OAuth / token-based auth: remove reliance on a single
process-wide ADMIN_API_KEY, implement a new dependency (replace require_admin)
that validates incoming Bearer tokens against GitHub/OIDC (or your configured
identity provider) using JWKS caching, extracts the real user identifier and
role (admin/reviewer/viewer) into the returned value (e.g., return a tuple or
object with actor_id and role), enforce role checks where needed, and ensure
audit writes use that actor_id instead of the literal "admin"; also support
configurable revocation/rotation by not baking secrets at import time (read
keys/jwks metadata dynamically or from a reloadable cache) and add clear errors
for misconfiguration (service unavailable) versus authorization failures.
- Around line 279-281: The code is conflating BountyStatus.COMPLETED with
disbursed payments; update the aggregation logic so only BountyStatus.PAID
counts as disbursed and COMPLETED counts as completed-but-unpaid (pending).
Specifically, change the sum that assigns total_fndry / total_fndry_paid (and
any other aggregations referencing BountyStatus.COMPLETED) to include
BountyStatus.PAID only for "paid" totals, move BountyStatus.COMPLETED into the
pending/payout calculations (e.g., pending_payouts, payout_history generation),
and audit the functions/variables total_fndry, total_fndry_paid, pending_payouts
and payout_history to ensure consistency with that contract. Ensure all filters
over the bounties list use BountyStatus.PAID when computing distributed money
and use BountyStatus.COMPLETED (not PAID) for items awaiting payout.
- Around line 168-173: BountyAdminUpdate.status is currently an unrestricted
string allowing invalid lifecycle values to be persisted to bounty.status;
change the Pydantic model to use the BountyStatus enum (or a constraining
validator) instead of str so FastAPI/Pydantic will reject unknown values, and
update the update_bounty_admin handler to accept only BountyStatus (or
validate/convert the incoming value) before assigning to bounty.status to
prevent typos/unsupported states from being stored and to keep downstream
status-based branches consistent; reference the BountyAdminUpdate model, the
BountyStatus enum, and the update_bounty_admin function/bounty.status assignment
when making the change.
- Around line 598-616: The payout history is built from bounty creation fields
(paid_bounties, getattr(b, "created_at"), getattr(b, "created_by")) which yields
incorrect recipient and timestamp; update the construction and sorting of
PayoutHistoryItem to source from the actual payout record fields (e.g. use
payout.completed_at or payout.created_at from the payout object, and
payout.recipient_wallet or payout.recipient instead of bounty.created_by),
change the sort key lambda to use the payout's completion timestamp, and ensure
PayoutHistoryItem.winner and completed_at are populated from the payout metadata
rather than the bounty metadata.
In `@backend/tests/test_admin.py`:
- Around line 302-308: Add a new test class TestSystemHealth with a test method
test_returns_healthy_status that uses the existing client fixture and
AUTH_HEADER to call GET "/api/admin/system/health" and assert the response
shape: check that data["status"] is one of ("healthy", "degraded") and that keys
"uptime_seconds" and "services" are present; ensure you name the test function
test_returns_healthy_status and reference the same AUTH_HEADER and client
fixtures used elsewhere so it integrates with the test suite.
- Around line 196-197: The PATCH handler accepts arbitrary status strings;
change the request model BountyAdminUpdate.status from Optional[str] to
Optional[BountyStatus] so Pydantic enforces valid enum values, or add explicit
validation in the PATCH handler to convert/validate incoming status against the
BountyStatus enum before assigning (rejecting invalid values), and ensure
updates mirror the POST /close behavior (use BountyStatus.CANCELLED rather than
raw strings).
In `@frontend/src/__tests__/AdminDashboard.test.tsx`:
- Around line 312-323: Add a new test that mocks adminData.useReviewPipeline to
return populated data (use noopQuery() as in the zero-state test) and assert
that ReviewPipeline renders rows for active reviews; specifically, create an
active item matching the component's expected shape (e.g., submission_id 's1')
and then render <ReviewPipeline/> inside Wrapper and assert
screen.getByTestId('review-row-s1') (or the component's actual test id for a
review row) is present to verify rendering of active review rows.
- Around line 1-379: The FinancialPanel component is missing test coverage; add
tests in AdminDashboard.test.tsx that mock adminData.useFinancialOverview and
adminData.usePayoutHistory to cover loading states, empty and populated data,
and pagination interactions. Specifically, add tests that: (1) mock
useFinancialOverview and usePayoutHistory returning isLoading=true and assert
skeleton/UI loading indicators; (2) return an empty payouts data set and assert
the "no payouts" or empty state message; (3) return populated overview data
(summary cards) and populated payout rows and assert key texts/rows exist; and
(4) simulate clicking pagination buttons (e.g., next/prev page controls rendered
by FinancialPanel) and verify the component requests/updates page state or that
the UI updates accordingly; reference FinancialPanel, useFinancialOverview,
usePayoutHistory, and any testids like payout-row-*, payout-pagination-next,
payout-pagination-prev to locate elements.
In `@frontend/src/App.tsx`:
- Around line 149-160: AdminRoutes currently registers <Route path="/admin"
element={<AdminPage />} /> which doesn't match the parent route's "/admin*"
wildcard; update the routing so they are consistent: either change the
AdminRoutes route to use a catch-all (e.g., Route path="/admin/*"
element={<AdminPage />}) so /admin/, /admin/anything also resolve, or remove the
wildcard from the parent and keep the single "/admin" route; locate the admin
routing in the AdminRoutes function and adjust the Route path to match the
parent route behavior.
In `@frontend/src/components/admin/AdminLayout.tsx`:
- Around line 36-88: AdminLoginForm currently accepts any non-empty key and
calls onSuccess immediately in handleSubmit, causing the UI to appear
authenticated even for invalid tokens; update handleSubmit to set the token via
setAdminToken(key.trim()), then perform a lightweight authenticated request
(e.g., GET /api/admin/overview) using the provided token, only calling
onSuccess() if the response is ok; on failure, call clearAdminToken(),
setError('Invalid API key'), and avoid calling onSuccess so the UI stays locked;
make these changes inside the handleSubmit function referenced in
AdminLoginForm.
- Around line 94-100: AdminLayout calls useAdminWebSocket() before checking
authed which triggers websocket reconnections when unauthenticated; fix by
restructuring so hooks run only after auth — e.g., keep AdminLayout to manage
authed and return <AdminLoginForm onSuccess={() => setAuthed(true)} /> when not
authed, then move the authenticated UI (the code that uses useAdminWebSocket(),
wsStatus, active, onNavigate, children) into a new child component (e.g.,
AdminAuthenticatedView) that is rendered only when authed; this ensures
useAdminWebSocket() is invoked inside the authenticated component and not
conditionally inside a single component.
In `@frontend/src/components/admin/AuditLogPanel.tsx`:
- Line 75: The error message in the AuditLogPanel component is missing an
accessibility role; update the JSX that renders the error (the conditional
rendering of error in AuditLogPanel) to include role="alert" on the <p> element
so screen readers announce it (keep the existing className and message
expression ((error as Error).message) intact).
- Around line 87-91: Replace the array-index key used in the map in
AuditLogPanel (the data.entries.map callback where key={i}) with a stable unique
identifier from each entry (e.g., entry.id or a composed stable key like
`${entry.timestamp}-${entry.event}`) so React can correctly track rows when new
audit entries are prepended; update the key prop in the <div> returned by the
map to use that stable identifier and fall back to a deterministic composite key
only if no server-assigned id is available.
In `@frontend/src/components/admin/BountyManagement.tsx`:
- Line 190: The error message paragraph in the BountyManagement component is
missing an accessibility alert role; update the conditional render that shows
the error (the JSX expression rendering {(error as Error).message}) to include
role="alert" on the <p> element so screen readers announce it (i.e., change the
existing <p className="text-sm text-red-400"> to include role="alert" while
keeping the className and message rendering intact).
- Around line 108-115: The Force Close button currently calls handleClose
directly and is too easy to trigger; change the UI to require explicit
confirmation before invoking handleClose: implement either a two-step arming
flow (replace the single button with a stateful control in BountyManagement that
toggles an "armed" boolean on first click and only calls handleClose on the
second click, updating the button label to "Confirm Close" and adding a short
timeout to disarm), or open a ConfirmationModal component when the button is
clicked that shows explicit warning text and a final "Confirm" button which then
calls handleClose; also ensure the disabled/ pending behavior still reflects
close.isPending and apply a stronger visual treatment (e.g., red
outline/animated pulse) while armed or in modal to emphasize danger.
- Around line 41-44: The Force Close flow currently calls close.mutateAsync in
handleClose but doesn't surface errors to the user; wrap the close.mutateAsync
call in a try/catch (or check close.isError) and only call onClose() when the
mutation succeeds, and surface the error message in the modal similarly to the
update block by rendering {(close.error as Error).message} (or a local error
state) where update.isError is shown; reference handleClose and
close.mutateAsync and ensure the modal remains open on failure so the user sees
the error.
- Around line 30-39: The save handler (handleSave) currently only checks
!isNaN(rewardNum) so zeros, negatives and out-of-range values can be submitted;
update handleSave to validate rewardNum is a finite positive integer > 0 (and
optionally enforce an upper bound consistent with backend limits) before adding
patch.reward_amount, and if validation fails do NOT call
update.mutateAsync/onClose but instead set or show a user-visible validation
error (e.g., via existing component state/error UI or add a rewardError state)
so the user can correct the input.
In `@frontend/src/components/admin/ContributorManagement.tsx`:
- Around line 19-23: The handleBan function currently awaits ban.mutateAsync({
id: contributor.id, reason: reason.trim() }) without a try/catch, which can
surface an unhandled promise rejection and prevent onClose from running; wrap
the mutateAsync call in a try/catch inside handleBan (keeping the existing early
return on short reason) so that onClose() is only called on success and any
thrown error is swallowed (the UI will still show ban.isError/ban.error),
preventing console unhandled rejection logs.
- Around line 199-206: The shared mutation instance unban causes unban.isPending
to disable every "Unban" button; fix by tracking pending state per contributor:
add a local state (e.g., unbanning: Set<string>) and create a wrapper handler
(e.g., handleUnban) that adds the id to the set before calling
unban.mutateAsync(id) and removes it in finally, then use unbanning.has(id) to
disable only that row's button; keep using the existing unban mutation for the
API call but switch button onClick to call handleUnban(c.id) and disabled to
check the per-id state.
In `@frontend/src/components/admin/FinancialPanel.tsx`:
- Around line 11-130: The component currently ignores API errors from
useFinancialOverview and usePayoutHistory, leaving the UI blank on failures; add
error captures (e.g. ovError and payError from useFinancialOverview and
usePayoutHistory respectively) and render a shared error row/message (like in
ReviewPipeline.tsx) when either error exists, showing the error message or a
fallback string and ensuring loading skeletons are still shown while isLoading
is true; update the hook destructuring to include error and add a conditional
render near the top of the payout/overview sections that displays (ovError as
Error)?.message || (payError as Error)?.message || 'Failed to load financial
data'.
In `@frontend/src/components/admin/ReviewPipeline.tsx`:
- Around line 88-96: Validate and sanitize r.pr_url before rendering to prevent
javascript:/data: XSS: add a URL-safety check (e.g., an isSafeUrl function that
tests /^https?:\/\//i) and use it in the ReviewPipeline render branch so that
only http/https URLs are rendered into the <a href> and any non-safe value falls
back to a non-clickable placeholder (e.g., a span or masked text); update the
block that currently renders r.pr_url to conditionally render the anchor only
when isSafeUrl(r.pr_url) is true and otherwise render the safe fallback.
In `@frontend/src/components/admin/SystemHealth.tsx`:
- Line 60: In SystemHealth.tsx update the error message rendering so it includes
an ARIA alert role: when rendering the error (the JSX that currently uses
{(error as Error).message} inside a <p>), add role="alert" to the element
(matching the pattern used in OverviewPanel.tsx) to ensure screen readers
announce the message; keep the existing className and message expression but
include the role attribute on the <p> that displays the error.
- Around line 19-36: MetricCard and fmtUptime are duplicates of StatCard and its
uptime formatter in OverviewPanel; extract MetricCard (the React component) and
fmtUptime (the formatter function) into a shared admin component/module, export
them, then replace the local definitions in SystemHealth (remove MetricCard and
fmtUptime) and import the shared MetricCard and fmtUptime where needed; ensure
the exported MetricCard prop types ({ label, value, sub }) and fmtUptime
signature (s: number) remain unchanged so callers like SystemHealth and
OverviewPanel continue to work without further changes.
In `@frontend/src/hooks/useAdminData.ts`:
- Around line 29-35: The setAdminToken function currently treats whitespace-only
strings as valid because it checks token truthiness; change it to trim the input
first and use the trimmed value when storing or deciding to remove the key so
whitespace-only tokens are rejected. Locate setAdminToken and STORAGE_KEY and
update the logic to call token.trim(), store the trimmed token when non-empty,
otherwise remove STORAGE_KEY from sessionStorage.
In `@frontend/src/hooks/useAdminWebSocket.ts`:
- Around line 31-83: The connect closure captures a stale onEvent because
connect is memoized with onEvent in its deps but the effect that calls connect
is not updated; fix by decoupling the callback reference: create an onEventRef
via useRef and update it in a short effect (useEffect(() => { onEventRef.current
= onEvent }, [onEvent])), then change ws.onmessage inside connect to call
onEventRef.current?.(event) instead of onEvent, and remove onEvent from the
connect useCallback dependencies so connect remains stable for the existing
effect and reconnect logic.
- Around line 40-42: The current code in useAdminWebSocket embeds the admin
token in the WebSocket URL (wsRef creation), which can be logged by proxies;
instead open the socket without the token and send the token immediately after
the connection is established (e.g., on ws.onopen send an auth message
containing the token), or implement a short-lived exchange if the backend
supports it. Update the logic around setStatus, wsRef, and the WebSocket event
handlers in useAdminWebSocket so the URL is `${WS_BASE}/ws` (no ?token), then in
the ws.onopen handler send a one-time authentication payload (e.g., JSON with
type: "auth" and the token) and only mark status as authenticated after the
server confirms; ensure wsRef.current still holds the socket and that any
reconnection flow uses the same approach.
In `@frontend/src/pages/AdminPage.tsx`:
- Around line 19-20: The code casts the raw URL param to AdminSection without
validating it; update the section extraction to validate the raw value before
typing it: get the rawSection from useSearchParams (fallback to
DEFAULT_SECTION), define a set of allowed AdminSection values (e.g.,
VALID_SECTIONS), and set section to rawSection only if VALID_SECTIONS has it,
otherwise DEFAULT_SECTION—this removes the unsafe as AdminSection cast and
ensures section is a valid AdminSection for downstream switch logic.
---
Outside diff comments:
In `@backend/app/main.py`:
- Around line 375-390: The /api/sync endpoint is unprotected and directly calls
sync_all(), so either move the trigger_sync function into the existing
admin_router or apply the admin auth dependency to it; specifically, wrap or
register trigger_sync with the same admin protection used elsewhere (the
verify_admin_key dependency/Bearer token) or add the dependency parameter to the
trigger_sync route decorator so it uses verify_admin_key, or relocate the
function into admin_router so it inherits the router's ADMIN_API_KEY validation
before calling sync_all().
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2afae2fd-78cd-42a8-b2e9-f85cc5aebc2b
📒 Files selected for processing (17)
backend/app/api/admin.pybackend/app/main.pybackend/tests/test_admin.pyfrontend/src/App.tsxfrontend/src/__tests__/AdminDashboard.test.tsxfrontend/src/components/admin/AdminLayout.tsxfrontend/src/components/admin/AuditLogPanel.tsxfrontend/src/components/admin/BountyManagement.tsxfrontend/src/components/admin/ContributorManagement.tsxfrontend/src/components/admin/FinancialPanel.tsxfrontend/src/components/admin/OverviewPanel.tsxfrontend/src/components/admin/ReviewPipeline.tsxfrontend/src/components/admin/SystemHealth.tsxfrontend/src/hooks/useAdminData.tsfrontend/src/hooks/useAdminWebSocket.tsfrontend/src/pages/AdminPage.tsxfrontend/src/types/admin.ts
backend/app/api/admin.py
Outdated
| # In-process audit ring buffer (last 1 000 admin actions) | ||
| _audit_log: deque[Dict[str, Any]] = deque(maxlen=1_000) |
There was a problem hiding this comment.
The audit-log implementation cannot satisfy “all admin actions” in a real deployment.
Lines 35-36 keep only the last 1,000 entries in a process-local deque, and the only _log call sites in this router are Lines 384, 403, 462, and 486. Read-only admin actions such as /overview, /contributors, /financial/*, and /audit-log are never captured, and the only secondary sink shown in backend/app/core/audit.py around Lines 73-80 is logger.info(...), not a durable queryable store. Across restarts or multiple workers, /api/admin/audit-log will expose partial history at best, which falls short of the acceptance criterion to capture all admin actions with timestamps.
As per coding guidelines, backend/**: Python FastAPI backend. Analyze thoroughly: API contract consistency with spec.
Also applies to: 82-91, 384-486, 690-720
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 35 - 36, The in-memory _audit_log
deque (and its _log call sites in admin router) is insufficient; persist every
admin action (including read-only endpoints like /overview, /contributors,
/financial/*, /audit-log) to a durable, queryable store instead of or in
addition to the process-local deque. Add a new Audit DB table/entity and update
the functions that call _log (and backend/app/core/audit.py’s logger.info sink)
to write a normalized record (timestamp, actor id, endpoint/action, resource
identifiers, input parameters, result/status) to that table; ensure all admin
route handlers invoke the audit routine (including read-only handlers) and
change the /api/admin/audit-log implementation to query the DB for paginated,
filterable results so history survives restarts and multi-worker deployments.
| total_fndry = sum( | ||
| b.reward_amount for b in bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED) | ||
| ) |
There was a problem hiding this comment.
Financial reporting double-counts COMPLETED bounties as both paid and pending.
Line 280 adds BountyStatus.COMPLETED into total_fndry_paid, Lines 564-571 count COMPLETED bounties as already distributed and still pending payout, and Lines 594-597 include that same lifecycle state in payout history. Because BountyStatus.PAID exists separately, the router is conflating “work finished” with “money disbursed,” so overview totals, pending payout totals, and payout history will contradict each other for the same bounty. That breaks one of the core acceptance criteria for the dashboard’s financial overview.
As per coding guidelines, backend/**: Python FastAPI backend. Analyze thoroughly: API contract consistency with spec.
Also applies to: 561-577, 594-597
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 279 - 281, The code is conflating
BountyStatus.COMPLETED with disbursed payments; update the aggregation logic so
only BountyStatus.PAID counts as disbursed and COMPLETED counts as
completed-but-unpaid (pending). Specifically, change the sum that assigns
total_fndry / total_fndry_paid (and any other aggregations referencing
BountyStatus.COMPLETED) to include BountyStatus.PAID only for "paid" totals,
move BountyStatus.COMPLETED into the pending/payout calculations (e.g.,
pending_payouts, payout_history generation), and audit the functions/variables
total_fndry, total_fndry_paid, pending_payouts and payout_history to ensure
consistency with that contract. Ensure all filters over the bounties list use
BountyStatus.PAID when computing distributed money and use
BountyStatus.COMPLETED (not PAID) for items awaiting payout.
| async def update_bounty_admin( | ||
| bounty_id: str, | ||
| update: BountyAdminUpdate, | ||
| actor: str = Depends(require_admin), | ||
| ) -> Dict[str, Any]: | ||
| """Allow an admin to patch a bounty's status, reward, or title.""" | ||
| bounty = _bounty_store.get(bounty_id) | ||
| if not bounty: | ||
| raise HTTPException(status_code=404, detail=f"Bounty {bounty_id!r} not found") | ||
|
|
||
| changes: Dict[str, Any] = {} | ||
|
|
||
| if update.status is not None: | ||
| bounty.status = update.status | ||
| changes["status"] = update.status | ||
|
|
||
| if update.reward_amount is not None: | ||
| old_reward = bounty.reward_amount | ||
| bounty.reward_amount = update.reward_amount | ||
| changes["reward_amount"] = {"from": old_reward, "to": update.reward_amount} | ||
|
|
||
| if update.title is not None: | ||
| bounty.title = update.title | ||
| changes["title"] = update.title | ||
|
|
||
| if not changes: | ||
| raise HTTPException(status_code=400, detail="No changes provided") | ||
|
|
||
| _log("admin_bounty_updated", actor=actor, bounty_id=bounty_id, changes=changes) | ||
| return {"ok": True, "bounty_id": bounty_id, "changes": changes} |
There was a problem hiding this comment.
The mutation endpoints do not agree on the JSON type of ok.
Line 385 returns ok as a boolean, while Lines 404, 469, and 492 return "true" as a string. That forces typed API consumers to special-case endpoints inside a single router for the same semantic field and creates an avoidable contract inconsistency between otherwise similar admin mutations.
As per coding guidelines, backend/**: Python FastAPI backend. Analyze thoroughly: API contract consistency with spec.
Also applies to: 392-405, 451-469, 476-492
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 356 - 385, Several admin mutation
endpoints are returning the "ok" field inconsistently (some as boolean True,
others as the string "true"); update them to always return a boolean.
Specifically, in update_bounty_admin ensure the response uses {"ok": True} (it
already does), and find every admin mutation that currently returns {"ok":
"true"} and change those to {"ok": True} so the API contract for the "ok" field
is consistently a JSON boolean across all admin mutation handlers.
backend/app/api/admin.py
Outdated
| paid_bounties.sort( | ||
| key=lambda b: getattr(b, "created_at", datetime.min), reverse=True | ||
| ) | ||
|
|
||
| total = len(paid_bounties) | ||
| offset = (page - 1) * per_page | ||
| page_items = paid_bounties[offset: offset + per_page] | ||
|
|
||
| items = [ | ||
| PayoutHistoryItem( | ||
| bounty_id=b.id, | ||
| bounty_title=b.title, | ||
| winner=getattr(b, "winner_wallet", "") or getattr(b, "created_by", ""), | ||
| amount=b.reward_amount, | ||
| status=b.status, | ||
| completed_at=( | ||
| b.created_at.isoformat() | ||
| if hasattr(b.created_at, "isoformat") else str(b.created_at) | ||
| ), |
There was a problem hiding this comment.
/financial/payouts is populated from bounty-creation metadata, not payout metadata.
Lines 598-600 sort the history by created_at, Lines 613-616 expose that same value as completed_at, and Line 610 falls back to created_by when winner_wallet is absent. So this endpoint can show the bounty author as the payout recipient and the bounty creation timestamp as the payout completion timestamp. Even after fixing status classification, the payout history remains factually wrong.
As per coding guidelines, backend/**: Python FastAPI backend. Analyze thoroughly: API contract consistency with spec.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 598 - 616, The payout history is built
from bounty creation fields (paid_bounties, getattr(b, "created_at"), getattr(b,
"created_by")) which yields incorrect recipient and timestamp; update the
construction and sorting of PayoutHistoryItem to source from the actual payout
record fields (e.g. use payout.completed_at or payout.created_at from the payout
object, and payout.recipient_wallet or payout.recipient instead of
bounty.created_by), change the sort key lambda to use the payout's completion
timestamp, and ensure PayoutHistoryItem.winner and completed_at are populated
from the payout metadata rather than the bounty metadata.
| </div> | ||
| )} | ||
|
|
||
| {error && <p className="text-sm text-red-400">{(error as Error).message}</p>} |
There was a problem hiding this comment.
Accessibility: Error message missing role="alert".
Line 60 renders the error without role="alert":
{error && <p className="text-sm text-red-400">{(error as Error).message}</p>}OverviewPanel.tsx correctly uses role="alert" for its error state (line 52). Apply consistently:
-{error && <p className="text-sm text-red-400">{(error as Error).message}</p>}
+{error && <p className="text-sm text-red-400" role="alert">{(error as Error).message}</p>}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/components/admin/SystemHealth.tsx` at line 60, In
SystemHealth.tsx update the error message rendering so it includes an ARIA alert
role: when rendering the error (the JSX that currently uses {(error as
Error).message} inside a <p>), add role="alert" to the element (matching the
pattern used in OverviewPanel.tsx) to ensure screen readers announce the
message; keep the existing className and message expression but include the role
attribute on the <p> that displays the error.
| export function setAdminToken(token: string): void { | ||
| if (token) { | ||
| sessionStorage.setItem(STORAGE_KEY, token); | ||
| } else { | ||
| sessionStorage.removeItem(STORAGE_KEY); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
setAdminToken stores whitespace-only tokens as valid.
The condition if (token) evaluates to true for whitespace strings like " ", storing an effectively invalid token. This could cause confusing auth failures.
Consider trimming the token:
export function setAdminToken(token: string): void {
const trimmed = token.trim();
if (trimmed) {
sessionStorage.setItem(STORAGE_KEY, trimmed);
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}Note: AdminLayout.tsx line 43 already trims before calling setAdminToken, but defensive handling here prevents misuse from other callers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/hooks/useAdminData.ts` around lines 29 - 35, The setAdminToken
function currently treats whitespace-only strings as valid because it checks
token truthiness; change it to trim the input first and use the trimmed value
when storing or deciding to remove the key so whitespace-only tokens are
rejected. Locate setAdminToken and STORAGE_KEY and update the logic to call
token.trim(), store the trimmed token when non-empty, otherwise remove
STORAGE_KEY from sessionStorage.
| const connect = useCallback(() => { | ||
| const token = getAdminToken(); | ||
| if (!token) { | ||
| setStatus('error'); | ||
| return; | ||
| } | ||
|
|
||
| if (wsRef.current?.readyState === WebSocket.OPEN) return; | ||
|
|
||
| setStatus('connecting'); | ||
| const ws = new WebSocket(`${WS_BASE}/ws?token=${encodeURIComponent(token)}`); | ||
| wsRef.current = ws; | ||
|
|
||
| ws.onopen = () => { | ||
| setStatus('connected'); | ||
| reconnectAttempts.current = 0; | ||
| // Subscribe to the admin channel | ||
| ws.send(JSON.stringify({ action: 'subscribe', topic: 'admin' })); | ||
| }; | ||
|
|
||
| ws.onmessage = (evt) => { | ||
| try { | ||
| const raw = JSON.parse(evt.data as string); | ||
| const event: AdminWsEvent = { | ||
| type: raw.type ?? raw.action ?? 'unknown', | ||
| payload: raw.payload ?? raw, | ||
| timestamp: raw.timestamp ?? new Date().toISOString(), | ||
| }; | ||
| setLastEvent(event); | ||
| onEvent?.(event); | ||
| } catch { | ||
| // Non-JSON frames (e.g. heartbeat pings) are silently ignored | ||
| } | ||
| }; | ||
|
|
||
| ws.onerror = () => { | ||
| setStatus('error'); | ||
| }; | ||
|
|
||
| ws.onclose = () => { | ||
| setStatus('disconnected'); | ||
| wsRef.current = null; | ||
|
|
||
| if (!shouldReconnect.current) return; | ||
|
|
||
| const delay = Math.min( | ||
| BASE_RECONNECT_DELAY_MS * 2 ** reconnectAttempts.current, | ||
| MAX_RECONNECT_DELAY_MS, | ||
| ); | ||
| reconnectAttempts.current += 1; | ||
| reconnectTimer.current = setTimeout(connect, delay); | ||
| }; | ||
| }, [onEvent]); |
There was a problem hiding this comment.
Stale onEvent callback due to incorrect dependency handling.
The connect function depends on onEvent (line 83), but the useEffect that calls connect has an empty dependency array (line 96). If the parent component passes a new onEvent function on re-render, the old callback remains captured in the active WebSocket's onmessage handler.
Either stabilize onEvent using useRef or include connect in the effect dependencies and handle reconnection:
const onEventRef = useRef(onEvent);
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
// Then in connect:
ws.onmessage = (evt) => {
// ...
onEventRef.current?.(event);
};
const connect = useCallback(() => {
// ... (remove onEvent from dependencies)
}, []); // Now stable🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/hooks/useAdminWebSocket.ts` around lines 31 - 83, The connect
closure captures a stale onEvent because connect is memoized with onEvent in its
deps but the effect that calls connect is not updated; fix by decoupling the
callback reference: create an onEventRef via useRef and update it in a short
effect (useEffect(() => { onEventRef.current = onEvent }, [onEvent])), then
change ws.onmessage inside connect to call onEventRef.current?.(event) instead
of onEvent, and remove onEvent from the connect useCallback dependencies so
connect remains stable for the existing effect and reconnect logic.
| setStatus('connecting'); | ||
| const ws = new WebSocket(`${WS_BASE}/ws?token=${encodeURIComponent(token)}`); | ||
| wsRef.current = ws; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Token passed in URL query string may be logged by proxies/load balancers.
The admin token is passed via ?token=... in the WebSocket URL. Query parameters are commonly logged by reverse proxies, CDNs, and server access logs, potentially exposing credentials.
Consider sending the token after connection via a WebSocket message, or using a short-lived token exchange if the backend supports it. This is a defense-in-depth consideration for admin credentials.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/hooks/useAdminWebSocket.ts` around lines 40 - 42, The current
code in useAdminWebSocket embeds the admin token in the WebSocket URL (wsRef
creation), which can be logged by proxies; instead open the socket without the
token and send the token immediately after the connection is established (e.g.,
on ws.onopen send an auth message containing the token), or implement a
short-lived exchange if the backend supports it. Update the logic around
setStatus, wsRef, and the WebSocket event handlers in useAdminWebSocket so the
URL is `${WS_BASE}/ws` (no ?token), then in the ws.onopen handler send a
one-time authentication payload (e.g., JSON with type: "auth" and the token) and
only mark status as authenticated after the server confirms; ensure
wsRef.current still holds the socket and that any reconnection flow uses the
same approach.
| const [searchParams, setSearchParams] = useSearchParams(); | ||
| const section = (searchParams.get('section') ?? DEFAULT_SECTION) as AdminSection; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Type assertion on URL param without validation.
Line 20 casts the URL search param directly to AdminSection:
const section = (searchParams.get('section') ?? DEFAULT_SECTION) as AdminSection;While the switch statement (lines 27-35) has a default case that safely renders OverviewPanel for invalid values, the as AdminSection cast is misleading—it implies the value is always valid when it's not validated.
Consider explicit validation for clarity:
const VALID_SECTIONS = new Set<AdminSection>(['overview', 'bounties', 'contributors', 'reviews', 'financial', 'health', 'audit-log']);
const rawSection = searchParams.get('section') ?? DEFAULT_SECTION;
const section: AdminSection = VALID_SECTIONS.has(rawSection as AdminSection)
? (rawSection as AdminSection)
: DEFAULT_SECTION;This is a minor type hygiene issue since runtime behavior is safe.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AdminPage.tsx` around lines 19 - 20, The code casts the
raw URL param to AdminSection without validating it; update the section
extraction to validate the raw value before typing it: get the rawSection from
useSearchParams (fallback to DEFAULT_SECTION), define a set of allowed
AdminSection values (e.g., VALID_SECTIONS), and set section to rawSection only
if VALID_SECTIONS has it, otherwise DEFAULT_SECTION—this removes the unsafe as
AdminSection cast and ensures section is a valid AdminSection for downstream
switch logic.
There was a problem hiding this comment.
Actionable comments posted: 13
♻️ Duplicate comments (8)
frontend/src/components/admin/BountyManagement.tsx (2)
316-316:⚠️ Potential issue | 🟡 MinorAccessibility: Error message missing
role="alert".Screen readers won't announce the error automatically. Add
role="alert"for accessibility compliance.— Past review flagged this same issue.
-{error && <p className="text-sm text-red-400">{(error as Error).message}</p>} +{error && <p className="text-sm text-red-400" role="alert">{(error as Error).message}</p>}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/components/admin/BountyManagement.tsx` at line 316, In BountyManagement.tsx update the error paragraph that renders {(error as Error).message} to include an accessibility role by adding role="alert" so screen readers announce the message; locate the JSX where error is rendered (the conditional expression using the error variable inside the component BountyManagement) and add role="alert" to that <p> element while keeping existing classes and message casting intact.
30-44:⚠️ Potential issue | 🟡 MinorMutation errors do not prevent modal closure.
Both
handleSave(line 37-38) andhandleClose(line 42-43) callonClose()unconditionally afterawait mutateAsync(...). If the mutation throws, the promise rejects andonClose()is never reached—but if it succeeds silently with an error status handled by React Query, the modal closes. More critically, neither handler has atry/catch, so unhandled rejections could occur.Additionally,
close.isErroris never rendered (onlyupdate.isErrorat line 94-96), so Force Close failures are invisible to users.— Past reviews flagged missing
close.isErrordisplay and the need for error handling on the close mutation.Suggested fix
const handleSave = async () => { const patch: Record<string, unknown> = {}; if (status !== bounty.status) patch.status = status; const rewardNum = Number(reward); if (!isNaN(rewardNum) && rewardNum !== bounty.reward_amount) patch.reward_amount = rewardNum; if (title !== bounty.title) patch.title = title; if (Object.keys(patch).length === 0) { onClose(); return; } - await update.mutateAsync({ id: bounty.id, update: patch }); - onClose(); + try { + await update.mutateAsync({ id: bounty.id, update: patch }); + onClose(); + } catch { + // Error state handled by React Query; modal stays open + } }; const handleClose = async () => { - await close.mutateAsync(bounty.id); - onClose(); + try { + await close.mutateAsync(bounty.id); + onClose(); + } catch { + // Error state handled by React Query; modal stays open + } };And add
close.isErrordisplay near line 96:{update.isError && ( <p className="text-xs text-red-400">{(update.error as Error).message}</p> )} +{close.isError && ( + <p className="text-xs text-red-400">{(close.error as Error).message}</p> +)}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/components/admin/BountyManagement.tsx` around lines 30 - 44, handleSave and handleClose call mutateAsync without try/catch and call onClose() unconditionally, so failures can cause unhandled rejections or hide errors (and close.isError isn't rendered); wrap both update.mutateAsync and close.mutateAsync calls in try/catch in the handleSave and handleClose functions so onClose() is only called on success and errors are caught and surfaced, call set an error state or rely on update.isError/close.isError after awaiting mutateAsync, and add rendering for close.isError alongside update.isError (referencing handleSave, handleClose, update.mutateAsync, close.mutateAsync, update.isError, close.isError, and onClose) so Force Close failures are visible to users.frontend/src/hooks/useAdminData.ts (1)
31-37:⚠️ Potential issue | 🟡 Minor
setAdminTokenaccepts whitespace-only strings as valid tokens.The check
if (token)evaluates totruefor strings like" ", storing an effectively invalid token that will cause auth failures.— Past review flagged this same issue with a suggested fix to trim the token.
export function setAdminToken(token: string): void { - if (token) { - sessionStorage.setItem(STORAGE_KEY, token); + const trimmed = token.trim(); + if (trimmed) { + sessionStorage.setItem(STORAGE_KEY, trimmed); } else { sessionStorage.removeItem(STORAGE_KEY); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/hooks/useAdminData.ts` around lines 31 - 37, The setAdminToken function currently treats whitespace-only strings as valid; change its check to trim the token (e.g., const t = token.trim()) and only call sessionStorage.setItem(STORAGE_KEY, t) when t.length > 0, otherwise call sessionStorage.removeItem(STORAGE_KEY); ensure you store the trimmed token so STORAGE_KEY never contains leading/trailing whitespace.backend/app/api/admin.py (5)
647-647:⚠️ Potential issue | 🟡 MinorContributor endpoints return
okas string instead of boolean.Lines 647 and 666 return
{"ok": "true"}(string) while other endpoints likecreate_bounty_admin(line 486) return{"ok": True}(boolean). This API contract inconsistency complicates frontend handling.— Part of the past review comment about inconsistent
okfield types.Also applies to: 666-666
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/api/admin.py` at line 647, The contributor admin endpoints return the ok field as a string; update the return objects in the contributor-related functions (the returns that currently produce {"ok": "true", "contributor_id": contributor_id, "action": "..."} in admin.py) to use a boolean True (i.e., {"ok": True, "contributor_id": contributor_id, "action": ...}) to match other endpoints like create_bounty_admin; locate the ban/unban contributor handlers by their use of contributor_id and change the "ok" value from the string "true" to the boolean True for both occurrences.
728-729:⚠️ Potential issue | 🟠 Major
COMPLETEDbounties counted as both paid and pending.Line 728 includes
COMPLETEDinpaid(total distributed), and line 729 includesCOMPLETEDinpending(awaiting payout). This double-counting inflates metrics. A bounty inCOMPLETEDstate means work is done but payout may not have occurred; it should only be in pending until moved toPAID.— Past review flagged this financial double-counting issue.
- paid = [b for b in bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED)] - pending = [b for b in bounties if b.status in (BountyStatus.UNDER_REVIEW, BountyStatus.COMPLETED)] + paid = [b for b in bounties if b.status == BountyStatus.PAID] + pending = [b for b in bounties if b.status in (BountyStatus.COMPLETED, BountyStatus.UNDER_REVIEW)]🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/api/admin.py` around lines 728 - 729, The code double-counts BountyStatus.COMPLETED in both paid and pending; change the paid list comprehension (paid = [b for b in bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED)]) to only include BountyStatus.PAID, and keep COMPLETED only in the pending calculation (pending = [b for b in bounties if b.status in (BountyStatus.UNDER_REVIEW, BountyStatus.COMPLETED)]) so completed bounties are counted as pending until transitioned to PAID.
766-771:⚠️ Potential issue | 🟠 MajorPayout history uses bounty creation metadata instead of payout metadata.
- Line 766:
winnerfalls back tocreated_by(bounty author, not winner)- Line 770:
completed_atusesb.created_at(bounty creation time, not payout time)This shows the bounty author as payout recipient and bounty creation as payout date—factually incorrect.
— Past review flagged this same issue.
The fix requires storing actual payout records with recipient wallet and completion timestamp, then querying those instead of bounty metadata.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/api/admin.py` around lines 766 - 771, The payout history is incorrectly using bounty metadata (b.winner_wallet or b.created_by and b.created_at) instead of the actual payout record; update the code that builds the payout entry (the section referencing b.winner_wallet, b.created_by, b.reward_amount, b.status, and b.created_at) to query and use the payout record’s recipient wallet and payout completion timestamp (e.g., payout.recipient_wallet and payout.completed_at) and fall back to bounty fields only if no payout record exists; ensure the amount/status come from the payout record (or are overridden by it) and format payout.completed_at with isoformat when available.
552-552:⚠️ Potential issue | 🟡 MinorInconsistent
okfield type: string vs boolean.Line 552 returns
{"ok": "true"}(string), while line 486 returns{"ok": True}(boolean). This inconsistency forces frontend consumers to handle both types.— Past review flagged this same issue.
- return {"ok": "true", "bounty_id": bounty_id, "status": BountyStatus.CANCELLED} + return {"ok": True, "bounty_id": bounty_id, "status": BountyStatus.CANCELLED.value}Note:
BountyStatus.CANCELLEDshould also be.valuefor JSON serialization.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/api/admin.py` at line 552, The return uses inconsistent types and non-serialized enum: change the literal string "true" to a boolean True for the ok field and return the enum's value for JSON serialization (use BountyStatus.CANCELLED.value) in the return statement in admin.py (the function that currently returns {"ok": "true", "bounty_id": bounty_id, "status": BountyStatus.CANCELLED}); this will match the other return that uses {"ok": True} and ensure the status is JSON-safe.
844-858:⚠️ Potential issue | 🟠 MajorSystem health ignores Redis status; webhook metric is audit count, not webhooks.
Line 845: Overall
statusis"healthy"if database is connected, regardless of Redis being"disconnected". Real-time features depend on Redis; its failure should degrade health.Line 855:
webhook_events_processedis populated fromAdminAuditLogTablecount, which tracks admin actions, not GitHub webhook events. This metric is misleading.— Past review flagged these same issues.
Suggested fix for health calculation:
- status="healthy" if db_status == "connected" else "degraded", + status="healthy" if db_status == "connected" and redis_status == "connected" else "degraded",For webhook metric, either rename the field or track actual GitHub webhook events in a separate counter/table.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/api/admin.py` around lines 844 - 858, The SystemHealthResponse currently sets overall status based only on db_status and uses AdminAuditLogTable count for webhook_events_processed; update the health logic in the SystemHealthResponse builder (where SystemHealthResponse is constructed) to derive status from both db_status and redis_status (e.g., "healthy" only if both are "connected", otherwise "degraded"/"unhealthy"), and ensure redis_status is considered when computing the top-level status; also stop populating webhook_events_processed from the admin audit count—either rename that field to reflect it comes from AdminAuditLogTable or, preferably, populate a new metric (e.g., github_webhook_event_count) from the actual GitHub webhook events counter/storage instead of AdminAuditLogTable, leaving github_webhook_status as the webhook delivery status.
🤖 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/app/api/admin.py`:
- Around line 269-273: Add a Pydantic field validator to BountyAdminUpdate that
validates the status field against the BountyStatus enum so invalid strings are
rejected during model validation; inside the BountyAdminUpdate class, define a
`@field_validator`('status') method (e.g., validate_status) that returns the value
when None or successfully constructs BountyStatus(v) and raises
ValueError(f"Invalid status: {v}") on failure, ensuring handlers no longer
receive invalid status strings.
- Around line 892-893: The current except Exception: return
AuditLogResponse(...) swallows all errors; change the except block to except
Exception as e: log the exception (e.g., logger.exception or
app_logger.exception) including the error and context, and then either return a
503 response by raising fastapi.HTTPException(status_code=503, detail="Audit log
query failed") or return an error-aware AuditLogResponse that surfaces the
failure; update the except block that handles the AuditLogResponse return to
capture the exception as e and perform the logging and 503 raise instead of
silently returning empty results.
- Around line 103-107: Replace the broad "except Exception" with targeted
exception handling so JWT-specific errors aren't swallowed: keep the existing
"except HTTPException: raise", then catch JWT-specific exceptions (e.g.,
InvalidTokenError, TokenExpiredError / ExpiredSignatureError from your JWT
library) and re-raise them so token-expired or invalid-token responses surface,
keep the intended fallback to API key auth only for the single case you want (if
you intentionally allow a certain invalid-jwt type to fall through), and add a
final generic "except Exception as e" that logs or re-raises unexpected errors;
also ensure the specific JWT exception classes are imported where the code
checks the token.
- Around line 612-613: The current except block that does "except Exception:
rows = []" swallows all DB errors and hides failures; change it to "except
Exception as e:" and either log the full exception using the module logger
(e.g., logger.exception or logger.error with e) and return a 500/error response
to the caller, or re-raise an appropriate error (HTTPException or the original
exception) instead of silently returning an empty "rows"; update the block that
assigns "rows = []" so it no longer discards the exception and includes the
logged/returned error information for the caller.
- Around line 161-179: The _log function currently swallows all exceptions
during DB persistence; change the bare except to capture the exception (except
Exception as e) and record the failure via the existing logging pathway (e.g.,
call audit_event or the module logger/structlog with context including event,
actor, role, and the exception/traceback) so failures are visible while still
not bubbling to callers; update the except block around get_db_session/
AdminAuditLogTable persistence to log the error details (message and stack) and
then return silently.
In `@frontend/src/components/admin/AdminLayout.tsx`:
- Around line 49-54: The OAuth redirect sets state=admin_login in
handleGitHubLogin but AdminPage only checks for access_token; update AdminPage
(the component that reads the URL on redirect) to validate that the URL's state
param exactly equals "admin_login" and that the sessionStorage flag
'sf_admin_oauth_pending' exists before accepting the token, otherwise
reject/ignore the token and clear the pending flag; implement validation using
URLSearchParams to read 'state' and 'access_token', compare to the literal
"admin_login", and ensure you clear sessionStorage and remove tokens on mismatch
to prevent token injection.
In `@frontend/src/components/admin/AuditLogPanel.tsx`:
- Around line 16-25: The relativeTime function can return negative values for
future timestamps; update relativeTime(iso: string) to clamp negative diffs
(e.g., if Date.now() - new Date(iso).getTime() < 0) and return a sensible value
such as "just now" or "0s ago" instead of "-5s ago". Keep the rest of the
formatting logic (s, m, h, date fallback) but ensure you use the non-negative
diff when computing s/m/h to avoid negative units; operate on the same function
name relativeTime to locate and modify the code.
In `@frontend/src/components/admin/BountyManagement.tsx`:
- Around line 349-350: The JSX currently calls b.reward_amount.toLocaleString()
which will throw if b.reward_amount is null or undefined; update the
BountyManagement/BountyAdminItem rendering to defensively handle nullish
reward_amount (in the table cell where b.reward_amount is used) by checking for
null/undefined and rendering a safe placeholder (e.g., "-" or "0") or formatting
the fallback numeric value before calling toLocaleString(); ensure the change is
applied where b.reward_amount is referenced in the BountyManagement.tsx
component so runtime errors are prevented.
- Around line 139-149: The handleSubmit function should validate the reward and
handle mutation errors: before calling create.mutateAsync, parse reward and
ensure it is a positive number (Number(reward) is not NaN and > 0) and set an
inline validation error state (e.g., setErrorMessage or a field error) to
prevent submission; wrap the await create.mutateAsync(payload) call in a
try/catch so failures are caught, display the mutation error to the user via
that error state, and only call onClose() on success; also consider disabling
the submit button while create.isLoading to avoid duplicate submissions. Use the
existing handleSubmit, create.mutateAsync, onClose, reward and
title/description/tier symbols to locate and update the logic.
In `@frontend/src/components/admin/ContributorManagement.tsx`:
- Line 143: The error paragraph in ContributorManagement.tsx currently renders
{(error as Error).message} without an accessibility role; update the JSX that
renders the error (the {error && <p ...>} expression in ContributorManagement
component) to include role="alert" on the <p> element so screen readers will
announce it (preserve existing className and message casting).
In `@frontend/src/components/admin/SystemHealth.tsx`:
- Around line 52-58: The loading skeleton renders 4 placeholders but the metrics
grid contains 6 MetricCard components, causing layout shift; update the
isLoading block in SystemHealth.tsx to render 6 skeletons (change Array.from({
length: 4 }) to Array.from({ length: 6 })) so the placeholder count matches the
number of MetricCard components, keeping the same mapping/key pattern used for
the skeleton divs.
- Line 93: The MetricCard at the call showing MetricCard(label="Audit Events",
value={data.webhook_events_processed}) is mislabeled; either change the label to
"Webhook Events" to match data.webhook_events_processed or replace the value
with the actual audit count field (e.g., data.audit_events_count) if the intent
was to show audit log entries; update the MetricCard invocation accordingly
(reference: MetricCard and data.webhook_events_processed).
In `@frontend/src/hooks/useAdminData.ts`:
- Around line 60-65: The adminFetch helper currently calls res.json() for all
successful responses which will throw for 204 No Content or non-JSON responses;
update the success path in adminFetch (the function wrapping this snippet) to
first check for empty responses (res.status === 204) or that the Content-Type
header contains "application/json" before calling res.json(), and return
undefined/null (or an empty object) cast to Promise<T> when there's no JSON
body; ensure the error path remains unchanged and keep references to res.status
and res.headers.get('content-type') in the new logic.
---
Duplicate comments:
In `@backend/app/api/admin.py`:
- Line 647: The contributor admin endpoints return the ok field as a string;
update the return objects in the contributor-related functions (the returns that
currently produce {"ok": "true", "contributor_id": contributor_id, "action":
"..."} in admin.py) to use a boolean True (i.e., {"ok": True, "contributor_id":
contributor_id, "action": ...}) to match other endpoints like
create_bounty_admin; locate the ban/unban contributor handlers by their use of
contributor_id and change the "ok" value from the string "true" to the boolean
True for both occurrences.
- Around line 728-729: The code double-counts BountyStatus.COMPLETED in both
paid and pending; change the paid list comprehension (paid = [b for b in
bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED)]) to only
include BountyStatus.PAID, and keep COMPLETED only in the pending calculation
(pending = [b for b in bounties if b.status in (BountyStatus.UNDER_REVIEW,
BountyStatus.COMPLETED)]) so completed bounties are counted as pending until
transitioned to PAID.
- Around line 766-771: The payout history is incorrectly using bounty metadata
(b.winner_wallet or b.created_by and b.created_at) instead of the actual payout
record; update the code that builds the payout entry (the section referencing
b.winner_wallet, b.created_by, b.reward_amount, b.status, and b.created_at) to
query and use the payout record’s recipient wallet and payout completion
timestamp (e.g., payout.recipient_wallet and payout.completed_at) and fall back
to bounty fields only if no payout record exists; ensure the amount/status come
from the payout record (or are overridden by it) and format payout.completed_at
with isoformat when available.
- Line 552: The return uses inconsistent types and non-serialized enum: change
the literal string "true" to a boolean True for the ok field and return the
enum's value for JSON serialization (use BountyStatus.CANCELLED.value) in the
return statement in admin.py (the function that currently returns {"ok": "true",
"bounty_id": bounty_id, "status": BountyStatus.CANCELLED}); this will match the
other return that uses {"ok": True} and ensure the status is JSON-safe.
- Around line 844-858: The SystemHealthResponse currently sets overall status
based only on db_status and uses AdminAuditLogTable count for
webhook_events_processed; update the health logic in the SystemHealthResponse
builder (where SystemHealthResponse is constructed) to derive status from both
db_status and redis_status (e.g., "healthy" only if both are "connected",
otherwise "degraded"/"unhealthy"), and ensure redis_status is considered when
computing the top-level status; also stop populating webhook_events_processed
from the admin audit count—either rename that field to reflect it comes from
AdminAuditLogTable or, preferably, populate a new metric (e.g.,
github_webhook_event_count) from the actual GitHub webhook events
counter/storage instead of AdminAuditLogTable, leaving github_webhook_status as
the webhook delivery status.
In `@frontend/src/components/admin/BountyManagement.tsx`:
- Line 316: In BountyManagement.tsx update the error paragraph that renders
{(error as Error).message} to include an accessibility role by adding
role="alert" so screen readers announce the message; locate the JSX where error
is rendered (the conditional expression using the error variable inside the
component BountyManagement) and add role="alert" to that <p> element while
keeping existing classes and message casting intact.
- Around line 30-44: handleSave and handleClose call mutateAsync without
try/catch and call onClose() unconditionally, so failures can cause unhandled
rejections or hide errors (and close.isError isn't rendered); wrap both
update.mutateAsync and close.mutateAsync calls in try/catch in the handleSave
and handleClose functions so onClose() is only called on success and errors are
caught and surfaced, call set an error state or rely on
update.isError/close.isError after awaiting mutateAsync, and add rendering for
close.isError alongside update.isError (referencing handleSave, handleClose,
update.mutateAsync, close.mutateAsync, update.isError, close.isError, and
onClose) so Force Close failures are visible to users.
In `@frontend/src/hooks/useAdminData.ts`:
- Around line 31-37: The setAdminToken function currently treats whitespace-only
strings as valid; change its check to trim the token (e.g., const t =
token.trim()) and only call sessionStorage.setItem(STORAGE_KEY, t) when t.length
> 0, otherwise call sessionStorage.removeItem(STORAGE_KEY); ensure you store the
trimmed token so STORAGE_KEY never contains leading/trailing whitespace.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4f1bb26d-7614-40fb-8ed0-6da984dfa4ba
📒 Files selected for processing (11)
backend/alembic/versions/003_add_admin_audit_log.pybackend/app/api/admin.pybackend/app/models/tables.pyfrontend/src/components/admin/AdminLayout.tsxfrontend/src/components/admin/AuditLogPanel.tsxfrontend/src/components/admin/BountyManagement.tsxfrontend/src/components/admin/ContributorManagement.tsxfrontend/src/components/admin/SystemHealth.tsxfrontend/src/hooks/useAdminData.tsfrontend/src/pages/AdminPage.tsxfrontend/src/types/admin.ts
| except HTTPException: | ||
| raise | ||
| except Exception: | ||
| # Not a valid JWT — fall through to API key check | ||
| pass |
There was a problem hiding this comment.
Bare except Exception swallows specific JWT errors silently.
Lines 103-107 catch HTTPException to re-raise, but the subsequent except Exception catches everything else (including InvalidTokenError, TokenExpiredError) and falls through to API key auth. This means:
- A user with an expired JWT will silently fall back to API key auth instead of getting a clear "token expired" error.
- Malformed JWTs are treated the same as legacy API keys.
Consider catching specific exceptions:
except HTTPException:
raise
- except Exception:
- # Not a valid JWT — fall through to API key check
- pass
+ except (InvalidTokenError, TokenExpiredError):
+ # Not a valid JWT — fall through to API key check
+ passThis preserves fallback behavior while allowing unexpected errors to surface.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 103 - 107, Replace the broad "except
Exception" with targeted exception handling so JWT-specific errors aren't
swallowed: keep the existing "except HTTPException: raise", then catch
JWT-specific exceptions (e.g., InvalidTokenError, TokenExpiredError /
ExpiredSignatureError from your JWT library) and re-raise them so token-expired
or invalid-token responses surface, keep the intended fallback to API key auth
only for the single case you want (if you intentionally allow a certain
invalid-jwt type to fall through), and add a final generic "except Exception as
e" that logs or re-raises unexpected errors; also ensure the specific JWT
exception classes are imported where the code checks the token.
| async def _log(event: str, actor: str, role: str = "admin", **details: Any) -> None: | ||
| """Insert an audit entry into PostgreSQL and emit to structlog.""" | ||
| audit_event(event, actor=actor, **details) | ||
| try: | ||
| async with get_db_session() as session: | ||
| row = AdminAuditLogTable( | ||
| id=uuid.uuid4(), | ||
| event=event, | ||
| actor=actor, | ||
| role=role, | ||
| details=details, | ||
| created_at=datetime.now(timezone.utc), | ||
| ) | ||
| session.add(row) | ||
| await session.commit() | ||
| except Exception: | ||
| # Never let audit log failures bubble up to the caller | ||
| pass | ||
|
|
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Audit log persistence implemented, but errors are silently swallowed.
This implementation addresses the past review concern about in-memory audit storage by persisting to PostgreSQL via AdminAuditLogTable. However, the bare except Exception: pass at lines 176-178 means audit failures are completely silent—no logging, no alerting.
Consider at minimum logging the failure:
except Exception:
- # Never let audit log failures bubble up to the caller
- pass
+ # Log but don't block the caller
+ import logging
+ logging.getLogger(__name__).exception("Failed to persist audit log entry")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 161 - 179, The _log function currently
swallows all exceptions during DB persistence; change the bare except to capture
the exception (except Exception as e) and record the failure via the existing
logging pathway (e.g., call audit_event or the module logger/structlog with
context including event, actor, role, and the exception/traceback) so failures
are visible while still not bubbling to callers; update the except block around
get_db_session/ AdminAuditLogTable persistence to log the error details (message
and stack) and then return silently.
| class BountyAdminUpdate(BaseModel): | ||
| """Fields an admin can update on a bounty.""" | ||
| status: Optional[str] = Field(None, description="New lifecycle status") | ||
| reward_amount: Optional[float] = Field(None, gt=0) | ||
| title: Optional[str] = Field(None, min_length=3, max_length=200) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Status validation deferred to handler; model allows any string.
BountyAdminUpdate.status is typed as Optional[str] without enum constraint. Validation happens in the endpoint handler (lines 501-520), which works but allows invalid values to reach the handler before rejection.
Consider adding a Pydantic validator:
from pydantic import field_validator
class BountyAdminUpdate(BaseModel):
status: Optional[str] = Field(None, description="New lifecycle status")
# ...
`@field_validator`('status')
`@classmethod`
def validate_status(cls, v):
if v is not None:
try:
BountyStatus(v)
except ValueError:
raise ValueError(f"Invalid status: {v}")
return vThis provides earlier validation with clearer error messages.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 269 - 273, Add a Pydantic field
validator to BountyAdminUpdate that validates the status field against the
BountyStatus enum so invalid strings are rejected during model validation;
inside the BountyAdminUpdate class, define a `@field_validator`('status') method
(e.g., validate_status) that returns the value when None or successfully
constructs BountyStatus(v) and raises ValueError(f"Invalid status: {v}") on
failure, ensuring handlers no longer receive invalid status strings.
| except Exception: | ||
| rows = [] |
There was a problem hiding this comment.
Database query failures silently return empty results.
Lines 612-613 catch all exceptions and return an empty list. The caller cannot distinguish between "no history exists" and "database is down." Consider returning an error or at least logging:
- except Exception:
- rows = []
+ except Exception as e:
+ import logging
+ logging.getLogger(__name__).exception("Failed to query contributor history")
+ raise HTTPException(status_code=503, detail="Database unavailable")Or if graceful degradation is preferred, at least log the error.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 612 - 613, The current except block
that does "except Exception: rows = []" swallows all DB errors and hides
failures; change it to "except Exception as e:" and either log the full
exception using the module logger (e.g., logger.exception or logger.error with
e) and return a 500/error response to the caller, or re-raise an appropriate
error (HTTPException or the original exception) instead of silently returning an
empty "rows"; update the block that assigns "rows = []" so it no longer discards
the exception and includes the logged/returned error information for the caller.
| except Exception: | ||
| return AuditLogResponse(entries=[], total=0) |
There was a problem hiding this comment.
Audit log query failures return empty results silently.
Lines 892-893 catch all exceptions and return an empty response. Operators querying the audit log won't know if the database is unavailable vs. legitimately empty. At minimum, log the exception:
- except Exception:
- return AuditLogResponse(entries=[], total=0)
+ except Exception:
+ import logging
+ logging.getLogger(__name__).exception("Failed to query audit log")
+ return AuditLogResponse(entries=[], total=0)Or consider returning a 503 for transparency.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/app/api/admin.py` around lines 892 - 893, The current except
Exception: return AuditLogResponse(...) swallows all errors; change the except
block to except Exception as e: log the exception (e.g., logger.exception or
app_logger.exception) including the error and context, and then either return a
503 response by raising fastapi.HTTPException(status_code=503, detail="Audit log
query failed") or return an error-aware AuditLogResponse that surfaces the
failure; update the except block that handles the AuditLogResponse return to
capture the exception as e and perform the logging and 503 raise instead of
silently returning empty results.
| <td className="px-4 py-3 text-right tabular-nums text-[#14F195]"> | ||
| {b.reward_amount.toLocaleString()} |
There was a problem hiding this comment.
Potential runtime error if reward_amount is nullish.
b.reward_amount.toLocaleString() will throw if reward_amount is undefined or null. The BountyAdminItem type declares it as number, but backend data might be inconsistent.
Defensive fix
<td className="px-4 py-3 text-right tabular-nums text-[`#14F195`]">
- {b.reward_amount.toLocaleString()}
+ {(b.reward_amount ?? 0).toLocaleString()}
</td>📝 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.
| <td className="px-4 py-3 text-right tabular-nums text-[#14F195]"> | |
| {b.reward_amount.toLocaleString()} | |
| <td className="px-4 py-3 text-right tabular-nums text-[`#14F195`]"> | |
| {(b.reward_amount ?? 0).toLocaleString()} | |
| </td> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/components/admin/BountyManagement.tsx` around lines 349 - 350,
The JSX currently calls b.reward_amount.toLocaleString() which will throw if
b.reward_amount is null or undefined; update the
BountyManagement/BountyAdminItem rendering to defensively handle nullish
reward_amount (in the table cell where b.reward_amount is used) by checking for
null/undefined and rendering a safe placeholder (e.g., "-" or "0") or formatting
the fallback numeric value before calling toLocaleString(); ensure the change is
applied where b.reward_amount is referenced in the BountyManagement.tsx
component so runtime errors are prevented.
| </div> | ||
| )} | ||
|
|
||
| {error && <p className="text-sm text-red-400">{(error as Error).message}</p>} |
There was a problem hiding this comment.
Accessibility: Error message missing role="alert".
Consistent with other admin panels, the error display should include role="alert" for screen reader announcement.
Proposed fix
- {error && <p className="text-sm text-red-400">{(error as Error).message}</p>}
+ {error && <p className="text-sm text-red-400" role="alert">{(error as Error).message}</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.
| {error && <p className="text-sm text-red-400">{(error as Error).message}</p>} | |
| {error && <p className="text-sm text-red-400" role="alert">{(error as Error).message}</p>} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/components/admin/ContributorManagement.tsx` at line 143, The
error paragraph in ContributorManagement.tsx currently renders {(error as
Error).message} without an accessibility role; update the JSX that renders the
error (the {error && <p ...>} expression in ContributorManagement component) to
include role="alert" on the <p> element so screen readers will announce it
(preserve existing className and message casting).
| {isLoading && ( | ||
| <div className="space-y-3"> | ||
| {Array.from({ length: 4 }).map((_, i) => ( | ||
| <div key={i} className="h-12 rounded-xl bg-white/[0.03] animate-pulse" /> | ||
| ))} | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
Skeleton count doesn't match actual metric cards.
The loading state renders 4 skeleton placeholders (line 54), but the metrics grid (lines 84-98) contains 6 MetricCard components. This causes a visual jump when data loads.
Proposed fix
{isLoading && (
<div className="space-y-3">
- {Array.from({ length: 4 }).map((_, i) => (
+ {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-12 rounded-xl bg-white/[0.03] animate-pulse" />
))}
</div>
)}📝 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.
| {isLoading && ( | |
| <div className="space-y-3"> | |
| {Array.from({ length: 4 }).map((_, i) => ( | |
| <div key={i} className="h-12 rounded-xl bg-white/[0.03] animate-pulse" /> | |
| ))} | |
| </div> | |
| )} | |
| {isLoading && ( | |
| <div className="space-y-3"> | |
| {Array.from({ length: 6 }).map((_, i) => ( | |
| <div key={i} className="h-12 rounded-xl bg-white/[0.03] animate-pulse" /> | |
| ))} | |
| </div> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/components/admin/SystemHealth.tsx` around lines 52 - 58, The
loading skeleton renders 4 placeholders but the metrics grid contains 6
MetricCard components, causing layout shift; update the isLoading block in
SystemHealth.tsx to render 6 skeletons (change Array.from({ length: 4 }) to
Array.from({ length: 6 })) so the placeholder count matches the number of
MetricCard components, keeping the same mapping/key pattern used for the
skeleton divs.
| sub={data.queue_depth > 0 ? 'pending reviews' : 'queue clear'} | ||
| /> | ||
| <MetricCard label="WS Connections" value={data.active_websocket_connections} /> | ||
| <MetricCard label="Audit Events" value={data.webhook_events_processed} sub="all-time" /> |
There was a problem hiding this comment.
Misleading metric label: "Audit Events" displays webhook count.
Line 93 labels the metric as "Audit Events" but displays data.webhook_events_processed. This conflates audit log entries with webhook processing counts, which are distinct concepts.
Proposed fix
- <MetricCard label="Audit Events" value={data.webhook_events_processed} sub="all-time" />
+ <MetricCard label="Webhooks Processed" value={data.webhook_events_processed} sub="all-time" />🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/components/admin/SystemHealth.tsx` at line 93, The MetricCard at
the call showing MetricCard(label="Audit Events",
value={data.webhook_events_processed}) is mislabeled; either change the label to
"Webhook Events" to match data.webhook_events_processed or replace the value
with the actual audit count field (e.g., data.audit_events_count) if the intent
was to show audit log entries; update the MetricCard invocation accordingly
(reference: MetricCard and data.webhook_events_processed).
| if (!res.ok) { | ||
| const body = await res.json().catch(() => ({})); | ||
| throw new Error(body.detail ?? `HTTP ${res.status}`); | ||
| } | ||
| return res.json() as Promise<T>; | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
adminFetch may throw on successful non-JSON responses.
Line 64 calls res.json() unconditionally on success. If any admin endpoint returns 204 No Content or a non-JSON body, this will throw unexpectedly.
Looking at the backend, mutation endpoints return JSON objects, but this is a defensive gap. Consider:
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail ?? `HTTP ${res.status}`);
}
- return res.json() as Promise<T>;
+ // Handle empty responses (e.g., 204 No Content)
+ const text = await res.text();
+ return text ? (JSON.parse(text) as T) : ({} as T);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/hooks/useAdminData.ts` around lines 60 - 65, The adminFetch
helper currently calls res.json() for all successful responses which will throw
for 204 No Content or non-JSON responses; update the success path in adminFetch
(the function wrapping this snippet) to first check for empty responses
(res.status === 204) or that the Content-Type header contains "application/json"
before calling res.json(), and return undefined/null (or an empty object) cast
to Promise<T> when there's no JSON body; ensure the error path remains unchanged
and keep references to res.status and res.headers.get('content-type') in the new
logic.
Description
Implements the SolFoundry admin dashboard — a full-stack management interface
for bounty operations, contributor management, review pipeline monitoring,
financial reporting, and system health observability.
The backend adds a dedicated
/api/admin/*router protected by anADMIN_API_KEYBearer token. The frontend adds a standalone/adminroutewith its own layout shell (sidebar + auth gate) that bypasses SiteLayout.
All data is fetched via React Query with auto-refresh intervals; real-time
updates use the existing
/wsWebSocket endpoint.Closes #599
Solana Wallet for Payout
Wallet: 4QhseKvBuaCQhdkP248iXoUxohPzVC5m8pE9hAv4nMYw
Type of Change
Checklist
console.logor debugging code left behindTesting
Screenshots (if applicable)
Additional Notes
New files
backend/app/api/admin.py/api/admin/*backend/tests/test_admin.pyfrontend/src/types/admin.tsfrontend/src/hooks/useAdminData.tsadminFetchhelper + token managementfrontend/src/hooks/useAdminWebSocket.tsfrontend/src/components/admin/AdminLayout.tsxfrontend/src/components/admin/OverviewPanel.tsxfrontend/src/components/admin/BountyManagement.tsxfrontend/src/components/admin/ContributorManagement.tsxfrontend/src/components/admin/ReviewPipeline.tsxfrontend/src/components/admin/FinancialPanel.tsxfrontend/src/components/admin/SystemHealth.tsxfrontend/src/components/admin/AuditLogPanel.tsxfrontend/src/pages/AdminPage.tsx?section=URL paramfrontend/src/__tests__/AdminDashboard.test.tsxModified files
backend/app/main.pyadmin_routerfrontend/src/App.tsx/admin*route (bypasses SiteLayout)Architecture decisions
ADMIN_API_KEYenv var checked against Bearer token — no DB schema change needed; stateless and easy to rotatedeque(maxlen=1000)ring buffer for instant API response without a DB round-trip; structlog stream ensures persistence via log aggregators?section=overviewparam makes each panel deep-linkable and shareable/wsWebSocket; React QueryrefetchIntervalprovides a polling fallback when WS is disconnected