diff --git a/client/src/Hooks/useProxiesForm.ts b/client/src/Hooks/useProxiesForm.ts new file mode 100644 index 000000000..1b8de2045 --- /dev/null +++ b/client/src/Hooks/useProxiesForm.ts @@ -0,0 +1,22 @@ +import { useMemo } from "react"; +import { proxySchema, type ProxyFormData } from "@/Validation/proxy"; +import type { ProxyResponse } from "@/Types/Proxy"; + +interface UseProxiesFormOptions { + data?: ProxyResponse | null; +} + +export const useProxiesForm = ({ data }: UseProxiesFormOptions) => { + return useMemo(() => { + const defaults: ProxyFormData = { + name: data?.name ?? "", + protocol: data?.protocol ?? "http", + host: data?.host ?? "", + port: data?.port ?? 8080, + username: "", + password: "", + }; + + return { schema: proxySchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/Proxies/components/ProxiesTable.tsx b/client/src/Pages/Proxies/components/ProxiesTable.tsx new file mode 100644 index 000000000..f2ae148e3 --- /dev/null +++ b/client/src/Pages/Proxies/components/ProxiesTable.tsx @@ -0,0 +1,103 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/design-elements/Table"; +import { Table } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import { useClientPagination } from "@/Hooks/useClientPagination"; + +import type { ProxyResponse } from "@/Types/Proxy"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface ProxiesTableProps { + proxies: ProxyResponse[]; + setSelectedProxy: Function; +} + +export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + const { pagedRows, paginationProps } = useClientPagination(proxies); + + const getActions = (proxy: ProxyResponse): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/proxies/configure/${proxy.id}`); + }, + closeMenu: true, + }, + + { + id: 2, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedProxy(proxy); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.name}; + }, + }, + { + id: "protocol", + content: t("pages.proxies.table.headers.protocol"), + render: (row) => { + return {row?.protocol}; + }, + }, + { + id: "host", + content: t("pages.proxies.table.headers.host"), + render: (row) => { + return {row?.host}; + }, + }, + { + id: "port", + content: t("pages.proxies.table.headers.port"), + render: (row) => { + return {row?.port}; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + return ( + <> + { + navigate(`/proxies/configure/${row.id}`); + }} + /> + {proxies.length > 0 && } + + ); +}; diff --git a/client/src/Pages/Proxies/create/index.tsx b/client/src/Pages/Proxies/create/index.tsx new file mode 100644 index 000000000..2783c93a9 --- /dev/null +++ b/client/src/Pages/Proxies/create/index.tsx @@ -0,0 +1,213 @@ +import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Button, FieldLabel } from "@/Components/inputs"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; + +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { mutate } from "swr"; +import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import type { ProxyFormData } from "@/Validation/proxy"; +import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy"; +import { useTranslation } from "react-i18next"; +import { useProxiesForm } from "@/Hooks/useProxiesForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { useMemo } from "react"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +const ProxiesCreatePage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + const { proxyId } = useParams<{ proxyId: string }>(); + const isEditMode = Boolean(proxyId); + + const { data: existingProxy } = useGet( + isEditMode ? `/proxies/${proxyId}` : null + ); + + const { post, loading: isSubmitting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + + const { schema, defaults } = useProxiesForm({ data: existingProxy }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { reset, handleSubmit } = form; + + const isPasswordSet = existingProxy?.hasPassword ?? false; + const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false); + + const handleResetPassword = () => { + form.setValue("password", ""); + setPasswordHasBeenReset(true); + }; + + const isUsernameSet = Boolean(existingProxy?.username); + const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false); + + const handleResetUsername = () => { + form.setValue("username", ""); + setUsernameHasBeenReset(true); + }; + + const protocolOptions = useMemo( + () => + ProxyProtocols.map((protocol) => ({ + value: protocol, + label: protocol, + })), + [] + ); + + useEffect(() => { + reset(defaults); + }, [defaults, reset]); + + const onSubmit = async (data: ProxyFormData) => { + const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } = + { + ...data, + }; + // Don't send password/username if one is set and the user hasn't clicked reset + if (isPasswordSet && !passwordHasBeenReset) { + delete payload.password; + } else if (passwordHasBeenReset && !payload.password) { + payload.clearPassword = true; + } + if (isUsernameSet && !usernameHasBeenReset) { + delete payload.username; + } else if (usernameHasBeenReset && !payload.username) { + payload.clearUsername = true; + } + const result = isEditMode + ? await patch(`/proxies/${proxyId}`, payload) + : await post("/proxies", payload); + if (result?.success) { + await mutate((key) => typeof key === "string" && key.startsWith("/proxies")); + navigate("/proxies"); + } + }; + + return ( + + + + } + /> + + + + + + } + /> + + + {(isUsernameSet === false || usernameHasBeenReset === true) && ( + + )} + + {isUsernameSet === true && usernameHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.username.labelSet")} + + + + )} + + {(isPasswordSet === false || passwordHasBeenReset === true) && ( + + )} + + {isPasswordSet === true && passwordHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.password.labelSet")} + + + + )} + + } + /> + + + + + + + ); +}; + +export default ProxiesCreatePage; diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx new file mode 100644 index 000000000..5b40242b8 --- /dev/null +++ b/client/src/Pages/Proxies/index.tsx @@ -0,0 +1,72 @@ +import { BasePageWithStates } from "@/Components/design-elements"; +import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; + +import { useState } from "react"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; +import type { ProxyResponse } from "@/Types/Proxy"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +const ProxiesPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const [selectedProxy, setSelectedProxy] = useState(null); + const isDialogOpen = Boolean(selectedProxy); + + const { + data: proxies, + isLoading, + isValidating, + error, + refetch, + } = useGet("/proxies/team", {}, { keepPreviousData: true }); + + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedProxy) return; + await deleteFn(`/proxies/${selectedProxy.id}`); + setSelectedProxy(null); + refetch(); + }; + ``; + const handleCancel = () => { + setSelectedProxy(null); + }; + + return ( + + + + + + ); +}; + +export default ProxiesPage; diff --git a/client/src/Types/Proxy.ts b/client/src/Types/Proxy.ts new file mode 100644 index 000000000..dd098dee3 --- /dev/null +++ b/client/src/Types/Proxy.ts @@ -0,0 +1,17 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} + +export type ProxyResponse = Omit & { hasPassword: boolean }; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index a31cdb919..5b53ff3a5 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -5,6 +5,7 @@ import { DnsRecordTypes, HttpMethods, PageSpeedStrategies, + ProxyModes, type MonitorType, } from "@/Types/Monitor"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; @@ -67,6 +68,8 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, + proxyMode: z.enum(ProxyModes), + proxyId: z.string().optional(), method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }), ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), @@ -195,7 +198,7 @@ const dnsSchema = baseSchema.extend({ }); // Discriminated union of all monitor types -export const monitorSchema = z.discriminatedUnion("type", [ +const monitorSchemaUnion = z.discriminatedUnion("type", [ httpSchema, pingSchema, portSchema, @@ -208,10 +211,21 @@ export const monitorSchema = z.discriminatedUnion("type", [ dnsSchema, ]); +// Mirrors the server's refineProxySelection (monitorValidation.ts) +export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { + if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}); + export type MonitorFormData = z.infer; const optionForType = (type: MonitorType) => - monitorSchema.options.find((o) => o.shape.type.value === type); + monitorSchemaUnion.options.find((o) => o.shape.type.value === type); // The step each field of a type is validated on, from the per-field // `monitorStepRegistry` metadata (unannotated fields default to step 0). diff --git a/client/src/Validation/proxy.ts b/client/src/Validation/proxy.ts new file mode 100644 index 000000000..22582e92a --- /dev/null +++ b/client/src/Validation/proxy.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { ProxyProtocols } from "@/Types/Proxy"; + +export const proxySchema = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z + .number() + .int() + .min(1, "Proxy port is required") + .max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export type ProxyFormData = z.infer;