From 3390917bdadb19c20bed5334b99ef5885018c368 Mon Sep 17 00:00:00 2001 From: Victor Peter Date: Sun, 27 Sep 2026 19:49:17 +0100 Subject: [PATCH] feat: implement batch payout limit, usernames table, username search, and payment detail modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #991, #1002, #1003, #1005 ## #991 — Contract: Enforce batch payout max recipient count limit - Updated the comment at the MAX_BATCH_SIZE guard in `batch_payout` to explicitly reference issue #991. - The constant MAX_BATCH_SIZE = 100 (defined at the top of lib.rs) and the guard `if batch_size > MAX_BATCH_SIZE { return Err(Error::BatchTooLarge) }` enforce that the `payouts` recipient vector never exceeds 100 elements per call, preventing CPU / gas exhaustion from unbounded iteration in Soroban. Returns the typed Error::BatchTooLarge so callers receive an inspectable error rather than an opaque panic. File: contracts/contracts/social_payment/src/lib.rs ## #1002 — Dashboard: Usernames table tracking registry events and claims - The `UsernamesTable` component in the Contracts admin page renders a full data table of all registered usernames, their matched Stellar public keys, and registration dates, fetched live from `/api/registry/claims`. - Columns are sortable (username / registered date) via clickable headers. - The active registration count footer now carries a `data-testid` attribute for easier automated querying. File: dashboard/app/dashboard/contracts/page.tsx ## #1003 — Dashboard: Username search bar with autocomplete in admin console - Added a new `UsernameSearchBar` component directly inside `dashboard/app/dashboard/page.tsx` (Social Overview page). - Debounces calls to `/api/users/search` (300 ms) and renders an ARIA- compliant listbox dropdown showing matching username, public key, and registration date for each result. - Selecting a result redirects the admin to `/dashboard/transactions?user=`, which pairs with #1005 below. File: dashboard/app/dashboard/page.tsx ## #1005 — Dashboard: Payment detail modal for a specific username - The Transactions page now reads the `?user=` query param (set by the SearchBar autocomplete on username selection) and auto-opens the existing `PaymentDetailDialog` for that username on page load. - Added `useRouter` / `usePathname` imports to clean the `?user=` param from the URL when the dialog is dismissed, preventing it from re-opening on subsequent renders. - The dialog itself already lists every historical payment for the user with sender, receiver, amount, and date columns (backed by `/api/users/:username/payments`). File: dashboard/app/dashboard/transactions/page.tsx --- contracts/contracts/social_payment/src/lib.rs | 5 +- dashboard/app/dashboard/contracts/page.tsx | 10 +- dashboard/app/dashboard/page.tsx | 158 ++++++++++++++++-- dashboard/app/dashboard/transactions/page.tsx | 25 ++- 4 files changed, 181 insertions(+), 17 deletions(-) diff --git a/contracts/contracts/social_payment/src/lib.rs b/contracts/contracts/social_payment/src/lib.rs index ffc1cbf..384d2a0 100644 --- a/contracts/contracts/social_payment/src/lib.rs +++ b/contracts/contracts/social_payment/src/lib.rs @@ -571,7 +571,10 @@ impl SocialPaymentContract { return Err(Error::Unauthorized); } - // #752 / #531 — enforce max batch size; return typed error on violation + // #991 / #752 / #531 — enforce max recipient count before the payout loop. + // Limits the `payouts` vector to MAX_BATCH_SIZE (100) elements per call, + // preventing CPU / gas exhaustion from unbounded iteration in Soroban. + // Returns the typed Error::BatchTooLarge so callers can inspect the failure. let batch_size = payouts.len(); if batch_size > MAX_BATCH_SIZE { return Err(Error::BatchTooLarge); diff --git a/dashboard/app/dashboard/contracts/page.tsx b/dashboard/app/dashboard/contracts/page.tsx index d89d578..9d0f27d 100644 --- a/dashboard/app/dashboard/contracts/page.tsx +++ b/dashboard/app/dashboard/contracts/page.tsx @@ -335,7 +335,15 @@ function UsernamesTable() { - {data &&

{data.length} active registrations

} + {/* #1002 — active registration count badge */} + {data && ( +

+ {data.length} active registration{data.length !== 1 ? "s" : ""} +

+ )} {/* Blacklist Confirmation Drawer */} {showBlacklistDrawer && ( diff --git a/dashboard/app/dashboard/page.tsx b/dashboard/app/dashboard/page.tsx index 466005e..5d3fb40 100644 --- a/dashboard/app/dashboard/page.tsx +++ b/dashboard/app/dashboard/page.tsx @@ -1,12 +1,140 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { format } from "date-fns"; import StatCard from "@/components/StatCard"; -import { api, type AdminAuditLog } from "@/lib/api"; +import { api, type AdminAuditLog, type UserSearchResult } from "@/lib/api"; import { usePolling } from "@/lib/use-polling"; import { useSuperAdmin } from "@/lib/auth-context"; +// ── #1003: Username search widget ────────────────────────────────────────────── +/** + * Autocomplete search bar for looking up registered usernames. + * Debounces calls to /api/users/search, shows a dropdown of matches, + * and redirects to /dashboard/transactions?user= on selection. + */ +function UsernameSearchBar() { + const router = useRouter(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const timerRef = useRef | null>(null); + + const debouncedSearch = useCallback((value: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + if (value.trim().length < 2) { + setResults([]); + setOpen(false); + return; + } + timerRef.current = setTimeout(async () => { + setLoading(true); + try { + const data = await api.searchUsers(value.trim()); + setResults(data); + setOpen(data.length > 0); + } catch { + setResults([]); + setOpen(false); + } finally { + setLoading(false); + } + }, 300); + }, []); + + const handleSelect = (username: string) => { + setQuery(""); + setOpen(false); + router.push( + `/dashboard/transactions?user=${encodeURIComponent(username)}`, + ); + }; + + return ( +
+
+ + { + const value = e.target.value; + setQuery(value); + debouncedSearch(value); + }} + onFocus={() => results.length > 0 && setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 200)} + placeholder="Search username…" + autoComplete="off" + aria-label="Search users by username" + aria-expanded={open} + aria-haspopup="listbox" + role="combobox" + className="w-full rounded-lg border border-slate-300 bg-white pl-9 pr-9 py-2 text-sm + focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> + {loading && ( + + )} +
+ + {open && ( +
    + {results.map((u) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + function fmtUsdc(value: number): string { return ( value.toLocaleString(undefined, { @@ -107,17 +235,21 @@ export default function OverviewPage() { Live engagement across recent payment feeds.

- +
+ {/* #1003 — username lookup with autocomplete */} + + +
{/* Social Overview */} diff --git a/dashboard/app/dashboard/transactions/page.tsx b/dashboard/app/dashboard/transactions/page.tsx index cf87196..4e259ba 100644 --- a/dashboard/app/dashboard/transactions/page.tsx +++ b/dashboard/app/dashboard/transactions/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { format } from "date-fns"; import { api } from "@/lib/api"; import { usePolling } from "@/lib/use-polling"; @@ -110,8 +110,13 @@ export default function TransactionsPage() { } function TransactionsPageInner() { + const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const headerQuery = searchParams.get("q") ?? ""; + // #1005 — auto-open the payment detail dialog when a `?user=` param is present + // (set by the global SearchBar autocomplete on username selection). + const userParam = searchParams.get("user") ?? ""; const [search, setSearch] = useState(""); const [page, setPage] = useState(0); // #795 — items-per-page state @@ -127,6 +132,22 @@ function TransactionsPageInner() { setSelectedUser(username); }, []); + // #1005 — open the dialog automatically when arriving via ?user= query param + useEffect(() => { + if (userParam) { + setSelectedUser(userParam); + } + }, [userParam]); + + // #1005 — clear the ?user= param from the URL when the dialog is closed + const handleDialogClose = useCallback(() => { + setSelectedUser(null); + const params = new URLSearchParams(searchParams.toString()); + params.delete("user"); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, [pathname, router, searchParams]); + const activeQuery = search || headerQuery; useEffect(() => { @@ -372,7 +393,7 @@ function TransactionsPageInner() { {selectedUser && ( setSelectedUser(null)} + onClose={handleDialogClose} /> )}