diff --git a/messages/en-US.json b/messages/en-US.json index 07ab4d6e8..e0ffdba1a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -982,6 +982,8 @@ "resourcePolicySharedDescription": "This resource uses a shared policy.", "sharedPolicy": "Shared Policy", "sharedPolicyNoneDescription": "This resource has its own policy.", + "resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.", + "resourceSharedPolicyInheritedDescription": "This resource inherits authentication and access rules controls from {policyName}.", "resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to {policyName}.", "resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit {policyName}.", "resourceUsersRoles": "Access Controls", @@ -1008,7 +1010,14 @@ "resourceVisibilityTitle": "Visibility", "resourceVisibilityTitleDescription": "Completely enable or disable resource visibility", "resourceGeneral": "General Settings", - "resourceGeneralDescription": "Configure the general settings for this resource", + "resourceGeneralDescription": "Configure name, address, and access policy for this resource.", + "resourceGeneralDetailsSubsection": "Resource Details", + "resourceGeneralDetailsSubsectionDescription": "Set the display name, identifier, and publicly accessible domain for this resource.", + "resourceGeneralDetailsSubsectionPortDescription": "Set the display name, identifier, and public port for this resource.", + "resourceGeneralPublicAddressSubsection": "Public Address", + "resourceGeneralPublicAddressSubsectionDescription": "Configure how users reach this resource.", + "resourceGeneralAuthenticationAccessSubsection": "Authentication & Access", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Choose whether this resource uses its own policy or inherits from a shared policy.", "resourceEnable": "Enable Resource", "resourceTransfer": "Transfer Resource", "resourceTransferDescription": "Transfer this resource to a different site", diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index 772c19624..d25ed9363 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -11,7 +11,6 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { useResourceContext } from "@app/hooks/useResourceContext"; import DomainPicker from "@app/components/DomainPicker"; import { @@ -24,10 +23,12 @@ import { SettingsFormGrid, SettingsSectionForm, SettingsSectionHeader, - SettingsSectionTitle + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle } 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"; @@ -37,7 +38,6 @@ import { UpdateResourceResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; -import { AlertCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; @@ -47,400 +47,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import z from "zod"; import { SharedPolicySelect } from "@app/components/shared-policy-selector"; import { useOrgContext } from "@app/hooks/useOrgContext"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; import { build } from "@server/build"; import { TierFeature } from "@server/lib/billing/tierMatrix"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { - Tooltip, - TooltipProvider, - TooltipTrigger -} from "@app/components/ui/tooltip"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { GetResourceResponse } from "@server/routers/resource/getResource"; -import type { ResourceContextType } from "@app/contexts/resourceContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import UptimeAlertSection from "@app/components/UptimeAlertSection"; -type MaintenanceSectionFormProps = { - resource: GetResourceResponse; - updateResource: ResourceContextType["updateResource"]; -}; - -function MaintenanceSectionForm({ - resource, - updateResource -}: MaintenanceSectionFormProps) { - const { env } = useEnvContext(); - const t = useTranslations(); - const api = createApiClient({ env }); - const { isPaidUser } = usePaidStatus(); - - const MaintenanceFormSchema = z.object({ - maintenanceModeEnabled: z.boolean().optional(), - maintenanceModeType: z.enum(["forced", "automatic"]).optional(), - maintenanceTitle: z.string().max(255).optional(), - maintenanceMessage: z.string().max(2000).optional(), - maintenanceEstimatedTime: z.string().max(100).optional() - }); - - const maintenanceForm = useForm({ - resolver: zodResolver(MaintenanceFormSchema), - defaultValues: { - maintenanceModeEnabled: resource.maintenanceModeEnabled || false, - maintenanceModeType: resource.maintenanceModeType || "automatic", - maintenanceTitle: - resource.maintenanceTitle || "We'll be back soon!", - maintenanceMessage: - resource.maintenanceMessage || - "We are currently performing scheduled maintenance. Please check back soon.", - maintenanceEstimatedTime: resource.maintenanceEstimatedTime || "" - }, - mode: "onChange" - }); - - const isMaintenanceEnabled = maintenanceForm.watch( - "maintenanceModeEnabled" - ); - const maintenanceModeType = maintenanceForm.watch("maintenanceModeType"); - - const [, maintenanceFormAction, maintenanceSaveLoading] = useActionState( - onMaintenanceSubmit, - null - ); - - async function onMaintenanceSubmit() { - const isValid = await maintenanceForm.trigger(); - if (!isValid) return; - - const data = maintenanceForm.getValues(); - - const res = await api - .post>( - `resource/${resource?.resourceId}`, - { - maintenanceModeEnabled: data.maintenanceModeEnabled, - maintenanceModeType: data.maintenanceModeType, - maintenanceTitle: data.maintenanceTitle || null, - maintenanceMessage: data.maintenanceMessage || null, - maintenanceEstimatedTime: - data.maintenanceEstimatedTime || null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorUpdate"), - description: formatAxiosError( - e, - t("resourceErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - updateResource({ - maintenanceModeEnabled: data.maintenanceModeEnabled, - maintenanceModeType: data.maintenanceModeType, - maintenanceTitle: data.maintenanceTitle || null, - maintenanceMessage: data.maintenanceMessage || null, - maintenanceEstimatedTime: data.maintenanceEstimatedTime || null - }); - - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - } - } - - if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { - return null; - } - - return ( - - - - {t("maintenanceMode")} - - - {t("maintenanceModeDescription")} - - - - - - - - - { - const isDisabled = - !isPaidUser(tierMatrix.maintencePage) || - !["http", "ssh", "rdp", "vnc"].includes( - resource.mode - ); - - return ( - - - - - - - - { - if ( - !isDisabled - ) { - maintenanceForm.setValue( - "maintenanceModeEnabled", - val - ); - } - }} - /> - - - - - - - - {t( - "enableMaintenanceModeDescription" - )} - - - - ); - }} - /> - - {isMaintenanceEnabled && ( - - ( - - - {t("maintenanceModeType")} - - - - - - - - - - - {t( - "automatic" - )} - {" "} - ( - {t( - "recommended" - )} - ) - - - {t( - "automaticModeDescription" - )} - - - - - - - - - - - {t( - "forced" - )} - - - - {t( - "forcedModeDescription" - )} - - - - - - - - )} - /> - - {maintenanceModeType === "forced" && ( - - - - {t("forcedeModeWarning")} - - - )} - - ( - - - {t("pageTitle")} - - - - - - {t("pageTitleDescription")} - - - - )} - /> - - ( - - - {t( - "maintenancePageMessage" - )} - - - - - - {t( - "maintenancePageMessageDescription" - )} - - - - )} - /> - - ( - - - {t( - "maintenancePageTimeTitle" - )} - - - - - - {t( - "maintenanceEstimatedTimeDescription" - )} - - - - )} - /> - - )} - - - - - - - - {t("saveSettings")} - - - - ); -} - export default function GeneralForm() { const params = useParams(); const { org } = useOrgContext(); @@ -467,6 +82,13 @@ export default function GeneralForm() { setSelectedSharedPolicyId(resource.resourcePolicyId ?? null); }, [resource.resourcePolicyId]); + const { data: selectedSharedPolicy } = useQuery({ + ...orgQueries.resourcePolicy({ + resourcePolicyId: selectedSharedPolicyId! + }), + enabled: showResourcePolicy && selectedSharedPolicyId !== null + }); + const [resourceFullDomain, setResourceFullDomain] = useState( `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); @@ -672,6 +294,28 @@ export default function GeneralForm() { /> + + + + {t( + "resourceGeneralDetailsSubsection" + )} + + + {t( + [ + "tcp", + "udp", + ].includes( + resource.mode + ) + ? "resourceGeneralDetailsSubsectionPortDescription" + : "resourceGeneralDetailsSubsectionDescription" + )} + + + + )} {showResourcePolicy && ( - - - - {t("sharedPolicy")} - - + + + + {t( + "resourceGeneralAuthenticationAccessSubsection" + )} + + + {t( + "resourceGeneralAuthenticationAccessSubsectionDescription" + )} + + + + + + + {t("sharedPolicy")} + + - - + /> + + {selectedSharedPolicyId === + null + ? t( + "resourceSharedPolicyOwnDescription" + ) + : selectedSharedPolicy + ? t.rich( + "resourceSharedPolicyInheritedDescription", + { + policyName: + selectedSharedPolicy.name, + policyLink: + ( + chunks + ) => ( + + { + chunks + } + + ) + } + ) + : null} + + + + > )} @@ -868,13 +557,6 @@ export default function GeneralForm() { - - {!env.flags.disableEnterpriseFeatures && ( - - )} > ); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 1b20dbc8e..1599cdceb 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -14,6 +14,7 @@ import OrgProvider from "@app/providers/OrgProvider"; import { cache } from "react"; import ResourceInfoBox from "@app/components/ResourceInfoBox"; import { getTranslations } from "next-intl/server"; +import { pullEnv } from "@app/lib/pullEnv"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -30,6 +31,7 @@ interface ResourceLayoutProps { export default async function ResourceLayout(props: ResourceLayoutProps) { const params = await props.params; const t = await getTranslations(); + const env = pullEnv(); const { children } = props; @@ -102,6 +104,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/public/{niceId}/rules` } ); + + if (!env.flags.disableEnterpriseFeatures) { + navItems.push({ + title: t("maintenanceMode"), + href: `/{orgId}/settings/resources/public/{niceId}/maintenance` + }); + } } return ( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/maintenance/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/maintenance/page.tsx new file mode 100644 index 000000000..39357105c --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/maintenance/page.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { AxiosResponse } from "axios"; +import { AlertCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useActionState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { + Tooltip, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; + +const maintenanceSupportedModes = ["http", "ssh", "rdp", "vnc"]; + +export default function ResourceMaintenancePage() { + const params = useParams(); + const router = useRouter(); + const { env } = useEnvContext(); + const { resource, updateResource } = useResourceContext(); + const t = useTranslations(); + const api = createApiClient({ env }); + const { isPaidUser } = usePaidStatus(); + + const supportsMaintenance = maintenanceSupportedModes.includes( + resource.mode + ); + + useEffect(() => { + if (env.flags.disableEnterpriseFeatures || !supportsMaintenance) { + router.replace( + `/${params.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [ + env.flags.disableEnterpriseFeatures, + params.orgId, + resource.niceId, + router, + supportsMaintenance + ]); + + const MaintenanceFormSchema = z.object({ + maintenanceModeEnabled: z.boolean().optional(), + maintenanceModeType: z.enum(["forced", "automatic"]).optional(), + maintenanceTitle: z.string().max(255).optional(), + maintenanceMessage: z.string().max(2000).optional(), + maintenanceEstimatedTime: z.string().max(100).optional() + }); + + const maintenanceForm = useForm({ + resolver: zodResolver(MaintenanceFormSchema), + defaultValues: { + maintenanceModeEnabled: resource.maintenanceModeEnabled || false, + maintenanceModeType: resource.maintenanceModeType || "automatic", + maintenanceTitle: + resource.maintenanceTitle || "We'll be back soon!", + maintenanceMessage: + resource.maintenanceMessage || + "We are currently performing scheduled maintenance. Please check back soon.", + maintenanceEstimatedTime: resource.maintenanceEstimatedTime || "" + }, + mode: "onChange" + }); + + const isMaintenanceEnabled = maintenanceForm.watch( + "maintenanceModeEnabled" + ); + const maintenanceModeType = maintenanceForm.watch("maintenanceModeType"); + + const [, maintenanceFormAction, maintenanceSaveLoading] = useActionState( + onMaintenanceSubmit, + null + ); + + async function onMaintenanceSubmit() { + const isValid = await maintenanceForm.trigger(); + if (!isValid) return; + + const data = maintenanceForm.getValues(); + + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + maintenanceModeEnabled: data.maintenanceModeEnabled, + maintenanceModeType: data.maintenanceModeType, + maintenanceTitle: data.maintenanceTitle || null, + maintenanceMessage: data.maintenanceMessage || null, + maintenanceEstimatedTime: + data.maintenanceEstimatedTime || null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + updateResource({ + maintenanceModeEnabled: data.maintenanceModeEnabled, + maintenanceModeType: data.maintenanceModeType, + maintenanceTitle: data.maintenanceTitle || null, + maintenanceMessage: data.maintenanceMessage || null, + maintenanceEstimatedTime: data.maintenanceEstimatedTime || null + }); + + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + } + } + + if (env.flags.disableEnterpriseFeatures || !supportsMaintenance) { + return null; + } + + return ( + + + + + {t("maintenanceMode")} + + + {t("maintenanceModeDescription")} + + + + + + + + + { + const isDisabled = !isPaidUser( + tierMatrix.maintencePage + ); + + return ( + + + + + + + + { + if ( + !isDisabled + ) { + maintenanceForm.setValue( + "maintenanceModeEnabled", + val + ); + } + }} + /> + + + + + + + + {t( + "enableMaintenanceModeDescription" + )} + + + + ); + }} + /> + + {isMaintenanceEnabled && ( + + ( + + + {t( + "maintenanceModeType" + )} + + + + + + + + + + + {t( + "automatic" + )} + {" "} + ( + {t( + "recommended" + )} + ) + + + {t( + "automaticModeDescription" + )} + + + + + + + + + + + {t( + "forced" + )} + + + + {t( + "forcedModeDescription" + )} + + + + + + + + )} + /> + + {maintenanceModeType === "forced" && ( + + + + {t("forcedeModeWarning")} + + + )} + + ( + + + {t("pageTitle")} + + + + + + {t( + "pageTitleDescription" + )} + + + + )} + /> + + ( + + + {t( + "maintenancePageMessage" + )} + + + + + + {t( + "maintenancePageMessageDescription" + )} + + + + )} + /> + + ( + + + {t( + "maintenancePageTimeTitle" + )} + + + + + + {t( + "maintenanceEstimatedTimeDescription" + )} + + + + )} + /> + + )} + + + + + + + + {t("saveSettings")} + + + + + ); +}