From 5c08a2391db161930614fe10eb9d623ff29b06b9 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Tue, 30 Sep 2025 22:31:12 +1000 Subject: [PATCH] Proxy host modal basis, other improvements --- backend/internal/proxy-host.js | 3 - backend/lib/access.js | 2 +- frontend/src/App.css | 4 + frontend/src/api/backend/models.ts | 4 +- .../src/components/Form/SSLOptionsFields.tsx | 5 +- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useProxyHost.ts | 65 +++ frontend/src/locale/lang/en.json | 11 +- frontend/src/locale/src/en.json | 29 +- frontend/src/modals/DeadHostModal.tsx | 4 +- frontend/src/modals/ProxyHostModal.tsx | 377 ++++++++++++++++++ frontend/src/modals/RedirectionHostModal.tsx | 17 +- frontend/src/modals/StreamModal.tsx | 9 +- frontend/src/modals/index.ts | 1 + .../pages/Nginx/DeadHosts/TableWrapper.tsx | 17 +- frontend/src/pages/Nginx/ProxyHosts/Empty.tsx | 23 +- frontend/src/pages/Nginx/ProxyHosts/Table.tsx | 49 ++- .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 89 ++++- .../pages/Nginx/RedirectionHosts/Table.tsx | 2 +- .../Nginx/RedirectionHosts/TableWrapper.tsx | 17 +- .../src/pages/Nginx/Streams/TableWrapper.tsx | 16 +- frontend/src/pages/Users/TableWrapper.tsx | 17 +- 22 files changed, 667 insertions(+), 95 deletions(-) create mode 100644 frontend/src/hooks/useProxyHost.ts create mode 100644 frontend/src/modals/ProxyHostModal.tsx diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2fb4d164..3299012a 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -422,7 +422,6 @@ const internalProxyHost = { */ getAll: async (access, expand, searchQuery) => { const accessData = await access.can("proxy_hosts:list"); - const query = proxyHostModel .query() .where("is_deleted", 0) @@ -446,11 +445,9 @@ const internalProxyHost = { } const rows = await query.then(utils.omitRows(omissions())); - if (typeof expand !== "undefined" && expand !== null && expand.indexOf("certificate") !== -1) { return internalHost.cleanAllRowsCertificateMeta(rows); } - return rows; }, diff --git a/backend/lib/access.js b/backend/lib/access.js index 133a24f5..4c1672e1 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -131,7 +131,7 @@ export default function (tokenString) { const rows = await query; objects = []; _.forEach(rows, (ruleRow) => { - result.push(ruleRow.id); + objects.push(ruleRow.id); }); // enum should not have less than 1 item diff --git a/frontend/src/App.css b/frontend/src/App.css index 30ff867f..6e2ba564 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -70,3 +70,7 @@ font-family: 'Courier New', Courier, monospace !important; resize: vertical; } + +label.row { + cursor: pointer; +} diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 6526fcc4..4667d445 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -103,6 +103,7 @@ export interface ProxyHost { modifiedOn: string; ownerUserId: number; domainNames: string[]; + forwardScheme: string; forwardHost: string; forwardPort: number; accessListId: number; @@ -114,9 +115,8 @@ export interface ProxyHost { meta: Record; allowWebsocketUpgrade: boolean; http2Support: boolean; - forwardScheme: string; enabled: boolean; - locations: string[]; // todo: string or object? + locations?: string[]; // todo: string or object? hstsEnabled: boolean; hstsSubdomains: boolean; // Expansions: diff --git a/frontend/src/components/Form/SSLOptionsFields.tsx b/frontend/src/components/Form/SSLOptionsFields.tsx index eeb232c8..9b3a6c15 100644 --- a/frontend/src/components/Form/SSLOptionsFields.tsx +++ b/frontend/src/components/Form/SSLOptionsFields.tsx @@ -7,8 +7,9 @@ interface Props { forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields forceDNSForNew?: boolean; requireDomainNames?: boolean; // used for streams + color?: string; } -export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames }: Props) { +export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) { const { values, setFieldValue } = useFormikContext(); const v: any = values || {}; @@ -31,7 +32,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain }; const toggleClasses = "form-check-input"; - const toggleEnabled = cn(toggleClasses, "bg-cyan"); + const toggleEnabled = cn(toggleClasses, color); const getHttpOptions = () => (
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 37011c1a..e4f23779 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -7,6 +7,7 @@ export * from "./useDeadHosts"; export * from "./useDnsProviders"; export * from "./useHealth"; export * from "./useHostReport"; +export * from "./useProxyHost"; export * from "./useProxyHosts"; export * from "./useRedirectionHost"; export * from "./useRedirectionHosts"; diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts new file mode 100644 index 00000000..d9733ee6 --- /dev/null +++ b/frontend/src/hooks/useProxyHost.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createProxyHost, getProxyHost, type ProxyHost, updateProxyHost } from "src/api/backend"; + +const fetchProxyHost = (id: number | "new") => { + if (id === "new") { + return Promise.resolve({ + id: 0, + createdOn: "", + modifiedOn: "", + ownerUserId: 0, + domainNames: [], + forwardHost: "", + forwardPort: 0, + accessListId: 0, + certificateId: 0, + sslForced: false, + cachingEnabled: false, + blockExploits: false, + advancedConfig: "", + meta: {}, + allowWebsocketUpgrade: false, + http2Support: false, + forwardScheme: "", + enabled: true, + hstsEnabled: false, + hstsSubdomains: false, + } as ProxyHost); + } + return getProxyHost(id, ["owner"]); +}; + +const useProxyHost = (id: number | "new", options = {}) => { + return useQuery({ + queryKey: ["proxy-host", id], + queryFn: () => fetchProxyHost(id), + staleTime: 60 * 1000, // 1 minute + ...options, + }); +}; + +const useSetProxyHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (values: ProxyHost) => (values.id ? updateProxyHost(values) : createProxyHost(values)), + onMutate: (values: ProxyHost) => { + if (!values.id) { + return; + } + const previousObject = queryClient.getQueryData(["proxy-host", values.id]); + queryClient.setQueryData(["proxy-host", values.id], (old: ProxyHost) => ({ + ...old, + ...values, + })); + return () => queryClient.setQueryData(["proxy-host", values.id], previousObject); + }, + onError: (_, __, rollback: any) => rollback(), + onSuccess: async ({ id }: ProxyHost) => { + queryClient.invalidateQueries({ queryKey: ["proxy-host", id] }); + queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] }); + queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + }, + }); +}; + +export { useProxyHost, useSetProxyHost }; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index fb024f5c..9ff54429 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -23,6 +23,7 @@ "close": "Close", "column.access": "Access", "column.authorization": "Authorization", + "column.custom-locations": "Custom Locations", "column.destination": "Destination", "column.details": "Details", "column.email": "Email", @@ -88,9 +89,13 @@ "event.updated-user": "Updated User", "footer.github-fork": "Fork me on Github", "host.flags.block-exploits": "Block Common Exploits", + "host.flags.cache-assets": "Cache Assets", "host.flags.preserve-path": "Preserve Path", "host.flags.protocols": "Protocols", "host.flags.title": "Options", + "host.flags.websockets-upgrade": "Websockets Support", + "host.forward-port": "Forward Port", + "host.forward-scheme": "Scheme", "hosts.title": "Hosts", "http-only": "HTTP Only", "lets-encrypt": "Let's Encrypt", @@ -128,13 +133,14 @@ "permissions.visibility.all": "All Items", "permissions.visibility.title": "Item Visibility", "permissions.visibility.user": "Created Items Only", + "proxy-host.forward-host": "Forward Hostname / IP", + "proxy-host.new": "New Proxy Host", "proxy-hosts.actions-title": "Proxy Host #{id}", "proxy-hosts.add": "Add Proxy Host", "proxy-hosts.count": "{count} Proxy Hosts", "proxy-hosts.empty": "There are no Proxy Hosts", "proxy-hosts.title": "Proxy Hosts", - "redirect-host.forward-domain": "Forward Domain", - "redirect-host.forward-scheme": "Scheme", + "redirection-host.forward-domain": "Forward Domain", "redirection-host.new": "New Redirection Host", "redirection-hosts.actions-title": "Redirection Host #{id}", "redirection-hosts.add": "Add Redirection Host", @@ -152,7 +158,6 @@ "stream.delete.content": "Are you sure you want to delete this Stream?", "stream.delete.title": "Delete Stream", "stream.forward-host": "Forward Host", - "stream.forward-port": "Forward Port", "stream.incoming-port": "Incoming Port", "stream.new": "New Stream", "streams.actions-title": "Stream #{id}", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 4c4d5479..6bc29e90 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -71,6 +71,9 @@ "column.authorization": { "defaultMessage": "Authorization" }, + "column.custom-locations": { + "defaultMessage": "Custom Locations" + }, "column.destination": { "defaultMessage": "Destination" }, @@ -266,6 +269,9 @@ "host.flags.block-exploits": { "defaultMessage": "Block Common Exploits" }, + "host.flags.cache-assets": { + "defaultMessage": "Cache Assets" + }, "host.flags.preserve-path": { "defaultMessage": "Preserve Path" }, @@ -275,6 +281,15 @@ "host.flags.title": { "defaultMessage": "Options" }, + "host.flags.websockets-upgrade": { + "defaultMessage": "Websockets Support" + }, + "host.forward-port": { + "defaultMessage": "Forward Port" + }, + "host.forward-scheme": { + "defaultMessage": "Scheme" + }, "hosts.title": { "defaultMessage": "Hosts" }, @@ -386,6 +401,12 @@ "permissions.visibility.user": { "defaultMessage": "Created Items Only" }, + "proxy-host.forward-host": { + "defaultMessage": "Forward Hostname / IP" + }, + "proxy-host.new": { + "defaultMessage": "New Proxy Host" + }, "proxy-hosts.actions-title": { "defaultMessage": "Proxy Host #{id}" }, @@ -401,12 +422,9 @@ "proxy-hosts.title": { "defaultMessage": "Proxy Hosts" }, - "redirect-host.forward-domain": { + "redirection-host.forward-domain": { "defaultMessage": "Forward Domain" }, - "redirect-host.forward-scheme": { - "defaultMessage": "Scheme" - }, "redirection-host.new": { "defaultMessage": "New Redirection Host" }, @@ -458,9 +476,6 @@ "stream.forward-host": { "defaultMessage": "Forward Host" }, - "stream.forward-port": { - "defaultMessage": "Forward Port" - }, "stream.incoming-port": { "defaultMessage": "Incoming Port" }, diff --git a/frontend/src/modals/DeadHostModal.tsx b/frontend/src/modals/DeadHostModal.tsx index 8e148060..1a420007 100644 --- a/frontend/src/modals/DeadHostModal.tsx +++ b/frontend/src/modals/DeadHostModal.tsx @@ -136,7 +136,7 @@ export function DeadHostModal({ id, onClose }: Props) { label="ssl-certificate" allowNew /> - +
@@ -152,7 +152,7 @@ export function DeadHostModal({ id, onClose }: Props) { + + + + )} + + )} + + ); +} diff --git a/frontend/src/modals/RedirectionHostModal.tsx b/frontend/src/modals/RedirectionHostModal.tsx index b3f28617..d47cbaa1 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -1,4 +1,5 @@ import { IconSettings } from "@tabler/icons-react"; +import cn from "classnames"; import { Field, Form, Formik } from "formik"; import { useState } from "react"; import { Alert } from "react-bootstrap"; @@ -150,7 +151,7 @@ export function RedirectionHostModal({ id, onClose }: Props) { htmlFor="forwardScheme" > {intl.formatMessage({ - id: "redirect-host.forward-scheme", + id: "host.forward-scheme", })} @@ -253,7 +256,9 @@ export function RedirectionHostModal({ id, onClose }: Props) { @@ -271,7 +276,7 @@ export function RedirectionHostModal({ id, onClose }: Props) { label="ssl-certificate" allowNew /> - +
@@ -287,7 +292,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index 11335ac9..918e12b0 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -3,6 +3,7 @@ export * from "./DeadHostModal"; export * from "./DeleteConfirmModal"; export * from "./EventDetailsModal"; export * from "./PermissionsModal"; +export * from "./ProxyHostModal"; export * from "./RedirectionHostModal"; export * from "./SetPasswordModal"; export * from "./StreamModal"; diff --git a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx index d2623a75..9507e1e4 100644 --- a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx @@ -56,9 +56,9 @@ export default function TableWrapper() {

{intl.formatMessage({ id: "dead-hosts.title" })}

-
-
- {data?.length ? ( + {data?.length ? ( +
+
@@ -71,12 +71,13 @@ export default function TableWrapper() { onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())} />
- ) : null} - + + +
-
+ ) : null}
; + onNew?: () => void; + isFiltered?: boolean; } -export default function Empty({ tableInstance }: Props) { +export default function Empty({ tableInstance, onNew, isFiltered }: Props) { return ( diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index bf423fc1..86d8a1d8 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -9,9 +9,14 @@ import Empty from "./Empty"; interface Props { data: ProxyHost[]; + isFiltered?: boolean; isFetching?: boolean; + onEdit?: (id: number) => void; + onDelete?: (id: number) => void; + onDisableToggle?: (id: number, enabled: boolean) => void; + onNew?: () => void; } -export default function Table({ data, isFetching }: Props) { +export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -64,7 +69,7 @@ export default function Table({ data, isFetching }: Props) { }, }), columnHelper.display({ - id: "id", // todo: not needed for a display? + id: "id", cell: (info: any) => { return ( @@ -85,16 +90,39 @@ export default function Table({ data, isFetching }: Props) { { id: info.row.original.id }, )} - + { + e.preventDefault(); + onEdit?.(info.row.original.id); + }} + > {intl.formatMessage({ id: "action.edit" })} - + { + e.preventDefault(); + onDisableToggle?.(info.row.original.id, !info.row.original.enabled); + }} + > - {intl.formatMessage({ id: "action.disable" })} + {intl.formatMessage({ + id: info.row.original.enabled ? "action.disable" : "action.enable", + })}
- + { + e.preventDefault(); + onDelete?.(info.row.original.id); + }} + > {intl.formatMessage({ id: "action.delete" })} @@ -107,7 +135,7 @@ export default function Table({ data, isFetching }: Props) { }, }), ], - [columnHelper], + [columnHelper, onEdit, onDisableToggle, onDelete], ); const tableInstance = useReactTable({ @@ -121,5 +149,10 @@ export default function Table({ data, isFetching }: Props) { enableSortingRemoval: false, }); - return } />; + return ( + } + /> + ); } diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index a297200d..fa1df704 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -1,11 +1,20 @@ import { IconSearch } from "@tabler/icons-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; import Alert from "react-bootstrap/Alert"; +import { deleteProxyHost, toggleProxyHost } from "src/api/backend"; import { Button, LoadingPage } from "src/components"; import { useProxyHosts } from "src/hooks"; import { intl } from "src/locale"; +import { DeleteConfirmModal, ProxyHostModal } from "src/modals"; +import { showSuccess } from "src/notifications"; import Table from "./Table"; export default function TableWrapper() { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [deleteId, setDeleteId] = useState(0); + const [editId, setEditId] = useState(0 as number | "new"); const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); if (isLoading) { @@ -16,6 +25,31 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } + const handleDelete = async () => { + await deleteProxyHost(deleteId); + showSuccess(intl.formatMessage({ id: "notification.host-deleted" })); + }; + + const handleDisableToggle = async (id: number, enabled: boolean) => { + await toggleProxyHost(id, enabled); + queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] }); + queryClient.invalidateQueries({ queryKey: ["proxy-host", id] }); + showSuccess(intl.formatMessage({ id: enabled ? "notification.host-enabled" : "notification.host-disabled" })); + }; + + let filtered = null; + if (search && data) { + filtered = data?.filter((_item) => { + return true; + // item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || + // item.forwardDomainName.toLowerCase().includes(search) + // ); + }); + } else if (search !== "") { + // this can happen if someone deletes the last item while searching + setSearch(""); + } + return (
@@ -25,27 +59,48 @@ export default function TableWrapper() {

{intl.formatMessage({ id: "proxy-hosts.title" })}

-
-
-
- - - - + {data?.length ? ( +
+
+
+ + + + +
+
-
-
+ ) : null}
-
-

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

-

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

- + {isFiltered ? ( +

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

+ ) : ( + <> +

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

+

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

+ + + )}
+
setEditId(id)} + onDelete={(id: number) => setDeleteId(id)} + onDisableToggle={handleDisableToggle} + onNew={() => setEditId("new")} + /> + {editId ? setEditId(0)} /> : null} + {deleteId ? ( + setDeleteId(0)} + invalidations={[["proxy-hosts"], ["proxy-host", deleteId]]} + > + {intl.formatMessage({ id: "proxy-host.delete.content" })} + + ) : null} ); diff --git a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx index f2ecbe08..74f61af1 100644 --- a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx +++ b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx @@ -74,7 +74,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog }, }), columnHelper.display({ - id: "id", // todo: not needed for a display? + id: "id", cell: (info: any) => { return ( diff --git a/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx index cfb847b7..98cd228e 100644 --- a/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx @@ -59,9 +59,9 @@ export default function TableWrapper() {

{intl.formatMessage({ id: "redirection-hosts.title" })}

-
-
- {data?.length ? ( + {data?.length ? ( +
+
@@ -74,12 +74,13 @@ export default function TableWrapper() { onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())} />
- ) : null} - + + +
-
+ ) : null}

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

-
-
- {data?.length ? ( + {data?.length ? ( +
+
@@ -77,12 +77,12 @@ export default function TableWrapper() { onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())} />
- ) : null} - + +
-
+ ) : null}

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

-
-
- {data?.length ? ( + {data?.length ? ( +
+
@@ -78,12 +78,13 @@ export default function TableWrapper() { onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())} />
- ) : null} - + + +
-
+ ) : null}