diff --git a/messages/en-US.json b/messages/en-US.json index a3bfe73b8..9f8ddebe4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -289,6 +289,20 @@ "siteSelectionDescription": "This site will provide connectivity to the target.", "resourceType": "Resource Type", "resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.", + "resourceTypeSearch": "Search resource types...", + "resourceTypeNotFound": "No resource type found", + "resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name", + "resourceTypeInferenceDescription": "Route AI inference requests through attached providers", + "resourceTypeSshDescription": "Access an SSH server from the browser", + "resourceTypeRdpDescription": "Access a remote desktop from the browser", + "resourceTypeVncDescription": "Access a VNC desktop from the browser", + "resourceTypeTcpDescription": "Proxy raw TCP traffic using a port number", + "resourceTypeUdpDescription": "Proxy raw UDP traffic using a port number", + "privateResourceTypeDescription": "This controls how clients reach the resource. This can’t be changed later.", + "privateResourceTypeHostDescription": "Expose a single host on the site network to connected clients", + "privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients", + "privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain", + "privateResourceTypeSshDescription": "Access an SSH server from connected clients", "resourceDomainDescription": "The resource will be served at this fully qualified domain name.", "resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", @@ -3522,8 +3536,8 @@ "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", - "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", - "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", + "licenseRequiredToUse": "Enterprise Edition license is required. Book a demo or POC", + "ossEnterpriseEditionRequired": "Enterprise Edition license is required. Book a demo or POC", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 90f115bfd..96e8f84d8 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -12,9 +12,9 @@ import { } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { - OptionSelect, - type OptionSelectOption -} from "@app/components/OptionSelect"; + DescribedSelect, + type DescribedSelectOption +} from "@app/components/DescribedSelect"; import DomainPicker from "@app/components/DomainPicker"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { Button } from "@app/components/ui/button"; @@ -147,24 +147,35 @@ export default function CreatePrivateResourcePage() { const authDaemonMode = form.watch("authDaemonMode"); const isNativeSsh = mode === "ssh" && authDaemonMode === "native"; - const modeOptions: OptionSelectOption[] = [ - { value: "host", label: t("createInternalResourceDialogModeHost") }, - { value: "cidr", label: t("createInternalResourceDialogModeCidr") }, + const modeOptions: DescribedSelectOption[] = [ + { + value: "host", + title: t("createInternalResourceDialogModeHost"), + description: t("privateResourceTypeHostDescription") + }, + { + value: "cidr", + title: t("createInternalResourceDialogModeCidr"), + description: t("privateResourceTypeCidrDescription") + }, ...(!disableEnterpriseFeatures ? [ { value: "http" as const, - label: t("createInternalResourceDialogModeHttp") + title: t("createInternalResourceDialogModeHttp"), + description: t("privateResourceTypeHttpDescription") }, { value: "ssh" as const, - label: t("createInternalResourceDialogModeSsh") + title: t("createInternalResourceDialogModeSsh"), + description: t("privateResourceTypeSshDescription") } ] : []), { value: "inference" as const, - label: t("createInternalResourceDialogModeInference") + title: t("createInternalResourceDialogModeInference"), + description: t("resourceTypeInferenceDescription") } ]; @@ -260,6 +271,110 @@ export default function CreatePrivateResourcePage() { + + ( + + + {t("type")} + + + + options={ + modeOptions + } + value={field.value} + onChange={( + newMode + ) => { + field.onChange( + newMode + ); + if ( + newMode === + "ssh" + ) { + form.setValue( + "authDaemonMode", + "native" + ); + form.setValue( + "standardDaemonLocation", + "site" + ); + form.setValue( + "destination", + null + ); + form.setValue( + "destinationPort", + null + ); + } else if ( + newMode === + "http" + ) { + form.setValue( + "destinationPort", + 443 + ); + } else if ( + newMode === + "inference" + ) { + form.setValue( + "siteIds", + [] + ); + setSelectedSites( + [] + ); + form.setValue( + "destination", + null + ); + form.setValue( + "destinationPort", + null + ); + form.setValue( + "providerIds", + [] + ); + setSelectedProviders( + [] + ); + } else { + form.setValue( + "destinationPort", + null + ); + } + }} + searchPlaceholder={t( + "resourceTypeSearch" + )} + emptyMessage={t( + "resourceTypeNotFound" + )} + placeholder={t( + "noneSelected" + )} + /> + + + + {t( + "privateResourceTypeDescription" + )} + + + )} + /> + + - - ( - - - {t("type")} - - - options={modeOptions} - value={field.value} - onChange={(newMode) => { - field.onChange( - newMode - ); - if ( - newMode === - "ssh" - ) { - form.setValue( - "authDaemonMode", - "native" - ); - form.setValue( - "standardDaemonLocation", - "site" - ); - form.setValue( - "destination", - null - ); - form.setValue( - "destinationPort", - null - ); - } else if ( - newMode === - "http" - ) { - form.setValue( - "destinationPort", - 443 - ); - } else if ( - newMode === - "inference" - ) { - form.setValue( - "siteIds", - [] - ); - setSelectedSites( - [] - ); - form.setValue( - "destination", - null - ); - form.setValue( - "destinationPort", - null - ); - form.setValue( - "providerIds", - [] - ); - setSelectedProviders( - [] - ); - } else { - form.setValue( - "destinationPort", - null - ); - } - }} - cols={4} - /> - - - )} - /> - - {(mode === "http" || mode === "inference") && ( diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 65529f74a..667819c21 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -18,9 +18,9 @@ import { } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { - OptionSelect, - type OptionSelectOption -} from "@app/components/OptionSelect"; + DescribedSelect, + type DescribedSelectOption +} from "@app/components/DescribedSelect"; import { StrategySelect, type StrategyOption @@ -775,26 +775,45 @@ export default function Page() { } ]; - let typeLabels: Partial> = { - http: "HTTP", - inference: t("createInternalResourceDialogModeInference"), - tcp: "TCP", - udp: "UDP" + const typeMeta: Record< + NewResourceType, + { title: string; description: string } + > = { + http: { + title: t("createInternalResourceDialogModeHttp"), + description: t("resourceTypeHttpDescription") + }, + inference: { + title: t("createInternalResourceDialogModeInference"), + description: t("resourceTypeInferenceDescription") + }, + ssh: { + title: t("createInternalResourceDialogModeSsh"), + description: t("resourceTypeSshDescription") + }, + rdp: { + title: t("rdpTitle"), + description: t("resourceTypeRdpDescription") + }, + vnc: { + title: t("vncTitle"), + description: t("resourceTypeVncDescription") + }, + tcp: { + title: t("createInternalResourceDialogTcp"), + description: t("resourceTypeTcpDescription") + }, + udp: { + title: t("createInternalResourceDialogUdp"), + description: t("resourceTypeUdpDescription") + } }; - if (enterpriseModesAllowed) { - typeLabels = { - ...typeLabels, - ssh: "SSH", - rdp: "RDP", - vnc: "VNC" - }; - } - - const typeOptions: OptionSelectOption[] = + const typeOptions: DescribedSelectOption[] = availableTypes.map((type) => ({ value: type, - label: typeLabels[type] ?? type.toUpperCase() + title: typeMeta[type].title, + description: typeMeta[type].description })); return ( @@ -831,6 +850,35 @@ export default function Page() { + +
+ + + options={typeOptions} + value={resourceType} + onChange={ + setResourceType + } + searchPlaceholder={t( + "resourceTypeSearch" + )} + emptyMessage={t( + "resourceTypeNotFound" + )} + placeholder={t( + "noneSelected" + )} + /> +

+ {t( + "resourceTypeDescription" + )} +

+
+
+
- -
-

- {t("type")} -

- - options={typeOptions} - value={resourceType} - onChange={ - setResourceType - } - cols={6} - /> -

- {t( - "resourceTypeDescription" - )} -

-
-
- {isHttpResource && ( diff --git a/src/components/DescribedSelect.tsx b/src/components/DescribedSelect.tsx new file mode 100644 index 000000000..c5a1f63d0 --- /dev/null +++ b/src/components/DescribedSelect.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { useState } from "react"; + +export type DescribedSelectOption = { + value: TValue; + title: string; + description: string; +}; + +type DescribedSelectProps = { + options: ReadonlyArray>; + value: TValue; + onChange: (value: TValue) => void; + searchPlaceholder: string; + emptyMessage: string; + placeholder?: string; + disabled?: boolean; + className?: string; +}; + +export function DescribedSelect({ + options, + value, + onChange, + searchPlaceholder, + emptyMessage, + placeholder, + disabled, + className +}: DescribedSelectProps) { + const [open, setOpen] = useState(false); + const selected = options.find((option) => option.value === value); + + return ( +
+ + + + + + + + + {emptyMessage} + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + +
+ + {option.title} + + + {option.description} + +
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index bf25fcfce..957d759eb 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -516,7 +516,9 @@ export default function DomainPicker({ return ( - {t("domainPickerNoDomainsAvailableTitle")} + + {t("domainPickerNoDomainsAvailableTitle")} +

{t("domainPickerNoDomainsAvailableDescription")}