cm_bot_v2/web/components/accounts-table.tsx
yiekheng 9eed051916 feat(web,api): row-level search, more sort columns, copy buttons
Three operator-quality-of-life features behind the same caching and
pagination contract that the existing tables already use:

- Search bar on /acc and /users (Find/Enter to apply, Clear to reset).
  Backed by a new `q` API param that filters via WHERE
  username/f_username LIKE 'q%' on both rows + count queries so the
  table header total stays consistent under a filter.
- Two more sortable columns: acc.status and user.t_username. Sort
  columns are whitelisted because sort_col is f-string'd into ORDER
  BY (parameterised binding doesn't apply to column names) — anything
  outside the allowed set falls back to the table's default.
- Copy button on every row that writes a multi-line credentials
  message to the clipboard. New lib/clipboard.ts helper tries
  navigator.clipboard.writeText() first and falls back to
  textarea+execCommand("copy") so it works over the internal-network
  HTTP deploy where the modern API is gated by secure-context rules.
  Acc message: Username/Password (+Link if set). User message:
  From/To username and password.

Also: inactive sort indicators now render ↓ (the direction they'll
sort on first click) instead of the more ambiguous ↕.

Test suite grows from 53 to 70: tests/test_user_search_filter.py
(9 tests) pins the q-filter contract on both /user/ and /acc/;
tests/test_sort_whitelist.py (8 tests) pins the allowed sort columns
and proves out-of-set values cannot reach the SQL parser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:26:11 +08:00

712 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import type { Acc } from "@/lib/types";
import {
deleteAccount,
loadMoreAccounts,
refreshAccounts,
updateAccount,
} from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast";
import { copyToClipboard } from "@/lib/clipboard";
type Props = {
initial: Acc[];
initialHasMore: boolean;
initialTotal: number;
prefixPattern: string;
};
type SortDir = "asc" | "desc";
type SortKey = "username" | "status";
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
wait: { bg: "bg-amber-100", fg: "text-amber-700", label: "wait" },
done: { bg: "bg-emerald-100", fg: "text-emerald-700", label: "done" },
};
const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
return (
<span
className={`inline-flex items-center rounded-full ${v.bg} ${v.fg} px-2 py-0.5 text-[11px] font-medium`}
>
{v.label}
</span>
);
}
function DeleteButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Delete ${label}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</button>
);
}
function CopyButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Copy credentials for ${label}`}
title="Copy username and password"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<rect x="5.5" y="5.5" width="8" height="8" rx="1.25" />
<path d="M3 10.5V3.5A1 1 0 0 1 4 2.5h7" />
</svg>
</button>
);
}
export default function AccountsTable({
initial,
initialHasMore,
initialTotal,
prefixPattern,
}: Props) {
const [sortKey, setSortKey] = useState<SortKey>("username");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
// iOS Safari (and sometimes Chrome on Android) keeps the document
// scroll position across SPA route changes, so tab-switching from
// /users back to / leaves the user halfway down the page. Force
// scroll-to-top on mount; route transitions remount the table.
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, []);
// Accumulated rows from initial server-side fetch + every loadMore.
const [rows, setRows] = useState<Acc[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
// Total row count in the DB; updated on every page fetch so it stays
// fresh as external writes (the cm99 monitor) add rows. Mutations
// adjust it locally so the header doesn't flash a stale count between
// the optimistic update and the next page-fresh count.
const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null);
// `searchInput` is what the user is typing; `appliedQuery` is what
// the server actually filtered on. Decoupled so typing doesn't fire a
// request per 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<Acc[], OptimisticPatch>(
rows,
(state, patch) =>
state.map((row) =>
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
),
);
function saveCell(username: string, field: keyof Acc, value: string) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => {
applyOptimistic({ username, field, value });
const row = rows.find((r) => r.username === username);
if (!row) return resolve({ ok: false, error: "row not found" });
const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next);
if (result.ok) {
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
});
});
}
// Force-evict the cache and re-fetch page 1. Used by the create-success
// path so a freshly added row appears in its sorted position. Keeping
// the function (instead of inlining at the call site) means a future
// 'pull to refresh' gesture has a single hook.
async function refresh() {
const page = await refreshAccounts({
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 refreshAccounts({
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) {
nextDir = sortDir === "asc" ? "desc" : "asc";
} else {
nextDir = "desc";
}
setSortKey(nextKey);
setSortDir(nextDir);
setLoadingMore(true);
try {
const page = await loadMoreAccounts({
offset: 0,
prefix: prefixPattern,
sort: nextKey,
dir: nextDir,
q: appliedQuery,
});
setRows(page.rows);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setLoadingMore(false);
}
}
// Infinite scroll: when sentinel enters viewport, fetch the next page.
// 300px rootMargin so the next page starts loading before the user
// hits the bottom — feels seamless when scrolling fast.
useEffect(() => {
if (!hasMore || loadingMore) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setLoadingMore(true);
loadMoreAccounts({
offset: rows.length,
prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
q: appliedQuery,
})
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
setTotal(page.total);
})
.catch((err) => console.error("loadMoreAccounts failed:", err))
.finally(() => setLoadingMore(false));
},
{ rootMargin: "300px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir, appliedQuery]);
async function handleCopy(row: Acc) {
// Build the message line-by-line so an empty `link` (which the
// monitor sometimes leaves blank for new accounts) drops out
// entirely instead of producing a dangling "Link:" with nothing
// after it.
const lines = [
`Username: ${row.username}`,
`Password: ${row.password}`,
];
if (row.link) lines.push(`Link: ${row.link}`);
const text = lines.join("\n");
const ok = await copyToClipboard(text);
setToast(
ok
? { type: "success", message: `Copied credentials for ${row.username}` }
: {
type: "error",
message: `Could not copy — clipboard access blocked. Select text manually.`,
},
);
}
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
const result = await deleteAccount(deleteTarget);
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.username !== deleted));
setTotal((t) => Math.max(0, t - 1));
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
}
const onSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
void applySearch(searchInput.trim());
};
const onSearchClear = () => {
setSearchInput("");
void applySearch("");
};
function HeaderTh({ k, label }: { k: SortKey; label: string }) {
const active = sortKey === k;
return (
<button
type="button"
onClick={() => changeSort(k)}
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
}`}
>
{label}
<span aria-hidden="true">{active && sortDir === "asc" ? "↑" : "↓"}</span>
</button>
);
}
if (rows.length === 0 && !appliedQuery) {
return (
<div>
<PageHead
total={total}
loaded={0}
onAdd={() => setCreateOpen(true)}
/>
<SearchBar
value={searchInput}
onChange={setSearchInput}
onSubmit={onSearchSubmit}
onClear={onSearchClear}
appliedQuery={appliedQuery}
searching={searching}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
</p>
</div>
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` });
// Force-refresh from page 1 so the new row appears in its
// sorted position. (We don't know where it ranks otherwise.)
await refresh();
}}
prefixPattern={prefixPattern}
/>
</div>
);
}
return (
<div>
<PageHead
total={total}
loaded={optimistic.length}
onAdd={() => setCreateOpen(true)}
/>
<SearchBar
value={searchInput}
onChange={setSearchInput}
onSubmit={onSearchSubmit}
onClear={onSearchClear}
appliedQuery={appliedQuery}
searching={searching}
/>
{rows.length === 0 && appliedQuery && (
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">
No accounts match <span className="font-medium text-zinc-700">{appliedQuery}</span>.
{" "}
<button
type="button"
onClick={onSearchClear}
className="font-medium text-zinc-700 underline-offset-2 hover:underline"
>
Clear search
</button>
.
</p>
</div>
)}
{rows.length > 0 && (
<>
{/* Desktop / tablet table */}
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left">
<HeaderTh k="username" label="Username" />
</th>
<Th>Password</Th>
<th className="w-[16%] px-5 py-3 text-left">
<HeaderTh k="status" label="Status" />
</th>
<Th>Link</Th>
<th className="w-20 px-3 py-3" aria-hidden="true" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
{row.username}
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
isCurrentlyEditing={editingKey === k("password")}
onEditStart={() => setEditingKey(k("password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
renderView={(v) => <StatusBadge status={v} />}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
isCurrentlyEditing={editingKey === k("link")}
onEditStart={() => setEditingKey(k("link"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</td>
<td className="px-3 py-3 text-right align-middle">
<div className="inline-flex items-center gap-1">
<CopyButton
label={row.username}
onClick={() => handleCopy(row)}
/>
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden">
{optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="shrink-0 font-mono text-base font-semibold text-zinc-900">
{row.username}
</span>
<div className="min-w-0 flex-1">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
renderView={(v) => <StatusBadge status={v} />}
/>
</div>
</div>
<div className="flex items-center gap-1">
<CopyButton
label={row.username}
onClick={() => handleCopy(row)}
/>
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</div>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<CardRow label="Password">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
isCurrentlyEditing={editingKey === k("password")}
onEditStart={() => setEditingKey(k("password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</CardRow>
<CardRow label="Link">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
isCurrentlyEditing={editingKey === k("link")}
onEditStart={() => setEditingKey(k("link"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</CardRow>
</dl>
</div>
);
})}
</div>
{/* Sentinel for infinite scroll. Hidden visually unless we're at
the bottom; the IntersectionObserver triggers loadMore as it
comes into view. */}
<div ref={sentinelRef} className="h-10" aria-hidden="true" />
{loadingMore && (
<p className="mt-4 text-center text-[11px] font-medium uppercase tracking-wider text-zinc-400">
Loading more
</p>
)}
{!hasMore && rows.length > 0 && (
<p className="mt-6 text-center text-[11px] text-zinc-400">
End of list {rows.length} accounts loaded
</p>
)}
</>
)}
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` });
await refresh();
}}
prefixPattern={prefixPattern}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDelete}
title={`Delete ${deleteTarget ?? ""}?`}
message={
<>
<p>
Permanently remove{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
{deleteTarget}
</code>{" "}
from the accounts table. This action cannot be undone.
</p>
{deleteError && (
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
{deleteError}
</p>
)}
</>
}
confirmLabel="Delete"
destructive
pending={deleting}
/>
</div>
);
}
function SearchBar({
value,
onChange,
onSubmit,
onClear,
appliedQuery,
searching,
}: {
value: string;
onChange: (v: string) => void;
onSubmit: (e: React.FormEvent) => void;
onClear: () => void;
appliedQuery: string;
searching: boolean;
}) {
return (
<form onSubmit={onSubmit} role="search" className="mt-4 flex flex-wrap items-center gap-2">
<label className="sr-only" htmlFor="accounts-search">Search by username</label>
<input
id="accounts-search"
type="search"
autoComplete="off"
spellCheck={false}
placeholder="Search username (e.g. 13c4511)"
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-w-0 flex-1 rounded-full bg-white px-4 py-1.5 text-sm text-zinc-900 ring-1 ring-zinc-200/60 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900"
/>
<button
type="submit"
disabled={searching}
className="inline-flex items-center rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{searching ? "Finding…" : "Find"}
</button>
{appliedQuery && (
<button
type="button"
onClick={onClear}
disabled={searching}
className="inline-flex items-center rounded-full bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 ring-1 ring-zinc-200/60 transition-colors hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Clear
</button>
)}
{appliedQuery && (
<span className="text-[11px] text-zinc-500">
Filtered by <span className="font-mono text-zinc-700">{appliedQuery}</span>
</span>
)}
</form>
);
}
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th
className={`px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500 ${className}`}
>
{children}
</th>
);
}
function CardRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{label}
</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function PageHead({
total,
loaded,
onAdd,
}: {
total: number;
loaded: number;
onAdd: () => void;
}) {
// total = COUNT(*) from the API; loaded = how many rows the user has
// scrolled in so far. Show the partial count only while it differs
// from the total, so a fully-scrolled list reads cleanly.
const showLoaded = loaded > 0 && loaded < total;
return (
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Table
</p>
<div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{total}
</span>
</h1>
<button
type="button"
onClick={onAdd}
aria-label="Add account"
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total}
</p>
)}
</div>
);
}