12 Commits

Author SHA1 Message Date
9eed051916 feat(web,api): row-level search, more sort columns, copy buttons
Three operator-quality-of-life features behind the same caching and
pagination contract that the existing tables already use:

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:26:11 +08:00
850fb71ddd fix(web): force scroll-to-top on tab switch (iOS Safari quirk)
Next.js App Router *should* reset scroll on route change, but iOS
Safari (and occasionally Chrome on Android) preserves the document
scroll position when navigating between routes that share a layout —
the user lands on the new tab halfway down the page and has to scroll
manually.

Adds a useEffect([]) in both AccountsTable and UsersTable that calls
window.scrollTo({top:0, left:0, behavior:'instant'}) on mount. Route
transitions remount the table component (different page module), so
the empty deps array fires exactly once per visit.

'instant' behavior avoids a smooth-scroll animation that would compete
with the loading-skeleton swap.
2026-05-03 11:51:27 +08:00
9b222eec56 fix(web): drop Refresh button, move Add right-aligned on the title row
- PageHead is now a single column: eyebrow, then a row with the title
  ('Accounts <total>') on the left and the Add pill flush right via
  flex justify-between. Subtitle ('Showing X of Y') sits below.
- Refresh button removed entirely. The internal refresh() function
  stays — the create-account / create-user dialog still calls it on
  success so the new row appears in its sorted position. The 30-second
  cache + revalidateTag-on-mutation already keeps the table fresh
  without a manual button.
- Removed the now-unused 'refreshing' state + the matching dependency
  from the IntersectionObserver effect.
2026-05-03 11:45:53 +08:00
fa93a8c866 fix(web): drop 'keep scrolling to load more' tail from subtitle 2026-05-03 11:43:21 +08:00
ee74ebda64 feat(web): show real DB total in table header (replaces '200+')
The header used to show '200+' once the user had loaded a partial set
of pages — opaque, useless for an operator who actually needs to know
'how many accounts are in the system right now'.

Server (app/cm_api.py):
- /acc/ and /user/ list responses now wrap the rows alongside a
  COUNT(*) of the table: { rows: [...], total: N }. The single-row
  /acc/<username> path is unchanged (still returns Acc[] with one row).
- Each list request issues both queries (the page SELECT and the COUNT)
  on the same pooled connection. COUNT(*) on a 3k-row table is sub-ms;
  even when the cache misses, total request latency stays well under
  20ms on warm-cache MySQL.

Web client:
- web/lib/api.ts: Page<T> gains a  field; getAccountsPage and
  getUsersPage parse the new wrapped response.
- web/app/page.tsx + users/page.tsx: pass page.total down as
  initialTotal.
- web/components/{accounts,users}-table.tsx: hold total in state, sync
  it from every page fetch (initial, loadMore, sort change, force
  refresh) so cm99 monitor inserts during the session bump it correctly.
  Delete decrements it by 1 immediately so the header doesn't lie
  between the optimistic delete and the next refresh.
- PageHead now shows '<total>' as the big number. When loaded < total,
  a small zinc-400 line below reads 'Showing X of N — keep scrolling
  to load more'. Once the user reaches the end, the line goes away.

No new round trips for the count: it piggybacks on the same /acc/?...
or /user/?... request that already fetches the page. The 30s cache
covers the count too — so tab switches still don't hit MySQL.
2026-05-03 11:38:38 +08:00
6bb85222d1 perf(web): server-side pagination + infinite-scroll for accounts/users
For 3k+ row deployments, returning the full table in one shot is the
bottleneck — the JSON payload alone is hundreds of KB and the client
mounts thousands of EditableCell instances on every visit. Pagination
with auto-fetch on scroll shrinks both the wire payload and the initial
render to a single page (200 rows).

Server (app/cm_api.py):
- /acc/ and /user/ accept ?limit, ?offset, ?prefix, ?dir, plus ?sort on
  /user/ (f_username | last_update_time). Defaults: limit=200 (capped at
  1000), offset=0, dir=desc.
- ORDER BY done in SQL with prefix-priority: rows whose username starts
  with the configured CM_PREFIX_PATTERN come first, then asc/desc by the
  sort column. The 'dir' value is whitelisted to ASC|DESC before string
  interpolation; everything else goes through parameterised binding.
- Schema verification (verify_tables_once) deferred to first request via
  a Flask before_request hook — keeps create_app() free of MySQL touches
  so unit tests + gunicorn preload still work without a live DB.

Web client:
- web/lib/api.ts: getAccountsPage / getUsersPage return { rows, hasMore }.
  hasMore = (rows.length === PAGE_SIZE), so the client knows when to
  stop fetching. Each page is its own Next.js cache entry (the URL is
  the cache key) — caching from the previous commit still applies.
- web/app/actions.ts: loadMoreAccounts / loadMoreUsers Server Actions
  for next-page requests; refreshAccounts / refreshUsers force-evict the
  cache via revalidateTag before refetching page 1.
- web/app/page.tsx + users/page.tsx: only fetch the first page now.
- web/components/{accounts,users}-table.tsx: rewrote state model. Rows
  accumulate as the user scrolls. An IntersectionObserver on a sentinel
  div near the bottom triggers loadMore when it enters the viewport
  (300px rootMargin so the next page starts loading before the user
  reaches the end). useOptimistic wraps the accumulated rows for in-
  flight edits; on success the row is committed locally so the change
  survives even though we no longer router.refresh.
- Sort toggle now refetches from page 1 with the new dir/sort param.
  Local sort over a partial set would be inconsistent.
- Mutations: delete filters from local state; create + refresh both
  reset to page 1 so the row appears in its sorted position.
- Header count shows '<loaded>+' when more pages exist so the operator
  knows what they're seeing isn't the full table.

Removed AutoRefresh:
- web/app/layout.tsx no longer mounts AutoRefresh.
- web/components/auto-refresh.tsx deleted.
- Reason: router.refresh every 30s would yank the user back to page 1
  every time, losing scroll position and accumulated rows. Manual
  Refresh button replaces it (now wired to refreshAccounts/refreshUsers
  which evict cache + refetch).

Tests: deferred verify_tables_once() means tests.test_bot_cli's
CreateAppFactoryTests pass without DB env vars again. All 38 existing
tests pass.
2026-05-03 11:29:34 +08:00
9771bb72c5 fix(web): move mobile status back into card header next to username
Earlier change made the badge the editable trigger and demoted it
into the body's Status row. That separated status from the row
identifier on mobile, which read as 'where is this status from?'.

Move the EditableCell-with-StatusBadge back into the card header,
right after the username, and drop the body Status row entirely.
Mobile now matches desktop's information density: identifier +
status badge inline, edit via badge click.
2026-05-03 08:17:23 +08:00
fe26878b38 fix(web): drop duplicate status — badge IS the editable trigger
The status column was rendering both a StatusBadge and a separate
EditableCell next to it, so 'done' showed twice in the same cell.
Adds a renderView prop to EditableCell so callers can override the
view-mode display; status now uses the badge as its visual, click-
to-edit behavior intact. Mobile card header drops its standalone
badge for the same reason — the body's Status row now shows the
badge inline.
2026-05-03 08:13:51 +08:00
eebbcb3db2 feat(web): success toast on confirmed create/delete
Adds a small top-centered <Toast> that fires only when the Server
Action returns { ok: true } (i.e., the DB write actually succeeded).
Auto-dismisses after 3s.

Wires both create dialogs (CreateAccountDialog, CreateUserDialog) with
an onSuccess callback that the table parent uses to push the toast,
and the delete confirm-flow does the same. Inline-edit success stays
quiet (no toast) — only add/delete trigger it, per the requested
scope.
2026-05-02 21:20:25 +08:00
e3ac94cada feat(web): manual create flow with input dialog for acc and user
api-server gets /create-acc-data and /create-user-data POST routes
that INSERT into the respective tables with required-field validation.
Frontend adds an 'Add' button next to Refresh in each table head;
opens a native <dialog> form with all fields. Inputs use 16px font on
phone (sm:text-[13px] desktop) so iOS doesn't auto-zoom.

A small form-dialog-shell helper centralizes the modal chrome,
field label, and input class so create-account-dialog and
create-user-dialog stay focused on their fields and validation.
2026-05-02 21:19:24 +08:00
e507714dc5 feat(web): delete with confirm dialog + fix iOS auto-zoom on edit
Adds × delete button per row in both tables (desktop column +
mobile card header). Click → native <dialog> confirm modal with
Esc/backdrop-cancel, destructive red button, error inline.
Wires deleteAccount/deleteUser Server Actions calling the new
api-server routes; revalidatePath refreshes the list on success.

EditableCell input switches to text-base (16px) on phone (sm:text-[13px]
above 640px), preventing iOS Safari auto-zoom-on-focus that was
shifting the layout when the soft keyboard appeared.
2026-05-02 21:17:19 +08:00
7b97e593e5 feat(web): add data tables and editable-cell primitive (frontend-design) 2026-05-02 20:56:49 +08:00