diff --git a/frontend/src/components/Form/SSLCertificateField.tsx b/frontend/src/components/Form/SSLCertificateField.tsx index 703d9638..caf5c5cb 100644 --- a/frontend/src/components/Form/SSLCertificateField.tsx +++ b/frontend/src/components/Form/SSLCertificateField.tsx @@ -31,6 +31,7 @@ interface Props { label?: string; required?: boolean; allowNew?: boolean; + forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields } export function SSLCertificateField({ name = "certificateId", @@ -38,6 +39,7 @@ export function SSLCertificateField({ id = "certificateId", required, allowNew, + forHttp = true, }: Props) { const { isLoading, isError, error, data } = useCertificates(); const { values, setFieldValue } = useFormikContext(); @@ -55,7 +57,7 @@ export function SSLCertificateField({ dnsProviderCredentials, propagationSeconds, } = v; - if (!newValue?.value) { + if (forHttp && !newValue?.value) { sslForced && setFieldValue("sslForced", false); http2Support && setFieldValue("http2Support", false); hstsEnabled && setFieldValue("hstsEnabled", false); @@ -94,7 +96,7 @@ export function SSLCertificateField({ options?.unshift({ value: 0, label: "None", - subLabel: "This host will not use HTTPS", + subLabel: forHttp ? "This host will not use HTTPS" : "No certificate assigned", icon: , }); } diff --git a/frontend/src/components/Form/SSLOptionsFields.tsx b/frontend/src/components/Form/SSLOptionsFields.tsx index ec71107b..eeb232c8 100644 --- a/frontend/src/components/Form/SSLOptionsFields.tsx +++ b/frontend/src/components/Form/SSLOptionsFields.tsx @@ -1,9 +1,14 @@ import cn from "classnames"; import { Field, useFormikContext } from "formik"; -import { DNSProviderFields } from "src/components"; +import { DNSProviderFields, DomainNamesField } from "src/components"; import { intl } from "src/locale"; -export function SSLOptionsFields() { +interface Props { + forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields + forceDNSForNew?: boolean; + requireDomainNames?: boolean; // used for streams +} +export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames }: Props) { const { values, setFieldValue } = useFormikContext(); const v: any = values || {}; @@ -12,6 +17,10 @@ export function SSLOptionsFields() { const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v; const { dnsChallenge } = meta || {}; + if (forceDNSForNew && newCertificate && !dnsChallenge) { + setFieldValue("meta.dnsChallenge", true); + } + const handleToggleChange = (e: any, fieldName: string) => { setFieldValue(fieldName, e.target.checked); if (fieldName === "meta.dnsChallenge" && !e.target.checked) { @@ -24,8 +33,8 @@ export function SSLOptionsFields() { const toggleClasses = "form-check-input"; const toggleEnabled = cn(toggleClasses, "bg-cyan"); - return ( - <> + const getHttpOptions = () => ( +
@@ -102,6 +111,12 @@ export function SSLOptionsFields() {
+
+ ); + + return ( +
+ {forHttp ? getHttpOptions() : null} {newCertificate ? ( <> @@ -110,7 +125,8 @@ export function SSLOptionsFields() { handleToggleChange(e, field.name)} /> @@ -119,10 +135,10 @@ export function SSLOptionsFields() { )} - + {requireDomainNames ? : null} {dnsChallenge ? : null} ) : null} - +
); } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 1e6b8daa..2c1e73db 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,6 +9,7 @@ export * from "./useHealth"; export * from "./useHostReport"; export * from "./useProxyHosts"; export * from "./useRedirectionHosts"; +export * from "./useStream"; export * from "./useStreams"; export * from "./useTheme"; export * from "./useUser"; diff --git a/frontend/src/hooks/useStream.ts b/frontend/src/hooks/useStream.ts new file mode 100644 index 00000000..4bd71826 --- /dev/null +++ b/frontend/src/hooks/useStream.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createStream, getStream, type Stream, updateStream } from "src/api/backend"; + +const fetchStream = (id: number | "new") => { + if (id === "new") { + return Promise.resolve({ + id: 0, + createdOn: "", + modifiedOn: "", + ownerUserId: 0, + tcpForwarding: true, + udpForwarding: false, + meta: {}, + enabled: true, + certificateId: 0, + } as Stream); + } + return getStream(id, ["owner"]); +}; + +const useStream = (id: number | "new", options = {}) => { + return useQuery({ + queryKey: ["stream", id], + queryFn: () => fetchStream(id), + staleTime: 60 * 1000, // 1 minute + ...options, + }); +}; + +const useSetStream = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (values: Stream) => (values.id ? updateStream(values) : createStream(values)), + onMutate: (values: Stream) => { + if (!values.id) { + return; + } + const previousObject = queryClient.getQueryData(["stream", values.id]); + queryClient.setQueryData(["stream", values.id], (old: Stream) => ({ + ...old, + ...values, + })); + return () => queryClient.setQueryData(["stream", values.id], previousObject); + }, + onError: (_, __, rollback: any) => rollback(), + onSuccess: async ({ id }: Stream) => { + queryClient.invalidateQueries({ queryKey: ["stream", id] }); + queryClient.invalidateQueries({ queryKey: ["streams"] }); + queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + }, + }); +}; + +export { useStream, useSetStream }; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index f3d7198e..f6eeab1f 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -130,6 +130,10 @@ "setup.title": "Welcome!", "sign-in": "Sign in", "ssl-certificate": "SSL Certificate", + "stream.forward-host": "Forward Host", + "stream.forward-port": "Forward Port", + "stream.incoming-port": "Incoming Port", + "stream.new": "New Stream", "streams.actions-title": "Stream #{id}", "streams.add": "Add Stream", "streams.count": "{count} Streams", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index ceddda30..2376d19c 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -392,6 +392,18 @@ "ssl-certificate": { "defaultMessage": "SSL Certificate" }, + "stream.forward-host": { + "defaultMessage": "Forward Host" + }, + "stream.forward-port": { + "defaultMessage": "Forward Port" + }, + "stream.incoming-port": { + "defaultMessage": "Incoming Port" + }, + "stream.new": { + "defaultMessage": "New Stream" + }, "streams.actions-title": { "defaultMessage": "Stream #{id}" }, diff --git a/frontend/src/modals/StreamModal.tsx b/frontend/src/modals/StreamModal.tsx new file mode 100644 index 00000000..a5b019e6 --- /dev/null +++ b/frontend/src/modals/StreamModal.tsx @@ -0,0 +1,292 @@ +import { Field, Form, Formik } from "formik"; +import { useState } from "react"; +import { Alert } from "react-bootstrap"; +import Modal from "react-bootstrap/Modal"; +import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components"; +import { useSetStream, useStream } from "src/hooks"; +import { intl } from "src/locale"; +import { validateNumber, validateString } from "src/modules/Validations"; +import { showSuccess } from "src/notifications"; + +interface Props { + id: number | "new"; + onClose: () => void; +} +export function StreamModal({ id, onClose }: Props) { + const { data, isLoading, error } = useStream(id); + const { mutate: setStream } = useSetStream(); + const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMsg(null); + + const { ...payload } = { + id: id === "new" ? undefined : id, + ...values, + }; + + setStream(payload, { + onError: (err: any) => setErrorMsg(err.message), + onSuccess: () => { + showSuccess(intl.formatMessage({ id: "notification.stream-saved" })); + onClose(); + }, + onSettled: () => { + setIsSubmitting(false); + setSubmitting(false); + }, + }); + }; + + return ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + {!isLoading && data && ( + + {({ setFieldValue }: any) => ( +
+ + + {intl.formatMessage({ id: data?.id ? "stream.edit" : "stream.new" })} + + + + setErrorMsg(null)} dismissible> + {errorMsg} + + +
+ +
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.incomingPort ? ( +
+ {form.errors.incomingPort && + form.touched.incomingPort + ? form.errors.incomingPort + : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.forwardingHost ? ( +
+ {form.errors.forwardingHost && + form.touched.forwardingHost + ? form.errors.forwardingHost + : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.forwardingPort ? ( +
+ {form.errors.forwardingPort && + form.touched.forwardingPort + ? form.errors.forwardingPort + : null} +
+ ) : null} +
+ )} +
+
+
+
+
Protocols
+ + {({ field }: any) => ( + + )} + + + {({ field }: any) => ( + + )} + +
+
+
+ + +
+
+
+
+
+ + + + +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index a5c7fa86..994f5cb2 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -4,4 +4,5 @@ export * from "./DeleteConfirmModal"; export * from "./EventDetailsModal"; export * from "./PermissionsModal"; export * from "./SetPasswordModal"; +export * from "./StreamModal"; export * from "./UserModal"; diff --git a/frontend/src/modules/Validations.tsx b/frontend/src/modules/Validations.tsx index ec537027..bb866218 100644 --- a/frontend/src/modules/Validations.tsx +++ b/frontend/src/modules/Validations.tsx @@ -85,18 +85,18 @@ const validateDomain = (allowWildcards = false) => { const validateDomains = (allowWildcards = false, maxDomains?: number) => { const vDom = validateDomain(allowWildcards); - return (value: string[]): string | undefined => { - if (!value.length) { + return (value?: string[]): string | undefined => { + if (!value?.length) { return intl.formatMessage({ id: "error.required" }); } // Deny if the list of domains is hit - if (maxDomains && value.length >= maxDomains) { + if (maxDomains && value?.length >= maxDomains) { return intl.formatMessage({ id: "error.max-domains" }, { max: maxDomains }); } // validate each domain - for (let i = 0; i < value.length; i++) { + for (let i = 0; i < value?.length; i++) { if (!vDom(value[i])) { return intl.formatMessage({ id: "error.invalid-domain" }, { domain: value[i] }); } diff --git a/frontend/src/pages/Nginx/DeadHosts/Empty.tsx b/frontend/src/pages/Nginx/DeadHosts/Empty.tsx index 59a22279..0b113394 100644 --- a/frontend/src/pages/Nginx/DeadHosts/Empty.tsx +++ b/frontend/src/pages/Nginx/DeadHosts/Empty.tsx @@ -5,17 +5,24 @@ import { intl } from "src/locale"; interface Props { tableInstance: ReactTable; onNew?: () => void; + isFiltered?: boolean; } -export default function Empty({ tableInstance, onNew }: Props) { +export default function Empty({ tableInstance, onNew, isFiltered }: Props) { return (
-

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

-

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

- + {isFiltered ? ( +

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

+ ) : ( + <> +

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

+

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

+ + + )}
diff --git a/frontend/src/pages/Nginx/DeadHosts/Table.tsx b/frontend/src/pages/Nginx/DeadHosts/Table.tsx index 23007a29..d03fb0f5 100644 --- a/frontend/src/pages/Nginx/DeadHosts/Table.tsx +++ b/frontend/src/pages/Nginx/DeadHosts/Table.tsx @@ -9,13 +9,14 @@ import Empty from "./Empty"; interface Props { data: DeadHost[]; + 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, onEdit, onDelete, onDisableToggle, onNew }: Props) { +export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -133,6 +134,9 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog }); return ( - } /> + } + /> ); } diff --git a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx index 3c4688d0..d2623a75 100644 --- a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx @@ -81,6 +81,7 @@ export default function TableWrapper() { setEditId(id)} onDelete={(id: number) => setDeleteId(id)} diff --git a/frontend/src/pages/Nginx/Streams/Empty.tsx b/frontend/src/pages/Nginx/Streams/Empty.tsx index 5ee73015..4b57ec65 100644 --- a/frontend/src/pages/Nginx/Streams/Empty.tsx +++ b/frontend/src/pages/Nginx/Streams/Empty.tsx @@ -4,15 +4,25 @@ import { intl } from "src/locale"; interface Props { tableInstance: ReactTable; + 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/Streams/Table.tsx b/frontend/src/pages/Nginx/Streams/Table.tsx index 49d007d9..13fb8abb 100644 --- a/frontend/src/pages/Nginx/Streams/Table.tsx +++ b/frontend/src/pages/Nginx/Streams/Table.tsx @@ -2,16 +2,21 @@ import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons- import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useMemo } from "react"; import type { Stream } from "src/api/backend"; -import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components"; +import { CertificateFormatter, GravatarFormatter, StatusFormatter, ValueWithDateFormatter } from "src/components"; import { TableLayout } from "src/components/Table/TableLayout"; import { intl } from "src/locale"; import Empty from "./Empty"; interface Props { data: Stream[]; + 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, isFiltered, onEdit, onDelete, onDisableToggle, onNew }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -30,8 +35,7 @@ export default function Table({ data, isFetching }: Props) { header: intl.formatMessage({ id: "column.incoming-port" }), cell: (info: any) => { const value = info.getValue(); - // Bit of a hack to reuse the DomainsFormatter component - return ; + return ; }, }), columnHelper.accessor((row: any) => row, { @@ -99,16 +103,37 @@ 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" })}
- + { + e.preventDefault(); + onDelete?.(info.row.original.id); + }} + > {intl.formatMessage({ id: "action.delete" })} @@ -121,7 +146,7 @@ export default function Table({ data, isFetching }: Props) { }, }), ], - [columnHelper], + [columnHelper, onEdit, onDisableToggle, onDelete], ); const tableInstance = useReactTable({ @@ -135,5 +160,10 @@ export default function Table({ data, isFetching }: Props) { enableSortingRemoval: false, }); - return } />; + return ( + } + /> + ); } diff --git a/frontend/src/pages/Nginx/Streams/TableWrapper.tsx b/frontend/src/pages/Nginx/Streams/TableWrapper.tsx index 7b6f8d50..7fc7f5f6 100644 --- a/frontend/src/pages/Nginx/Streams/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/Streams/TableWrapper.tsx @@ -1,11 +1,19 @@ import { IconSearch } from "@tabler/icons-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; import Alert from "react-bootstrap/Alert"; import { Button, LoadingPage } from "src/components"; import { useStreams } from "src/hooks"; import { intl } from "src/locale"; +import { DeleteConfirmModal, StreamModal } from "src/modals"; +import { showSuccess } from "src/notifications"; import Table from "./Table"; export default function TableWrapper() { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [editId, setEditId] = useState(0 as number | "new"); + const [deleteId, setDeleteId] = useState(0); const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate"]); if (isLoading) { @@ -16,6 +24,29 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } + const handleDelete = async () => { + // await deleteDeadHost(deleteId); + showSuccess(intl.formatMessage({ id: "notification.host-deleted" })); + }; + + const handleDisableToggle = async (id: number, enabled: boolean) => { + // await toggleDeadHost(id, enabled); + queryClient.invalidateQueries({ queryKey: ["dead-hosts"] }); + queryClient.invalidateQueries({ queryKey: ["dead-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; + // return item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)); + }); + } else if (search !== "") { + // this can happen if someone deletes the last item while searching + setSearch(""); + } + return (
@@ -27,25 +58,46 @@ export default function TableWrapper() {
-
- - - - -
-
-
-

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

-

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

- + {isFiltered ? ( +

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

+ ) : ( + <> +

{intl.formatMessage({ id: "streams.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={[["streams"], ["stream", deleteId]]} + > + {intl.formatMessage({ id: "stream.delete.content" })} + + ) : null} ); diff --git a/frontend/src/pages/Users/Empty.tsx b/frontend/src/pages/Users/Empty.tsx index 3346a6c5..c3922ce8 100644 --- a/frontend/src/pages/Users/Empty.tsx +++ b/frontend/src/pages/Users/Empty.tsx @@ -8,9 +8,6 @@ interface Props { isFiltered?: boolean; } export default function Empty({ tableInstance, onNewUser, isFiltered }: Props) { - if (isFiltered) { - } - return (