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):
|
||||
return jsonify({"error": "limit and offset must be integers"}), 400
|
||||
prefix = (request.args.get('prefix') or '').strip()
|
||||
# `q` is the user-facing search filter (WHERE), distinct from
|
||||
# `prefix` which is the deployment tenant boost (ORDER BY).
|
||||
q = (request.args.get('q') or '').strip()
|
||||
sort_arg = request.args.get('sort', 'username')
|
||||
# Whitelist the sort column (see /user/ for rationale).
|
||||
sort_col = sort_arg if sort_arg in ('username', 'status') else 'username'
|
||||
# Whitelist direction so it's safe to interpolate into the
|
||||
# ORDER BY clause (parameterised binding doesn't apply to
|
||||
# column names or sort directions).
|
||||
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
|
||||
|
||||
where_sql = "WHERE username LIKE %s" if q else ""
|
||||
where_params = [f"{q}%"] if q else []
|
||||
|
||||
if prefix:
|
||||
query = (
|
||||
"SELECT username, password, status, link FROM acc "
|
||||
f"{where_sql} "
|
||||
"ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), "
|
||||
f"username {direction} "
|
||||
f"{sort_col} {direction} "
|
||||
"LIMIT %s OFFSET %s"
|
||||
)
|
||||
params = [f"{prefix}%", limit, offset]
|
||||
params = [*where_params, f"{prefix}%", limit, offset]
|
||||
else:
|
||||
query = (
|
||||
"SELECT username, password, status, link FROM acc "
|
||||
f"ORDER BY username {direction} "
|
||||
f"{where_sql} "
|
||||
f"ORDER BY {sort_col} {direction} "
|
||||
"LIMIT %s OFFSET %s"
|
||||
)
|
||||
params = [limit, offset]
|
||||
params = [*where_params, limit, offset]
|
||||
|
||||
rows = db.query(query, params)
|
||||
count_rows = db.query("SELECT COUNT(*) AS c FROM acc", [])
|
||||
count_rows = db.query(f"SELECT COUNT(*) AS c FROM acc {where_sql}", where_params)
|
||||
total = int(count_rows[0]["c"]) if count_rows else 0
|
||||
return jsonify({"rows": rows, "total": total})
|
||||
|
||||
@ -152,30 +163,42 @@ class CM_API:
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "limit and offset must be integers"}), 400
|
||||
prefix = (request.args.get('prefix') or '').strip()
|
||||
# `q` is the user-facing search filter (WHERE), distinct from
|
||||
# `prefix` which is the deployment tenant boost (ORDER BY).
|
||||
# Both can be active at once — search refines what the
|
||||
# tenant prefix already promotes.
|
||||
q = (request.args.get('q') or '').strip()
|
||||
sort_arg = request.args.get('sort', 'last_update_time')
|
||||
sort_col = 'f_username' if sort_arg == 'f_username' else 'last_update_time'
|
||||
# Whitelist the sort column — `sort_col` is f-string'd into
|
||||
# the ORDER BY clause, which can't be parameterised. Anything
|
||||
# outside the allowed set falls back to the default so a
|
||||
# malformed query never hits the SQL parser unsanitised.
|
||||
sort_col = sort_arg if sort_arg in ('f_username', 't_username', 'last_update_time') else 'last_update_time'
|
||||
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
|
||||
|
||||
where_sql = "WHERE f_username LIKE %s" if q else ""
|
||||
where_params = [f"{q}%"] if q else []
|
||||
|
||||
if prefix:
|
||||
query = (
|
||||
"SELECT f_username, f_password, t_username, t_password, last_update_time "
|
||||
"FROM user "
|
||||
f"FROM user {where_sql} "
|
||||
"ORDER BY (CASE WHEN f_username LIKE %s THEN 0 ELSE 1 END), "
|
||||
f"{sort_col} {direction} "
|
||||
"LIMIT %s OFFSET %s"
|
||||
)
|
||||
params = [f"{prefix}%", limit, offset]
|
||||
params = [*where_params, f"{prefix}%", limit, offset]
|
||||
else:
|
||||
query = (
|
||||
"SELECT f_username, f_password, t_username, t_password, last_update_time "
|
||||
"FROM user "
|
||||
f"FROM user {where_sql} "
|
||||
f"ORDER BY {sort_col} {direction} "
|
||||
"LIMIT %s OFFSET %s"
|
||||
)
|
||||
params = [limit, offset]
|
||||
params = [*where_params, limit, offset]
|
||||
|
||||
rows = db.query(query, params)
|
||||
count_rows = db.query("SELECT COUNT(*) AS c FROM user", [])
|
||||
count_rows = db.query(f"SELECT COUNT(*) AS c FROM user {where_sql}", where_params)
|
||||
total = int(count_rows[0]["c"]) if count_rows else 0
|
||||
return jsonify({"rows": rows, "total": total})
|
||||
|
||||
|
||||
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 CreateAccountDialog from "./create-account-dialog";
|
||||
import Toast, { type ToastMessage } from "./toast";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
||||
type Props = {
|
||||
initial: Acc[];
|
||||
@ -20,6 +21,7 @@ type Props = {
|
||||
prefixPattern: string;
|
||||
};
|
||||
type SortDir = "asc" | "desc";
|
||||
type SortKey = "username" | "status";
|
||||
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
@ -59,12 +61,45 @@ function DeleteButton({
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={`Copy credentials for ${label}`}
|
||||
title="Copy username and password"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.25" />
|
||||
<path d="M3 10.5V3.5A1 1 0 0 1 4 2.5h7" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountsTable({
|
||||
initial,
|
||||
initialHasMore,
|
||||
initialTotal,
|
||||
prefixPattern,
|
||||
}: Props) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>("username");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
@ -93,6 +128,15 @@ export default function AccountsTable({
|
||||
const [total, setTotal] = useState<number>(initialTotal);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// `searchInput` is what the user is typing; `appliedQuery` is what
|
||||
// the server actually filtered on. Decoupled so typing doesn't fire a
|
||||
// request per keystroke — only on Enter / Find / Clear. `appliedQuery`
|
||||
// flows into refresh / changeSort / loadMore so sort + infinite-scroll
|
||||
// respect the active search.
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||||
rows,
|
||||
(state, patch) =>
|
||||
@ -124,21 +168,52 @@ export default function AccountsTable({
|
||||
// the function (instead of inlining at the call site) means a future
|
||||
// 'pull to refresh' gesture has a single hook.
|
||||
async function refresh() {
|
||||
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
|
||||
const page = await refreshAccounts({
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
q: appliedQuery,
|
||||
});
|
||||
setRows(page.rows);
|
||||
setHasMore(page.hasMore);
|
||||
setTotal(page.total);
|
||||
}
|
||||
|
||||
async function changeSort(next: SortDir) {
|
||||
if (next === sortDir) return;
|
||||
setSortDir(next);
|
||||
async function applySearch(nextQuery: string) {
|
||||
setSearching(true);
|
||||
setAppliedQuery(nextQuery);
|
||||
try {
|
||||
const page = await refreshAccounts({
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
q: nextQuery,
|
||||
});
|
||||
setRows(page.rows);
|
||||
setHasMore(page.hasMore);
|
||||
setTotal(page.total);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSort(nextKey: SortKey) {
|
||||
let nextDir: SortDir;
|
||||
if (nextKey === sortKey) {
|
||||
nextDir = sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
nextDir = "desc";
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDir(nextDir);
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await loadMoreAccounts({
|
||||
offset: 0,
|
||||
prefix: prefixPattern,
|
||||
dir: next,
|
||||
sort: nextKey,
|
||||
dir: nextDir,
|
||||
q: appliedQuery,
|
||||
});
|
||||
setRows(page.rows);
|
||||
setHasMore(page.hasMore);
|
||||
@ -163,7 +238,9 @@ export default function AccountsTable({
|
||||
loadMoreAccounts({
|
||||
offset: rows.length,
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
q: appliedQuery,
|
||||
})
|
||||
.then((page) => {
|
||||
setRows((prev) => [...prev, ...page.rows]);
|
||||
@ -177,7 +254,29 @@ export default function AccountsTable({
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadingMore, rows.length, prefixPattern, sortDir]);
|
||||
}, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir, appliedQuery]);
|
||||
|
||||
async function handleCopy(row: Acc) {
|
||||
// Build the message line-by-line so an empty `link` (which the
|
||||
// monitor sometimes leaves blank for new accounts) drops out
|
||||
// entirely instead of producing a dangling "Link:" with nothing
|
||||
// after it.
|
||||
const lines = [
|
||||
`Username: ${row.username}`,
|
||||
`Password: ${row.password}`,
|
||||
];
|
||||
if (row.link) lines.push(`Link: ${row.link}`);
|
||||
const text = lines.join("\n");
|
||||
const ok = await copyToClipboard(text);
|
||||
setToast(
|
||||
ok
|
||||
? { type: "success", message: `Copied credentials for ${row.username}` }
|
||||
: {
|
||||
type: "error",
|
||||
message: `Could not copy — clipboard access blocked. Select text manually.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
@ -196,7 +295,32 @@ export default function AccountsTable({
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
const onSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void applySearch(searchInput.trim());
|
||||
};
|
||||
const onSearchClear = () => {
|
||||
setSearchInput("");
|
||||
void applySearch("");
|
||||
};
|
||||
|
||||
function HeaderTh({ k, label }: { k: SortKey; label: string }) {
|
||||
const active = sortKey === k;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(k)}
|
||||
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
|
||||
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span aria-hidden="true">{active && sortDir === "asc" ? "↑" : "↓"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0 && !appliedQuery) {
|
||||
return (
|
||||
<div>
|
||||
<PageHead
|
||||
@ -204,6 +328,14 @@ export default function AccountsTable({
|
||||
loaded={0}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={onSearchSubmit}
|
||||
onClear={onSearchClear}
|
||||
appliedQuery={appliedQuery}
|
||||
searching={searching}
|
||||
/>
|
||||
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||||
<p className="text-sm text-zinc-500">
|
||||
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
|
||||
@ -231,26 +363,47 @@ export default function AccountsTable({
|
||||
loaded={optimistic.length}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={onSearchSubmit}
|
||||
onClear={onSearchClear}
|
||||
appliedQuery={appliedQuery}
|
||||
searching={searching}
|
||||
/>
|
||||
{rows.length === 0 && appliedQuery && (
|
||||
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||||
<p className="text-sm text-zinc-500">
|
||||
No accounts match <span className="font-medium text-zinc-700">{appliedQuery}</span>.
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchClear}
|
||||
className="font-medium text-zinc-700 underline-offset-2 hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
{/* Desktop / tablet table */}
|
||||
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/60">
|
||||
<th className="w-[18%] px-5 py-3 text-left">
|
||||
<button
|
||||
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>
|
||||
<HeaderTh k="username" label="Username" />
|
||||
</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 className="w-12 px-3 py-3" aria-hidden="true" />
|
||||
<th className="w-20 px-3 py-3" aria-hidden="true" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
@ -293,6 +446,11 @@ export default function AccountsTable({
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right align-middle">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<CopyButton
|
||||
label={row.username}
|
||||
onClick={() => handleCopy(row)}
|
||||
/>
|
||||
<DeleteButton
|
||||
label={row.username}
|
||||
onClick={() => {
|
||||
@ -300,6 +458,7 @@ export default function AccountsTable({
|
||||
setDeleteTarget(row.username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@ -331,6 +490,11 @@ export default function AccountsTable({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CopyButton
|
||||
label={row.username}
|
||||
onClick={() => handleCopy(row)}
|
||||
/>
|
||||
<DeleteButton
|
||||
label={row.username}
|
||||
onClick={() => {
|
||||
@ -339,6 +503,7 @@ export default function AccountsTable({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
|
||||
<CardRow label="Password">
|
||||
<EditableCell
|
||||
@ -380,6 +545,8 @@ export default function AccountsTable({
|
||||
End of list — {rows.length} accounts loaded
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CreateAccountDialog
|
||||
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 }) {
|
||||
return (
|
||||
<th
|
||||
|
||||
@ -12,6 +12,7 @@ import EditableCell from "./editable-cell";
|
||||
import ConfirmDialog from "./confirm-dialog";
|
||||
import CreateUserDialog from "./create-user-dialog";
|
||||
import Toast, { type ToastMessage } from "./toast";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
||||
type Props = {
|
||||
initial: User[];
|
||||
@ -20,7 +21,7 @@ type Props = {
|
||||
prefixPattern: string;
|
||||
};
|
||||
type SortDir = "asc" | "desc";
|
||||
type SortKey = "f_username" | "last_update_time";
|
||||
type SortKey = "f_username" | "t_username" | "last_update_time";
|
||||
type OptimisticPatch = {
|
||||
f_username: string;
|
||||
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({
|
||||
initial,
|
||||
initialHasMore,
|
||||
@ -89,6 +122,15 @@ export default function UsersTable({
|
||||
const [total, setTotal] = useState<number>(initialTotal);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// `searchInput` is what the user is typing; `appliedQuery` is what
|
||||
// the server actually filtered on. 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>(
|
||||
rows,
|
||||
(state, patch) =>
|
||||
@ -136,12 +178,31 @@ export default function UsersTable({
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
q: appliedQuery,
|
||||
});
|
||||
setRows(page.rows);
|
||||
setHasMore(page.hasMore);
|
||||
setTotal(page.total);
|
||||
}
|
||||
|
||||
async function applySearch(nextQuery: string) {
|
||||
setSearching(true);
|
||||
setAppliedQuery(nextQuery);
|
||||
try {
|
||||
const page = await refreshUsers({
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
q: nextQuery,
|
||||
});
|
||||
setRows(page.rows);
|
||||
setHasMore(page.hasMore);
|
||||
setTotal(page.total);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSort(nextKey: SortKey) {
|
||||
let nextDir: SortDir;
|
||||
if (nextKey === sortKey) {
|
||||
@ -158,6 +219,7 @@ export default function UsersTable({
|
||||
prefix: prefixPattern,
|
||||
sort: nextKey,
|
||||
dir: nextDir,
|
||||
q: appliedQuery,
|
||||
});
|
||||
setRows(page.rows);
|
||||
setHasMore(page.hasMore);
|
||||
@ -180,6 +242,7 @@ export default function UsersTable({
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
q: appliedQuery,
|
||||
})
|
||||
.then((page) => {
|
||||
setRows((prev) => [...prev, ...page.rows]);
|
||||
@ -193,7 +256,24 @@ export default function UsersTable({
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir]);
|
||||
}, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir, appliedQuery]);
|
||||
|
||||
async function handleCopy(row: User) {
|
||||
const text =
|
||||
`From Username: ${row.f_username}\n` +
|
||||
`From Password: ${row.f_password}\n` +
|
||||
`To Username: ${row.t_username}\n` +
|
||||
`To Password: ${row.t_password}`;
|
||||
const ok = await copyToClipboard(text);
|
||||
setToast(
|
||||
ok
|
||||
? { type: "success", message: `Copied credentials for ${row.f_username}` }
|
||||
: {
|
||||
type: "error",
|
||||
message: `Could not copy — clipboard access blocked. Select text manually.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
@ -223,12 +303,21 @@ export default function UsersTable({
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span>
|
||||
<span aria-hidden="true">{active && sortDir === "asc" ? "↑" : "↓"}</span>
|
||||
</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 (
|
||||
<div>
|
||||
<PageHead
|
||||
@ -236,6 +325,14 @@ export default function UsersTable({
|
||||
loaded={0}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={onSearchSubmit}
|
||||
onClear={onSearchClear}
|
||||
appliedQuery={appliedQuery}
|
||||
searching={searching}
|
||||
/>
|
||||
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||||
<p className="text-sm text-zinc-500">
|
||||
No 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}
|
||||
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">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<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">
|
||||
From password
|
||||
</th>
|
||||
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
To username
|
||||
<th className="w-[18%] px-5 py-3 text-left">
|
||||
<HeaderTh k="t_username" label="To username" />
|
||||
</th>
|
||||
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
To password
|
||||
@ -281,7 +404,7 @@ export default function UsersTable({
|
||||
<th className="px-5 py-3 text-left">
|
||||
<HeaderTh k="last_update_time" label="Last update" />
|
||||
</th>
|
||||
<th className="w-12 px-3 py-3" aria-hidden="true" />
|
||||
<th className="w-20 px-3 py-3" aria-hidden="true" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
@ -326,6 +449,11 @@ export default function UsersTable({
|
||||
{formatTime(row.last_update_time)}
|
||||
</td>
|
||||
<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
|
||||
label={row.f_username}
|
||||
onClick={() => {
|
||||
@ -333,6 +461,7 @@ export default function UsersTable({
|
||||
setDeleteTarget(row.f_username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@ -354,6 +483,10 @@ export default function UsersTable({
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
{formatTime(row.last_update_time)}
|
||||
</span>
|
||||
<CopyButton
|
||||
label={row.f_username}
|
||||
onClick={() => handleCopy(row)}
|
||||
/>
|
||||
<DeleteButton
|
||||
label={row.f_username}
|
||||
onClick={() => {
|
||||
@ -411,6 +544,8 @@ export default function UsersTable({
|
||||
End of list — {rows.length} users loaded
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CreateUserDialog
|
||||
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 }) {
|
||||
return (
|
||||
<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 = {
|
||||
offset?: number;
|
||||
prefix?: string;
|
||||
sort?: "username" | "status";
|
||||
dir?: "asc" | "desc";
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export type UsersPageOpts = {
|
||||
offset?: number;
|
||||
prefix?: string;
|
||||
sort?: "f_username" | "last_update_time";
|
||||
sort?: "f_username" | "t_username" | "last_update_time";
|
||||
dir?: "asc" | "desc";
|
||||
q?: string;
|
||||
};
|
||||
|
||||
type FetchInit = {
|
||||
@ -57,18 +60,7 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise<u
|
||||
}
|
||||
|
||||
function buildAccountsUrl(opts: AccountsPageOpts): string {
|
||||
const { offset = 0, prefix = "", dir = "desc" } = 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 { offset = 0, prefix = "", sort = "username", dir = "desc", q = "" } = opts;
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(offset),
|
||||
@ -76,6 +68,20 @@ function buildUsersUrl(opts: UsersPageOpts): string {
|
||||
dir,
|
||||
});
|
||||
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()}`;
|
||||
}
|
||||
|
||||
|
||||
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