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>
This commit is contained in:
yiekheng 2026-05-04 09:26:11 +08:00
parent 1c3d4ef893
commit 9eed051916
7 changed files with 921 additions and 69 deletions

View File

@ -106,29 +106,40 @@ class CM_API:
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400 return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip() 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 # Whitelist direction so it's safe to interpolate into the
# ORDER BY clause (parameterised binding doesn't apply to # ORDER BY clause (parameterised binding doesn't apply to
# column names or sort directions). # column names or sort directions).
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC' 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: if prefix:
query = ( query = (
"SELECT username, password, status, link FROM acc " "SELECT username, password, status, link FROM acc "
f"{where_sql} "
"ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), " "ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), "
f"username {direction} " f"{sort_col} {direction} "
"LIMIT %s OFFSET %s" "LIMIT %s OFFSET %s"
) )
params = [f"{prefix}%", limit, offset] params = [*where_params, f"{prefix}%", limit, offset]
else: else:
query = ( query = (
"SELECT username, password, status, link FROM acc " "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" "LIMIT %s OFFSET %s"
) )
params = [limit, offset] params = [*where_params, limit, offset]
rows = db.query(query, params) 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 total = int(count_rows[0]["c"]) if count_rows else 0
return jsonify({"rows": rows, "total": total}) return jsonify({"rows": rows, "total": total})
@ -152,30 +163,42 @@ class CM_API:
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400 return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip() 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_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' 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: if prefix:
query = ( query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time " "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), " "ORDER BY (CASE WHEN f_username LIKE %s THEN 0 ELSE 1 END), "
f"{sort_col} {direction} " f"{sort_col} {direction} "
"LIMIT %s OFFSET %s" "LIMIT %s OFFSET %s"
) )
params = [f"{prefix}%", limit, offset] params = [*where_params, f"{prefix}%", limit, offset]
else: else:
query = ( query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time " "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} " f"ORDER BY {sort_col} {direction} "
"LIMIT %s OFFSET %s" "LIMIT %s OFFSET %s"
) )
params = [limit, offset] params = [*where_params, limit, offset]
rows = db.query(query, params) 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 total = int(count_rows[0]["c"]) if count_rows else 0
return jsonify({"rows": rows, "total": total}) return jsonify({"rows": rows, "total": total})

View File

@ -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()

View File

@ -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()

View File

@ -12,6 +12,7 @@ import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog"; import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog"; import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast"; import Toast, { type ToastMessage } from "./toast";
import { copyToClipboard } from "@/lib/clipboard";
type Props = { type Props = {
initial: Acc[]; initial: Acc[];
@ -20,6 +21,7 @@ type Props = {
prefixPattern: string; prefixPattern: string;
}; };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
type SortKey = "username" | "status";
type OptimisticPatch = { username: string; field: keyof Acc; value: string }; type OptimisticPatch = { username: string; field: keyof Acc; value: string };
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
@ -59,12 +61,45 @@ function DeleteButton({
); );
} }
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({ export default function AccountsTable({
initial, initial,
initialHasMore, initialHasMore,
initialTotal, initialTotal,
prefixPattern, prefixPattern,
}: Props) { }: Props) {
const [sortKey, setSortKey] = useState<SortKey>("username");
const [sortDir, setSortDir] = useState<SortDir>("desc"); const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
@ -93,6 +128,15 @@ export default function AccountsTable({
const [total, setTotal] = useState<number>(initialTotal); const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null); 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>( const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
rows, rows,
(state, patch) => (state, patch) =>
@ -124,21 +168,52 @@ export default function AccountsTable({
// the function (instead of inlining at the call site) means a future // the function (instead of inlining at the call site) means a future
// 'pull to refresh' gesture has a single hook. // 'pull to refresh' gesture has a single hook.
async function refresh() { 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); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
setTotal(page.total); setTotal(page.total);
} }
async function changeSort(next: SortDir) { async function applySearch(nextQuery: string) {
if (next === sortDir) return; setSearching(true);
setSortDir(next); 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); setLoadingMore(true);
try { try {
const page = await loadMoreAccounts({ const page = await loadMoreAccounts({
offset: 0, offset: 0,
prefix: prefixPattern, prefix: prefixPattern,
dir: next, sort: nextKey,
dir: nextDir,
q: appliedQuery,
}); });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
@ -163,7 +238,9 @@ export default function AccountsTable({
loadMoreAccounts({ loadMoreAccounts({
offset: rows.length, offset: rows.length,
prefix: prefixPattern, prefix: prefixPattern,
sort: sortKey,
dir: sortDir, dir: sortDir,
q: appliedQuery,
}) })
.then((page) => { .then((page) => {
setRows((prev) => [...prev, ...page.rows]); setRows((prev) => [...prev, ...page.rows]);
@ -177,7 +254,29 @@ export default function AccountsTable({
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); 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() { async function confirmDelete() {
if (!deleteTarget) return; 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 (
<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 ( return (
<div> <div>
<PageHead <PageHead
@ -204,6 +328,14 @@ export default function AccountsTable({
loaded={0} loaded={0}
onAdd={() => setCreateOpen(true)} 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"> <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"> <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. No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
@ -231,26 +363,47 @@ export default function AccountsTable({
loaded={optimistic.length} loaded={optimistic.length}
onAdd={() => setCreateOpen(true)} 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 */} {/* Desktop / tablet table */}
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block"> <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"> <table className="w-full table-fixed border-collapse">
<thead> <thead>
<tr className="bg-zinc-50/60"> <tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left"> <th className="w-[18%] px-5 py-3 text-left">
<button <HeaderTh k="username" label="Username" />
type="button"
onClick={() => changeSort(sortDir === "asc" ? "desc" : "asc")}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
>
Username
<span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span>
</button>
</th> </th>
<Th>Password</Th> <Th>Password</Th>
<Th className="w-[16%]">Status</Th> <th className="w-[16%] px-5 py-3 text-left">
<HeaderTh k="status" label="Status" />
</th>
<Th>Link</Th> <Th>Link</Th>
<th className="w-12 px-3 py-3" aria-hidden="true" /> <th className="w-20 px-3 py-3" aria-hidden="true" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
@ -293,6 +446,11 @@ export default function AccountsTable({
/> />
</td> </td>
<td className="px-3 py-3 text-right align-middle"> <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 <DeleteButton
label={row.username} label={row.username}
onClick={() => { onClick={() => {
@ -300,6 +458,7 @@ export default function AccountsTable({
setDeleteTarget(row.username); setDeleteTarget(row.username);
}} }}
/> />
</div>
</td> </td>
</tr> </tr>
); );
@ -331,6 +490,11 @@ export default function AccountsTable({
/> />
</div> </div>
</div> </div>
<div className="flex items-center gap-1">
<CopyButton
label={row.username}
onClick={() => handleCopy(row)}
/>
<DeleteButton <DeleteButton
label={row.username} label={row.username}
onClick={() => { onClick={() => {
@ -339,6 +503,7 @@ export default function AccountsTable({
}} }}
/> />
</div> </div>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4"> <dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<CardRow label="Password"> <CardRow label="Password">
<EditableCell <EditableCell
@ -380,6 +545,8 @@ export default function AccountsTable({
End of list {rows.length} accounts loaded End of list {rows.length} accounts loaded
</p> </p>
)} )}
</>
)}
<CreateAccountDialog <CreateAccountDialog
open={createOpen} open={createOpen}
@ -424,6 +591,60 @@ export default function AccountsTable({
); );
} }
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 }) { function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return ( return (
<th <th

View File

@ -12,6 +12,7 @@ import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog"; import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog"; import CreateUserDialog from "./create-user-dialog";
import Toast, { type ToastMessage } from "./toast"; import Toast, { type ToastMessage } from "./toast";
import { copyToClipboard } from "@/lib/clipboard";
type Props = { type Props = {
initial: User[]; initial: User[];
@ -20,7 +21,7 @@ type Props = {
prefixPattern: string; prefixPattern: string;
}; };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
type SortKey = "f_username" | "last_update_time"; type SortKey = "f_username" | "t_username" | "last_update_time";
type OptimisticPatch = { type OptimisticPatch = {
f_username: string; f_username: string;
field: keyof Pick<User, "f_password" | "t_username" | "t_password">; field: keyof Pick<User, "f_password" | "t_username" | "t_password">;
@ -60,6 +61,38 @@ function DeleteButton({
); );
} }
function CopyButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Copy credentials for ${label}`}
title="Copy from/to credentials"
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 UsersTable({ export default function UsersTable({
initial, initial,
initialHasMore, initialHasMore,
@ -89,6 +122,15 @@ export default function UsersTable({
const [total, setTotal] = useState<number>(initialTotal); const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(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<User[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
rows, rows,
(state, patch) => (state, patch) =>
@ -136,12 +178,31 @@ export default function UsersTable({
prefix: prefixPattern, prefix: prefixPattern,
sort: sortKey, sort: sortKey,
dir: sortDir, dir: sortDir,
q: appliedQuery,
}); });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
setTotal(page.total); 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) { async function changeSort(nextKey: SortKey) {
let nextDir: SortDir; let nextDir: SortDir;
if (nextKey === sortKey) { if (nextKey === sortKey) {
@ -158,6 +219,7 @@ export default function UsersTable({
prefix: prefixPattern, prefix: prefixPattern,
sort: nextKey, sort: nextKey,
dir: nextDir, dir: nextDir,
q: appliedQuery,
}); });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
@ -180,6 +242,7 @@ export default function UsersTable({
prefix: prefixPattern, prefix: prefixPattern,
sort: sortKey, sort: sortKey,
dir: sortDir, dir: sortDir,
q: appliedQuery,
}) })
.then((page) => { .then((page) => {
setRows((prev) => [...prev, ...page.rows]); setRows((prev) => [...prev, ...page.rows]);
@ -193,7 +256,24 @@ export default function UsersTable({
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); 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() { async function confirmDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
@ -223,12 +303,21 @@ export default function UsersTable({
}`} }`}
> >
{label} {label}
<span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span> <span aria-hidden="true">{active && sortDir === "asc" ? "↑" : "↓"}</span>
</button> </button>
); );
} }
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 ( return (
<div> <div>
<PageHead <PageHead
@ -236,6 +325,14 @@ export default function UsersTable({
loaded={0} loaded={0}
onAdd={() => setCreateOpen(true)} 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"> <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"> <p className="text-sm text-zinc-500">
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually. No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
@ -261,7 +358,33 @@ export default function UsersTable({
loaded={optimistic.length} loaded={optimistic.length}
onAdd={() => setCreateOpen(true)} 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 users 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 && (
<>
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block"> <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"> <table className="w-full table-fixed border-collapse">
<thead> <thead>
@ -272,8 +395,8 @@ export default function UsersTable({
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500"> <th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
From password From password
</th> </th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500"> <th className="w-[18%] px-5 py-3 text-left">
To username <HeaderTh k="t_username" label="To username" />
</th> </th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500"> <th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
To password To password
@ -281,7 +404,7 @@ export default function UsersTable({
<th className="px-5 py-3 text-left"> <th className="px-5 py-3 text-left">
<HeaderTh k="last_update_time" label="Last update" /> <HeaderTh k="last_update_time" label="Last update" />
</th> </th>
<th className="w-12 px-3 py-3" aria-hidden="true" /> <th className="w-20 px-3 py-3" aria-hidden="true" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
@ -326,6 +449,11 @@ export default function UsersTable({
{formatTime(row.last_update_time)} {formatTime(row.last_update_time)}
</td> </td>
<td className="px-3 py-3 text-right align-middle"> <td className="px-3 py-3 text-right align-middle">
<div className="inline-flex items-center gap-1">
<CopyButton
label={row.f_username}
onClick={() => handleCopy(row)}
/>
<DeleteButton <DeleteButton
label={row.f_username} label={row.f_username}
onClick={() => { onClick={() => {
@ -333,6 +461,7 @@ export default function UsersTable({
setDeleteTarget(row.f_username); setDeleteTarget(row.f_username);
}} }}
/> />
</div>
</td> </td>
</tr> </tr>
); );
@ -354,6 +483,10 @@ export default function UsersTable({
<span className="text-[11px] text-zinc-500"> <span className="text-[11px] text-zinc-500">
{formatTime(row.last_update_time)} {formatTime(row.last_update_time)}
</span> </span>
<CopyButton
label={row.f_username}
onClick={() => handleCopy(row)}
/>
<DeleteButton <DeleteButton
label={row.f_username} label={row.f_username}
onClick={() => { onClick={() => {
@ -411,6 +544,8 @@ export default function UsersTable({
End of list {rows.length} users loaded End of list {rows.length} users loaded
</p> </p>
)} )}
</>
)}
<CreateUserDialog <CreateUserDialog
open={createOpen} open={createOpen}
@ -455,6 +590,60 @@ export default function UsersTable({
); );
} }
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="users-search">Search by username</label>
<input
id="users-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 MobileRow({ label, children }: { label: string; children: React.ReactNode }) { function MobileRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4"> <div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">

View File

@ -20,14 +20,17 @@ export type Page<T> = { rows: T[]; hasMore: boolean; total: number };
export type AccountsPageOpts = { export type AccountsPageOpts = {
offset?: number; offset?: number;
prefix?: string; prefix?: string;
sort?: "username" | "status";
dir?: "asc" | "desc"; dir?: "asc" | "desc";
q?: string;
}; };
export type UsersPageOpts = { export type UsersPageOpts = {
offset?: number; offset?: number;
prefix?: string; prefix?: string;
sort?: "f_username" | "last_update_time"; sort?: "f_username" | "t_username" | "last_update_time";
dir?: "asc" | "desc"; dir?: "asc" | "desc";
q?: string;
}; };
type FetchInit = { type FetchInit = {
@ -57,18 +60,7 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise<u
} }
function buildAccountsUrl(opts: AccountsPageOpts): string { function buildAccountsUrl(opts: AccountsPageOpts): string {
const { offset = 0, prefix = "", dir = "desc" } = opts; const { offset = 0, prefix = "", sort = "username", dir = "desc", q = "" } = opts;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
dir,
});
if (prefix) params.set("prefix", prefix);
return `/acc/?${params.toString()}`;
}
function buildUsersUrl(opts: UsersPageOpts): string {
const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc" } = opts;
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: String(PAGE_SIZE), limit: String(PAGE_SIZE),
offset: String(offset), offset: String(offset),
@ -76,6 +68,20 @@ function buildUsersUrl(opts: UsersPageOpts): string {
dir, dir,
}); });
if (prefix) params.set("prefix", prefix); if (prefix) params.set("prefix", prefix);
if (q) params.set("q", q);
return `/acc/?${params.toString()}`;
}
function buildUsersUrl(opts: UsersPageOpts): string {
const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc", q = "" } = opts;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
sort,
dir,
});
if (prefix) params.set("prefix", prefix);
if (q) params.set("q", q);
return `/user/?${params.toString()}`; return `/user/?${params.toString()}`;
} }

46
web/lib/clipboard.ts Normal file
View File

@ -0,0 +1,46 @@
/**
* Copy `text` to the system clipboard.
*
* The modern `navigator.clipboard.writeText()` only works in secure
* contexts (HTTPS / localhost / file://). This deployment runs over
* plain HTTP on the internal network, where that API is gated, so we
* fall back to the legacy `textarea + execCommand("copy")` pattern.
* `execCommand` is deprecated but every shipping browser still
* implements it for the copy command, and it works in non-secure
* contexts where the modern API doesn't.
*
* Returns true on success, false if both paths fail.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
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);
}
}