From 9eed051916ccfe195be689de0a7148f99215c114 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Mon, 4 May 2026 09:26:11 +0800 Subject: [PATCH] feat(web,api): row-level search, more sort columns, copy buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/cm_api.py | 45 +++-- tests/test_sort_whitelist.py | 153 ++++++++++++++++ tests/test_user_search_filter.py | 214 ++++++++++++++++++++++ web/components/accounts-table.tsx | 283 ++++++++++++++++++++++++++---- web/components/users-table.tsx | 217 +++++++++++++++++++++-- web/lib/api.ts | 32 ++-- web/lib/clipboard.ts | 46 +++++ 7 files changed, 921 insertions(+), 69 deletions(-) create mode 100644 tests/test_sort_whitelist.py create mode 100644 tests/test_user_search_filter.py create mode 100644 web/lib/clipboard.ts diff --git a/app/cm_api.py b/app/cm_api.py index b8f25f7..095f8f2 100644 --- a/app/cm_api.py +++ b/app/cm_api.py @@ -106,29 +106,40 @@ class CM_API: except (TypeError, ValueError): return jsonify({"error": "limit and offset must be integers"}), 400 prefix = (request.args.get('prefix') or '').strip() + # `q` is the user-facing search filter (WHERE), distinct from + # `prefix` which is the deployment tenant boost (ORDER BY). + q = (request.args.get('q') or '').strip() + sort_arg = request.args.get('sort', 'username') + # Whitelist the sort column (see /user/ for rationale). + sort_col = sort_arg if sort_arg in ('username', 'status') else 'username' # Whitelist direction so it's safe to interpolate into the # ORDER BY clause (parameterised binding doesn't apply to # column names or sort directions). direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC' + where_sql = "WHERE username LIKE %s" if q else "" + where_params = [f"{q}%"] if q else [] + if prefix: query = ( "SELECT username, password, status, link FROM acc " + f"{where_sql} " "ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), " - f"username {direction} " + f"{sort_col} {direction} " "LIMIT %s OFFSET %s" ) - params = [f"{prefix}%", limit, offset] + params = [*where_params, f"{prefix}%", limit, offset] else: query = ( "SELECT username, password, status, link FROM acc " - f"ORDER BY username {direction} " + f"{where_sql} " + f"ORDER BY {sort_col} {direction} " "LIMIT %s OFFSET %s" ) - params = [limit, offset] + params = [*where_params, limit, offset] rows = db.query(query, params) - count_rows = db.query("SELECT COUNT(*) AS c FROM acc", []) + count_rows = db.query(f"SELECT COUNT(*) AS c FROM acc {where_sql}", where_params) total = int(count_rows[0]["c"]) if count_rows else 0 return jsonify({"rows": rows, "total": total}) @@ -152,30 +163,42 @@ class CM_API: except (TypeError, ValueError): return jsonify({"error": "limit and offset must be integers"}), 400 prefix = (request.args.get('prefix') or '').strip() + # `q` is the user-facing search filter (WHERE), distinct from + # `prefix` which is the deployment tenant boost (ORDER BY). + # Both can be active at once — search refines what the + # tenant prefix already promotes. + q = (request.args.get('q') or '').strip() sort_arg = request.args.get('sort', 'last_update_time') - sort_col = 'f_username' if sort_arg == 'f_username' else 'last_update_time' + # Whitelist the sort column — `sort_col` is f-string'd into + # the ORDER BY clause, which can't be parameterised. Anything + # outside the allowed set falls back to the default so a + # malformed query never hits the SQL parser unsanitised. + sort_col = sort_arg if sort_arg in ('f_username', 't_username', 'last_update_time') else 'last_update_time' direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC' + where_sql = "WHERE f_username LIKE %s" if q else "" + where_params = [f"{q}%"] if q else [] + if prefix: query = ( "SELECT f_username, f_password, t_username, t_password, last_update_time " - "FROM user " + f"FROM user {where_sql} " "ORDER BY (CASE WHEN f_username LIKE %s THEN 0 ELSE 1 END), " f"{sort_col} {direction} " "LIMIT %s OFFSET %s" ) - params = [f"{prefix}%", limit, offset] + params = [*where_params, f"{prefix}%", limit, offset] else: query = ( "SELECT f_username, f_password, t_username, t_password, last_update_time " - "FROM user " + f"FROM user {where_sql} " f"ORDER BY {sort_col} {direction} " "LIMIT %s OFFSET %s" ) - params = [limit, offset] + params = [*where_params, limit, offset] rows = db.query(query, params) - count_rows = db.query("SELECT COUNT(*) AS c FROM user", []) + count_rows = db.query(f"SELECT COUNT(*) AS c FROM user {where_sql}", where_params) total = int(count_rows[0]["c"]) if count_rows else 0 return jsonify({"rows": rows, "total": total}) diff --git a/tests/test_sort_whitelist.py b/tests/test_sort_whitelist.py new file mode 100644 index 0000000..3658b47 --- /dev/null +++ b/tests/test_sort_whitelist.py @@ -0,0 +1,153 @@ +"""Tests for the `sort` parameter whitelist on /user/ and /acc/. + +`sort_col` is f-string-interpolated into ORDER BY (parameterised +binding doesn't apply to column names), so an unsanitised value would +be a SQL injection vector. The whitelist is the safety boundary — +these tests pin both the allowed-values matrix AND the safe fallback +for any out-of-set value. + +Allowed sort columns: + /user/ → f_username, t_username, last_update_time + /acc/ → username, status + +Out-of-set values must fall back to the table's default sort column, +NEVER appear in the SQL. +""" + +import unittest +from unittest import mock + + +def _make_client(query_responder): + db_patcher = mock.patch("app.cm_api.DB") + verify_patcher = mock.patch("app.cm_api.verify_tables_once") + mock_db_class = db_patcher.start() + verify_patcher.start() + + mock_db = mock.Mock() + mock_db.query.side_effect = query_responder + mock_db_class.return_value = mock_db + + from app.cm_api import create_app + app = create_app() + app.testing = True + return app.test_client() + + +def _capture(calls): + """Responder that records every (sql, params) call and returns + plausible empty results.""" + def responder(sql, params): + calls.append((sql, list(params))) + if "COUNT(*)" in sql: + return [{"c": 0}] + return [] + return responder + + +def _rows_query(calls): + """Pick out the rows SELECT (the one with LIMIT/OFFSET, not the + COUNT query). Fails the test if not exactly one is found.""" + rows = [c for c in calls if "COUNT(*)" not in c[0]] + assert len(rows) == 1, f"expected 1 rows query, got {len(rows)}" + return rows[0] + + +class UserSortWhitelistTests(unittest.TestCase): + def tearDown(self): + mock.patch.stopall() + + def test_t_username_sort_routes_into_order_by(self): + # The whole point of this change — t_username is now an + # accepted sort column for the user listing. + calls = [] + client = _make_client(_capture(calls)) + + client.get("/user/?sort=t_username") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY t_username", sql, + f"sort=t_username should produce 'ORDER BY t_username', got: {sql!r}") + + def test_f_username_sort_still_works(self): + calls = [] + client = _make_client(_capture(calls)) + + client.get("/user/?sort=f_username") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY f_username", sql) + + def test_default_sort_is_last_update_time(self): + calls = [] + client = _make_client(_capture(calls)) + + client.get("/user/") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY last_update_time", sql) + + def test_unknown_sort_falls_back_to_default_not_user_input(self): + # Critical: if a malicious or buggy client passes + # ?sort=DROP TABLE users--, we MUST NOT see that string in + # the SQL. Whitelist falls back to last_update_time. + calls = [] + client = _make_client(_capture(calls)) + + client.get("/user/?sort=DROP+TABLE+users--") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY last_update_time", sql) + self.assertNotIn("DROP", sql.upper()) + + +class AccSortWhitelistTests(unittest.TestCase): + def tearDown(self): + mock.patch.stopall() + + def test_status_sort_routes_into_order_by(self): + calls = [] + client = _make_client(_capture(calls)) + + client.get("/acc/?sort=status") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY status", sql, + f"sort=status should produce 'ORDER BY status', got: {sql!r}") + + def test_username_sort_still_works(self): + calls = [] + client = _make_client(_capture(calls)) + + client.get("/acc/?sort=username") + + sql, _ = _rows_query(calls) + # `username` appears in the SELECT too — pin specifically the + # ORDER BY clause occurrence. + self.assertIn("ORDER BY username", sql) + + def test_default_sort_is_username(self): + # Pre-change behaviour: /acc/ always sorted by username. After + # adding the whitelist, the default must remain username so + # callers that don't pass `sort=` see no behaviour change. + calls = [] + client = _make_client(_capture(calls)) + + client.get("/acc/") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY username", sql) + + def test_unknown_sort_falls_back_to_username(self): + calls = [] + client = _make_client(_capture(calls)) + + client.get("/acc/?sort=evil_column") + + sql, _ = _rows_query(calls) + self.assertIn("ORDER BY username", sql) + self.assertNotIn("evil_column", sql) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_search_filter.py b/tests/test_user_search_filter.py new file mode 100644 index 0000000..92f1833 --- /dev/null +++ b/tests/test_user_search_filter.py @@ -0,0 +1,214 @@ +"""Tests for the `q` search filter on /user/ and /acc/ in app.cm_api. + +`q` is the user-facing search parameter; `prefix` is the deployment +tenant boost. Both can be active at once but they affect the SQL +differently: + - q → WHERE [col] LIKE 'q%' (filters which rows return) + - prefix → ORDER BY ... CASE WHEN ... (boosts matching rows to top) + +These tests pin three behaviors that an operator would notice if broken: + 1. The `total` count must respect the same WHERE as the row query + (otherwise "Showing 5 of 200" displays under a filter that only + matched 5 rows — confusing). + 2. Empty/whitespace `q` must NOT add a WHERE clause (regression risk: + "WHERE [col] LIKE '%'" is fine but "WHERE [col] LIKE ''" + would return zero rows). + 3. `q` and `prefix` must coexist — search refines what tenant boosts. + +Both /user/ (filters on f_username) and /acc/ (filters on username) +share the same pattern, so the test classes mirror each other. +""" + +import unittest +from unittest import mock + + +def _make_client(query_responder): + """Build a Flask test client with a custom DB.query() responder. + + `query_responder(sql, params) -> list[dict]` is called for every + db.query() so tests can route count vs. row queries and inspect + SQL/params per call. + """ + db_patcher = mock.patch("app.cm_api.DB") + verify_patcher = mock.patch("app.cm_api.verify_tables_once") + mock_db_class = db_patcher.start() + verify_patcher.start() + + mock_db = mock.Mock() + mock_db.query.side_effect = query_responder + mock_db_class.return_value = mock_db + + from app.cm_api import create_app + app = create_app() + app.testing = True + return app.test_client(), mock_db, (db_patcher, verify_patcher) + + +class UserSearchFilterTests(unittest.TestCase): + def tearDown(self): + mock.patch.stopall() + + def _route(self, calls): + """Return a query_responder that records calls and returns + plausible empty results. Forces every test to inspect the + calls list rather than the response body.""" + def responder(sql, params): + calls.append((sql, list(params))) + if "COUNT(*)" in sql: + return [{"c": 0}] + return [] + return responder + + def test_q_param_adds_where_clause_with_starts_with(self): + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/user/?q=13c45") + + # Both queries should have the WHERE clause + same params. + self.assertEqual(len(calls), 2, f"expected count + rows queries, got {len(calls)}") + for sql, params in calls: + self.assertIn("WHERE f_username LIKE", sql, + f"missing WHERE on query: {sql!r}") + # Search uses 'starts with' matching → trailing '%' only + self.assertEqual(params[0], "13c45%", + f"search param should be 'q%', got {params[0]!r}") + + def test_count_query_respects_q_filter(self): + # If the count query doesn't filter, the UI shows "Showing 3 of + # 200" when the filtered total is actually 3 — confusing the + # operator. Pin that count and rows use the same WHERE. + count_calls = [] + def responder(sql, params): + if "COUNT(*)" in sql: + count_calls.append((sql, list(params))) + return [{"c": 7}] + return [] + + client, _, _ = _make_client(responder) + client.get("/user/?q=13c4") + + self.assertEqual(len(count_calls), 1) + sql, params = count_calls[0] + self.assertIn("WHERE f_username LIKE", sql) + self.assertEqual(params, ["13c4%"]) + + def test_no_q_means_no_where_clause(self): + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/user/") + + self.assertEqual(len(calls), 2) + for sql, _ in calls: + self.assertNotIn("WHERE f_username LIKE", sql, + f"unexpected WHERE on query: {sql!r}") + + def test_whitespace_only_q_is_treated_as_no_filter(self): + # Operators sometimes accidentally type a space in the search + # box. " " or "" should NOT generate "WHERE f_username LIKE ' %'" + # — that filter would surprise nobody by returning 0 rows. + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/user/?q=%20%20") # URL-encoded " " + + self.assertEqual(len(calls), 2) + for sql, _ in calls: + self.assertNotIn("WHERE f_username LIKE", sql) + + def test_q_and_prefix_coexist(self): + # `q` filters via WHERE; `prefix` promotes via ORDER BY CASE. + # When both are present, the rows query has BOTH a WHERE + # parameter and a CASE-WHEN parameter (the prefix appears as + # the first ORDER BY arg). Pin the parameter ordering so a + # future SQL refactor can't accidentally swap them. + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/user/?q=13c4&prefix=13c") + + # Find the rows query (the one with LIMIT/OFFSET, not COUNT). + rows_calls = [c for c in calls if "COUNT(*)" not in c[0]] + self.assertEqual(len(rows_calls), 1) + sql, params = rows_calls[0] + self.assertIn("WHERE f_username LIKE", sql) + self.assertIn("CASE WHEN f_username LIKE", sql) + # Order: [where_param, prefix_param, limit, offset] + self.assertEqual(params[0], "13c4%", "WHERE param must come first") + self.assertEqual(params[1], "13c%", "prefix CASE param must come after WHERE") + + +class AccSearchFilterTests(unittest.TestCase): + """Mirror of UserSearchFilterTests for /acc/. The acc table filters + on `username` (not `f_username`) but the contract is otherwise + identical.""" + + def tearDown(self): + mock.patch.stopall() + + def _route(self, calls): + def responder(sql, params): + calls.append((sql, list(params))) + if "COUNT(*)" in sql: + return [{"c": 0}] + return [] + return responder + + def test_q_param_adds_where_clause_with_starts_with(self): + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/acc/?q=13c45") + + self.assertEqual(len(calls), 2) + for sql, params in calls: + self.assertIn("WHERE username LIKE", sql, + f"missing WHERE on query: {sql!r}") + self.assertEqual(params[0], "13c45%", + f"search param should be 'q%', got {params[0]!r}") + + def test_count_query_respects_q_filter(self): + count_calls = [] + def responder(sql, params): + if "COUNT(*)" in sql: + count_calls.append((sql, list(params))) + return [{"c": 7}] + return [] + + client, _, _ = _make_client(responder) + client.get("/acc/?q=13c4") + + self.assertEqual(len(count_calls), 1) + sql, params = count_calls[0] + self.assertIn("WHERE username LIKE", sql) + self.assertEqual(params, ["13c4%"]) + + def test_no_q_means_no_where_clause(self): + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/acc/") + + self.assertEqual(len(calls), 2) + for sql, _ in calls: + self.assertNotIn("WHERE username LIKE", sql) + + def test_q_and_prefix_coexist(self): + calls = [] + client, _, _ = _make_client(self._route(calls)) + + client.get("/acc/?q=13c4&prefix=13c") + + rows_calls = [c for c in calls if "COUNT(*)" not in c[0]] + self.assertEqual(len(rows_calls), 1) + sql, params = rows_calls[0] + self.assertIn("WHERE username LIKE", sql) + self.assertIn("CASE WHEN username LIKE", sql) + self.assertEqual(params[0], "13c4%", "WHERE param must come first") + self.assertEqual(params[1], "13c%", "prefix CASE param must come after WHERE") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/components/accounts-table.tsx b/web/components/accounts-table.tsx index 9693db7..3fae239 100644 --- a/web/components/accounts-table.tsx +++ b/web/components/accounts-table.tsx @@ -12,6 +12,7 @@ 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[]; @@ -20,6 +21,7 @@ type Props = { prefixPattern: string; }; type SortDir = "asc" | "desc"; +type SortKey = "username" | "status"; type OptimisticPatch = { username: string; field: keyof Acc; value: string }; function StatusBadge({ status }: { status: string }) { @@ -59,12 +61,45 @@ function DeleteButton({ ); } +function CopyButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} + export default function AccountsTable({ initial, initialHasMore, initialTotal, prefixPattern, }: Props) { + const [sortKey, setSortKey] = useState("username"); const [sortDir, setSortDir] = useState("desc"); const [editingKey, setEditingKey] = useState(null); const [, startTransition] = useTransition(); @@ -93,6 +128,15 @@ export default function AccountsTable({ const [total, setTotal] = useState(initialTotal); const sentinelRef = useRef(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( rows, (state, patch) => @@ -124,21 +168,52 @@ export default function AccountsTable({ // 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, dir: sortDir }); + const page = await refreshAccounts({ + prefix: prefixPattern, + sort: sortKey, + dir: sortDir, + q: appliedQuery, + }); setRows(page.rows); setHasMore(page.hasMore); setTotal(page.total); } - async function changeSort(next: SortDir) { - if (next === sortDir) return; - setSortDir(next); + 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, - dir: next, + sort: nextKey, + dir: nextDir, + q: appliedQuery, }); setRows(page.rows); setHasMore(page.hasMore); @@ -163,7 +238,9 @@ export default function AccountsTable({ loadMoreAccounts({ offset: rows.length, prefix: prefixPattern, + sort: sortKey, dir: sortDir, + q: appliedQuery, }) .then((page) => { setRows((prev) => [...prev, ...page.rows]); @@ -177,7 +254,29 @@ export default function AccountsTable({ ); observer.observe(sentinel); return () => observer.disconnect(); - }, [hasMore, loadingMore, rows.length, prefixPattern, sortDir]); + }, [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; @@ -196,7 +295,32 @@ export default function AccountsTable({ } } - if (rows.length === 0) { + 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 ( + + ); + } + + if (rows.length === 0 && !appliedQuery) { return (
setCreateOpen(true)} /> +

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 */}
- + - @@ -293,13 +446,19 @@ export default function AccountsTable({ /> ); @@ -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 ( +
+ + 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" + /> + + {appliedQuery && ( + + )} + {appliedQuery && ( + + Filtered by {appliedQuery} + + )} + + ); +} + function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) { return (
- + PasswordStatus + + Link
- { - setDeleteError(null); - setDeleteTarget(row.username); - }} - /> +
+ handleCopy(row)} + /> + { + setDeleteError(null); + setDeleteTarget(row.username); + }} + /> +
; @@ -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} - + ); } - 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({ - - @@ -326,13 +449,19 @@ export default function UsersTable({ {formatTime(row.last_update_time)} ); @@ -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 ( +
+ + 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" + /> + + {appliedQuery && ( + + )} + {appliedQuery && ( + + Filtered by {appliedQuery} + + )} + + ); +} + 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); + } +}
From password - To username + + To password @@ -281,7 +404,7 @@ export default function UsersTable({
- { - setDeleteError(null); - setDeleteTarget(row.f_username); - }} - /> +
+ handleCopy(row)} + /> + { + setDeleteError(null); + setDeleteTarget(row.f_username); + }} + /> +