No accounts yet. Click Add above to create one manually, or wait for the monitor.
@@ -231,26 +363,47 @@ export default function AccountsTable({
loaded={optimistic.length}
onAdd={() => setCreateOpen(true)}
/>
+
+ {rows.length === 0 && appliedQuery && (
+
+
+ No accounts match {appliedQuery}.
+ {" "}
+
+ .
+
+
+ )}
+ {rows.length > 0 && (
+ <>
{/* Desktop / tablet table */}
|
-
+
|
Password |
- Status |
+
+
+ |
Link |
- |
+ |
@@ -293,13 +446,19 @@ export default function AccountsTable({
/>
- {
- setDeleteError(null);
- setDeleteTarget(row.username);
- }}
- />
+
+ handleCopy(row)}
+ />
+ {
+ setDeleteError(null);
+ setDeleteTarget(row.username);
+ }}
+ />
+
|
);
@@ -331,13 +490,19 @@ export default function AccountsTable({
/>
- {
- setDeleteError(null);
- setDeleteTarget(row.username);
- }}
- />
+
+ handleCopy(row)}
+ />
+ {
+ setDeleteError(null);
+ setDeleteTarget(row.username);
+ }}
+ />
+
@@ -380,6 +545,8 @@ export default function AccountsTable({
End of list — {rows.length} accounts loaded
)}
+ >
+ )}
void;
+ onSubmit: (e: React.FormEvent) => void;
+ onClear: () => void;
+ appliedQuery: string;
+ searching: boolean;
+}) {
+ return (
+
+ );
+}
+
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
;
@@ -60,6 +61,38 @@ function DeleteButton({
);
}
+function CopyButton({
+ label,
+ onClick,
+}: {
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
export default function UsersTable({
initial,
initialHasMore,
@@ -89,6 +122,15 @@ export default function UsersTable({
const [total, setTotal] = useState(initialTotal);
const sentinelRef = useRef(null);
+ // `searchInput` is what the user is typing; `appliedQuery` is what
+ // the server actually filtered on. Decoupling them means the input
+ // doesn't fire a request on every keystroke — only on Enter / Find /
+ // Clear. `appliedQuery` flows into refresh / changeSort / loadMore so
+ // sort + infinite-scroll respect the active search.
+ const [searchInput, setSearchInput] = useState("");
+ const [appliedQuery, setAppliedQuery] = useState("");
+ const [searching, setSearching] = useState(false);
+
const [optimistic, applyOptimistic] = useOptimistic(
rows,
(state, patch) =>
@@ -136,12 +178,31 @@ export default function UsersTable({
prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
+ q: appliedQuery,
});
setRows(page.rows);
setHasMore(page.hasMore);
setTotal(page.total);
}
+ async function applySearch(nextQuery: string) {
+ setSearching(true);
+ setAppliedQuery(nextQuery);
+ try {
+ const page = await refreshUsers({
+ prefix: prefixPattern,
+ sort: sortKey,
+ dir: sortDir,
+ q: nextQuery,
+ });
+ setRows(page.rows);
+ setHasMore(page.hasMore);
+ setTotal(page.total);
+ } finally {
+ setSearching(false);
+ }
+ }
+
async function changeSort(nextKey: SortKey) {
let nextDir: SortDir;
if (nextKey === sortKey) {
@@ -158,6 +219,7 @@ export default function UsersTable({
prefix: prefixPattern,
sort: nextKey,
dir: nextDir,
+ q: appliedQuery,
});
setRows(page.rows);
setHasMore(page.hasMore);
@@ -180,6 +242,7 @@ export default function UsersTable({
prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
+ q: appliedQuery,
})
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
@@ -193,7 +256,24 @@ export default function UsersTable({
);
observer.observe(sentinel);
return () => observer.disconnect();
- }, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir]);
+ }, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir, appliedQuery]);
+
+ async function handleCopy(row: User) {
+ const text =
+ `From Username: ${row.f_username}\n` +
+ `From Password: ${row.f_password}\n` +
+ `To Username: ${row.t_username}\n` +
+ `To Password: ${row.t_password}`;
+ const ok = await copyToClipboard(text);
+ setToast(
+ ok
+ ? { type: "success", message: `Copied credentials for ${row.f_username}` }
+ : {
+ type: "error",
+ message: `Could not copy — clipboard access blocked. Select text manually.`,
+ },
+ );
+ }
async function confirmDelete() {
if (!deleteTarget) return;
@@ -223,12 +303,21 @@ export default function UsersTable({
}`}
>
{label}
- {active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}
+ {active && sortDir === "asc" ? "↑" : "↓"}
);
}
- if (rows.length === 0) {
+ const onSearchSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ void applySearch(searchInput.trim());
+ };
+ const onSearchClear = () => {
+ setSearchInput("");
+ void applySearch("");
+ };
+
+ if (rows.length === 0 && !appliedQuery) {
return (
setCreateOpen(true)}
/>
+
No users yet. Click Add to create one manually.
@@ -261,7 +358,33 @@ export default function UsersTable({
loaded={optimistic.length}
onAdd={() => setCreateOpen(true)}
/>
+
+ {rows.length === 0 && appliedQuery && (
+
+
+ No users match {appliedQuery}.
+ {" "}
+
+ .
+
+
+ )}
+ {rows.length > 0 && (
+ <>
@@ -272,8 +395,8 @@ export default function UsersTable({
|
From password
|
-
- To username
+ |
+
|
To password
@@ -281,7 +404,7 @@ export default function UsersTable({
|
|
- |
+ |
@@ -326,13 +449,19 @@ export default function UsersTable({
{formatTime(row.last_update_time)}
- {
- setDeleteError(null);
- setDeleteTarget(row.f_username);
- }}
- />
+
+ handleCopy(row)}
+ />
+ {
+ setDeleteError(null);
+ setDeleteTarget(row.f_username);
+ }}
+ />
+
|
);
@@ -354,6 +483,10 @@ export default function UsersTable({
{formatTime(row.last_update_time)}
+ handleCopy(row)}
+ />
{
@@ -411,6 +544,8 @@ export default function UsersTable({
End of list — {rows.length} users loaded
)}
+ >
+ )}
void;
+ onSubmit: (e: React.FormEvent) => void;
+ onClear: () => void;
+ appliedQuery: string;
+ searching: boolean;
+}) {
+ return (
+
+ );
+}
+
function MobileRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
diff --git a/web/lib/api.ts b/web/lib/api.ts
index e107fd0..dcf7d5e 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -20,14 +20,17 @@ export type Page = { rows: T[]; hasMore: boolean; total: number };
export type AccountsPageOpts = {
offset?: number;
prefix?: string;
+ sort?: "username" | "status";
dir?: "asc" | "desc";
+ q?: string;
};
export type UsersPageOpts = {
offset?: number;
prefix?: string;
- sort?: "f_username" | "last_update_time";
+ sort?: "f_username" | "t_username" | "last_update_time";
dir?: "asc" | "desc";
+ q?: string;
};
type FetchInit = {
@@ -57,18 +60,7 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise {
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fall through to legacy path — secure-context block, permission
+ // denial, or any other modern-API failure.
+ }
+ }
+
+ if (typeof document === "undefined") return false;
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.setAttribute("readonly", "");
+ // Position offscreen but still focusable; some browsers refuse to
+ // copy from elements that are display:none or visibility:hidden.
+ ta.style.position = "fixed";
+ ta.style.top = "0";
+ ta.style.left = "0";
+ ta.style.opacity = "0";
+ ta.style.pointerEvents = "none";
+ document.body.appendChild(ta);
+ try {
+ ta.focus();
+ ta.select();
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ document.body.removeChild(ta);
+ }
+}
|