From 17f40dd8b20e40d6e36d4b581bfff9ed56c40fc0 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Wed, 17 Sep 2025 18:01:00 +1000 Subject: [PATCH] Certificates react table basis --- backend/internal/certificate.js | 5 +- frontend/src/api/backend/getCertificates.ts | 4 +- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useCertificates.ts | 17 +++ frontend/src/locale/lang/en.json | 6 + frontend/src/locale/src/en.json | 18 +++ frontend/src/pages/Certificates/Empty.tsx | 36 ++++++ frontend/src/pages/Certificates/Table.tsx | 116 ++++++++++++++++++ .../src/pages/Certificates/TableWrapper.tsx | 71 +++++++++++ frontend/src/pages/Certificates/index.tsx | 4 +- 10 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/useCertificates.ts create mode 100644 frontend/src/pages/Certificates/Empty.tsx create mode 100644 frontend/src/pages/Certificates/Table.tsx create mode 100644 frontend/src/pages/Certificates/TableWrapper.tsx diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 76eb8fa5..ec1abf26 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -406,10 +406,7 @@ const internalCertificate = { .query() .where("is_deleted", 0) .groupBy("id") - .allowGraph("[owner]") - .allowGraph("[proxy_hosts]") - .allowGraph("[redirection_hosts]") - .allowGraph("[dead_hosts]") + .allowGraph("[owner,proxy_hosts,redirection_hosts,dead_hosts]") .orderBy("nice_name", "ASC"); if (accessData.permission_visibility !== "all") { diff --git a/frontend/src/api/backend/getCertificates.ts b/frontend/src/api/backend/getCertificates.ts index 15660b97..9389fe44 100644 --- a/frontend/src/api/backend/getCertificates.ts +++ b/frontend/src/api/backend/getCertificates.ts @@ -1,7 +1,9 @@ import * as api from "./base"; import type { Certificate } from "./models"; -export async function getCertificates(expand?: string[], params = {}): Promise { +export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts"; + +export async function getCertificates(expand?: CertificateExpansion[], params = {}): Promise { return await api.get({ url: "/nginx/certificates", params: { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index f163b904..91738be9 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from "./useAccessLists"; export * from "./useAuditLog"; export * from "./useAuditLogs"; +export * from "./useCertificates"; export * from "./useDeadHosts"; export * from "./useHealth"; export * from "./useHostReport"; diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts new file mode 100644 index 00000000..261c79d8 --- /dev/null +++ b/frontend/src/hooks/useCertificates.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { type Certificate, type CertificateExpansion, getCertificates } from "src/api/backend"; + +const fetchCertificates = (expand?: CertificateExpansion[]) => { + return getCertificates(expand); +}; + +const useCertificates = (expand?: CertificateExpansion[], options = {}) => { + return useQuery({ + queryKey: ["certificates", { expand }], + queryFn: () => fetchCertificates(expand), + staleTime: 60 * 1000, + ...options, + }); +}; + +export { fetchCertificates, useCertificates }; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index c857c08a..cabcdcde 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -15,6 +15,10 @@ "action.view-details": "View Details", "auditlog.title": "Audit Log", "cancel": "Cancel", + "certificates.actions-title": "Certificate #{id}", + "certificates.add": "Add Certificate", + "certificates.custom": "Custom Certificate", + "certificates.empty": "There are no Certificates", "certificates.title": "SSL Certificates", "close": "Close", "column.access": "Access", @@ -22,10 +26,12 @@ "column.destination": "Destination", "column.email": "Email", "column.event": "Event", + "column.expires": "Expires", "column.http-code": "Access", "column.incoming-port": "Incoming Port", "column.name": "Name", "column.protocol": "Protocol", + "column.provider": "Provider", "column.roles": "Roles", "column.satisfy": "Satisfy", "column.scheme": "Scheme", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index b67e8207..78a2bd16 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -47,6 +47,18 @@ "cancel": { "defaultMessage": "Cancel" }, + "certificates.actions-title": { + "defaultMessage": "Certificate #{id}" + }, + "certificates.add": { + "defaultMessage": "Add Certificate" + }, + "certificates.custom": { + "defaultMessage": "Custom Certificate" + }, + "certificates.empty": { + "defaultMessage": "There are no Certificates" + }, "certificates.title": { "defaultMessage": "SSL Certificates" }, @@ -71,6 +83,9 @@ "column.event": { "defaultMessage": "Event" }, + "column.expires": { + "defaultMessage": "Expires" + }, "column.http-code": { "defaultMessage": "Access" }, @@ -83,6 +98,9 @@ "column.protocol": { "defaultMessage": "Protocol" }, + "column.provider": { + "defaultMessage": "Provider" + }, "column.roles": { "defaultMessage": "Roles" }, diff --git a/frontend/src/pages/Certificates/Empty.tsx b/frontend/src/pages/Certificates/Empty.tsx new file mode 100644 index 00000000..ba96e86a --- /dev/null +++ b/frontend/src/pages/Certificates/Empty.tsx @@ -0,0 +1,36 @@ +import type { Table as ReactTable } from "@tanstack/react-table"; +import { intl } from "src/locale"; + +/** + * This component should never render as there should always be 1 user minimum, + * but I'm keeping it for consistency. + */ + +interface Props { + tableInstance: ReactTable; +} +export default function Empty({ tableInstance }: Props) { + return ( + + +
+

{intl.formatMessage({ id: "certificates.empty" })}

+

{intl.formatMessage({ id: "empty-subtitle" })}

+ +
+ + + ); +} diff --git a/frontend/src/pages/Certificates/Table.tsx b/frontend/src/pages/Certificates/Table.tsx new file mode 100644 index 00000000..77007031 --- /dev/null +++ b/frontend/src/pages/Certificates/Table.tsx @@ -0,0 +1,116 @@ +import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react"; +import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useMemo } from "react"; +import type { Certificate } from "src/api/backend"; +import { DomainsFormatter, GravatarFormatter } from "src/components"; +import { TableLayout } from "src/components/Table/TableLayout"; +import { intl } from "src/locale"; +import Empty from "./Empty"; + +interface Props { + data: Certificate[]; + isFetching?: boolean; +} +export default function Table({ data, isFetching }: Props) { + const columnHelper = createColumnHelper(); + const columns = useMemo( + () => [ + columnHelper.accessor((row: any) => row.owner, { + id: "owner", + cell: (info: any) => { + const value = info.getValue(); + return ; + }, + meta: { + className: "w-1", + }, + }), + columnHelper.accessor((row: any) => row, { + id: "domainNames", + header: intl.formatMessage({ id: "column.name" }), + cell: (info: any) => { + const value = info.getValue(); + return ; + }, + }), + columnHelper.accessor((row: any) => row.provider, { + id: "provider", + header: intl.formatMessage({ id: "column.provider" }), + cell: (info: any) => { + return info.getValue(); + }, + }), + columnHelper.accessor((row: any) => row.expires_on, { + id: "expires_on", + header: intl.formatMessage({ id: "column.expires" }), + cell: (info: any) => { + return info.getValue(); + }, + }), + columnHelper.accessor((row: any) => row, { + id: "id", + header: intl.formatMessage({ id: "column.status" }), + cell: (info: any) => { + return info.getValue(); + }, + }), + columnHelper.display({ + id: "id", // todo: not needed for a display? + cell: (info: any) => { + return ( + + +
+ + {intl.formatMessage( + { + id: "certificates.actions-title", + }, + { id: info.row.original.id }, + )} + + + + {intl.formatMessage({ id: "action.edit" })} + + + + {intl.formatMessage({ id: "action.disable" })} + + + + ); + }, + meta: { + className: "text-end w-1", + }, + }), + ], + [columnHelper], + ); + + const tableInstance = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + rowCount: data.length, + meta: { + isFetching, + }, + enableSortingRemoval: false, + }); + + return } />; +} diff --git a/frontend/src/pages/Certificates/TableWrapper.tsx b/frontend/src/pages/Certificates/TableWrapper.tsx new file mode 100644 index 00000000..06588ebc --- /dev/null +++ b/frontend/src/pages/Certificates/TableWrapper.tsx @@ -0,0 +1,71 @@ +import { IconSearch } from "@tabler/icons-react"; +import Alert from "react-bootstrap/Alert"; +import { LoadingPage } from "src/components"; +import { useCertificates } from "src/hooks"; +import { intl } from "src/locale"; +import Table from "./Table"; + +export default function TableWrapper() { + const { isFetching, isLoading, isError, error, data } = useCertificates([ + "owner", + "dead_hosts", + "proxy_hosts", + "redirection_hosts", + ]); + + if (isLoading) { + return ; + } + + if (isError) { + return {error?.message || "Unknown error"}; + } + + return ( +
+
+
+
+
+
+

{intl.formatMessage({ id: "certificates.title" })}

+
+
+
+
+ + + + +
+ +
+
+
+
+ + + + ); +} diff --git a/frontend/src/pages/Certificates/index.tsx b/frontend/src/pages/Certificates/index.tsx index 7f1bd3a0..f667e9af 100644 --- a/frontend/src/pages/Certificates/index.tsx +++ b/frontend/src/pages/Certificates/index.tsx @@ -1,10 +1,10 @@ import { HasPermission } from "src/components"; -import CertificateTable from "./CertificateTable"; +import TableWrapper from "./TableWrapper"; const Certificates = () => { return ( - + ); };