Skip to content

Implement full stack admin dashboard#644

Closed
codebestia wants to merge 2 commits intoSolFoundry:mainfrom
codebestia:feat/full-stack-admin-dashboard
Closed

Implement full stack admin dashboard#644
codebestia wants to merge 2 commits intoSolFoundry:mainfrom
codebestia:feat/full-stack-admin-dashboard

Conversation

@codebestia
Copy link
Copy Markdown
Contributor

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 an
ADMIN_API_KEY Bearer token. The frontend adds a standalone /admin route
with 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 /ws WebSocket endpoint.

Closes #599

Solana Wallet for Payout

Wallet: 4QhseKvBuaCQhdkP248iXoUxohPzVC5m8pE9hAv4nMYw

Type of Change

  • 🐛 Bug fix
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change
  • 📝 Documentation update
  • 🎨 Style/UI update
  • ♻️ Code refactoring
  • ⚡ Performance improvement
  • ✅ Test addition/update

Checklist

  • Code is clean and follows the issue spec exactly
  • One PR per bounty (no multiple bounties in one PR)
  • Tests included for new functionality
  • All existing tests pass
  • No console.log or debugging code left behind
  • No hardcoded secrets or API keys

Testing

  • Manual testing performed
  • Unit tests added/updated
  • Integration tests added/updated

Screenshots (if applicable)

Additional Notes

New files

File Purpose
backend/app/api/admin.py 12 protected REST endpoints under /api/admin/*
backend/tests/test_admin.py 33 pytest tests (auth, bounties, contributors, financial, audit log)
frontend/src/types/admin.ts Shared TypeScript interfaces for all admin API responses
frontend/src/hooks/useAdminData.ts React Query hooks + adminFetch helper + token management
frontend/src/hooks/useAdminWebSocket.ts WebSocket hook with exponential-backoff reconnection
frontend/src/components/admin/AdminLayout.tsx Sidebar shell + auth gate login form
frontend/src/components/admin/OverviewPanel.tsx 6-stat card grid + bounty breakdown
frontend/src/components/admin/BountyManagement.tsx Table with search/filter + edit/close modal
frontend/src/components/admin/ContributorManagement.tsx Table with ban/unban modal + reason validation
frontend/src/components/admin/ReviewPipeline.tsx Active reviews + pass-rate + avg-score metrics
frontend/src/components/admin/FinancialPanel.tsx Distribution summary + paginated payout history
frontend/src/components/admin/SystemHealth.tsx Service badges + uptime + queue depth + WS connections
frontend/src/components/admin/AuditLogPanel.tsx Filterable timestamped audit log
frontend/src/pages/AdminPage.tsx Route entry point; section switching via ?section= URL param
frontend/src/__tests__/AdminDashboard.test.tsx 17 Vitest tests for all panels

Modified files

File Change
backend/app/main.py Import and register admin_router
frontend/src/App.tsx Add /admin* route (bypasses SiteLayout)

Architecture decisions

  • Admin auth: ADMIN_API_KEY env var checked against Bearer token — no DB schema change needed; stateless and easy to rotate
  • In-process audit log: deque(maxlen=1000) ring buffer for instant API response without a DB round-trip; structlog stream ensures persistence via log aggregators
  • No SiteLayout for admin: The admin shell is self-contained (its own sidebar, header, auth state) to avoid coupling to wallet/user context
  • URL-driven navigation: ?section=overview param makes each panel deep-linkable and shareable
  • Real-time: Connects to existing /ws WebSocket; React Query refetchInterval provides a polling fallback when WS is disconnected

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 22, 2026

📝 Walkthrough

Walkthrough

Adds 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

approved, paid

Suggested reviewers

  • chronoeth-creator
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive PR implements substantial admin dashboard features but diverges from key acceptance criteria: uses single ADMIN_API_KEY instead of GitHub OAuth RBAC [#599], in-memory audit log instead of PostgreSQL persistence [#599], and lacks confirmed end-to-end WebSocket integration for real-time bounty/review events [#599]. Address authentication model (GitHub OAuth with admin/reviewer/viewer roles), migrate audit log to PostgreSQL with schema/migrations, and implement end-to-end WebSocket event handling for bounty claims, PR submissions, and review results.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title 'Implement full stack admin dashboard' clearly and concisely summarizes the primary change: adding a complete admin dashboard with backend and frontend components.
Description check ✅ Passed Description is comprehensive and directly related to the changeset, detailing objectives, architectural decisions, file changes, and testing approach for the admin dashboard implementation.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the admin dashboard as specified in issue #599; no unrelated modifications or scope creep detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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 | 🟠 Major

Security gap: /api/sync endpoint lacks authentication despite triggering admin-level operations.

The /api/sync endpoint (lines 379-391) has no authentication protection, unlike the admin router routes which are guarded by ADMIN_API_KEY Bearer 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_key dependency) 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

📥 Commits

Reviewing files that changed from the base of the PR and between ac12b07 and 809d0d1.

📒 Files selected for processing (17)
  • backend/app/api/admin.py
  • backend/app/main.py
  • backend/tests/test_admin.py
  • frontend/src/App.tsx
  • frontend/src/__tests__/AdminDashboard.test.tsx
  • frontend/src/components/admin/AdminLayout.tsx
  • frontend/src/components/admin/AuditLogPanel.tsx
  • frontend/src/components/admin/BountyManagement.tsx
  • frontend/src/components/admin/ContributorManagement.tsx
  • frontend/src/components/admin/FinancialPanel.tsx
  • frontend/src/components/admin/OverviewPanel.tsx
  • frontend/src/components/admin/ReviewPipeline.tsx
  • frontend/src/components/admin/SystemHealth.tsx
  • frontend/src/hooks/useAdminData.ts
  • frontend/src/hooks/useAdminWebSocket.ts
  • frontend/src/pages/AdminPage.tsx
  • frontend/src/types/admin.ts

Comment on lines +35 to +36
# In-process audit ring buffer (last 1 000 admin actions)
_audit_log: deque[Dict[str, Any]] = deque(maxlen=1_000)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +279 to +281
total_fndry = sum(
b.reward_amount for b in bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED)
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +356 to +385
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}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +598 to +616
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)
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

/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>}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +29 to +35
export function setAdminToken(token: string): void {
if (token) {
sessionStorage.setItem(STORAGE_KEY, token);
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Comment on lines +31 to +83
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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +40 to +42
setStatus('connecting');
const ws = new WebSocket(`${WS_BASE}/ws?token=${encodeURIComponent(token)}`);
wsRef.current = ws;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Comment on lines +19 to +20
const [searchParams, setSearchParams] = useSearchParams();
const section = (searchParams.get('section') ?? DEFAULT_SECTION) as AdminSection;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

♻️ Duplicate comments (8)
frontend/src/components/admin/BountyManagement.tsx (2)

316-316: ⚠️ Potential issue | 🟡 Minor

Accessibility: 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 | 🟡 Minor

Mutation errors do not prevent modal closure.

Both handleSave (line 37-38) and handleClose (line 42-43) call onClose() unconditionally after await mutateAsync(...). If the mutation throws, the promise rejects and onClose() is never reached—but if it succeeds silently with an error status handled by React Query, the modal closes. More critically, neither handler has a try/catch, so unhandled rejections could occur.

Additionally, close.isError is never rendered (only update.isError at line 94-96), so Force Close failures are invisible to users.

— Past reviews flagged missing close.isError display 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.isError display 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

setAdminToken accepts whitespace-only strings as valid tokens.

The check if (token) evaluates to true for 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 | 🟡 Minor

Contributor endpoints return ok as string instead of boolean.

Lines 647 and 666 return {"ok": "true"} (string) while other endpoints like create_bounty_admin (line 486) return {"ok": True} (boolean). This API contract inconsistency complicates frontend handling.

— Part of the past review comment about inconsistent ok field 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

COMPLETED bounties counted as both paid and pending.

Line 728 includes COMPLETED in paid (total distributed), and line 729 includes COMPLETED in pending (awaiting payout). This double-counting inflates metrics. A bounty in COMPLETED state means work is done but payout may not have occurred; it should only be in pending until moved to PAID.

— 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 | 🟠 Major

Payout history uses bounty creation metadata instead of payout metadata.

  • Line 766: winner falls back to created_by (bounty author, not winner)
  • Line 770: completed_at uses b.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 | 🟡 Minor

Inconsistent ok field 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.CANCELLED should also be .value for 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 | 🟠 Major

System health ignores Redis status; webhook metric is audit count, not webhooks.

  1. Line 845: Overall status is "healthy" if database is connected, regardless of Redis being "disconnected". Real-time features depend on Redis; its failure should degrade health.

  2. Line 855: webhook_events_processed is populated from AdminAuditLogTable count, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 809d0d1 and 56468a2.

📒 Files selected for processing (11)
  • backend/alembic/versions/003_add_admin_audit_log.py
  • backend/app/api/admin.py
  • backend/app/models/tables.py
  • frontend/src/components/admin/AdminLayout.tsx
  • frontend/src/components/admin/AuditLogPanel.tsx
  • frontend/src/components/admin/BountyManagement.tsx
  • frontend/src/components/admin/ContributorManagement.tsx
  • frontend/src/components/admin/SystemHealth.tsx
  • frontend/src/hooks/useAdminData.ts
  • frontend/src/pages/AdminPage.tsx
  • frontend/src/types/admin.ts

Comment on lines +103 to +107
except HTTPException:
raise
except Exception:
# Not a valid JWT — fall through to API key check
pass
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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:

  1. A user with an expired JWT will silently fall back to API key auth instead of getting a clear "token expired" error.
  2. 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
+        pass

This 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.

Comment on lines +161 to +179
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Comment on lines +269 to +273
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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 v

This 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.

Comment on lines +612 to +613
except Exception:
rows = []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +892 to +893
except Exception:
return AuditLogResponse(entries=[], total=0)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +349 to +350
<td className="px-4 py-3 text-right tabular-nums text-[#14F195]">
{b.reward_amount.toLocaleString()}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
<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>}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
{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).

Comment on lines +52 to +58
{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>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
{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" />
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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).

Comment on lines +60 to +65
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

@codebestia codebestia closed this Mar 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant