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>
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import type { Acc, User } from "./types";
|
|
|
|
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
|
|
|
|
// Tab-switch responses come from the Next.js data cache for this many
|
|
// seconds before re-fetching. Mutations call revalidateTag() to evict
|
|
// the cached entry and force the next read to hit MySQL.
|
|
const CACHE_REVALIDATE_SECONDS = 30;
|
|
|
|
export const ACCOUNTS_TAG = "accounts";
|
|
export const USERS_TAG = "users";
|
|
|
|
// Page size used for both initial server-side fetch and infinite scroll
|
|
// on the client. 200 keeps each cached payload under ~50KB and renders
|
|
// in well under one frame even on phones.
|
|
export const PAGE_SIZE = 200;
|
|
|
|
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" | "t_username" | "last_update_time";
|
|
dir?: "asc" | "desc";
|
|
q?: string;
|
|
};
|
|
|
|
type FetchInit = {
|
|
method?: "GET" | "POST";
|
|
body?: unknown;
|
|
cache?: RequestCache;
|
|
next?: { revalidate?: number; tags?: string[] };
|
|
};
|
|
|
|
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
|
|
const url = `${API_BASE_URL}${path}`;
|
|
const init: RequestInit & { next?: FetchInit["next"] } = {
|
|
method: options.method ?? "GET",
|
|
headers: options.body ? { "content-type": "application/json" } : undefined,
|
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
};
|
|
if (options.next) {
|
|
init.next = options.next;
|
|
} else {
|
|
init.cache = options.cache ?? "no-store";
|
|
}
|
|
const res = await fetch(url, init);
|
|
if (!res.ok) {
|
|
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function buildAccountsUrl(opts: AccountsPageOpts): string {
|
|
const { offset = 0, prefix = "", sort = "username", 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 `/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()}`;
|
|
}
|
|
|
|
export async function getAccountsPage(opts: AccountsPageOpts = {}): Promise<Page<Acc>> {
|
|
const data = (await fetchApi(buildAccountsUrl(opts), {
|
|
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] },
|
|
})) as { rows: Acc[]; total: number };
|
|
return {
|
|
rows: data.rows,
|
|
hasMore: data.rows.length === PAGE_SIZE,
|
|
total: data.total,
|
|
};
|
|
}
|
|
|
|
export async function getUsersPage(opts: UsersPageOpts = {}): Promise<Page<User>> {
|
|
const data = (await fetchApi(buildUsersUrl(opts), {
|
|
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] },
|
|
})) as { rows: User[]; total: number };
|
|
return {
|
|
rows: data.rows,
|
|
hasMore: data.rows.length === PAGE_SIZE,
|
|
total: data.total,
|
|
};
|
|
}
|