mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 16:30:15 +02:00
improve resource type picker
This commit is contained in:
+16
-2
@@ -289,6 +289,20 @@
|
|||||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||||
"resourceType": "Resource Type",
|
"resourceType": "Resource Type",
|
||||||
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
|
"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.",
|
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
||||||
"resourceHTTPSSettings": "HTTPS Settings",
|
"resourceHTTPSSettings": "HTTPS Settings",
|
||||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||||
@@ -3522,8 +3536,8 @@
|
|||||||
"sourceAddress": "Source Address",
|
"sourceAddress": "Source Address",
|
||||||
"destinationAddress": "Destination Address",
|
"destinationAddress": "Destination Address",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
|
"licenseRequiredToUse": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or POC</bookADemoLink>",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or POC</bookADemoLink>",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import {
|
import {
|
||||||
OptionSelect,
|
DescribedSelect,
|
||||||
type OptionSelectOption
|
type DescribedSelectOption
|
||||||
} from "@app/components/OptionSelect";
|
} from "@app/components/DescribedSelect";
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -147,24 +147,35 @@ export default function CreatePrivateResourcePage() {
|
|||||||
const authDaemonMode = form.watch("authDaemonMode");
|
const authDaemonMode = form.watch("authDaemonMode");
|
||||||
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
||||||
|
|
||||||
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
|
const modeOptions: DescribedSelectOption<PrivateResourceMode>[] = [
|
||||||
{ value: "host", label: t("createInternalResourceDialogModeHost") },
|
{
|
||||||
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
|
value: "host",
|
||||||
|
title: t("createInternalResourceDialogModeHost"),
|
||||||
|
description: t("privateResourceTypeHostDescription")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cidr",
|
||||||
|
title: t("createInternalResourceDialogModeCidr"),
|
||||||
|
description: t("privateResourceTypeCidrDescription")
|
||||||
|
},
|
||||||
...(!disableEnterpriseFeatures
|
...(!disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
value: "http" as const,
|
value: "http" as const,
|
||||||
label: t("createInternalResourceDialogModeHttp")
|
title: t("createInternalResourceDialogModeHttp"),
|
||||||
|
description: t("privateResourceTypeHttpDescription")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "ssh" as const,
|
value: "ssh" as const,
|
||||||
label: t("createInternalResourceDialogModeSsh")
|
title: t("createInternalResourceDialogModeSsh"),
|
||||||
|
description: t("privateResourceTypeSshDescription")
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
value: "inference" as const,
|
value: "inference" as const,
|
||||||
label: t("createInternalResourceDialogModeInference")
|
title: t("createInternalResourceDialogModeInference"),
|
||||||
|
description: t("resourceTypeInferenceDescription")
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -260,6 +271,110 @@ export default function CreatePrivateResourcePage() {
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<SettingsFormGrid>
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("type")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<DescribedSelect<PrivateResourceMode>
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"privateResourceTypeDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
<SettingsFormCell span="half">
|
<SettingsFormCell span="half">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -283,91 +398,6 @@ export default function CreatePrivateResourcePage() {
|
|||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="mode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("type")}
|
|
||||||
</FormLabel>
|
|
||||||
<OptionSelect<PrivateResourceMode>
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsFormCell>
|
|
||||||
|
|
||||||
{(mode === "http" ||
|
{(mode === "http" ||
|
||||||
mode === "inference") && (
|
mode === "inference") && (
|
||||||
<SettingsFormCell span="full">
|
<SettingsFormCell span="full">
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import {
|
import {
|
||||||
OptionSelect,
|
DescribedSelect,
|
||||||
type OptionSelectOption
|
type DescribedSelectOption
|
||||||
} from "@app/components/OptionSelect";
|
} from "@app/components/DescribedSelect";
|
||||||
import {
|
import {
|
||||||
StrategySelect,
|
StrategySelect,
|
||||||
type StrategyOption
|
type StrategyOption
|
||||||
@@ -775,26 +775,45 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let typeLabels: Partial<Record<NewResourceType, string>> = {
|
const typeMeta: Record<
|
||||||
http: "HTTP",
|
NewResourceType,
|
||||||
inference: t("createInternalResourceDialogModeInference"),
|
{ title: string; description: string }
|
||||||
tcp: "TCP",
|
> = {
|
||||||
udp: "UDP"
|
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) {
|
const typeOptions: DescribedSelectOption<NewResourceType>[] =
|
||||||
typeLabels = {
|
|
||||||
...typeLabels,
|
|
||||||
ssh: "SSH",
|
|
||||||
rdp: "RDP",
|
|
||||||
vnc: "VNC"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeOptions: OptionSelectOption<NewResourceType>[] =
|
|
||||||
availableTypes.map((type) => ({
|
availableTypes.map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: typeLabels[type] ?? type.toUpperCase()
|
title: typeMeta[type].title,
|
||||||
|
description: typeMeta[type].description
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -831,6 +850,35 @@ export default function Page() {
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<SettingsFormGrid>
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>
|
||||||
|
{t("type")}
|
||||||
|
</Label>
|
||||||
|
<DescribedSelect<NewResourceType>
|
||||||
|
options={typeOptions}
|
||||||
|
value={resourceType}
|
||||||
|
onChange={
|
||||||
|
setResourceType
|
||||||
|
}
|
||||||
|
searchPlaceholder={t(
|
||||||
|
"resourceTypeSearch"
|
||||||
|
)}
|
||||||
|
emptyMessage={t(
|
||||||
|
"resourceTypeNotFound"
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
"noneSelected"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"resourceTypeDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
<SettingsFormCell span="half">
|
<SettingsFormCell span="half">
|
||||||
<Form {...baseForm}>
|
<Form {...baseForm}>
|
||||||
<form
|
<form
|
||||||
@@ -876,27 +924,6 @@ export default function Page() {
|
|||||||
</Form>
|
</Form>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t("type")}
|
|
||||||
</p>
|
|
||||||
<OptionSelect<NewResourceType>
|
|
||||||
options={typeOptions}
|
|
||||||
value={resourceType}
|
|
||||||
onChange={
|
|
||||||
setResourceType
|
|
||||||
}
|
|
||||||
cols={6}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"resourceTypeDescription"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SettingsFormCell>
|
|
||||||
|
|
||||||
{isHttpResource && (
|
{isHttpResource && (
|
||||||
<SettingsFormCell span="full">
|
<SettingsFormCell span="full">
|
||||||
<Form {...httpForm}>
|
<Form {...httpForm}>
|
||||||
|
|||||||
@@ -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<TValue extends string> = {
|
||||||
|
value: TValue;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DescribedSelectProps<TValue extends string> = {
|
||||||
|
options: ReadonlyArray<DescribedSelectOption<TValue>>;
|
||||||
|
value: TValue;
|
||||||
|
onChange: (value: TValue) => void;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DescribedSelect<TValue extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
className
|
||||||
|
}: DescribedSelectProps<TValue>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full", className)}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full justify-between font-normal",
|
||||||
|
!selected && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-left">
|
||||||
|
{selected?.title ?? placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={`${option.value} ${option.title} ${option.description}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4 shrink-0",
|
||||||
|
option.value === value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="truncate">
|
||||||
|
{option.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -516,7 +516,9 @@ export default function DomainPicker({
|
|||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
<AlertTitle>{t("domainPickerNoDomainsAvailableTitle")}</AlertTitle>
|
<AlertTitle>
|
||||||
|
{t("domainPickerNoDomainsAvailableTitle")}
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription className="space-y-3">
|
<AlertDescription className="space-y-3">
|
||||||
<p>{t("domainPickerNoDomainsAvailableDescription")}</p>
|
<p>{t("domainPickerNoDomainsAvailableDescription")}</p>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
@@ -592,7 +594,6 @@ export default function DomainPicker({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<PaidFeaturesAlert
|
<PaidFeaturesAlert
|
||||||
showBookADemo={false}
|
|
||||||
tiers={
|
tiers={
|
||||||
tierMatrix[
|
tierMatrix[
|
||||||
TierFeature.WildcardSubdomain
|
TierFeature.WildcardSubdomain
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ const bannerClassName =
|
|||||||
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
|
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
|
||||||
const bannerContentClassName = "py-3 px-4";
|
const bannerContentClassName = "py-3 px-4";
|
||||||
const bannerRowClassName =
|
const bannerRowClassName =
|
||||||
"flex items-center gap-2.5 text-sm text-muted-foreground";
|
"flex items-center gap-2.5 text-sm text-muted-foreground whitespace-nowrap";
|
||||||
const bannerIconClassName = "size-4 shrink-0 text-black-500";
|
const bannerIconClassName = "size-4 shrink-0 text-black-500";
|
||||||
|
const bannerTextClassName = "whitespace-nowrap shrink-0";
|
||||||
const docsLinkClassName =
|
const docsLinkClassName =
|
||||||
"inline-flex items-center gap-1 font-medium text-black-600 underline";
|
"inline-flex items-center gap-1 font-medium text-black-600 underline";
|
||||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
|
||||||
const ENTERPRISE_DOCS_URL =
|
const ENTERPRISE_DOCS_URL =
|
||||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||||
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
|
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
|
||||||
@@ -64,34 +64,21 @@ function getTierLinkRenderer(billingHref: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPangolinCloudLinkRenderer() {
|
|
||||||
return function pangolinCloudLinkRenderer(chunks: React.ReactNode) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={PANGOLIN_CLOUD_SIGNUP_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={docsLinkClassName}
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
<ExternalLink className="size-3.5 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBookADemoLinkRenderer() {
|
function getBookADemoLinkRenderer() {
|
||||||
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
|
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<span className="whitespace-nowrap">
|
||||||
href={BOOK_A_DEMO_URL}
|
<Link
|
||||||
target="_blank"
|
href={BOOK_A_DEMO_URL}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className={docsLinkClassName}
|
rel="noopener noreferrer"
|
||||||
>
|
className={docsLinkClassName}
|
||||||
{chunks}
|
>
|
||||||
<ExternalLink className="size-3.5 shrink-0" />
|
{chunks}
|
||||||
</Link>
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -114,10 +101,9 @@ function getDocsLinkRenderer(href: string) {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tiers: Tier[];
|
tiers: Tier[];
|
||||||
showBookADemo?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
export function PaidFeaturesAlert({ tiers }: Props) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const orgId = params?.orgId as string | undefined;
|
const orgId = params?.orgId as string | undefined;
|
||||||
@@ -133,11 +119,8 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
|||||||
? `/${orgId}/settings/billing`
|
? `/${orgId}/settings/billing`
|
||||||
: "https://pangolin.net/pricing";
|
: "https://pangolin.net/pricing";
|
||||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
|
||||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||||
const bookADemoLinkRenderer = showBookADemo
|
const bookADemoLinkRenderer = getBookADemoLinkRenderer();
|
||||||
? getBookADemoLinkRenderer()
|
|
||||||
: () => null;
|
|
||||||
|
|
||||||
if (env.flags.disableEnterpriseFeatures) {
|
if (env.flags.disableEnterpriseFeatures) {
|
||||||
return null;
|
return null;
|
||||||
@@ -150,17 +133,12 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
|||||||
<CardContent className={bannerContentClassName}>
|
<CardContent className={bannerContentClassName}>
|
||||||
<div className={bannerRowClassName}>
|
<div className={bannerRowClassName}>
|
||||||
<KeyRound className={bannerIconClassName} />
|
<KeyRound className={bannerIconClassName} />
|
||||||
<span>
|
<span className={bannerTextClassName}>
|
||||||
{requiredTiersLabel
|
{requiredTiersLabel
|
||||||
? isActive
|
? t.rich("upgradeToTierToUse", {
|
||||||
? t.rich("upgradeToTierToUse", {
|
tier: requiredTiersLabel,
|
||||||
tier: requiredTiersLabel,
|
tierLink: tierLinkRenderer
|
||||||
tierLink: tierLinkRenderer
|
})
|
||||||
})
|
|
||||||
: t.rich("upgradeToTierToUse", {
|
|
||||||
tier: requiredTiersLabel,
|
|
||||||
tierLink: tierLinkRenderer
|
|
||||||
})
|
|
||||||
: isActive
|
: isActive
|
||||||
? t("mustUpgradeToUse")
|
? t("mustUpgradeToUse")
|
||||||
: t("subscriptionRequiredToUse")}
|
: t("subscriptionRequiredToUse")}
|
||||||
@@ -170,34 +148,16 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{build === "enterprise" && !hasEnterpriseLicense ? (
|
{(build === "enterprise" || build === "oss") &&
|
||||||
|
!hasEnterpriseLicense ? (
|
||||||
<Card className={bannerClassName}>
|
<Card className={bannerClassName}>
|
||||||
<CardContent className={bannerContentClassName}>
|
<CardContent className={bannerContentClassName}>
|
||||||
<div className={bannerRowClassName}>
|
<div className={bannerRowClassName}>
|
||||||
<KeyRound className={bannerIconClassName} />
|
<KeyRound className={bannerIconClassName} />
|
||||||
<span>
|
<span className={bannerTextClassName}>
|
||||||
{t.rich("licenseRequiredToUse", {
|
|
||||||
enterpriseLicenseLink:
|
|
||||||
enterpriseDocsLinkRenderer,
|
|
||||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
|
||||||
bookADemoLink: bookADemoLinkRenderer
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{build === "oss" && !hasEnterpriseLicense ? (
|
|
||||||
<Card className={bannerClassName}>
|
|
||||||
<CardContent className={bannerContentClassName}>
|
|
||||||
<div className={bannerRowClassName}>
|
|
||||||
<KeyRound className={bannerIconClassName} />
|
|
||||||
<span>
|
|
||||||
{t.rich("ossEnterpriseEditionRequired", {
|
{t.rich("ossEnterpriseEditionRequired", {
|
||||||
enterpriseEditionLink:
|
enterpriseEditionLink:
|
||||||
enterpriseDocsLinkRenderer,
|
enterpriseDocsLinkRenderer,
|
||||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
|
||||||
bookADemoLink: bookADemoLinkRenderer
|
bookADemoLink: bookADemoLinkRenderer
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user