Merge branch 'fix/f1-master-certificates-page-ux' (F-1 master, 2 audit findings)

This commit is contained in:
shankar0123
2026-04-25 17:38:54 +00:00
2 changed files with 150 additions and 7 deletions
+82 -2
View File
@@ -5,6 +5,24 @@ interface Column<T> {
className?: string;
}
// F-1 closure (cat-k-e85d1099b2d7): DataTable was a render-only
// component pre-F-1 — every consumer page handed it the first 50
// rows from a paginated endpoint and there was no way for the
// operator to advance. The backend has always returned `{data,
// total, page, per_page}` but the frontend never surfaced page
// 2+. The pagination prop below opt-ins reusable controls in the
// table footer; CertificatesPage is the first consumer (and the
// audit's flagged page), but TargetsPage / IssuersPage / others
// can adopt by passing the same prop.
interface PaginationProps {
page: number;
perPage: number;
total: number;
onPageChange: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
perPageOptions?: number[];
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
@@ -15,9 +33,10 @@ interface DataTableProps<T> {
selectable?: boolean;
selectedKeys?: Set<string>;
onSelectionChange?: (keys: Set<string>) => void;
pagination?: PaginationProps;
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) {
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-ink-muted">
@@ -111,8 +130,69 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
})}
</tbody>
</table>
{pagination && pagination.total > 0 && (
<PaginationControls {...pagination} />
)}
</div>
);
}
export type { Column };
// F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable
// consumers that want prev/next + page counter + per-page selector
// against a paginated backend response. Disabling logic guards the
// boundaries (prev disabled on page 1; next disabled when page *
// per_page >= total).
function PaginationControls({ page, perPage, total, onPageChange, onPerPageChange, perPageOptions }: PaginationProps) {
const start = total === 0 ? 0 : (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
const lastPage = Math.max(1, Math.ceil(total / perPage));
const isFirst = page <= 1;
const isLast = page >= lastPage;
const options = perPageOptions ?? [25, 50, 100, 200];
return (
<div className="flex items-center justify-between border-t border-surface-border px-4 py-3 text-sm text-ink-muted">
<span>
Showing <span className="font-medium text-ink">{start}</span><span className="font-medium text-ink">{end}</span> of <span className="font-medium text-ink">{total.toLocaleString()}</span>
</span>
<div className="flex items-center gap-3">
{onPerPageChange && (
<label className="flex items-center gap-2 text-xs">
<span>Rows per page:</span>
<select
value={perPage}
onChange={e => onPerPageChange(Number(e.target.value))}
className="rounded border border-surface-border bg-white px-2 py-1 text-xs text-ink focus:outline-none focus:border-brand-400"
>
{options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</label>
)}
<span className="text-xs">
Page <span className="font-medium text-ink">{page}</span> of <span className="font-medium text-ink">{lastPage}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={isFirst}
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Prev
</button>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={isLast}
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
);
}
export type { Column, PaginationProps };
+68 -5
View File
@@ -398,6 +398,24 @@ export default function CertificatesPage() {
const [issuerFilter, setIssuerFilter] = useState('');
const [ownerFilter, setOwnerFilter] = useState('');
const [profileFilter, setProfileFilter] = useState('');
// F-1 closure (cat-e-610251c8f72d): pre-F-1 the page exposed only 5 of
// the backend handler's 17 supported query filters. Three new operator-
// facing filters added: team_id (already first-class elsewhere),
// expires_before (drives the "expiring in N days" workflow), and a
// sort selector (defaults to backend ordering). Audit-recommended
// minimum-add per the closure rationale; remaining filters
// (agent_id, expires_after, created_after, updated_after, cursor,
// fields, sort_desc) are deferred until a consumer use case
// demands them — over-stuffing the toolbar is its own UX cost.
const [teamFilter, setTeamFilter] = useState('');
const [expiresBefore, setExpiresBefore] = useState('');
const [sortBy, setSortBy] = useState('');
// F-1 closure (cat-k-e85d1099b2d7): pre-F-1 the page rendered the
// first 50 certs returned by the backend with no way to advance.
// The reusable DataTable pagination prop (added in this same
// commit) takes the page + per_page state declared here.
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
@@ -407,13 +425,21 @@ export default function CertificatesPage() {
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
// F-1 closure: hydrate the team filter dropdown.
const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) });
const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter;
if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter;
if (teamFilter) params.team_id = teamFilter;
if (expiresBefore) params.expires_before = expiresBefore;
if (sortBy) params.sort = sortBy;
// Pagination (F-1) — re-fetch on page / per_page change.
params.page = String(page);
params.per_page = String(perPage);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['certificates', params],
@@ -587,6 +613,36 @@ export default function CertificatesPage() {
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */}
<select
value={teamFilter}
onChange={e => { setTeamFilter(e.target.value); setPage(1); }}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All teams</option>
{teamsFilterData?.data?.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<input
type="date"
value={expiresBefore}
onChange={e => { setExpiresBefore(e.target.value); setPage(1); }}
title="Expires before (drives the 'expiring in N days' workflow)"
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
/>
<select
value={sortBy}
onChange={e => { setSortBy(e.target.value); setPage(1); }}
title="Sort order"
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">Default sort</option>
<option value="notAfter">Expires soonest</option>
<option value="-notAfter">Expires latest</option>
<option value="createdAt">Created earliest</option>
<option value="-createdAt">Created latest</option>
</select>
</div>
<div className="flex-1 overflow-y-auto">
{error ? (
@@ -601,6 +657,13 @@ export default function CertificatesPage() {
selectable
selectedKeys={selectedIds}
onSelectionChange={setSelectedIds}
pagination={{
page,
perPage,
total: data?.total ?? 0,
onPageChange: setPage,
onPerPageChange: (n) => { setPerPage(n); setPage(1); },
}}
/>
)}
</div>