Files
caddy-proxy-manager/docs/plans/2026-02-27-acme-cert-info-and-pagination.md
akanealw 99819b70ff
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
added caddy-proxy-manager for testing
2026-04-21 22:49:08 +00:00

28 KiB
Executable File

ACME Cert Info + Sitewide Pagination Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Show real expiry/issuer info for ACME-managed certs in the Certificates page, and add server-side URL-param pagination to all data tables across the dashboard.

Architecture: Mount caddy-data volume read-only to the web container so we can parse Caddy's stored .crt files with X509Certificate. For pagination, each page.tsx reads searchParams.page, queries the DB with LIMIT/OFFSET, and passes { total, page, perPage } to the client. DataTable gains a pagination prop that renders MUI Pagination and uses useRouter to push ?page=N.

Tech Stack: Next.js 16 server components, Drizzle ORM (count(), .limit(), .offset()), MUI Pagination, Node.js X509Certificate, node:fs glob scanning.


Task 1: Mount caddy-data to web container

Files:

  • Modify: docker-compose.yml

Step 1: Add the read-only volume mount

In docker-compose.yml, find the web service's volumes: block and add:

- caddy-data:/caddy-data:ro

The final volumes block for the web service should look like:

volumes:
  - caddy-manager-data:/app/data
  - geoip-data:/usr/share/GeoIP:ro,z
  - caddy-logs:/logs:ro
  - caddy-data:/caddy-data:ro

Step 2: Verify

docker compose config | grep -A8 "caddy-data"

Expected: the web service lists caddy-data:/caddy-data:ro in its volume bindings.

Step 3: Commit

git add docker-compose.yml
git commit -m "chore: mount caddy-data read-only to web container for cert scanning"

Task 2: ACME cert scanner utility

Files:

  • Create: src/lib/acme-certs.ts

Step 1: Create the scanner

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { X509Certificate } from 'node:crypto';

export type AcmeCertInfo = {
  validTo: string;
  validFrom: string;
  issuer: string;
  domains: string[];
};

/**
 * Walks Caddy's certificate storage directory and parses every .crt file.
 * Returns a map from lowercase domain → cert info (most recent cert wins for
 * a given domain if multiple exist).
 *
 * Caddy stores certs under:
 *   <CADDY_CERTS_DIR>/acme-v02.api.letsencrypt.org-directory/<domain>/<domain>.crt
 *   <CADDY_CERTS_DIR>/acme.zerossl.com-v2-DV90/<domain>/<domain>.crt
 *   ...etc
 *
 * The directory is mounted at /caddy-data in the web container, so:
 *   CADDY_CERTS_DIR defaults to /caddy-data/caddy/certificates
 */
const CADDY_CERTS_DIR =
  process.env.CADDY_CERTS_DIR ?? '/caddy-data/caddy/certificates';

function walkCrtFiles(dir: string): string[] {
  const results: string[] = [];
  let entries: string[];
  try {
    entries = readdirSync(dir);
  } catch {
    return results; // directory doesn't exist yet (e.g. no certs issued)
  }
  for (const entry of entries) {
    const full = join(dir, entry);
    try {
      const stat = statSync(full);
      if (stat.isDirectory()) {
        results.push(...walkCrtFiles(full));
      } else if (entry.endsWith('.crt')) {
        results.push(full);
      }
    } catch {
      // skip unreadable entries
    }
  }
  return results;
}

export function scanAcmeCerts(): Map<string, AcmeCertInfo> {
  const map = new Map<string, AcmeCertInfo>();
  const crtFiles = walkCrtFiles(CADDY_CERTS_DIR);

  for (const file of crtFiles) {
    try {
      const pem = readFileSync(file, 'utf-8');
      const cert = new X509Certificate(pem);

      const sanDomains =
        cert.subjectAltName
          ?.split(',')
          .map(s => s.trim())
          .filter(s => s.startsWith('DNS:'))
          .map(s => s.slice(4).toLowerCase()) ?? [];

      const issuerLine = cert.issuer ?? '';
      const issuer = (
        issuerLine.match(/O=([^\n,]+)/)?.[1] ??
        issuerLine.match(/CN=([^\n,]+)/)?.[1] ??
        issuerLine
      ).trim();

      const info: AcmeCertInfo = {
        validTo: new Date(cert.validTo).toISOString(),
        validFrom: new Date(cert.validFrom).toISOString(),
        issuer,
        domains: sanDomains,
      };

      for (const domain of sanDomains) {
        // Keep the cert with the latest validTo for each domain
        const existing = map.get(domain);
        if (!existing || info.validTo > existing.validTo) {
          map.set(domain, info);
        }
      }
    } catch {
      // skip unreadable / malformed certs
    }
  }

  return map;
}

Step 2: Verify TypeScript compiles

npx tsc --noEmit

Expected: no errors.

Step 3: Commit

git add src/lib/acme-certs.ts
git commit -m "feat: add ACME cert scanner utility"

Task 3: Show ACME cert expiry in Certificates page

Files:

  • Modify: app/(dashboard)/certificates/page.tsx
  • Modify: app/(dashboard)/certificates/CertificatesClient.tsx

page.tsx changes

Step 1: Extend AcmeHost type and import scanner

At the top of page.tsx, add:

import { scanAcmeCerts } from '@/src/lib/acme-certs';

Change the AcmeHost type to:

export type AcmeHost = {
  id: number;
  name: string;
  domains: string[];
  ssl_forced: boolean;
  enabled: boolean;
  certValidTo: string | null;
  certValidFrom: string | null;
  certIssuer: string | null;
  certExpiryStatus: CertExpiryStatus | null;
};

Step 2: Add scanAcmeCerts() call in the page function

In CertificatesPage, after await requireAdmin(), call the scanner:

const acmeCertMap = scanAcmeCerts(); // synchronous, reads from disk

Step 3: Use the cert map when building acmeHosts

Replace the acmeHosts mapping with:

const acmeHosts: AcmeHost[] = acmeRows.map(r => {
  const domains = JSON.parse(r.domains) as string[];
  // Find a matching cert for any of this host's domains
  let certInfo = null;
  for (const domain of domains) {
    const info = acmeCertMap.get(domain.toLowerCase());
    if (info) { certInfo = info; break; }
  }
  return {
    id: r.id,
    name: r.name,
    domains,
    ssl_forced: r.sslForced,
    enabled: r.enabled,
    certValidTo: certInfo?.validTo ?? null,
    certValidFrom: certInfo?.validFrom ?? null,
    certIssuer: certInfo?.issuer ?? null,
    certExpiryStatus: certInfo?.validTo ? getExpiryStatus(certInfo.validTo) : null,
  };
});

CertificatesClient.tsx changes

Step 4: Add Expiry and Issuer columns to the ACME DataTable

Replace the acmeColumns array in CertificatesClient.tsx:

const acmeColumns = [
  {
    id: 'name',
    label: 'Proxy Host',
    render: (r: AcmeHost) => <Typography fontWeight={600}>{r.name}</Typography>,
  },
  {
    id: 'domains',
    label: 'Domains',
    render: (r: AcmeHost) => (
      <Typography variant="body2" color="text.secondary">
        {r.domains.join(', ')}
      </Typography>
    ),
  },
  {
    id: 'issuer',
    label: 'Issuer',
    render: (r: AcmeHost) => (
      <Typography variant="body2" color="text.secondary">
        {r.certIssuer ?? '—'}
      </Typography>
    ),
  },
  {
    id: 'expiry',
    label: 'Expiry',
    render: (r: AcmeHost) => <ExpiryChip validTo={r.certValidTo} status={r.certExpiryStatus} />,
  },
  {
    id: 'status',
    label: 'Status',
    render: (r: AcmeHost) => (
      <Chip
        label={r.enabled ? 'Active' : 'Disabled'}
        color={r.enabled ? 'success' : 'default'}
        size="small"
      />
    ),
  },
];

Step 5: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 6: Commit

git add app/(dashboard)/certificates/page.tsx app/(dashboard)/certificates/CertificatesClient.tsx
git commit -m "feat: show ACME cert expiry and issuer in certificates page"

Task 4: Add pagination support to DataTable

Files:

  • Modify: src/components/ui/DataTable.tsx

Step 1: Rewrite DataTable with "use client" and pagination prop

Replace the entire file with:

"use client";

import {
  Box,
  Card,
  Pagination,
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableHead,
  TableRow,
  Typography,
} from "@mui/material";
import { ReactNode } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";

export type Column<T> = {
  id: string;
  label: string;
  align?: "left" | "right" | "center";
  width?: string | number;
  render?: (row: T) => ReactNode;
};

type DataTableProps<T> = {
  columns: Column<T>[];
  data: T[];
  keyField: keyof T;
  emptyMessage?: string;
  loading?: boolean;
  pagination?: {
    total: number;
    page: number;
    perPage: number;
  };
};

export function DataTable<T>({
  columns,
  data,
  keyField,
  emptyMessage = "No data available",
  loading = false,
  pagination,
}: DataTableProps<T>) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const pageCount = pagination
    ? Math.ceil(pagination.total / pagination.perPage)
    : 0;

  function handlePageChange(_: React.ChangeEvent<unknown>, page: number) {
    const params = new URLSearchParams(searchParams.toString());
    params.set("page", String(page));
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <Box>
      <TableContainer component={Card} variant="outlined">
        <Table>
          <TableHead>
            <TableRow>
              {columns.map((col) => (
                <TableCell
                  key={col.id}
                  align={col.align || "left"}
                  width={col.width}
                >
                  {col.label}
                </TableCell>
              ))}
            </TableRow>
          </TableHead>
          <TableBody>
            {data.length === 0 && !loading ? (
              <TableRow>
                <TableCell colSpan={columns.length} align="center" sx={{ py: 8 }}>
                  <Typography color="text.secondary">{emptyMessage}</Typography>
                </TableCell>
              </TableRow>
            ) : (
              data.map((row) => (
                <TableRow key={String(row[keyField])}>
                  {columns.map((col) => (
                    <TableCell key={col.id} align={col.align || "left"}>
                      {col.render ? col.render(row) : (row as Record<string, unknown>)[col.id] as ReactNode}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            )}
          </TableBody>
        </Table>
      </TableContainer>

      {pagination && pageCount > 1 && (
        <Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
          <Pagination
            count={pageCount}
            page={pagination.page}
            onChange={handlePageChange}
            color="primary"
            shape="rounded"
          />
        </Box>
      )}
    </Box>
  );
}

Note: Column<T> is now exported so consumer files can import and type their columns.

Step 2: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 3: Commit

git add src/components/ui/DataTable.tsx
git commit -m "feat: add pagination support to DataTable component"

Task 5: Add paginated DB query helpers

Files:

  • Modify: src/lib/models/proxy-hosts.ts
  • Modify: src/lib/models/audit.ts
  • Modify: src/lib/models/access-lists.ts

proxy-hosts.ts

Step 1: Add count function and paginated list

At the top of the file, add count to the drizzle-orm imports (it's imported from drizzle-orm):

import { desc, eq, count } from "drizzle-orm";

After the existing listProxyHosts() function, add:

export async function countProxyHosts(): Promise<number> {
  const [row] = await db.select({ value: count() }).from(proxyHosts);
  return row?.value ?? 0;
}

export async function listProxyHostsPaginated(limit: number, offset: number): Promise<ProxyHost[]> {
  const hosts = await db
    .select()
    .from(proxyHosts)
    .orderBy(desc(proxyHosts.createdAt))
    .limit(limit)
    .offset(offset);
  return hosts.map(parseProxyHost);
}

audit.ts

Step 2: Add count + search to audit events

Replace listAuditEvents and add countAuditEvents:

import { desc, ilike, or, count, sql } from "drizzle-orm";

export async function countAuditEvents(search?: string): Promise<number> {
  const where = search
    ? or(
        ilike(auditEvents.summary, `%${search}%`),
        ilike(auditEvents.action, `%${search}%`),
        ilike(auditEvents.entityType, `%${search}%`)
      )
    : undefined;
  const [row] = await db.select({ value: count() }).from(auditEvents).where(where);
  return row?.value ?? 0;
}

export async function listAuditEvents(
  limit = 100,
  offset = 0,
  search?: string
): Promise<AuditEvent[]> {
  const where = search
    ? or(
        ilike(auditEvents.summary, `%${search}%`),
        ilike(auditEvents.action, `%${search}%`),
        ilike(auditEvents.entityType, `%${search}%`)
      )
    : undefined;
  const events = await db
    .select()
    .from(auditEvents)
    .where(where)
    .orderBy(desc(auditEvents.createdAt))
    .limit(limit)
    .offset(offset);

  return events.map((event) => ({
    id: event.id,
    user_id: event.userId,
    action: event.action,
    entity_type: event.entityType,
    entity_id: event.entityId,
    summary: event.summary,
    created_at: toIso(event.createdAt)!,
  }));
}

Note on SQLite and ilike: SQLite's LIKE is case-insensitive for ASCII by default. Drizzle's ilike maps to LIKE on SQLite, so this works correctly.

access-lists.ts

Step 3: Add count function and paginated list

Add count to imports:

import { asc, eq, inArray, count } from "drizzle-orm";

After listAccessLists(), add:

export async function countAccessLists(): Promise<number> {
  const [row] = await db.select({ value: count() }).from(accessLists);
  return row?.value ?? 0;
}

export async function listAccessListsPaginated(limit: number, offset: number): Promise<AccessList[]> {
  const lists = await db.query.accessLists.findMany({
    orderBy: (table) => asc(table.name),
    limit,
    offset,
  });

  if (lists.length === 0) return [];

  const listIds = lists.map((list) => list.id);
  const entries = await db
    .select()
    .from(accessListEntries)
    .where(inArray(accessListEntries.accessListId, listIds));

  const entriesByList = new Map<number, (typeof accessListEntries.$inferSelect)[]>();
  for (const entry of entries) {
    const bucket = entriesByList.get(entry.accessListId) ?? [];
    bucket.push(entry);
    entriesByList.set(entry.accessListId, bucket);
  }

  return lists.map((list) => toAccessList(list, entriesByList.get(list.id) ?? []));
}

Step 4: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 5: Commit

git add src/lib/models/proxy-hosts.ts src/lib/models/audit.ts src/lib/models/access-lists.ts
git commit -m "feat: add paginated list functions to DB models"

Task 6: Paginate proxy-hosts page

Files:

  • Modify: app/(dashboard)/proxy-hosts/page.tsx
  • Modify: app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx

page.tsx

Step 1: Add searchParams + paginated queries

Replace the file:

import ProxyHostsClient from "./ProxyHostsClient";
import { listProxyHostsPaginated, countProxyHosts } from "@/src/lib/models/proxy-hosts";
import { listCertificates } from "@/src/lib/models/certificates";
import { listAccessLists } from "@/src/lib/models/access-lists";
import { getAuthentikSettings } from "@/src/lib/settings";
import { requireAdmin } from "@/src/lib/auth";

const PER_PAGE = 25;

interface PageProps {
  searchParams: { page?: string };
}

export default async function ProxyHostsPage({ searchParams }: PageProps) {
  await requireAdmin();
  const page = Math.max(1, parseInt(searchParams.page ?? "1", 10) || 1);
  const offset = (page - 1) * PER_PAGE;

  const [hosts, total, certificates, accessLists, authentikDefaults] = await Promise.all([
    listProxyHostsPaginated(PER_PAGE, offset),
    countProxyHosts(),
    listCertificates(),
    listAccessLists(),
    getAuthentikSettings(),
  ]);

  return (
    <ProxyHostsClient
      hosts={hosts}
      certificates={certificates}
      accessLists={accessLists}
      authentikDefaults={authentikDefaults}
      pagination={{ total, page, perPage: PER_PAGE }}
    />
  );
}

ProxyHostsClient.tsx

Step 2: Add pagination prop and pass it to DataTable

Find the type Props definition in ProxyHostsClient.tsx and add a pagination field:

type Props = {
  hosts: ProxyHost[];
  certificates: Certificate[];
  accessLists: AccessList[];
  authentikDefaults: AuthentikSettings;
  pagination: { total: number; page: number; perPage: number };
};

Update the function signature:

export default function ProxyHostsClient({ hosts, certificates, accessLists, authentikDefaults, pagination }: Props) {

Find the <DataTable call and add the pagination prop:

<DataTable
  columns={columns}
  data={hosts}
  keyField="id"
  emptyMessage="No proxy hosts configured"
  pagination={pagination}
/>

Step 3: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 4: Commit

git add app/(dashboard)/proxy-hosts/page.tsx app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
git commit -m "feat: add server-side pagination to proxy-hosts page"

Task 7: Paginate audit-log page

Files:

  • Modify: app/(dashboard)/audit-log/page.tsx
  • Modify: app/(dashboard)/audit-log/AuditLogClient.tsx

page.tsx

Step 1: Replace with paginated + search-aware version

import AuditLogClient from "./AuditLogClient";
import { listAuditEvents, countAuditEvents } from "@/src/lib/models/audit";
import { listUsers } from "@/src/lib/models/user";
import { requireAdmin } from "@/src/lib/auth";

const PER_PAGE = 50;

interface PageProps {
  searchParams: { page?: string; search?: string };
}

export default async function AuditLogPage({ searchParams }: PageProps) {
  await requireAdmin();
  const page = Math.max(1, parseInt(searchParams.page ?? "1", 10) || 1);
  const search = searchParams.search?.trim() || undefined;
  const offset = (page - 1) * PER_PAGE;

  const [events, total, users] = await Promise.all([
    listAuditEvents(PER_PAGE, offset, search),
    countAuditEvents(search),
    listUsers(),
  ]);

  const userMap = new Map(users.map((user) => [user.id, user]));

  return (
    <AuditLogClient
      events={events.map((event) => ({
        id: event.id,
        created_at: event.created_at,
        summary: event.summary ?? `${event.action} on ${event.entity_type}`,
        user: event.user_id
          ? userMap.get(event.user_id)?.name ??
            userMap.get(event.user_id)?.email ??
            "System"
          : "System",
      }))}
      pagination={{ total, page, perPage: PER_PAGE }}
      initialSearch={search ?? ""}
    />
  );
}

AuditLogClient.tsx

Step 2: Replace client-side search with URL-driven search + DataTable pagination

The client keeps a search input but now uses useRouter to update ?search= in the URL (with debounce). Replace the entire file:

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Chip, Stack, TextField, Typography } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import { DataTable } from "@/src/components/ui/DataTable";

type EventRow = {
  id: number;
  created_at: string;
  user: string;
  summary: string;
};

type Props = {
  events: EventRow[];
  pagination: { total: number; page: number; perPage: number };
  initialSearch: string;
};

export default function AuditLogClient({ events, pagination, initialSearch }: Props) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [searchTerm, setSearchTerm] = useState(initialSearch);
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const updateSearch = useCallback(
    (value: string) => {
      if (debounceRef.current) clearTimeout(debounceRef.current);
      debounceRef.current = setTimeout(() => {
        const params = new URLSearchParams(searchParams.toString());
        if (value.trim()) {
          params.set("search", value.trim());
        } else {
          params.delete("search");
        }
        params.delete("page"); // reset to page 1 on new search
        router.push(`${pathname}?${params.toString()}`);
      }, 400);
    },
    [router, pathname, searchParams]
  );

  useEffect(() => {
    return () => {
      if (debounceRef.current) clearTimeout(debounceRef.current);
    };
  }, []);

  const columns = [
    {
      id: "created_at",
      label: "Time",
      width: 180,
      render: (r: EventRow) => (
        <Typography variant="body2" color="text.secondary" sx={{ whiteSpace: "nowrap" }}>
          {new Date(r.created_at).toLocaleString()}
        </Typography>
      ),
    },
    {
      id: "user",
      label: "User",
      width: 160,
      render: (r: EventRow) => (
        <Chip label={r.user} size="small" variant="outlined" />
      ),
    },
    {
      id: "summary",
      label: "Event",
      render: (r: EventRow) => (
        <Typography variant="body2">{r.summary}</Typography>
      ),
    },
  ];

  return (
    <Stack spacing={2} sx={{ width: "100%" }}>
      <Typography variant="h4" fontWeight={600}>
        Audit Log
      </Typography>
      <Typography color="text.secondary">Review configuration changes and user activity.</Typography>

      <TextField
        placeholder="Search audit log..."
        value={searchTerm}
        onChange={(e) => {
          setSearchTerm(e.target.value);
          updateSearch(e.target.value);
        }}
        slotProps={{
          input: {
            startAdornment: <SearchIcon sx={{ mr: 1, color: "rgba(255, 255, 255, 0.5)" }} />,
          },
        }}
        size="small"
        sx={{ maxWidth: 400 }}
      />

      <DataTable
        columns={columns}
        data={events}
        keyField="id"
        emptyMessage="No audit events found"
        pagination={pagination}
      />
    </Stack>
  );
}

Step 3: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 4: Commit

git add app/(dashboard)/audit-log/page.tsx app/(dashboard)/audit-log/AuditLogClient.tsx
git commit -m "feat: add server-side pagination and search to audit-log page"

Task 8: Paginate access-lists page

Files:

  • Modify: app/(dashboard)/access-lists/page.tsx
  • Modify: app/(dashboard)/access-lists/AccessListsClient.tsx

page.tsx

Step 1: Add searchParams + paginated queries

import AccessListsClient from "./AccessListsClient";
import { listAccessListsPaginated, countAccessLists } from "@/src/lib/models/access-lists";
import { requireAdmin } from "@/src/lib/auth";

const PER_PAGE = 25;

interface PageProps {
  searchParams: { page?: string };
}

export default async function AccessListsPage({ searchParams }: PageProps) {
  await requireAdmin();
  const page = Math.max(1, parseInt(searchParams.page ?? "1", 10) || 1);
  const offset = (page - 1) * PER_PAGE;

  const [lists, total] = await Promise.all([
    listAccessListsPaginated(PER_PAGE, offset),
    countAccessLists(),
  ]);

  return (
    <AccessListsClient
      lists={lists}
      pagination={{ total, page, perPage: PER_PAGE }}
    />
  );
}

AccessListsClient.tsx

Step 2: Add MUI Pagination to the card-based list

Access lists use cards, not DataTable. Add a Pagination component at the bottom using the same URL-push pattern.

Add these imports to AccessListsClient.tsx:

import { Box, Pagination } from "@mui/material";
import { usePathname, useRouter, useSearchParams } from "next/navigation";

Add pagination to the Props type:

type Props = {
  lists: AccessList[];
  pagination: { total: number; page: number; perPage: number };
};

Update the function signature and add page navigation at the bottom of the returned JSX, just before the closing </Stack>:

export default function AccessListsClient({ lists, pagination }: Props) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const pageCount = Math.ceil(pagination.total / pagination.perPage);

  function handlePageChange(_: React.ChangeEvent<unknown>, page: number) {
    const params = new URLSearchParams(searchParams.toString());
    params.set("page", String(page));
    router.push(`${pathname}?${params.toString()}`);
  }

  // ... existing JSX ...

  // Add at the bottom of the Stack, after the lists rendering:
  {pageCount > 1 && (
    <Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
      <Pagination
        count={pageCount}
        page={pagination.page}
        onChange={handlePageChange}
        color="primary"
        shape="rounded"
      />
    </Box>
  )}

Step 3: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 4: Commit

git add app/(dashboard)/access-lists/page.tsx app/(dashboard)/access-lists/AccessListsClient.tsx
git commit -m "feat: add server-side pagination to access-lists page"

Task 9: Paginate certificates page (ACME table)

Files:

  • Modify: app/(dashboard)/certificates/page.tsx
  • Modify: app/(dashboard)/certificates/CertificatesClient.tsx

page.tsx

Step 1: Add searchParams + paginate the ACME hosts query

Add PER_PAGE and searchParams to the page:

const PER_PAGE = 25;

interface PageProps {
  searchParams: { page?: string };
}

export default async function CertificatesPage({ searchParams }: PageProps) {
  await requireAdmin();
  const page = Math.max(1, parseInt(searchParams.page ?? "1", 10) || 1);
  const offset = (page - 1) * PER_PAGE;

Change the ACME query to use limit/offset and add a count query:

const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([
  db
    .select({
      id: proxyHosts.id,
      name: proxyHosts.name,
      domains: proxyHosts.domains,
      sslForced: proxyHosts.sslForced,
      enabled: proxyHosts.enabled,
    })
    .from(proxyHosts)
    .where(isNull(proxyHosts.certificateId))
    .orderBy(proxyHosts.name)
    .limit(PER_PAGE)
    .offset(offset),
  db
    .select({ value: count() })
    .from(proxyHosts)
    .where(isNull(proxyHosts.certificateId))
    .then(([r]) => r?.value ?? 0),
  db.select().from(certificates),
  db
    .select({
      certId: proxyHosts.certificateId,
      hostId: proxyHosts.id,
      hostName: proxyHosts.name,
      hostDomains: proxyHosts.domains,
    })
    .from(proxyHosts)
    .where(isNotNull(proxyHosts.certificateId)),
]);

Add count to drizzle-orm imports: import { isNull, isNotNull, count } from 'drizzle-orm';

Pass acmePagination to the client:

return (
  <CertificatesClient
    acmeHosts={acmeHosts}
    importedCerts={importedCerts}
    managedCerts={managedCerts}
    acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }}
  />
);

CertificatesClient.tsx

Step 2: Add acmePagination prop and pass to DataTable

Add acmePagination to the Props type:

type Props = {
  acmeHosts: AcmeHost[];
  importedCerts: ImportedCertView[];
  managedCerts: ManagedCertView[];
  acmePagination: { total: number; page: number; perPage: number };
};

Pass it to DataTable:

<DataTable
  columns={acmeColumns}
  data={acmeHosts}
  keyField="id"
  emptyMessage="No proxy hosts using automatic ACME certificates"
  pagination={acmePagination}
/>

Step 3: Verify TypeScript

npx tsc --noEmit

Expected: no errors.

Step 4: Commit

git add app/(dashboard)/certificates/page.tsx app/(dashboard)/certificates/CertificatesClient.tsx
git commit -m "feat: add pagination to certificates ACME table"

Final Verification

npx tsc --noEmit

Expected: zero errors across all modified files.