diff --git a/cli/commands/clearExitNodes.ts b/cli/commands/clearExitNodes.ts index 092e9761..2a283203 100644 --- a/cli/commands/clearExitNodes.ts +++ b/cli/commands/clearExitNodes.ts @@ -23,9 +23,9 @@ export const clearExitNodes: CommandModule< // Delete all exit nodes const deletedCount = await db .delete(exitNodes) - .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)); // delete all + .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all - console.log(`Deleted ${deletedCount.changes} exit node(s) from the database`); + console.log(`Deleted ${deletedCount.length} exit node(s) from the database`); process.exit(0); } catch (error) { diff --git a/messages/en-US.json b/messages/en-US.json index 704ad880..1d705dae 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1310,8 +1310,11 @@ "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", "documentation": "Documentation", "saveAllSettings": "Save All Settings", + "saveResourceTargets": "Save Targets", + "saveResourceHttp": "Save Additional fields", + "saveProxyProtocol": "Save Proxy protocol settings", "settingsUpdated": "Settings updated", - "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsUpdatedDescription": "Settings updated successfully", "settingsErrorUpdate": "Failed to update settings", "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", @@ -1874,6 +1877,8 @@ "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", "securitySettings": "Security Settings", + "dangerSection": "Danger section", + "dangerSectionDescription": "Delete organization alongside all its sites, clients, resources, etc...", "securitySettingsDescription": "Configure security policies for the organization", "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", @@ -2084,7 +2089,7 @@ "request": "Request", "requests": "Requests", "logs": "Logs", - "logsSettingsDescription": "Monitor logs collected from this orginization", + "logsSettingsDescription": "Monitor logs collected from this organization", "searchLogs": "Search logs...", "action": "Action", "actor": "Actor", diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index bb009ef3..fa28dd05 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -8,7 +8,13 @@ import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; -import { useState, useRef } from "react"; +import { + useState, + useRef, + useTransition, + useActionState, + type ComponentRef +} from "react"; import { Form, FormControl, @@ -53,6 +59,8 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import type { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; +import type { OrgContextType } from "@app/contexts/orgContext"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ @@ -111,82 +119,35 @@ const LOG_RETENTION_OPTIONS = [ ]; export default function GeneralPage() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const router = useRouter(); const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); - const { user } = useUserContext(); + return ( + +
+ + + + + {build !== "oss" && ( + + )} + {build !== "saas" && } +
+
+ ); +} + +type SectionFormProps = { + org: OrgContextType["org"]["org"]; +}; + +function DeleteForm({ org }: SectionFormProps) { const t = useTranslations(); - const { env } = useEnvContext(); - const { isPaidUser, hasSaasSubscription } = usePaidStatus(); + const api = createApiClient(useEnvContext()); - const [loadingDelete, setLoadingDelete] = useState(false); - const [loadingSave, setLoadingSave] = useState(false); - const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = - useState(false); - - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), - defaultValues: { - name: org?.org.name, - subnet: org?.org.subnet || "", // Add default value for subnet - requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null, - settingsLogRetentionDaysRequest: - org.org.settingsLogRetentionDaysRequest ?? 15, - settingsLogRetentionDaysAccess: - org.org.settingsLogRetentionDaysAccess ?? 15, - settingsLogRetentionDaysAction: - org.org.settingsLogRetentionDaysAction ?? 15 - }, - mode: "onChange" - }); - - // Track initial security policy values - const initialSecurityValues = { - requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null - }; - - // Check if security policies have changed - const hasSecurityPolicyChanged = () => { - const currentValues = form.getValues(); - return ( - currentValues.requireTwoFactor !== - initialSecurityValues.requireTwoFactor || - currentValues.maxSessionLengthHours !== - initialSecurityValues.maxSessionLengthHours || - currentValues.passwordExpiryDays !== - initialSecurityValues.passwordExpiryDays - ); - }; - - async function deleteOrg() { - setLoadingDelete(true); - try { - const res = await api.delete>( - `/org/${org?.org.orgId}` - ); - toast({ - title: t("orgDeleted"), - description: t("orgDeletedMessage") - }); - if (res.status === 200) { - pickNewOrgAndNavigate(); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("orgErrorDelete"), - description: formatAxiosError(err, t("orgErrorDeleteMessage")) - }); - } finally { - setLoadingDelete(false); - } - } + const router = useRouter(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [loadingDelete, startTransition] = useTransition(); + const { user } = useUserContext(); async function pickNewOrgAndNavigate() { try { @@ -213,57 +174,29 @@ export default function GeneralPage() { }); } } - - async function onSubmit(data: GeneralFormValues) { - // Check if security policies have changed - if (hasSecurityPolicyChanged()) { - setIsSecurityPolicyConfirmOpen(true); - return; - } - - await performSave(data); - } - - async function performSave(data: GeneralFormValues) { - setLoadingSave(true); - + async function deleteOrg() { try { - const reqData = { - name: data.name, - settingsLogRetentionDaysRequest: - data.settingsLogRetentionDaysRequest, - settingsLogRetentionDaysAccess: - data.settingsLogRetentionDaysAccess, - settingsLogRetentionDaysAction: - data.settingsLogRetentionDaysAction - } as any; - if (build !== "oss") { - reqData.requireTwoFactor = data.requireTwoFactor || false; - reqData.maxSessionLengthHours = data.maxSessionLengthHours; - reqData.passwordExpiryDays = data.passwordExpiryDays; - } - - // Update organization - await api.post(`/org/${org?.org.orgId}`, reqData); - + const res = await api.delete>( + `/org/${org.orgId}` + ); toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") + title: t("orgDeleted"), + description: t("orgDeletedMessage") }); - router.refresh(); - } catch (e) { + if (res.status === 200) { + pickNewOrgAndNavigate(); + } + } catch (err) { + console.error(err); toast({ variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) + title: t("orgErrorDelete"), + description: formatAxiosError(err, t("orgErrorDeleteMessage")) }); - } finally { - setLoadingSave(false); } } - return ( - + <> { @@ -276,533 +209,20 @@ export default function GeneralPage() { } buttonText={t("orgDeleteConfirm")} - onConfirm={deleteOrg} - string={org?.org.name || ""} + onConfirm={async () => startTransition(deleteOrg)} + string={org.name || ""} title={t("orgDelete")} /> - -

{t("securityPolicyChangeDescription")}

- - } - buttonText={t("saveSettings")} - onConfirm={() => performSave(form.getValues())} - string={t("securityPolicyChangeConfirmMessage")} - title={t("securityPolicyChangeWarning")} - warningText={t("securityPolicyChangeWarningText")} - /> - -
- - - - - {t("general")} - - - {t("orgGeneralSettingsDescription")} - - - - - ( - - {t("name")} - - - - - - {t("orgDisplayName")} - - - )} - /> - ( - - {t("subnet")} - - - - - - {t("subnetDescription")} - - - )} - /> - - - - - - - - {t("logRetention")} - - - {t("logRetentionDescription")} - - - - - ( - - - {t("logRetentionRequestLabel")} - - - - - - - )} - /> - - {build !== "oss" && ( - <> - - - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionActionLabel" - )} - - - - - - - ); - }} - /> - - )} - - - - - {build !== "oss" && ( - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = !isPaidUser; - - return ( - -
- - { - if ( - !isDisabled - ) { - form.setValue( - "requireTwoFactor", - val - ); - } - }} - /> - -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "passwordExpiryDays" - )} - - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> -
-
-
- )} -
- - -
- {build !== "saas" && ( + + + + {t("dangerSection")} + + + {t("dangerSectionDescription")} + + +
- )} +
+
+ + ); +} + +function GeneralSectionForm({ org }: SectionFormProps) { + const form = useForm({ + resolver: zodResolver( + GeneralFormSchema.pick({ + name: true, + subnet: true + }) + ), + defaultValues: { + name: org.name, + subnet: org.subnet || "" // Add default value for subnet + }, + mode: "onChange" + }); + const t = useTranslations(); + const router = useRouter(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + name: data.name + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("general")} + + {t("orgGeneralSettingsDescription")} + + + + +
+ + ( + + {t("name")} + + + + + + {t("orgDisplayName")} + + + )} + /> + ( + + {t("subnet")} + + + + + + {t("subnetDescription")} + + + )} + /> + + +
+
+ +
- +
+ ); +} + +function LogRetentionSectionForm({ org }: SectionFormProps) { + const form = useForm({ + resolver: zodResolver( + GeneralFormSchema.pick({ + settingsLogRetentionDaysRequest: true, + settingsLogRetentionDaysAccess: true, + settingsLogRetentionDaysAction: true + }) + ), + defaultValues: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.settingsLogRetentionDaysAction ?? 15 + }, + mode: "onChange" + }); + + const router = useRouter(); + const t = useTranslations(); + const { isPaidUser, hasSaasSubscription } = usePaidStatus(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("logRetention")} + + {t("logRetentionDescription")} + + + + +
+ + ( + + + {t("logRetentionRequestLabel")} + + + + + + + )} + /> + + {build !== "oss" && ( + <> + + + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + + )} + + +
+
+ +
+ +
+
+ ); +} + +function SecuritySettingsSectionForm({ org }: SectionFormProps) { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver( + GeneralFormSchema.pick({ + requireTwoFactor: true, + maxSessionLengthHours: true, + passwordExpiryDays: true + }) + ), + defaultValues: { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }, + mode: "onChange" + }); + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }; + + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays + ); + }; + + const [, formAction, loadingSave] = useActionState(onSubmit, null); + const api = createApiClient(useEnvContext()); + + const formRef = useRef>(null); + + async function onSubmit() { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(); + } + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + requireTwoFactor: data.requireTwoFactor || false, + maxSessionLengthHours: data.maxSessionLengthHours, + passwordExpiryDays: data.passwordExpiryDays + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + <> + +

{t("securityPolicyChangeDescription")}

+
+ } + buttonText={t("saveSettings")} + onConfirm={performSave} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + +
+ + + { + const isDisabled = !isPaidUser; + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("maxSessionLength")} + + + + + + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + +
+
+ +
+ +
+
+ ); } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 27f4fd73..7ace8450 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,21 +1,22 @@ "use client"; -import { useEffect, useState } from "react"; -import { ListRolesResponse } from "@server/routers/role"; -import { toast } from "@app/hooks/useToast"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { AxiosResponse } from "axios"; -import { formatAxiosError } from "@app/lib/api"; +import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; +import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import { - GetResourceWhitelistResponse, - ListResourceRolesResponse, - ListResourceUsersResponse -} from "@server/routers/resource"; + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Form, FormControl, @@ -25,32 +26,7 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { ListUsersResponse } from "@server/routers/user"; -import { Binary, Key, Bot } from "lucide-react"; -import SetResourcePasswordForm from "components/SetResourcePasswordForm"; -import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; -import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionTitle, - SettingsSectionHeader, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionFooter, - SettingsSectionForm -} from "@app/components/Settings"; -import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { useRouter } from "next/navigation"; -import { UserType } from "@server/types/UserTypes"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Select, SelectContent, @@ -58,10 +34,32 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { Separator } from "@app/components/ui/separator"; -import { build } from "@server/build"; +import type { ResourceContextType } from "@app/contexts/resourceContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { useResourceContext } from "@app/hooks/useResourceContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { TierId } from "@server/lib/billing/tiers"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import SetResourcePasswordForm from "components/SetResourcePasswordForm"; +import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { + useActionState, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -100,14 +98,83 @@ export default function ResourceAuthenticationPage() { const subscription = useSubscriptionStatusContext(); - const [pageLoading, setPageLoading] = useState(true); + const queryClient = useQueryClient(); + const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } = + useQuery( + resourceQueries.resourceRoles({ + resourceId: resource.resourceId + }) + ); + const { data: resourceUsers = [], isLoading: isLoadingResourceUsers } = + useQuery( + resourceQueries.resourceUsers({ + resourceId: resource.resourceId + }) + ); - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: whitelist = [], isLoading: isLoadingWhiteList } = useQuery( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] + + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( + orgQueries.roles({ + orgId: org.org.orgId + }) ); + const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( + orgQueries.users({ + orgId: org.org.orgId + }) + ); + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( + orgQueries.identityProviders({ + orgId: org.org.orgId + }) + ); + + const pageLoading = + isLoadingOrgRoles || + isLoadingOrgUsers || + isLoadingResourceRoles || + isLoadingResourceUsers || + isLoadingWhiteList || + isLoadingOrgIdps; + + const allRoles = useMemo(() => { + return orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + }, [orgRoles]); + + const allUsers = useMemo(() => { + return orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + }, [orgUsers]); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (subscription?.subscribed) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + return []; + }, [orgIdps]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -115,15 +182,7 @@ export default function ResourceAuthenticationPage() { number | null >(null); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - const [ssoEnabled, setSsoEnabled] = useState(resource.sso); - // const [blockAccess, setBlockAccess] = useState(resource.blockAccess); - const [whitelistEnabled, setWhitelistEnabled] = useState( - resource.emailWhitelistEnabled - ); const [autoLoginEnabled, setAutoLoginEnabled] = useState( resource.skipToIdpId !== null && resource.skipToIdpId !== undefined @@ -131,10 +190,6 @@ export default function ResourceAuthenticationPage() { const [selectedIdpId, setSelectedIdpId] = useState( resource.skipToIdpId || null ); - const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]); - - const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); - const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false); const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = useState(false); @@ -159,167 +214,61 @@ export default function ResourceAuthenticationPage() { defaultValues: { emails: [] } }); + const hasInitializedRef = useRef(false); + useEffect(() => { - const fetchData = async () => { - try { - const [ - rolesResponse, - resourceRolesResponse, - usersResponse, - resourceUsersResponse, - whitelist, - idpsResponse - ] = await Promise.all([ - api.get>( - `/org/${org?.org.orgId}/roles` - ), - api.get>( - `/resource/${resource.resourceId}/roles` - ), - api.get>( - `/org/${org?.org.orgId}/users` - ), - api.get>( - `/resource/${resource.resourceId}/users` - ), - api.get>( - `/resource/${resource.resourceId}/whitelist` - ), - api.get< - AxiosResponse<{ - idps: { idpId: number; name: string }[]; - }> - >(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp") - ]); + if (pageLoading || hasInitializedRef.current) return; - setAllRoles( - rolesResponse.data.data.roles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin") - ); + usersRolesForm.setValue( + "roles", + resourceRoles + .map((i) => ({ + id: i.roleId.toString(), + text: i.name + })) + .filter((role) => role.text !== "Admin") + ); + usersRolesForm.setValue( + "users", + resourceUsers.map((i) => ({ + id: i.userId.toString(), + text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` + })) + ); - usersRolesForm.setValue( - "roles", - resourceRolesResponse.data.data.roles - .map((i) => ({ - id: i.roleId.toString(), - text: i.name - })) - .filter((role) => role.text !== "Admin") - ); - - setAllUsers( - usersResponse.data.data.users.map((user) => ({ - id: user.id.toString(), - text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - ); - - usersRolesForm.setValue( - "users", - resourceUsersResponse.data.data.users.map((i) => ({ - id: i.userId.toString(), - text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })) - ); - - whitelistForm.setValue( - "emails", - whitelist.data.data.whitelist.map((w) => ({ - id: w.email, - text: w.email - })) - ); - - if (build === "saas") { - if (subscription?.subscribed) { - setAllIdps( - idpsResponse.data.data.idps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })) - ); - } - } else { - setAllIdps( - idpsResponse.data.data.idps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })) - ); - } - - if ( - autoLoginEnabled && - !selectedIdpId && - idpsResponse.data.data.idps.length > 0 - ) { - setSelectedIdpId(idpsResponse.data.data.idps[0].idpId); - } - - setPageLoading(false); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: t("resourceErrorAuthFetch"), - description: formatAxiosError( - e, - t("resourceErrorAuthFetchDescription") - ) - }); - } - }; - - fetchData(); - }, []); - - async function saveWhitelist() { - setLoadingSaveWhitelist(true); - try { - await api.post(`/resource/${resource.resourceId}`, { - emailWhitelistEnabled: whitelistEnabled - }); - - if (whitelistEnabled) { - await api.post(`/resource/${resource.resourceId}/whitelist`, { - emails: whitelistForm.getValues().emails.map((i) => i.text) - }); - } - - updateResource({ - emailWhitelistEnabled: whitelistEnabled - }); - - toast({ - title: t("resourceWhitelistSave"), - description: t("resourceWhitelistSaveDescription") - }); - router.refresh(); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: t("resourceErrorWhitelistSave"), - description: formatAxiosError( - e, - t("resourceErrorWhitelistSaveDescription") - ) - }); - } finally { - setLoadingSaveWhitelist(false); + whitelistForm.setValue( + "emails", + whitelist.map((w) => ({ + id: w.email, + text: w.email + })) + ); + if (autoLoginEnabled && !selectedIdpId && orgIdps.length > 0) { + setSelectedIdpId(orgIdps[0].idpId); } - } + hasInitializedRef.current = true; + }, [ + pageLoading, + resourceRoles, + resourceUsers, + whitelist, + autoLoginEnabled, + selectedIdpId, + orgIdps + ]); + + const [, submitUserRolesForm, loadingSaveUsersRoles] = useActionState( + onSubmitUsersRoles, + null + ); + + async function onSubmitUsersRoles() { + const isValid = usersRolesForm.trigger(); + if (!isValid) return; + + const data = usersRolesForm.getValues(); - async function onSubmitUsersRoles( - data: z.infer - ) { try { - setLoadingSaveUsersRoles(true); - // Validate that an IDP is selected if auto login is enabled if (autoLoginEnabled && !selectedIdpId) { toast({ @@ -358,6 +307,17 @@ export default function ResourceAuthenticationPage() { title: t("resourceAuthSettingsSave"), description: t("resourceAuthSettingsSaveDescription") }); + await queryClient.invalidateQueries({ + predicate(query) { + const resourceKey = resourceQueries.resourceClients({ + resourceId: resource.resourceId + }).queryKey; + return ( + query.queryKey[0] === resourceKey[0] && + query.queryKey[1] === resourceKey[1] + ); + } + }); router.refresh(); } catch (e) { console.error(e); @@ -369,8 +329,6 @@ export default function ResourceAuthenticationPage() { t("resourceErrorUsersRolesSaveDescription") ) }); - } finally { - setLoadingSaveUsersRoles(false); } } @@ -534,9 +492,7 @@ export default function ResourceAuthenticationPage() {
@@ -864,136 +820,202 @@ export default function ResourceAuthenticationPage() { - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!env.email.emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - - - {whitelistEnabled && env.email.emailEnabled && ( - - - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse( - tag - ).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t( - "otpEmailEnter" - )} - tags={ - whitelistForm.getValues() - .emails - } - setTags={( - newRoles - ) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={ - false - } - sortTags={true} - /> - - - {t( - "otpEmailEnterDescription" - )} - - - )} - /> - - - )} - - - - - -
+
); } + +type OneTimePasswordFormSectionProps = Pick< + ResourceContextType, + "resource" | "updateResource" +>; + +function OneTimePasswordFormSection({ + resource, + updateResource +}: OneTimePasswordFormSectionProps) { + const { env } = useEnvContext(); + const [whitelistEnabled, setWhitelistEnabled] = useState( + resource.emailWhitelistEnabled + ); + const queryClient = useQueryClient(); + + const [loadingSaveWhitelist, startTransition] = useTransition(); + const whitelistForm = useForm({ + resolver: zodResolver(whitelistSchema), + defaultValues: { emails: [] } + }); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + async function saveWhitelist() { + try { + await api.post(`/resource/${resource.resourceId}`, { + emailWhitelistEnabled: whitelistEnabled + }); + + if (whitelistEnabled) { + await api.post(`/resource/${resource.resourceId}/whitelist`, { + emails: whitelistForm.getValues().emails.map((i) => i.text) + }); + } + + updateResource({ + emailWhitelistEnabled: whitelistEnabled + }); + + toast({ + title: t("resourceWhitelistSave"), + description: t("resourceWhitelistSaveDescription") + }); + router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) + ); + } catch (e) { + console.error(e); + toast({ + variant: "destructive", + title: t("resourceErrorWhitelistSave"), + description: formatAxiosError( + e, + t("resourceErrorWhitelistSaveDescription") + ) + }); + } + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + + + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + whitelistForm.getValues() + .emails + } + setTags={(newRoles) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + + + )} +
+
+ + + +
+ ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index db783fda..547c8f6a 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -1,8 +1,5 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { formatAxiosError } from "@app/lib/api"; import { Button } from "@/components/ui/button"; import { Form, @@ -15,31 +12,6 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { useResourceContext } from "@app/hooks/useResourceContext"; -import { ListSitesResponse } from "@server/routers/site"; -import { useEffect, useMemo, useState } from "react"; -import { AxiosResponse } from "axios"; -import { useParams, useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Label } from "@app/components/ui/label"; -import { ListDomainsResponse } from "@server/routers/domain"; -import { UpdateResourceResponse } from "@server/routers/resource"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { useTranslations } from "next-intl"; -import { Checkbox } from "@app/components/ui/checkbox"; import { Credenza, CredenzaBody, @@ -51,26 +23,39 @@ import { CredenzaTitle } from "@app/components/Credenza"; import DomainPicker from "@app/components/DomainPicker"; -import { Globe } from "lucide-react"; -import { build } from "@server/build"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Label } from "@app/components/ui/label"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { DomainRow } from "@app/components/DomainsTable"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { AxiosResponse } from "axios"; +import { Globe } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { useUserContext } from "@app/hooks/useUserContext"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; export default function GeneralForm() { - const [formKey, setFormKey] = useState(0); const params = useParams(); const { resource, updateResource } = useResourceContext(); - const { org } = useOrgContext(); const router = useRouter(); const t = useTranslations(); const [editDomainOpen, setEditDomainOpen] = useState(false); - const { licenseStatus } = useLicenseStatusContext(); - const subscriptionStatus = useSubscriptionStatusContext(); - const { user } = useUserContext(); const { env } = useEnvContext(); @@ -78,15 +63,6 @@ export default function GeneralForm() { const api = createApiClient({ env }); - const [sites, setSites] = useState([]); - const [saveLoading, setSaveLoading] = useState(false); - const [transferLoading, setTransferLoading] = useState(false); - const [open, setOpen] = useState(false); - const [baseDomains, setBaseDomains] = useState< - ListDomainsResponse["domains"] - >([]); - - const [loadingPage, setLoadingPage] = useState(true); const [resourceFullDomain, setResourceFullDomain] = useState( `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); @@ -112,7 +88,6 @@ export default function GeneralForm() { niceId: z.string().min(1).max(255).optional(), domainId: z.string().optional(), proxyPort: z.int().min(1).max(65535).optional() - // enableProxy: z.boolean().optional() }) .refine( (data) => { @@ -131,8 +106,6 @@ export default function GeneralForm() { } ); - type GeneralFormValues = z.infer; - const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { @@ -142,58 +115,17 @@ export default function GeneralForm() { subdomain: resource.subdomain ? resource.subdomain : undefined, domainId: resource.domainId || undefined, proxyPort: resource.proxyPort || undefined - // enableProxy: resource.enableProxy || false }, mode: "onChange" }); - useEffect(() => { - const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); - }; + const [, formAction, saveLoading] = useActionState(onSubmit, null); - const fetchDomains = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${orgId}/domains/`) - .catch((e) => { - toast({ - variant: "destructive", - title: t("domainErrorFetch"), - description: formatAxiosError( - e, - t("domainErrorFetchDescription") - ) - }); - }); + async function onSubmit() { + const isValid = await form.trigger(); + if (!isValid) return; - if (res?.status === 200) { - const rawDomains = res.data.data.domains as DomainRow[]; - const domains = rawDomains.map((domain) => ({ - ...domain, - baseDomain: toUnicode(domain.baseDomain) - })); - setBaseDomains(domains); - setFormKey((key) => key + 1); - } - }; - - const load = async () => { - await fetchDomains(); - await fetchSites(); - - setLoadingPage(false); - }; - - load(); - }, []); - - async function onSubmit(data: GeneralFormValues) { - setSaveLoading(true); + const data = form.getValues(); const res = await api .post>( @@ -207,9 +139,6 @@ export default function GeneralForm() { : undefined, domainId: data.domainId, proxyPort: data.proxyPort - // ...(!resource.http && { - // enableProxy: data.enableProxy - // }) } ) .catch((e) => { @@ -248,320 +177,265 @@ export default function GeneralForm() { router.replace( `/${updated.orgId}/settings/resources/proxy/${data.niceId}/general` ); - } else { - router.refresh(); } - setSaveLoading(false); + router.refresh(); } - - setSaveLoading(false); } return ( - !loadingPage && ( - <> - - - - - {t("resourceGeneral")} - - - {t("resourceGeneralDescription")} - - + <> + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + - - -
- - ( - -
- - + + + + ( + +
+ + + form.setValue( + "enabled", val - ) => - form.setValue( - "enabled", - val + ) + } + /> + +
+ +
+ )} + /> + + ( + + + {t("name")} + + + + + + + )} + /> + + ( + + + {t("identifier")} + + + + + + + )} + /> + + {!resource.http && ( + <> + ( + + + {t( + "resourcePortNumber" + )} + + + + field.onChange( + e.target + .value + ? parseInt( + e + .target + .value + ) + : undefined ) } /> -
- -
- )} - /> - - ( - - - {t("name")} - - - - - - - )} - /> - - ( - - - {t("identifier")} - - - + + {t( + "resourcePortNumberDescription" )} - className="flex-1" - /> - - - - )} - /> + + + )} + /> + + )} - {!resource.http && ( - <> - ( - - - {t( - "resourcePortNumber" - )} - - - - field.onChange( - e - .target - .value - ? parseInt( - e - .target - .value - ) - : undefined - ) - } - /> - - - - {t( - "resourcePortNumberDescription" - )} - - - )} - /> - - {/* {build == "oss" && ( - ( - - - - -
- - {t( - "resourceEnableProxy" - )} - - - {t( - "resourceEnableProxyDescription" - )} - -
-
- )} - /> - )} */} - - )} - - {resource.http && ( -
- -
- - - {resourceFullDomain} - - -
+ {resource.http && ( +
+ +
+ + + {resourceFullDomain} + +
- )} - - - - +
+ )} + + + + - - - - - + + + + + - setEditDomainOpen(setOpen)} - > - - - Edit Domain - - Select a domain for your resource - - - - setEditDomainOpen(setOpen)} + > + + + Edit Domain + + Select a domain for your resource + + + + { + const selected = + res === null + ? null + : { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain, + baseDomain: res.baseDomain, + domainNamespaceId: + res.domainNamespaceId + }; + + setSelectedDomain(selected); + }} + /> + + + + + + - - - - - - - ) + }} + > + Select Domain + + + + + ); } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index c453b577..f410b4c8 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -13,9 +13,10 @@ import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { cache } from "react"; import ResourceInfoBox from "@app/components/ResourceInfoBox"; -import { GetSiteResponse } from "@server/routers/site"; import { getTranslations } from "next-intl/server"; +export const dynamic = "force-dynamic"; + interface ResourceLayoutProps { children: React.ReactNode; params: Promise<{ niceId: string; orgId: string }>; diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 00f487ea..ada2defe 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, use } from "react"; +import HealthCheckDialog from "@/components/HealthCheckDialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -11,11 +11,34 @@ import { SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { AxiosResponse } from "axios"; -import { ListTargetsResponse } from "@server/routers/target/listTargets"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { ContainersSelector } from "@app/components/ContainersSelector"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Badge } from "@app/components/ui/badge"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; import { Form, FormControl, @@ -25,17 +48,11 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { CreateTargetResponse } from "@server/routers/target"; import { - ColumnDef, - getFilteredRowModel, - getSortedRowModel, - getPaginationRowModel, - getCoreRowModel, - useReactTable, - flexRender, - Row -} from "@tanstack/react-table"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Table, TableBody, @@ -44,153 +61,55 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { toast } from "@app/hooks/useToast"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { ArrayElement } from "@server/types/ArrayElement"; -import { formatAxiosError } from "@app/lib/api/formatAxiosError"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { createApiClient } from "@app/lib/api"; -import { GetSiteResponse, ListSitesResponse } from "@server/routers/site"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm -} from "@app/components/Settings"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { useRouter } from "next/navigation"; -import { isTargetValid } from "@server/lib/validators"; -import { tlsNameSchema } from "@server/lib/schemas"; -import { - CheckIcon, - ChevronsUpDown, - Settings, - Heart, - Check, - CircleCheck, - CircleX, - ArrowRight, - Plus, - MoveRight, - ArrowUp, - Info, - ArrowDown, - AlertTriangle -} from "lucide-react"; -import { ContainersSelector } from "@app/components/ContainersSelector"; -import { useTranslations } from "next-intl"; -import { build } from "@server/build"; -import HealthCheckDialog from "@/components/HealthCheckDialog"; -import { DockerManager, DockerState } from "@app/lib/docker"; -import { Container } from "@server/routers/site"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { cn } from "@app/lib/cn"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { parseHostTarget } from "@app/lib/parseHostTarget"; -import { HeadersInput } from "@app/components/HeadersInput"; -import { - PathMatchDisplay, - PathMatchModal, - PathRewriteDisplay, - PathRewriteModal -} from "@app/components/PathMatchRenameModal"; -import { Badge } from "@app/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; - -const addTargetSchema = z - .object({ - ip: z.string().refine(isTargetValid), - method: z.string().nullable(), - port: z.coerce.number().int().positive(), - siteId: z.int().positive({ - error: "You must select a site for a target." - }), - path: z.string().optional().nullable(), - pathMatchType: z - .enum(["exact", "prefix", "regex"]) - .optional() - .nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional() - }) - .refine( - (data) => { - // If path is provided, pathMatchType must be provided - if (data.path && !data.pathMatchType) { - return false; - } - // If pathMatchType is provided, path must be provided - if (data.pathMatchType && !data.path) { - return false; - } - // Validate path based on pathMatchType - if (data.path && data.pathMatchType) { - switch (data.pathMatchType) { - case "exact": - case "prefix": - // Path should start with / - return data.path.startsWith("/"); - case "regex": - // Validate regex - try { - new RegExp(data.path); - return true; - } catch { - return false; - } - } - } - return true; - }, - { - error: "Invalid path configuration" - } - ) - .refine( - (data) => { - // If rewritePath is provided, rewritePathType must be provided - if (data.rewritePath && !data.rewritePathType) { - return false; - } - // If rewritePathType is provided, rewritePath must be provided - // Exception: stripPrefix can have an empty rewritePath (to just strip the prefix) - if (data.rewritePathType && !data.rewritePath) { - // Allow empty rewritePath for stripPrefix type - if (data.rewritePathType !== "stripPrefix") { - return false; - } - } - return true; - }, - { - error: "Invalid rewrite path configuration" - } - ); +import type { ResourceContextType } from "@app/contexts/resourceContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { cn } from "@app/lib/cn"; +import { DockerManager, DockerState } from "@app/lib/docker"; +import { parseHostTarget } from "@app/lib/parseHostTarget"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { tlsNameSchema } from "@server/lib/schemas"; +import { type GetResourceResponse } from "@server/routers/resource"; +import type { ListSitesResponse } from "@server/routers/site"; +import { CreateTargetResponse } from "@server/routers/target"; +import { ListTargetsResponse } from "@server/routers/target/listTargets"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { AxiosResponse } from "axios"; +import { + AlertTriangle, + CheckIcon, + CircleCheck, + CircleX, + Info, + Plus, + Settings +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { use, useActionState, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; const targetsSettingsSchema = z.object({ stickySession: z.boolean() @@ -205,23 +124,72 @@ type LocalTarget = Omit< "protocol" >; -export default function ReverseProxyTargets(props: { +export default function ReverseProxyTargetsPage(props: { params: Promise<{ resourceId: number; orgId: string }>; }) { const params = use(props.params); - const t = useTranslations(); - const { env } = useEnvContext(); - const { resource, updateResource } = useResourceContext(); + const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + const { data: sites = [], isLoading: isLoadingSites } = useQuery( + orgQueries.sites({ + orgId: params.orgId + }) + ); + + if (isLoadingSites || isLoadingTargets) { + return null; + } + + return ( + + + + {resource.http && ( + + )} + + {!resource.http && resource.protocol == "tcp" && ( + + )} + + ); +} + +function ProxyResourceTargetsForm({ + sites, + initialTargets, + resource +}: { + initialTargets: LocalTarget[]; + sites: ListSitesResponse["sites"]; + resource: GetResourceResponse; +}) { + const t = useTranslations(); const api = createApiClient(useEnvContext()); - const [targets, setTargets] = useState([]); + const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); - const [sites, setSites] = useState([]); const [dockerStates, setDockerStates] = useState>( new Map() ); + const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = + useState(null); const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { @@ -258,12 +226,6 @@ export default function ReverseProxyTargets(props: { ); }; - const [httpsTlsLoading, setHttpsTlsLoading] = useState(false); - const [targetsLoading, setTargetsLoading] = useState(false); - const [proxySettingsLoading, setProxySettingsLoading] = useState(false); - - const [pageLoading, setPageLoading] = useState(true); - const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isAdvancedMode, setIsAdvancedMode] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("proxy-advanced-mode"); @@ -271,572 +233,14 @@ export default function ReverseProxyTargets(props: { } return false; }); - const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); - const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = - useState(null); - const router = useRouter(); - - const proxySettingsSchema = z.object({ - setHostHeader: z - .string() - .optional() - .refine( - (data) => { - if (data) { - return tlsNameSchema.safeParse(data).success; - } - return true; - }, - { - message: t("proxyErrorInvalidHeader") - } - ), - headers: z - .array(z.object({ name: z.string(), value: z.string() })) - .nullable(), - proxyProtocol: z.boolean().optional(), - proxyProtocolVersion: z.int().min(1).max(2).optional() - }); - - const tlsSettingsSchema = z.object({ - ssl: z.boolean(), - tlsServerName: z - .string() - .optional() - .refine( - (data) => { - if (data) { - return tlsNameSchema.safeParse(data).success; - } - return true; - }, - { - message: t("proxyErrorTls") - } - ) - }); - - type ProxySettingsValues = z.infer; - type TlsSettingsValues = z.infer; - type TargetsSettingsValues = z.infer; - - const tlsSettingsForm = useForm({ - resolver: zodResolver(tlsSettingsSchema), - defaultValues: { - ssl: resource.ssl, - tlsServerName: resource.tlsServerName || "" - } - }); - - const proxySettingsForm = useForm({ - resolver: zodResolver(proxySettingsSchema), - defaultValues: { - setHostHeader: resource.setHostHeader || "", - headers: resource.headers, - proxyProtocol: resource.proxyProtocol || false, - proxyProtocolVersion: resource.proxyProtocolVersion || 1 - } - }); - - const targetsSettingsForm = useForm({ - resolver: zodResolver(targetsSettingsSchema), - defaultValues: { - stickySession: resource.stickySession - } - }); - - useEffect(() => { - const fetchTargets = async () => { - try { - const res = await api.get>( - `/resource/${resource.resourceId}/targets` - ); - - if (res.status === 200) { - setTargets(res.data.data.targets); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("targetErrorFetch"), - description: formatAxiosError( - err, - t("targetErrorFetchDescription") - ) - }); - } finally { - setPageLoading(false); - } - }; - fetchTargets(); - - const fetchSites = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${params.orgId}/sites`) - .catch((e) => { - toast({ - variant: "destructive", - title: t("sitesErrorFetch"), - description: formatAxiosError( - e, - t("sitesErrorFetchDescription") - ) - }); - }); - - if (res?.status === 200) { - setSites(res.data.data.sites); - - // Initialize Docker for newt sites - const newtSites = res.data.data.sites.filter( - (site) => site.type === "newt" - ); - for (const site of newtSites) { - initializeDockerForSite(site.siteId); - } - - // Sites loaded successfully - } - }; - fetchSites(); - - // const fetchSite = async () => { - // try { - // const res = await api.get>( - // `/site/${resource.siteId}` - // ); - // - // if (res.status === 200) { - // setSite(res.data.data); - // } - // } catch (err) { - // console.error(err); - // toast({ - // variant: "destructive", - // title: t("siteErrorFetch"), - // description: formatAxiosError( - // err, - // t("siteErrorFetchDescription") - // ) - // }); - // } - // }; - // fetchSite(); - }, []); - - // Save advanced mode preference to localStorage - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem( - "proxy-advanced-mode", - isAdvancedMode.toString() - ); - } - }, [isAdvancedMode]); - - function addNewTarget() { - const isHttp = resource.http; - - const newTarget: LocalTarget = { - targetId: -Date.now(), // Use negative timestamp as temporary ID - ip: "", - method: isHttp ? "http" : null, - port: 0, - siteId: sites.length > 0 ? sites[0].siteId : 0, - path: isHttp ? null : null, - pathMatchType: isHttp ? null : null, - rewritePath: isHttp ? null : null, - rewritePathType: isHttp ? null : null, - priority: isHttp ? 100 : 100, - enabled: true, - resourceId: resource.resourceId, - hcEnabled: false, - hcPath: null, - hcMethod: null, - hcInterval: null, - hcTimeout: null, - hcHeaders: null, - hcScheme: null, - hcHostname: null, - hcPort: null, - hcFollowRedirects: null, - hcHealth: "unknown", - hcStatus: null, - hcMode: null, - hcUnhealthyInterval: null, - hcTlsServerName: null, - siteType: sites.length > 0 ? sites[0].type : null, - new: true, - updated: false - }; - - setTargets((prev) => [...prev, newTarget]); - } - - async function saveNewTarget(target: LocalTarget) { - // Validate the target - if (!isTargetValid(target.ip)) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidIp"), - description: t("targetErrorInvalidIpDescription") - }); - return; - } - - if (!target.port || target.port <= 0) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidPort"), - description: t("targetErrorInvalidPortDescription") - }); - return; - } - - if (!target.siteId) { - toast({ - variant: "destructive", - title: t("targetErrorNoSite"), - description: t("targetErrorNoSiteDescription") - }); - return; - } - - try { - setTargetsLoading(true); - - const data: any = { - resourceId: resource.resourceId, - siteId: target.siteId, - ip: target.ip, - method: target.method, - port: target.port, - enabled: target.enabled, - hcEnabled: target.hcEnabled, - hcPath: target.hcPath || null, - hcScheme: target.hcScheme || null, - hcHostname: target.hcHostname || null, - hcPort: target.hcPort || null, - hcInterval: target.hcInterval || null, - hcTimeout: target.hcTimeout || null, - hcHeaders: target.hcHeaders || null, - hcFollowRedirects: target.hcFollowRedirects || null, - hcMethod: target.hcMethod || null, - hcStatus: target.hcStatus || null, - hcUnhealthyInterval: target.hcUnhealthyInterval || null, - hcMode: target.hcMode || null - }; - - // Only include path-related fields for HTTP resources - if (resource.http) { - data.path = target.path; - data.pathMatchType = target.pathMatchType; - data.rewritePath = target.rewritePath; - data.rewritePathType = target.rewritePathType; - data.priority = target.priority; - } - - const response = await api.post< - AxiosResponse - >(`/target`, data); - - if (response.status === 200) { - // Update the target with the new ID and remove the new flag - setTargets((prev) => - prev.map((t) => - t.targetId === target.targetId - ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } - : t - ) - ); - - toast({ - title: t("targetCreated"), - description: t("targetCreatedDescription") - }); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("targetErrorCreate"), - description: formatAxiosError( - err, - t("targetErrorCreateDescription") - ) - }); - } finally { - setTargetsLoading(false); - } - } - - async function addTarget(data: z.infer) { - // if (site && site.type == "wireguard" && site.subnet) { - // // make sure that the target IP is within the site subnet - // const targetIp = data.ip; - // const subnet = site.subnet; - // try { - // if (!isIPInSubnet(targetIp, subnet)) { - // toast({ - // variant: "destructive", - // title: t("targetWireGuardErrorInvalidIp"), - // description: t( - // "targetWireGuardErrorInvalidIpDescription" - // ) - // }); - // return; - // } - // } catch (error) { - // console.error(error); - // toast({ - // variant: "destructive", - // title: t("targetWireGuardErrorInvalidIp"), - // description: t("targetWireGuardErrorInvalidIpDescription") - // }); - // return; - // } - // } - - const site = sites.find((site) => site.siteId === data.siteId); - const isHttp = resource.http; - - const newTarget: LocalTarget = { - ...data, - path: isHttp ? data.path || null : null, - pathMatchType: isHttp ? data.pathMatchType || null : null, - rewritePath: isHttp ? data.rewritePath || null : null, - rewritePathType: isHttp ? data.rewritePathType || null : null, - siteType: site?.type || null, - enabled: true, - targetId: new Date().getTime(), - new: true, - resourceId: resource.resourceId, - priority: isHttp ? data.priority || 100 : 100, - hcEnabled: false, - hcPath: null, - hcMethod: null, - hcInterval: null, - hcTimeout: null, - hcHeaders: null, - hcScheme: null, - hcHostname: null, - hcPort: null, - hcFollowRedirects: null, - hcHealth: "unknown", - hcStatus: null, - hcMode: null, - hcUnhealthyInterval: null, - hcTlsServerName: null - }; - - setTargets([...targets, newTarget]); - } - - const removeTarget = (targetId: number) => { - setTargets([ - ...targets.filter((target) => target.targetId !== targetId) - ]); - - if (!targets.find((target) => target.targetId === targetId)?.new) { - setTargetsToRemove([...targetsToRemove, targetId]); - } - }; - - async function updateTarget(targetId: number, data: Partial) { - const site = sites.find((site) => site.siteId === data.siteId); - setTargets( - targets.map((target) => - target.targetId === targetId - ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } - : target - ) - ); - } - - function updateTargetHealthCheck(targetId: number, config: any) { - setTargets( - targets.map((target) => - target.targetId === targetId - ? { - ...target, - ...config, - updated: true - } - : target - ) - ); - } - - const openHealthCheckDialog = (target: LocalTarget) => { - console.log(target); - setSelectedTargetForHealthCheck(target); - setHealthCheckDialogOpen(true); - }; - - async function saveAllSettings() { - // Validate that no targets have blank IPs or invalid ports - const targetsWithInvalidFields = targets.filter( - (target) => - !target.ip || - target.ip.trim() === "" || - !target.port || - target.port <= 0 || - isNaN(target.port) - ); - console.log(targetsWithInvalidFields); - if (targetsWithInvalidFields.length > 0) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidIp"), - description: t("targetErrorInvalidIpDescription") - }); - return; - } - - try { - setTargetsLoading(true); - setHttpsTlsLoading(true); - setProxySettingsLoading(true); - - for (const targetId of targetsToRemove) { - await api.delete(`/target/${targetId}`); - } - - // Save targets - for (const target of targets) { - const data: any = { - ip: target.ip, - port: target.port, - method: target.method, - enabled: target.enabled, - siteId: target.siteId, - hcEnabled: target.hcEnabled, - hcPath: target.hcPath || null, - hcScheme: target.hcScheme || null, - hcHostname: target.hcHostname || null, - hcPort: target.hcPort || null, - hcInterval: target.hcInterval || null, - hcTimeout: target.hcTimeout || null, - hcHeaders: target.hcHeaders || null, - hcFollowRedirects: target.hcFollowRedirects || null, - hcMethod: target.hcMethod || null, - hcStatus: target.hcStatus || null, - hcUnhealthyInterval: target.hcUnhealthyInterval || null, - hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName - }; - - // Only include path-related fields for HTTP resources - if (resource.http) { - data.path = target.path; - data.pathMatchType = target.pathMatchType; - data.rewritePath = target.rewritePath; - data.rewritePathType = target.rewritePathType; - data.priority = target.priority; - } - - if (target.new) { - const res = await api.put< - AxiosResponse - >(`/resource/${resource.resourceId}/target`, data); - target.targetId = res.data.data.targetId; - target.new = false; - } else if (target.updated) { - await api.post(`/target/${target.targetId}`, data); - target.updated = false; - } - } - - if (resource.http) { - // Gather all settings - const stickySessionData = targetsSettingsForm.getValues(); - const tlsData = tlsSettingsForm.getValues(); - const proxyData = proxySettingsForm.getValues(); - - // Combine into one payload - const payload = { - stickySession: stickySessionData.stickySession, - ssl: tlsData.ssl, - tlsServerName: tlsData.tlsServerName || null, - setHostHeader: proxyData.setHostHeader || null, - headers: proxyData.headers || null - }; - - // Single API call to update all settings - await api.post(`/resource/${resource.resourceId}`, payload); - - // Update local resource context - updateResource({ - ...resource, - stickySession: stickySessionData.stickySession, - ssl: tlsData.ssl, - tlsServerName: tlsData.tlsServerName || null, - setHostHeader: proxyData.setHostHeader || null, - headers: proxyData.headers || null - }); - } else { - // For TCP/UDP resources, save proxy protocol settings - const proxyData = proxySettingsForm.getValues(); - - const payload = { - proxyProtocol: proxyData.proxyProtocol || false, - proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 - }; - - await api.post(`/resource/${resource.resourceId}`, payload); - - updateResource({ - ...resource, - proxyProtocol: proxyData.proxyProtocol || false, - proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 - }); - } - - toast({ - title: t("settingsUpdated"), - description: t("settingsUpdatedDescription") - }); - - setTargetsToRemove([]); - router.refresh(); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("settingsErrorUpdate"), - description: formatAxiosError( - err, - t("settingsErrorUpdateDescription") - ) - }); - } finally { - setTargetsLoading(false); - setHttpsTlsLoading(false); - setProxySettingsLoading(false); - } - } const getColumns = (): ColumnDef[] => { - const baseColumns: ColumnDef[] = []; const isHttp = resource.http; const priorityColumn: ColumnDef = { id: "priority", header: () => ( -
+
{t("priority")} @@ -1363,6 +767,91 @@ export default function ReverseProxyTargets(props: { } }; + function addNewTarget() { + const isHttp = resource.http; + + const newTarget: LocalTarget = { + targetId: -Date.now(), // Use negative timestamp as temporary ID + ip: "", + method: isHttp ? "http" : null, + port: 0, + siteId: sites.length > 0 ? sites[0].siteId : 0, + path: isHttp ? null : null, + pathMatchType: isHttp ? null : null, + rewritePath: isHttp ? null : null, + rewritePathType: isHttp ? null : null, + priority: isHttp ? 100 : 100, + enabled: true, + resourceId: resource.resourceId, + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcFollowRedirects: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null, + hcTlsServerName: null, + siteType: sites.length > 0 ? sites[0].type : null, + new: true, + updated: false + }; + + setTargets((prev) => [...prev, newTarget]); + } + + const removeTarget = (targetId: number) => { + setTargets([ + ...targets.filter((target) => target.targetId !== targetId) + ]); + + if (!targets.find((target) => target.targetId === targetId)?.new) { + setTargetsToRemove([...targetsToRemove, targetId]); + } + }; + + async function updateTarget(targetId: number, data: Partial) { + const site = sites.find((site) => site.siteId === data.siteId); + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } + : target + ) + ); + } + + function updateTargetHealthCheck(targetId: number, config: any) { + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...config, + updated: true + } + : target + ) + ); + } + + const openHealthCheckDialog = (target: LocalTarget) => { + console.log(target); + setSelectedTargetForHealthCheck(target); + setHealthCheckDialogOpen(true); + }; + const columns = getColumns(); const table = useReactTable({ @@ -1380,12 +869,128 @@ export default function ReverseProxyTargets(props: { } }); - if (pageLoading) { - return <>; + const router = useRouter(); + + const queryClient = useQueryClient(); + + useEffect(() => { + const newtSites = sites.filter((site) => site.type === "newt"); + for (const site of newtSites) { + initializeDockerForSite(site.siteId); + } + }, [sites]); + + // Save advanced mode preference to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "proxy-advanced-mode", + isAdvancedMode.toString() + ); + } + }, [isAdvancedMode]); + + const [, formAction, isSubmitting] = useActionState(saveTargets, null); + + async function saveTargets() { + // Validate that no targets have blank IPs or invalid ports + const targetsWithInvalidFields = targets.filter( + (target) => + !target.ip || + target.ip.trim() === "" || + !target.port || + target.port <= 0 || + isNaN(target.port) + ); + console.log(targetsWithInvalidFields); + if (targetsWithInvalidFields.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + + try { + await Promise.all( + targetsToRemove.map((targetId) => + api.delete(`/target/${targetId}`) + ) + ); + + // Save targets + for (const target of targets) { + const data: any = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcFollowRedirects: target.hcFollowRedirects || null, + hcMethod: target.hcMethod || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName + }; + + // Only include path-related fields for HTTP resources + if (resource.http) { + data.path = target.path; + data.pathMatchType = target.pathMatchType; + data.rewritePath = target.rewritePath; + data.rewritePathType = target.rewritePathType; + data.priority = target.priority; + } + + if (target.new) { + const res = await api.put< + AxiosResponse + >(`/resource/${resource.resourceId}/target`, data); + target.targetId = res.data.data.targetId; + target.new = false; + } else if (target.updated) { + await api.post(`/target/${target.targetId}`, data); + target.updated = false; + } + } + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + setTargetsToRemove([]); + router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } } return ( - + <> {t("targets")} @@ -1514,7 +1119,7 @@ export default function ReverseProxyTargets(props: {
) : ( -
+

{t("targetNoOne")}

@@ -1525,330 +1130,18 @@ export default function ReverseProxyTargets(props: {
)} + +
+ +
- {resource.http && ( - - - - {t("proxyAdditional")} - - - {t("proxyAdditionalDescription")} - - - - -
- - {!env.flags.usePangolinDns && ( - ( - - - { - field.onChange( - val - ); - }} - /> - - - )} - /> - )} - ( - - - {t("targetTlsSni")} - - - - - - {t( - "targetTlsSniDescription" - )} - - - - )} - /> - - -
- - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - -
- - -
- - ( - - - {t("proxyCustomHeader")} - - - - - - {t( - "proxyCustomHeaderDescription" - )} - - - - )} - /> - ( - - - {t("customHeaders")} - - - { - field.onChange( - value - ); - }} - rows={4} - /> - - - {t( - "customHeadersDescription" - )} - - - - )} - /> - - -
-
-
- )} - - {!resource.http && resource.protocol == "tcp" && ( - - - - {t("proxyProtocol")} - - - {t("proxyProtocolDescription")} - - - - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - {proxySettingsForm.watch( - "proxyProtocol" - ) && ( - <> - ( - - - {t( - "proxyProtocolVersion" - )} - - - - - - {t( - "versionDescription" - )} - - - )} - /> - - - - - - {t("warning")}: - {" "} - {t("proxyProtocolWarning")} - - - - )} - - -
-
-
- )} - -
- -
- {selectedTargetForHealthCheck && ( )} - + ); } -function isIPInSubnet(subnet: string, ip: string): boolean { - const [subnetIP, maskBits] = subnet.split("/"); - const mask = parseInt(maskBits); +function ProxyResourceHttpForm({ + resource, + updateResource +}: Pick) { + const t = useTranslations(); - if (mask < 0 || mask > 32) { - throw new Error("subnetMaskErrorInvalid"); - } + const tlsSettingsSchema = z.object({ + ssl: z.boolean(), + tlsServerName: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorTls") + } + ) + }); - // Convert IP addresses to binary numbers - const subnetNum = ipToNumber(subnetIP); - const ipNum = ipToNumber(ip); - - // Calculate subnet mask - const maskNum = mask === 32 ? -1 : ~((1 << (32 - mask)) - 1); - - // Check if the IP is in the subnet - return (subnetNum & maskNum) === (ipNum & maskNum); -} - -function ipToNumber(ip: string): number { - // Validate IP address format - const parts = ip.split("."); - - if (parts.length !== 4) { - throw new Error("ipAddressErrorInvalidFormat"); - } - - // Convert IP octets to 32-bit number - return parts.reduce((num, octet) => { - const oct = parseInt(octet); - if (isNaN(oct) || oct < 0 || oct > 255) { - throw new Error("ipAddressErrorInvalidOctet"); + const tlsSettingsForm = useForm({ + resolver: zodResolver(tlsSettingsSchema), + defaultValues: { + ssl: resource.ssl, + tlsServerName: resource.tlsServerName || "" } - return (num << 8) + oct; - }, 0); + }); + + const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorInvalidHeader") + } + ), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.int().min(1).max(2).optional() + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "", + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 + } + }); + + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const targetsSettingsForm = useForm({ + resolver: zodResolver(targetsSettingsSchema), + defaultValues: { + stickySession: resource.stickySession + } + }); + + const router = useRouter(); + const [, formAction, isSubmitting] = useActionState( + saveResourceHttpSettings, + null + ); + + async function saveResourceHttpSettings() { + const isValidTLS = await tlsSettingsForm.trigger(); + const isValidProxy = await proxySettingsForm.trigger(); + const targetSettingsForm = await targetsSettingsForm.trigger(); + if (!isValidTLS || !isValidProxy || !targetSettingsForm) return; + + try { + // Gather all settings + const stickySessionData = targetsSettingsForm.getValues(); + const tlsData = tlsSettingsForm.getValues(); + const proxyData = proxySettingsForm.getValues(); + + // Combine into one payload + const payload = { + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null + }; + + // Single API call to update all settings + await api.post(`/resource/${resource.resourceId}`, payload); + + // Update local resource context + updateResource({ + ...resource, + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null + }); + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + + + + {t("proxyAdditional")} + + + {t("proxyAdditionalDescription")} + + + + +
+ + {!env.flags.usePangolinDns && ( + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + )} + ( + + + {t("targetTlsSni")} + + + + + + {t("targetTlsSniDescription")} + + + + )} + /> + + +
+ + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + +
+ + +
+ + ( + + + {t("proxyCustomHeader")} + + + + + + {t("proxyCustomHeaderDescription")} + + + + )} + /> + ( + + + {t("customHeaders")} + + + { + field.onChange(value); + }} + rows={4} + /> + + + {t("customHeadersDescription")} + + + + )} + /> + + +
+
+ +
+
+
+ ); +} + +function ProxyResourceProtocolForm({ + resource, + updateResource +}: Pick) { + const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorInvalidHeader") + } + ), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.int().min(1).max(2).optional() + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "", + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 + } + }); + + const router = useRouter(); + + const [, formAction, isSubmitting] = useActionState( + saveProtocolSettings, + null + ); + + async function saveProtocolSettings() { + const isValid = proxySettingsForm.trigger(); + if (!isValid) return; + + try { + // For TCP/UDP resources, save proxy protocol settings + const proxyData = proxySettingsForm.getValues(); + + const payload = { + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }; + + await api.post(`/resource/${resource.resourceId}`, payload); + + updateResource({ + ...resource, + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }); + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + + {t("proxyProtocolVersion")} + + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}:{" "} + {t("proxyProtocolWarning")} + + + + )} + + +
+
+ +
+
+
+ ); } diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 071bc1af..5c99fa7e 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -94,6 +94,12 @@ export default function DomainPicker({ const api = createApiClient({ env }); const t = useTranslations(); + console.log({ + defaultFullDomain, + defaultSubdomain, + defaultDomainId + }); + const { data = [], isLoading: loadingDomains } = useQuery( orgQueries.domains({ orgId }) ); diff --git a/src/contexts/orgContext.ts b/src/contexts/orgContext.ts index dbe92d50..e5141bde 100644 --- a/src/contexts/orgContext.ts +++ b/src/contexts/orgContext.ts @@ -1,9 +1,8 @@ import { GetOrgResponse } from "@server/routers/org"; import { createContext } from "react"; -interface OrgContextType { +export interface OrgContextType { org: GetOrgResponse; - updateOrg: (updateOrg: Partial) => void; } const OrgContext = createContext(undefined); diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index bb5501a6..84fa31ac 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -2,7 +2,7 @@ import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; import { createContext } from "react"; -interface ResourceContextType { +export interface ResourceContextType { resource: GetResourceResponse; authInfo: GetResourceAuthInfoResponse; updateResource: (updatedResource: Partial) => void; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 997d160d..0dc44147 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -15,7 +15,11 @@ import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; -import type { ListResourceNamesResponse } from "@server/routers/resource"; +import type { + GetResourceWhitelistResponse, + ListResourceNamesResponse +} from "@server/routers/resource"; +import type { ListTargetsResponse } from "@server/routers/target"; import type { ListDomainsResponse } from "@server/routers/domain"; export type ProductUpdate = { @@ -151,6 +155,18 @@ export const orgQueries = { >(`/org/${orgId}/domains`, { signal }); return res.data.data.domains; } + }), + identityProviders: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "IDPS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse<{ + idps: { idpId: number; name: string }[]; + }> + >(build === "saas" ? `/org/${orgId}/idp` : "/idp", { signal }); + return res.data.data.idps; + } }) }; @@ -212,7 +228,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/site-resource/${resourceId}/users`, { signal }); + >(`/resource/${resourceId}/users`, { signal }); return res.data.data.users; } }), @@ -222,7 +238,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/site-resource/${resourceId}/roles`, { signal }); + >(`/resource/${resourceId}/roles`, { signal }); return res.data.data.roles; } @@ -233,11 +249,33 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/site-resource/${resourceId}/clients`, { signal }); + >(`/resource/${resourceId}/clients`, { signal }); return res.data.data.clients; } }), + resourceTargets: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource/${resourceId}/targets`, { signal }); + + return res.data.data.targets; + } + }), + resourceWhitelist: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource/${resourceId}/whitelist`, { signal }); + + return res.data.data.whitelist; + } + }), listNamesPerOrg: (orgId: string) => queryOptions({ queryKey: ["RESOURCES_NAMES", orgId] as const, diff --git a/src/providers/OrgProvider.tsx b/src/providers/OrgProvider.tsx index adceeff0..122e0127 100644 --- a/src/providers/OrgProvider.tsx +++ b/src/providers/OrgProvider.tsx @@ -10,36 +10,15 @@ interface OrgProviderProps { org: GetOrgResponse | null; } -export function OrgProvider({ children, org: serverOrg }: OrgProviderProps) { - const [org, setOrg] = useState(serverOrg); - +export function OrgProvider({ children, org }: OrgProviderProps) { const t = useTranslations(); if (!org) { throw new Error(t("orgErrorNoProvided")); } - const updateOrg = (updatedOrg: Partial) => { - if (!org) { - throw new Error(t("orgErrorNoUpdate")); - } - - setOrg((prev) => { - if (!prev) { - return prev; - } - - return { - ...prev, - ...updatedOrg - }; - }); - }; - return ( - - {children} - + {children} ); }