Skip to content

Commit 3390917

Browse files
committed
feat: implement batch payout limit, usernames table, username search, and payment detail modal
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=<username>`, 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
1 parent b6ea810 commit 3390917

4 files changed

Lines changed: 181 additions & 17 deletions

File tree

‎contracts/contracts/social_payment/src/lib.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,10 @@ impl SocialPaymentContract {
571571
return Err(Error::Unauthorized);
572572
}
573573

574-
// #752 / #531 — enforce max batch size; return typed error on violation
574+
// #991 / #752 / #531 — enforce max recipient count before the payout loop.
575+
// Limits the `payouts` vector to MAX_BATCH_SIZE (100) elements per call,
576+
// preventing CPU / gas exhaustion from unbounded iteration in Soroban.
577+
// Returns the typed Error::BatchTooLarge so callers can inspect the failure.
575578
let batch_size = payouts.len();
576579
if batch_size > MAX_BATCH_SIZE {
577580
return Err(Error::BatchTooLarge);

‎dashboard/app/dashboard/contracts/page.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,15 @@ function UsernamesTable() {
335335
</tbody>
336336
</table>
337337
</div>
338-
{data && <p className="mt-2 text-xs text-slate-400">{data.length} active registrations</p>}
338+
{/* #1002 — active registration count badge */}
339+
{data && (
340+
<p
341+
data-testid="active-registration-count"
342+
className="mt-2 text-xs text-slate-400"
343+
>
344+
{data.length} active registration{data.length !== 1 ? "s" : ""}
345+
</p>
346+
)}
339347

340348
{/* Blacklist Confirmation Drawer */}
341349
{showBlacklistDrawer && (

‎dashboard/app/dashboard/page.tsx‎

Lines changed: 145 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,140 @@
11
"use client";
22

3-
import { useCallback } from "react";
3+
import { useCallback, useRef, useState } from "react";
4+
import { useRouter } from "next/navigation";
45
import { format } from "date-fns";
56
import StatCard from "@/components/StatCard";
6-
import { api, type AdminAuditLog } from "@/lib/api";
7+
import { api, type AdminAuditLog, type UserSearchResult } from "@/lib/api";
78
import { usePolling } from "@/lib/use-polling";
89
import { useSuperAdmin } from "@/lib/auth-context";
910

11+
// ── #1003: Username search widget ──────────────────────────────────────────────
12+
/**
13+
* Autocomplete search bar for looking up registered usernames.
14+
* Debounces calls to /api/users/search, shows a dropdown of matches,
15+
* and redirects to /dashboard/transactions?user=<username> on selection.
16+
*/
17+
function UsernameSearchBar() {
18+
const router = useRouter();
19+
const [query, setQuery] = useState("");
20+
const [results, setResults] = useState<UserSearchResult[]>([]);
21+
const [loading, setLoading] = useState(false);
22+
const [open, setOpen] = useState(false);
23+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24+
25+
const debouncedSearch = useCallback((value: string) => {
26+
if (timerRef.current) clearTimeout(timerRef.current);
27+
if (value.trim().length < 2) {
28+
setResults([]);
29+
setOpen(false);
30+
return;
31+
}
32+
timerRef.current = setTimeout(async () => {
33+
setLoading(true);
34+
try {
35+
const data = await api.searchUsers(value.trim());
36+
setResults(data);
37+
setOpen(data.length > 0);
38+
} catch {
39+
setResults([]);
40+
setOpen(false);
41+
} finally {
42+
setLoading(false);
43+
}
44+
}, 300);
45+
}, []);
46+
47+
const handleSelect = (username: string) => {
48+
setQuery("");
49+
setOpen(false);
50+
router.push(
51+
`/dashboard/transactions?user=${encodeURIComponent(username)}`,
52+
);
53+
};
54+
55+
return (
56+
<div className="relative w-full max-w-md">
57+
<div className="relative">
58+
<svg
59+
xmlns="http://www.w3.org/2000/svg"
60+
width="15"
61+
height="15"
62+
viewBox="0 0 24 24"
63+
fill="none"
64+
stroke="currentColor"
65+
strokeWidth="2"
66+
strokeLinecap="round"
67+
strokeLinejoin="round"
68+
aria-hidden="true"
69+
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"
70+
>
71+
<circle cx="11" cy="11" r="8" />
72+
<path d="m21 21-4.3-4.3" />
73+
</svg>
74+
<input
75+
id="username-lookup"
76+
type="text"
77+
value={query}
78+
onChange={(e) => {
79+
const value = e.target.value;
80+
setQuery(value);
81+
debouncedSearch(value);
82+
}}
83+
onFocus={() => results.length > 0 && setOpen(true)}
84+
onBlur={() => setTimeout(() => setOpen(false), 200)}
85+
placeholder="Search username…"
86+
autoComplete="off"
87+
aria-label="Search users by username"
88+
aria-expanded={open}
89+
aria-haspopup="listbox"
90+
role="combobox"
91+
className="w-full rounded-lg border border-slate-300 bg-white pl-9 pr-9 py-2 text-sm
92+
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200"
93+
/>
94+
{loading && (
95+
<span
96+
aria-label="Searching…"
97+
className="absolute right-3 top-2.5 h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-indigo-600"
98+
/>
99+
)}
100+
</div>
101+
102+
{open && (
103+
<ul
104+
role="listbox"
105+
aria-label="Username suggestions"
106+
className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-slate-200 bg-white shadow-lg"
107+
>
108+
{results.map((u) => (
109+
<li key={u.username} role="option" aria-selected={false}>
110+
<button
111+
type="button"
112+
onMouseDown={() => handleSelect(u.username)}
113+
className="flex w-full flex-col px-4 py-2.5 text-left text-sm hover:bg-indigo-50 transition-colors"
114+
>
115+
<span className="font-semibold text-slate-900">
116+
@{u.username}
117+
</span>
118+
<span className="text-xs text-slate-500 font-mono truncate">
119+
{u.public_key}
120+
</span>
121+
<span className="text-xs text-slate-400">
122+
Registered{" "}
123+
{new Date(u.registered_at).toLocaleDateString(undefined, {
124+
year: "numeric",
125+
month: "short",
126+
day: "numeric",
127+
})}
128+
</span>
129+
</button>
130+
</li>
131+
))}
132+
</ul>
133+
)}
134+
</div>
135+
);
136+
}
137+
10138
function fmtUsdc(value: number): string {
11139
return (
12140
value.toLocaleString(undefined, {
@@ -107,17 +235,21 @@ export default function OverviewPage() {
107235
Live engagement across recent payment feeds.
108236
</p>
109237
</div>
110-
<button
111-
onClick={handleExportUsers}
112-
disabled={!isSuperAdmin}
113-
title={!isSuperAdmin ? "Superadmin access required" : undefined}
114-
aria-disabled={!isSuperAdmin}
115-
data-testid="export-users-btn"
116-
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition-colors disabled:cursor-not-allowed disabled:opacity-40"
117-
>
118-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
119-
Export Users (CSV)
120-
</button>
238+
<div className="flex items-center gap-3">
239+
{/* #1003 — username lookup with autocomplete */}
240+
<UsernameSearchBar />
241+
<button
242+
onClick={handleExportUsers}
243+
disabled={!isSuperAdmin}
244+
title={!isSuperAdmin ? "Superadmin access required" : undefined}
245+
aria-disabled={!isSuperAdmin}
246+
data-testid="export-users-btn"
247+
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition-colors disabled:cursor-not-allowed disabled:opacity-40 whitespace-nowrap"
248+
>
249+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
250+
Export Users (CSV)
251+
</button>
252+
</div>
121253
</div>
122254

123255
{/* Social Overview */}

‎dashboard/app/dashboard/transactions/page.tsx‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
4-
import { useSearchParams } from "next/navigation";
4+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
55
import { format } from "date-fns";
66
import { api } from "@/lib/api";
77
import { usePolling } from "@/lib/use-polling";
@@ -110,8 +110,13 @@ export default function TransactionsPage() {
110110
}
111111

112112
function TransactionsPageInner() {
113+
const router = useRouter();
114+
const pathname = usePathname();
113115
const searchParams = useSearchParams();
114116
const headerQuery = searchParams.get("q") ?? "";
117+
// #1005 — auto-open the payment detail dialog when a `?user=` param is present
118+
// (set by the global SearchBar autocomplete on username selection).
119+
const userParam = searchParams.get("user") ?? "";
115120
const [search, setSearch] = useState("");
116121
const [page, setPage] = useState(0);
117122
// #795 — items-per-page state
@@ -127,6 +132,22 @@ function TransactionsPageInner() {
127132
setSelectedUser(username);
128133
}, []);
129134

135+
// #1005 — open the dialog automatically when arriving via ?user= query param
136+
useEffect(() => {
137+
if (userParam) {
138+
setSelectedUser(userParam);
139+
}
140+
}, [userParam]);
141+
142+
// #1005 — clear the ?user= param from the URL when the dialog is closed
143+
const handleDialogClose = useCallback(() => {
144+
setSelectedUser(null);
145+
const params = new URLSearchParams(searchParams.toString());
146+
params.delete("user");
147+
const qs = params.toString();
148+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
149+
}, [pathname, router, searchParams]);
150+
130151
const activeQuery = search || headerQuery;
131152

132153
useEffect(() => {
@@ -372,7 +393,7 @@ function TransactionsPageInner() {
372393
{selectedUser && (
373394
<PaymentDetailDialog
374395
username={selectedUser}
375-
onClose={() => setSelectedUser(null)}
396+
onClose={handleDialogClose}
376397
/>
377398
)}
378399
</div>

0 commit comments

Comments
 (0)