Skip to content

Commit 17f40dd

Browse files
committed
Certificates react table basis
1 parent 68b2393 commit 17f40dd

File tree

10 files changed

+271
-7
lines changed

10 files changed

+271
-7
lines changed

backend/internal/certificate.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,7 @@ const internalCertificate = {
406406
.query()
407407
.where("is_deleted", 0)
408408
.groupBy("id")
409-
.allowGraph("[owner]")
410-
.allowGraph("[proxy_hosts]")
411-
.allowGraph("[redirection_hosts]")
412-
.allowGraph("[dead_hosts]")
409+
.allowGraph("[owner,proxy_hosts,redirection_hosts,dead_hosts]")
413410
.orderBy("nice_name", "ASC");
414411

415412
if (accessData.permission_visibility !== "all") {

frontend/src/api/backend/getCertificates.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as api from "./base";
22
import type { Certificate } from "./models";
33

4-
export async function getCertificates(expand?: string[], params = {}): Promise<Certificate[]> {
4+
export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts";
5+
6+
export async function getCertificates(expand?: CertificateExpansion[], params = {}): Promise<Certificate[]> {
57
return await api.get({
68
url: "/nginx/certificates",
79
params: {

frontend/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./useAccessLists";
22
export * from "./useAuditLog";
33
export * from "./useAuditLogs";
4+
export * from "./useCertificates";
45
export * from "./useDeadHosts";
56
export * from "./useHealth";
67
export * from "./useHostReport";
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { type Certificate, type CertificateExpansion, getCertificates } from "src/api/backend";
3+
4+
const fetchCertificates = (expand?: CertificateExpansion[]) => {
5+
return getCertificates(expand);
6+
};
7+
8+
const useCertificates = (expand?: CertificateExpansion[], options = {}) => {
9+
return useQuery<Certificate[], Error>({
10+
queryKey: ["certificates", { expand }],
11+
queryFn: () => fetchCertificates(expand),
12+
staleTime: 60 * 1000,
13+
...options,
14+
});
15+
};
16+
17+
export { fetchCertificates, useCertificates };

frontend/src/locale/lang/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,23 @@
1515
"action.view-details": "View Details",
1616
"auditlog.title": "Audit Log",
1717
"cancel": "Cancel",
18+
"certificates.actions-title": "Certificate #{id}",
19+
"certificates.add": "Add Certificate",
20+
"certificates.custom": "Custom Certificate",
21+
"certificates.empty": "There are no Certificates",
1822
"certificates.title": "SSL Certificates",
1923
"close": "Close",
2024
"column.access": "Access",
2125
"column.authorization": "Authorization",
2226
"column.destination": "Destination",
2327
"column.email": "Email",
2428
"column.event": "Event",
29+
"column.expires": "Expires",
2530
"column.http-code": "Access",
2631
"column.incoming-port": "Incoming Port",
2732
"column.name": "Name",
2833
"column.protocol": "Protocol",
34+
"column.provider": "Provider",
2935
"column.roles": "Roles",
3036
"column.satisfy": "Satisfy",
3137
"column.scheme": "Scheme",

frontend/src/locale/src/en.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@
4747
"cancel": {
4848
"defaultMessage": "Cancel"
4949
},
50+
"certificates.actions-title": {
51+
"defaultMessage": "Certificate #{id}"
52+
},
53+
"certificates.add": {
54+
"defaultMessage": "Add Certificate"
55+
},
56+
"certificates.custom": {
57+
"defaultMessage": "Custom Certificate"
58+
},
59+
"certificates.empty": {
60+
"defaultMessage": "There are no Certificates"
61+
},
5062
"certificates.title": {
5163
"defaultMessage": "SSL Certificates"
5264
},
@@ -71,6 +83,9 @@
7183
"column.event": {
7284
"defaultMessage": "Event"
7385
},
86+
"column.expires": {
87+
"defaultMessage": "Expires"
88+
},
7489
"column.http-code": {
7590
"defaultMessage": "Access"
7691
},
@@ -83,6 +98,9 @@
8398
"column.protocol": {
8499
"defaultMessage": "Protocol"
85100
},
101+
"column.provider": {
102+
"defaultMessage": "Provider"
103+
},
86104
"column.roles": {
87105
"defaultMessage": "Roles"
88106
},
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Table as ReactTable } from "@tanstack/react-table";
2+
import { intl } from "src/locale";
3+
4+
/**
5+
* This component should never render as there should always be 1 user minimum,
6+
* but I'm keeping it for consistency.
7+
*/
8+
9+
interface Props {
10+
tableInstance: ReactTable<any>;
11+
}
12+
export default function Empty({ tableInstance }: Props) {
13+
return (
14+
<tr>
15+
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
16+
<div className="text-center my-4">
17+
<h2>{intl.formatMessage({ id: "certificates.empty" })}</h2>
18+
<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
19+
<div className="dropdown">
20+
<button type="button" className="btn dropdown-toggle btn-pink my-3" data-bs-toggle="dropdown">
21+
{intl.formatMessage({ id: "certificates.add" })}
22+
</button>
23+
<div className="dropdown-menu">
24+
<a className="dropdown-item" href="#">
25+
{intl.formatMessage({ id: "lets-encrypt" })}
26+
</a>
27+
<a className="dropdown-item" href="#">
28+
{intl.formatMessage({ id: "certificates.custom" })}
29+
</a>
30+
</div>
31+
</div>
32+
</div>
33+
</td>
34+
</tr>
35+
);
36+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
2+
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
3+
import { useMemo } from "react";
4+
import type { Certificate } from "src/api/backend";
5+
import { DomainsFormatter, GravatarFormatter } from "src/components";
6+
import { TableLayout } from "src/components/Table/TableLayout";
7+
import { intl } from "src/locale";
8+
import Empty from "./Empty";
9+
10+
interface Props {
11+
data: Certificate[];
12+
isFetching?: boolean;
13+
}
14+
export default function Table({ data, isFetching }: Props) {
15+
const columnHelper = createColumnHelper<Certificate>();
16+
const columns = useMemo(
17+
() => [
18+
columnHelper.accessor((row: any) => row.owner, {
19+
id: "owner",
20+
cell: (info: any) => {
21+
const value = info.getValue();
22+
return <GravatarFormatter url={value.avatar} name={value.name} />;
23+
},
24+
meta: {
25+
className: "w-1",
26+
},
27+
}),
28+
columnHelper.accessor((row: any) => row, {
29+
id: "domainNames",
30+
header: intl.formatMessage({ id: "column.name" }),
31+
cell: (info: any) => {
32+
const value = info.getValue();
33+
return <DomainsFormatter domains={value.domainNames} createdOn={value.createdOn} />;
34+
},
35+
}),
36+
columnHelper.accessor((row: any) => row.provider, {
37+
id: "provider",
38+
header: intl.formatMessage({ id: "column.provider" }),
39+
cell: (info: any) => {
40+
return info.getValue();
41+
},
42+
}),
43+
columnHelper.accessor((row: any) => row.expires_on, {
44+
id: "expires_on",
45+
header: intl.formatMessage({ id: "column.expires" }),
46+
cell: (info: any) => {
47+
return info.getValue();
48+
},
49+
}),
50+
columnHelper.accessor((row: any) => row, {
51+
id: "id",
52+
header: intl.formatMessage({ id: "column.status" }),
53+
cell: (info: any) => {
54+
return info.getValue();
55+
},
56+
}),
57+
columnHelper.display({
58+
id: "id", // todo: not needed for a display?
59+
cell: (info: any) => {
60+
return (
61+
<span className="dropdown">
62+
<button
63+
type="button"
64+
className="btn dropdown-toggle btn-action btn-sm px-1"
65+
data-bs-boundary="viewport"
66+
data-bs-toggle="dropdown"
67+
>
68+
<IconDotsVertical />
69+
</button>
70+
<div className="dropdown-menu dropdown-menu-end">
71+
<span className="dropdown-header">
72+
{intl.formatMessage(
73+
{
74+
id: "certificates.actions-title",
75+
},
76+
{ id: info.row.original.id },
77+
)}
78+
</span>
79+
<a className="dropdown-item" href="#">
80+
<IconEdit size={16} />
81+
{intl.formatMessage({ id: "action.edit" })}
82+
</a>
83+
<a className="dropdown-item" href="#">
84+
<IconPower size={16} />
85+
{intl.formatMessage({ id: "action.disable" })}
86+
</a>
87+
<div className="dropdown-divider" />
88+
<a className="dropdown-item" href="#">
89+
<IconTrash size={16} />
90+
{intl.formatMessage({ id: "action.delete" })}
91+
</a>
92+
</div>
93+
</span>
94+
);
95+
},
96+
meta: {
97+
className: "text-end w-1",
98+
},
99+
}),
100+
],
101+
[columnHelper],
102+
);
103+
104+
const tableInstance = useReactTable<Certificate>({
105+
columns,
106+
data,
107+
getCoreRowModel: getCoreRowModel(),
108+
rowCount: data.length,
109+
meta: {
110+
isFetching,
111+
},
112+
enableSortingRemoval: false,
113+
});
114+
115+
return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
116+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { IconSearch } from "@tabler/icons-react";
2+
import Alert from "react-bootstrap/Alert";
3+
import { LoadingPage } from "src/components";
4+
import { useCertificates } from "src/hooks";
5+
import { intl } from "src/locale";
6+
import Table from "./Table";
7+
8+
export default function TableWrapper() {
9+
const { isFetching, isLoading, isError, error, data } = useCertificates([
10+
"owner",
11+
"dead_hosts",
12+
"proxy_hosts",
13+
"redirection_hosts",
14+
]);
15+
16+
if (isLoading) {
17+
return <LoadingPage />;
18+
}
19+
20+
if (isError) {
21+
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
22+
}
23+
24+
return (
25+
<div className="card mt-4">
26+
<div className="card-status-top bg-pink" />
27+
<div className="card-table">
28+
<div className="card-header">
29+
<div className="row w-full">
30+
<div className="col">
31+
<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "certificates.title" })}</h2>
32+
</div>
33+
<div className="col-md-auto col-sm-12">
34+
<div className="ms-auto d-flex flex-wrap btn-list">
35+
<div className="input-group input-group-flat w-auto">
36+
<span className="input-group-text input-group-text-sm">
37+
<IconSearch size={16} />
38+
</span>
39+
<input
40+
id="advanced-table-search"
41+
type="text"
42+
className="form-control form-control-sm"
43+
autoComplete="off"
44+
/>
45+
</div>
46+
<div className="dropdown">
47+
<button
48+
type="button"
49+
className="btn btn-sm dropdown-toggle btn-pink mt-1"
50+
data-bs-toggle="dropdown"
51+
>
52+
{intl.formatMessage({ id: "certificates.add" })}
53+
</button>
54+
<div className="dropdown-menu">
55+
<a className="dropdown-item" href="#">
56+
{intl.formatMessage({ id: "lets-encrypt" })}
57+
</a>
58+
<a className="dropdown-item" href="#">
59+
{intl.formatMessage({ id: "certificates.custom" })}
60+
</a>
61+
</div>
62+
</div>
63+
</div>
64+
</div>
65+
</div>
66+
</div>
67+
<Table data={data ?? []} isFetching={isFetching} />
68+
</div>
69+
</div>
70+
);
71+
}

frontend/src/pages/Certificates/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { HasPermission } from "src/components";
2-
import CertificateTable from "./CertificateTable";
2+
import TableWrapper from "./TableWrapper";
33

44
const Certificates = () => {
55
return (
66
<HasPermission permission="certificates" type="view" pageLoading loadingNoLogo>
7-
<CertificateTable />
7+
<TableWrapper />
88
</HasPermission>
99
);
1010
};

0 commit comments

Comments
 (0)