Merge branch 'site-exit-node' into dev

This commit is contained in:
Owen
2026-09-28 20:34:21 -04:00
25 changed files with 475 additions and 100 deletions
@@ -524,6 +524,7 @@ export default function BillingPage() {
};
const currentPlanId = getCurrentPlanId();
const currentPlan = planOptions.find((p) => p.id === currentPlanId);
const visiblePlanOptions = planOptions.filter(
(plan) => plan.id !== "home" || currentPlanId === "home"
@@ -873,10 +874,8 @@ export default function BillingPage() {
{/* Trial Banner */}
{isTrial && (
<TrialBillingBanner
tierName={currentPlan?.name ?? "Business"}
onUpgrade={() => {
const currentPlan = planOptions.find(
(p) => p.id === currentPlanId
);
if (currentPlan?.tierType) {
handleStartSubscription(currentPlan.tierType);
}
@@ -0,0 +1,106 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createGatewayFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceGatewayPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createGatewayFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "gateway"
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "gateway"
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("gatewaySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationGatewayDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-gateway-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-gateway-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -53,7 +53,8 @@ export default async function PrivateResourceLayout(
| "cidrSettings"
| "httpSettings"
| "sshSettings"
| "inferenceSettings";
| "inferenceSettings"
| "gatewaySettings";
const navItems = [
{
@@ -164,6 +164,11 @@ export default function CreatePrivateResourcePage() {
value: "inference" as const,
title: t("createInternalResourceDialogModeInference"),
description: t("resourceTypeInferenceDescription")
},
{
value: "gateway" as const,
title: t("createInternalResourceDialogModeGateway"),
description: t("resourceTypeGatewayDescription")
}
];
@@ -560,6 +565,38 @@ export default function CreatePrivateResourcePage() {
</SettingsSection>
)}
{/* Gateway destination */}
{mode === "gateway" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("gatewaySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationGatewayDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* HTTP configuration */}
{mode === "http" && (
<SettingsSection>
+23 -15
View File
@@ -93,7 +93,8 @@ export function PrivateResourceInfoSections({
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
inference: t("editInternalResourceDialogModeInference"),
gateway: t("editInternalResourceDialogModeGateway")
};
const destination = formatSiteResourceDestinationDisplay({
@@ -107,15 +108,19 @@ export function PrivateResourceInfoSections({
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
});
const showAccess = siteResource.mode !== "gateway";
const showAlias =
siteResource.mode !== "cidr" &&
siteResource.mode !== "http" &&
siteResource.mode !== "inference";
siteResource.mode !== "inference" &&
siteResource.mode !== "gateway";
const showDestination =
!(
siteResource.mode === "ssh" &&
siteResource.authDaemonMode === "native"
) && siteResource.mode !== "inference";
) &&
siteResource.mode !== "inference" &&
siteResource.mode !== "gateway";
const showCertificate = !!(
(siteResource.mode === "http" || siteResource.mode === "inference") &&
siteResource.ssl &&
@@ -128,7 +133,8 @@ export function PrivateResourceInfoSections({
siteResource.mode !== "inference";
const numSections =
2 +
1 +
(showAccess ? 1 : 0) +
(showDestination ? 1 : 0) +
(showAlias ? 1 : 0) +
(showCertificate ? 1 : 0) +
@@ -143,17 +149,19 @@ export function PrivateResourceInfoSections({
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={access.accessDisplay}
accessCopyValue={access.accessCopyValue}
accessUrl={access.accessUrl}
className={accessClassName}
/>
</InfoSectionContent>
</InfoSection>
{showAccess ? (
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={access.accessDisplay}
accessCopyValue={access.accessCopyValue}
accessUrl={access.accessUrl}
className={accessClassName}
/>
</InfoSectionContent>
</InfoSection>
) : null}
{showDestination ? (
<InfoSection>
+7 -2
View File
@@ -376,7 +376,8 @@ export default function PrivateResourcesTable({
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
inference: t("editInternalResourceDialogModeInference"),
gateway: t("editInternalResourceDialogModeGateway")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
@@ -392,7 +393,11 @@ export default function PrivateResourcesTable({
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
if (resourceRow.destination) {
if (
resourceRow.destination &&
resourceRow.mode !== "gateway"
) {
// don't show the gateway resource destination which is 0.0.0.0/0 to not confuse people
return (
<CopyToClipboard
text={display}
+2 -1
View File
@@ -71,7 +71,8 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
inference: t("editInternalResourceDialogModeInference"),
gateway: t("editInternalResourceDialogModeGateway")
};
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
+8 -3
View File
@@ -1,6 +1,5 @@
"use client";
import React from "react";
import { Button } from "@app/components/ui/button";
import { ClockIcon, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -8,9 +7,13 @@ import DismissableBanner from "./DismissableBanner";
type TrialBillingBannerProps = {
onUpgrade: () => void;
tierName: string;
};
export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
export const TrialBillingBanner = ({
onUpgrade,
tierName
}: TrialBillingBannerProps) => {
const t = useTranslations();
return (
@@ -19,7 +22,9 @@ export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
version={1}
title={t("billingTrialBannerTitle")}
titleIcon={<ClockIcon className="w-5 h-5 text-primary" />}
description={t("billingTrialBannerDescription")}
description={t("billingTrialBannerDescription", {
tier: tierName
})}
dismissable={false}
>
<Button
+16 -1
View File
@@ -421,7 +421,14 @@ export function createCreateFormSchema(t: TranslateFn) {
.min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")),
siteIds: z.array(z.number().int().positive()).optional(),
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
mode: z.enum([
"host",
"cidr",
"http",
"ssh",
"inference",
"gateway"
]),
destination: z.string().nullish(),
alias: z.string().nullish(),
destinationPort: z
@@ -468,6 +475,7 @@ export function createCreateFormSchema(t: TranslateFn) {
if (
data.mode !== "ssh" &&
data.mode !== "inference" &&
data.mode !== "gateway" &&
(!trimmedDestination || trimmedDestination.length < 1)
) {
ctx.addIssue({
@@ -652,6 +660,13 @@ export function createCidrFormSchema(t: TranslateFn) {
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createGatewayFormSchema(t: TranslateFn) {
return z.object({
siteIds: z.array(z.number().int().positive()).min(1),
mode: z.literal("gateway")
});
}
export function createHttpFormSchema(t: TranslateFn) {
return z
.object({