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:
parent
1c3d4ef893
commit
9eed051916
@ -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})
|
||||||
|
|
||||||
|
|||||||
153
tests/test_sort_whitelist.py
Normal file
153
tests/test_sort_whitelist.py
Normal 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()
|
||||||
214
tests/test_user_search_filter.py
Normal file
214
tests/test_user_search_filter.py
Normal 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()
|
||||||
@ -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,13 +446,19 @@ 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">
|
||||||
<DeleteButton
|
<div className="inline-flex items-center gap-1">
|
||||||
label={row.username}
|
<CopyButton
|
||||||
onClick={() => {
|
label={row.username}
|
||||||
setDeleteError(null);
|
onClick={() => handleCopy(row)}
|
||||||
setDeleteTarget(row.username);
|
/>
|
||||||
}}
|
<DeleteButton
|
||||||
/>
|
label={row.username}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeleteTarget(row.username);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@ -331,13 +490,19 @@ export default function AccountsTable({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DeleteButton
|
<div className="flex items-center gap-1">
|
||||||
label={row.username}
|
<CopyButton
|
||||||
onClick={() => {
|
label={row.username}
|
||||||
setDeleteError(null);
|
onClick={() => handleCopy(row)}
|
||||||
setDeleteTarget(row.username);
|
/>
|
||||||
}}
|
<DeleteButton
|
||||||
/>
|
label={row.username}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeleteTarget(row.username);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,13 +449,19 @@ 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">
|
||||||
<DeleteButton
|
<div className="inline-flex items-center gap-1">
|
||||||
label={row.f_username}
|
<CopyButton
|
||||||
onClick={() => {
|
label={row.f_username}
|
||||||
setDeleteError(null);
|
onClick={() => handleCopy(row)}
|
||||||
setDeleteTarget(row.f_username);
|
/>
|
||||||
}}
|
<DeleteButton
|
||||||
/>
|
label={row.f_username}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null);
|
||||||
|
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">
|
||||||
|
|||||||
@ -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
46
web/lib/clipboard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user