basic ui working

This commit is contained in:
miloschwartz
2026-04-09 22:24:39 -04:00
parent 510931e7d6
commit 79751c208d
12 changed files with 365 additions and 167 deletions

View File

@@ -1819,6 +1819,9 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable SSL",
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestination": "Destination",
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
@@ -1864,11 +1867,17 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable SSL",
"createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
"siteConfiguration": "Configuration", "siteConfiguration": "Configuration",
"siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnections": "Accept Client Connections",
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",

View File

@@ -16,6 +16,20 @@ import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): {
mode: "host" | "cidr" | "http";
ssl: boolean;
scheme: "http" | "https" | null;
} {
if (mode === "https") {
return { mode: "http", ssl: true, scheme: "https" };
}
if (mode === "http") {
return { mode: "http", ssl: false, scheme: "http" };
}
return { mode, ssl: false, scheme: null };
}
export type ClientResourcesResults = { export type ClientResourcesResults = {
newSiteResource: SiteResource; newSiteResource: SiteResource;
oldSiteResource?: SiteResource; oldSiteResource?: SiteResource;
@@ -76,13 +90,16 @@ export async function updateClientResources(
} }
if (existingResource) { if (existingResource) {
const mappedMode = siteResourceModeForDb(resourceData.mode);
// Update existing resource // Update existing resource
const [updatedResource] = await trx const [updatedResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
siteId: site.siteId, siteId: site.siteId,
mode: resourceData.mode, mode: mappedMode.mode,
ssl: mappedMode.ssl,
scheme: mappedMode.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
@@ -208,9 +225,9 @@ export async function updateClientResources(
oldSiteResource: existingResource oldSiteResource: existingResource
}); });
} else { } else {
const mappedMode = siteResourceModeForDb(resourceData.mode);
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
if (resourceData.mode == "host") { if (mappedMode.mode === "host" || mappedMode.mode === "http") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
@@ -222,7 +239,9 @@ export async function updateClientResources(
siteId: site.siteId, siteId: site.siteId,
niceId: resourceNiceId, niceId: resourceNiceId,
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
mode: resourceData.mode, mode: mappedMode.mode,
ssl: mappedMode.ssl,
scheme: mappedMode.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: true, // hardcoded for now

View File

@@ -652,7 +652,7 @@ export function generateSubnetProxyTargetV2(
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId
}; };
} else if (siteResource.mode == "http" || siteResource.mode == "https") { } else if (siteResource.mode == "http") {
let destination = siteResource.destination; let destination = siteResource.destination;
// check if this is a valid ip // check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]); const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -667,10 +667,11 @@ export function generateSubnetProxyTargetV2(
!siteResource.scheme !siteResource.scheme
) { ) {
logger.debug( logger.debug(
`Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address or destinationPort, skipping alias target generation.` `Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.`
); );
return; return;
} }
const publicProtocol = siteResource.ssl ? "https" : "http";
// also push a match for the alias address // also push a match for the alias address
target = { target = {
sourcePrefixes: [], sourcePrefixes: [],
@@ -679,14 +680,14 @@ export function generateSubnetProxyTargetV2(
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId,
protocol: siteResource.mode, // will be either http or https, protocol: publicProtocol,
httpTargets: [ httpTargets: [
{ {
destAddr: siteResource.destination, destAddr: siteResource.destination,
destPort: siteResource.destinationPort, destPort: siteResource.destinationPort,
scheme: siteResource.scheme scheme: siteResource.scheme
} }
], ]
// tlsCert: "", // tlsCert: "",
// tlsKey: "" // tlsKey: ""
}; };

View File

@@ -267,7 +267,7 @@ export async function getTraefikConfig(
}); });
}); });
// Query siteResources in http/https mode that have aliases - needed for cert generation // Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
const siteResourcesWithAliases = await db const siteResourcesWithAliases = await db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
@@ -280,7 +280,8 @@ export async function getTraefikConfig(
and( and(
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
isNotNull(siteResources.alias), isNotNull(siteResources.alias),
inArray(siteResources.mode, ["http", "https"]), eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
or( or(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
and( and(
@@ -900,7 +901,7 @@ export async function getTraefikConfig(
} }
} }
// Add Traefik routes for siteResource aliases in http/https mode so that // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no // Traefik generates TLS certificates for those domains even when no
// matching resource exists yet. // matching resource exists yet.
if (siteResourcesWithAliases.length > 0) { if (siteResourcesWithAliases.length > 0) {

View File

@@ -111,6 +111,21 @@ const createSiteResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" message: "Destination must be a valid CIDR notation for cidr mode"
} }
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.destinationPort !== undefined &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
); );
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>; export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;

View File

@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
mode: z mode: z
.enum(["host", "cidr", "http", "https"]) .enum(["host", "cidr", "http"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["host", "cidr", "http", "https"], enum: ["host", "cidr", "http"],
description: "Filter site resources by mode" description: "Filter site resources by mode"
}), }),
sort_by: z sort_by: z
@@ -88,6 +88,7 @@ function querySiteResourcesBase() {
niceId: siteResources.niceId, niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
mode: siteResources.mode, mode: siteResources.mode,
ssl: siteResources.ssl,
scheme: siteResources.scheme, scheme: siteResources.scheme,
proxyPort: siteResources.proxyPort, proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort, destinationPort: siteResources.destinationPort,
@@ -193,7 +194,9 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions)); const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count( const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") querySiteResourcesBase()
.where(and(...conditions))
.as("filtered_site_resources")
); );
const [siteResourcesList, totalCount] = await Promise.all([ const [siteResourcesList, totalCount] = await Promise.all([

View File

@@ -125,6 +125,23 @@ const updateSiteResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" message: "Destination must be a valid CIDR notation for cidr mode"
} }
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.scheme !== null &&
data.destinationPort !== undefined &&
data.destinationPort !== null &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
); );
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>; export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;

View File

@@ -56,13 +56,25 @@ export default async function ClientResourcesPage(
const internalResourceRows: InternalResourceRow[] = siteResources.map( const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => { (siteResource) => {
const rawMode = siteResource.mode as string | undefined;
const normalizedMode =
rawMode === "https"
? ("http" as const)
: rawMode === "host" || rawMode === "cidr" || rawMode === "http"
? rawMode
: ("host" as const);
return { return {
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
orgId: params.orgId, orgId: params.orgId,
siteName: siteResource.siteName, siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null, siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || ("port" as any), mode: normalizedMode,
scheme:
siteResource.scheme ??
(rawMode === "https" ? ("https" as const) : null),
ssl:
siteResource.ssl === true || rawMode === "https",
// protocol: siteResource.protocol, // protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId, siteId: siteResource.siteId,

View File

@@ -46,7 +46,9 @@ export type InternalResourceRow = {
siteName: string; siteName: string;
siteAddress: string | null; siteAddress: string | null;
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http" | "https"; mode: "host" | "cidr" | "http";
scheme: "http" | "https" | null;
ssl: boolean;
// protocol: string | null; // protocol: string | null;
// proxyPort: number | null; // proxyPort: number | null;
siteId: number; siteId: number;
@@ -64,30 +66,27 @@ export type InternalResourceRow = {
}; };
function resolveHttpHttpsDisplayPort( function resolveHttpHttpsDisplayPort(
mode: "http" | "https", mode: "http",
httpHttpsPort: number | null httpHttpsPort: number | null
): number { ): number {
if (httpHttpsPort != null) { if (httpHttpsPort != null) {
return httpHttpsPort; return httpHttpsPort;
} }
return mode === "https" ? 443 : 80; return 80;
} }
function formatDestinationDisplay(row: InternalResourceRow): string { function formatDestinationDisplay(row: InternalResourceRow): string {
const { mode, destination, httpHttpsPort } = row; const { mode, destination, httpHttpsPort, scheme } = row;
if (mode !== "http" && mode !== "https") { if (mode !== "http") {
return destination; return destination;
} }
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
const downstreamScheme = scheme ?? "http";
const hostPart = const hostPart =
destination.includes(":") && !destination.startsWith("[") destination.includes(":") && !destination.startsWith("[")
? `[${destination}]` ? `[${destination}]`
: destination; : destination;
return `${hostPart}:${port}`; return `${downstreamScheme}://${hostPart}:${port}`;
}
function formatHttpHttpsAliasUrl(mode: "http" | "https", alias: string): string {
return `${mode}://${alias}`;
} }
function isSafeUrlForLink(href: string): boolean { function isSafeUrlForLink(href: string): boolean {
@@ -255,10 +254,6 @@ export default function ClientResourcesTable({
{ {
value: "http", value: "http",
label: t("editInternalResourceDialogModeHttp") label: t("editInternalResourceDialogModeHttp")
},
{
value: "https",
label: t("editInternalResourceDialogModeHttps")
} }
]} ]}
selectedValue={searchParams.get("mode") ?? undefined} selectedValue={searchParams.get("mode") ?? undefined}
@@ -272,14 +267,13 @@ export default function ClientResourcesTable({
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const modeLabels: Record< const modeLabels: Record<
"host" | "cidr" | "port" | "http" | "https", "host" | "cidr" | "port" | "http",
string string
> = { > = {
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"), port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp"), http: t("editInternalResourceDialogModeHttp")
https: t("editInternalResourceDialogModeHttps")
}; };
return <span>{modeLabels[resourceRow.mode]}</span>; return <span>{modeLabels[resourceRow.mode]}</span>;
} }
@@ -319,15 +313,8 @@ export default function ClientResourcesTable({
/> />
); );
} }
if ( if (resourceRow.mode === "http" && resourceRow.alias) {
(resourceRow.mode === "http" || const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`;
resourceRow.mode === "https") &&
resourceRow.alias
) {
const url = formatHttpHttpsAliasUrl(
resourceRow.mode,
resourceRow.alias
);
return ( return (
<CopyToClipboard <CopyToClipboard
text={url} text={url}

View File

@@ -51,9 +51,7 @@ export default function CreateInternalResourceDialog({
try { try {
let data = { ...values }; let data = { ...values };
if ( if (
(data.mode === "host" || (data.mode === "host" || data.mode === "http") &&
data.mode === "http" ||
data.mode === "https") &&
isHostname(data.destination) isHostname(data.destination)
) { ) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
@@ -74,21 +72,42 @@ export default function CreateInternalResourceDialog({
mode: data.mode, mode: data.mode,
destination: data.destination, destination: data.destination,
enabled: true, enabled: true,
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined, ...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? undefined
}),
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString, udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false, disableIcmp: data.disableIcmp ?? false,
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }), ...(data.authDaemonMode != null && {
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }), authDaemonMode: data.authDaemonMode
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [], }),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
}),
roleIds: data.roles
? data.roles.map((r) => parseInt(r.id))
: [],
userIds: data.users ? data.users.map((u) => u.id) : [], userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
} }
); );
toast({ toast({
title: t("createInternalResourceDialogSuccess"), title: t("createInternalResourceDialogSuccess"),
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default" variant: "default"
}); });
setOpen(false); setOpen(false);
@@ -98,7 +117,9 @@ export default function CreateInternalResourceDialog({
title: t("createInternalResourceDialogError"), title: t("createInternalResourceDialogError"),
description: formatAxiosError( description: formatAxiosError(
error, error,
t("createInternalResourceDialogFailedToCreateInternalResource") t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
), ),
variant: "destructive" variant: "destructive"
}); });
@@ -111,9 +132,13 @@ export default function CreateInternalResourceDialog({
<Credenza open={open} onOpenChange={setOpen}> <Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl"> <CredenzaContent className="max-w-3xl">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle> <CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
{t("createInternalResourceDialogCreateClientResourceDescription")} {t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
@@ -128,7 +153,11 @@ export default function CreateInternalResourceDialog({
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}> <Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")} {t("createInternalResourceDialogCancel")}
</Button> </Button>
</CredenzaClose> </CredenzaClose>

View File

@@ -55,9 +55,7 @@ export default function EditInternalResourceDialog({
try { try {
let data = { ...values }; let data = { ...values };
if ( if (
(data.mode === "host" || (data.mode === "host" || data.mode === "http") &&
data.mode === "http" ||
data.mode === "https") &&
isHostname(data.destination) isHostname(data.destination)
) { ) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
@@ -76,6 +74,11 @@ export default function EditInternalResourceDialog({
mode: data.mode, mode: data.mode,
niceId: data.niceId, niceId: data.niceId,
destination: data.destination, destination: data.destination,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null
}),
alias: alias:
data.alias && data.alias &&
typeof data.alias === "string" && typeof data.alias === "string" &&

View File

@@ -46,6 +46,7 @@ import { SitesSelector, type Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -121,7 +122,7 @@ export const cleanForFQDN = (name: string): string =>
type Site = ListSitesResponse["sites"][0]; type Site = ListSitesResponse["sites"][0];
export type InternalResourceMode = "host" | "cidr" | "http" | "https"; export type InternalResourceMode = "host" | "cidr" | "http";
export type InternalResourceData = { export type InternalResourceData = {
id: number; id: number;
@@ -139,6 +140,8 @@ export type InternalResourceData = {
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
httpHttpsPort?: number | null; httpHttpsPort?: number | null;
scheme?: "http" | "https" | null;
ssl?: boolean;
httpConfigSubdomain?: string | null; httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null; httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null; httpConfigFullDomain?: string | null;
@@ -159,6 +162,8 @@ export type InternalResourceFormValues = {
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
httpHttpsPort?: number | null; httpHttpsPort?: number | null;
scheme?: "http" | "https";
ssl?: boolean;
httpConfigSubdomain?: string | null; httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null; httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null; httpConfigFullDomain?: string | null;
@@ -226,10 +231,18 @@ export function InternalResourceForm({
variant === "create" variant === "create"
? "createInternalResourceDialogModeHttp" ? "createInternalResourceDialogModeHttp"
: "editInternalResourceDialogModeHttp"; : "editInternalResourceDialogModeHttp";
const modeHttpsKey = const schemeLabelKey =
variant === "create" variant === "create"
? "createInternalResourceDialogModeHttps" ? "createInternalResourceDialogScheme"
: "editInternalResourceDialogModeHttps"; : "editInternalResourceDialogScheme";
const enableSslLabelKey =
variant === "create"
? "createInternalResourceDialogEnableSsl"
: "editInternalResourceDialogEnableSsl";
const enableSslDescriptionKey =
variant === "create"
? "createInternalResourceDialogEnableSslDescription"
: "editInternalResourceDialogEnableSslDescription";
const destinationLabelKey = const destinationLabelKey =
variant === "create" variant === "create"
? "createInternalResourceDialogDestination" ? "createInternalResourceDialogDestination"
@@ -255,48 +268,78 @@ export function InternalResourceForm({
? "createInternalResourceDialogHttpConfigurationDescription" ? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription"; : "editInternalResourceDialogHttpConfigurationDescription";
const formSchema = z.object({ const formSchema = z
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), .object({
siteId: z name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
.number() siteId: z
.int() .number()
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined), .int()
mode: z.enum(["host", "cidr", "http", "https"]), .positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
destination: z mode: z.enum(["host", "cidr", "http"]),
.string() destination: z
.min( .string()
1, .min(
destinationRequiredKey 1,
? { message: t(destinationRequiredKey) } destinationRequiredKey
: undefined ? { message: t(destinationRequiredKey) }
), : undefined
alias: z.string().nullish(), ),
httpHttpsPort: z.number().int().min(1).max(65535).optional().nullable(), alias: z.string().nullish(),
httpConfigSubdomain: z.string().nullish(), httpHttpsPort: z
httpConfigDomainId: z.string().nullish(), .number()
httpConfigFullDomain: z.string().nullish(), .int()
niceId: z .min(1)
.string() .max(65535)
.min(1) .optional()
.max(255) .nullable(),
.regex(/^[a-zA-Z0-9-]+$/) scheme: z.enum(["http", "https"]).optional(),
.optional(), ssl: z.boolean().optional(),
tcpPortRangeString: createPortRangeStringSchema(t), httpConfigSubdomain: z.string().nullish(),
udpPortRangeString: createPortRangeStringSchema(t), httpConfigDomainId: z.string().nullish(),
disableIcmp: z.boolean().optional(), httpConfigFullDomain: z.string().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), niceId: z
authDaemonPort: z.number().int().positive().optional().nullable(), .string()
roles: z.array(tagSchema).optional(), .min(1)
users: z.array(tagSchema).optional(), .max(255)
clients: z .regex(/^[a-zA-Z0-9-]+$/)
.array( .optional(),
z.object({ tcpPortRangeString: createPortRangeStringSchema(t),
clientId: z.number(), udpPortRangeString: createPortRangeStringSchema(t),
name: z.string() disableIcmp: z.boolean().optional(),
}) authDaemonMode: z.enum(["site", "remote"]).optional().nullable(),
) authDaemonPort: z.number().int().positive().optional().nullable(),
.optional() roles: z.array(tagSchema).optional(),
}); users: z.array(tagSchema).optional(),
clients: z
.array(
z.object({
clientId: z.number(),
name: z.string()
})
)
.optional()
})
.superRefine((data, ctx) => {
if (data.mode !== "http") return;
if (!data.scheme) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceDownstreamSchemeRequired"),
path: ["scheme"]
});
}
if (
data.httpHttpsPort == null ||
!Number.isFinite(data.httpHttpsPort) ||
data.httpHttpsPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["httpHttpsPort"]
});
}
});
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
@@ -430,6 +473,8 @@ export function InternalResourceForm({
authDaemonMode: resource.authDaemonMode ?? "site", authDaemonMode: resource.authDaemonMode ?? "site",
authDaemonPort: resource.authDaemonPort ?? null, authDaemonPort: resource.authDaemonPort ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null, httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.httpConfigSubdomain ?? null, httpConfigSubdomain: resource.httpConfigSubdomain ?? null,
httpConfigDomainId: resource.httpConfigDomainId ?? null, httpConfigDomainId: resource.httpConfigDomainId ?? null,
httpConfigFullDomain: resource.httpConfigFullDomain ?? null, httpConfigFullDomain: resource.httpConfigFullDomain ?? null,
@@ -445,6 +490,8 @@ export function InternalResourceForm({
destination: "", destination: "",
alias: null, alias: null,
httpHttpsPort: null, httpHttpsPort: null,
scheme: "http",
ssl: false,
httpConfigSubdomain: null, httpConfigSubdomain: null,
httpConfigDomainId: null, httpConfigDomainId: null,
httpConfigFullDomain: null, httpConfigFullDomain: null,
@@ -471,7 +518,7 @@ export function InternalResourceForm({
const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigSubdomain = form.watch("httpConfigSubdomain");
const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigDomainId = form.watch("httpConfigDomainId");
const httpConfigFullDomain = form.watch("httpConfigFullDomain"); const httpConfigFullDomain = form.watch("httpConfigFullDomain");
const isHttpOrHttps = mode === "http" || mode === "https"; const isHttpMode = mode === "http";
const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const authDaemonMode = form.watch("authDaemonMode") ?? "site";
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null); const previousResourceId = useRef<number | null>(null);
@@ -496,6 +543,8 @@ export function InternalResourceForm({
destination: "", destination: "",
alias: null, alias: null,
httpHttpsPort: null, httpHttpsPort: null,
scheme: "http",
ssl: false,
httpConfigSubdomain: null, httpConfigSubdomain: null,
httpConfigDomainId: null, httpConfigDomainId: null,
httpConfigFullDomain: null, httpConfigFullDomain: null,
@@ -527,6 +576,8 @@ export function InternalResourceForm({
destination: resource.destination ?? "", destination: resource.destination ?? "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null, httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.httpConfigSubdomain ?? null, httpConfigSubdomain: resource.httpConfigSubdomain ?? null,
httpConfigDomainId: resource.httpConfigDomainId ?? null, httpConfigDomainId: resource.httpConfigDomainId ?? null,
httpConfigFullDomain: resource.httpConfigFullDomain ?? null, httpConfigFullDomain: resource.httpConfigFullDomain ?? null,
@@ -681,6 +732,37 @@ export function InternalResourceForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t(modeLabelKey)}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="host">
{t(modeHostKey)}
</SelectItem>
<SelectItem value="cidr">
{t(modeCidrKey)}
</SelectItem>
<SelectItem value="http">
{t(modeHttpKey)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<HorizontalTabs <HorizontalTabs
@@ -718,63 +800,56 @@ export function InternalResourceForm({
<div <div
className={cn( className={cn(
"grid gap-4 items-start", "grid gap-4 items-start",
mode === "cidr" mode === "cidr" && "grid-cols-1",
? "grid-cols-4" mode === "http" && "grid-cols-3",
: "grid-cols-12" mode === "host" && "grid-cols-2"
)} )}
> >
{mode === "http" && (
<div className="min-w-0">
<FormField
control={form.control}
name="scheme"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(schemeLabelKey)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={
field.value ??
"http"
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div <div
className={ className={cn(
mode === "cidr" mode === "cidr" && "col-span-1",
? "col-span-1" (mode === "http" || mode === "host") &&
: "col-span-3" "min-w-0"
} )}
>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(modeLabelKey)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="host">
{t(modeHostKey)}
</SelectItem>
<SelectItem value="cidr">
{t(modeCidrKey)}
</SelectItem>
<SelectItem value="http">
{t(modeHttpKey)}
</SelectItem>
<SelectItem value="https">
{t(modeHttpsKey)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-5"
}
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -785,7 +860,7 @@ export function InternalResourceForm({
{t(destinationLabelKey)} {t(destinationLabelKey)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} className="w-full" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -793,7 +868,7 @@ export function InternalResourceForm({
/> />
</div> </div>
{mode === "host" && ( {mode === "host" && (
<div className="col-span-4"> <div className="min-w-0">
<FormField <FormField
control={form.control} control={form.control}
name="alias" name="alias"
@@ -805,6 +880,7 @@ export function InternalResourceForm({
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
className="w-full"
value={ value={
field.value ?? field.value ??
"" ""
@@ -817,8 +893,8 @@ export function InternalResourceForm({
/> />
</div> </div>
)} )}
{(mode === "http" || mode === "https") && ( {mode === "http" && (
<div className="col-span-4"> <div className="min-w-0">
<FormField <FormField
control={form.control} control={form.control}
name="httpHttpsPort" name="httpHttpsPort"
@@ -831,6 +907,7 @@ export function InternalResourceForm({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full"
type="number" type="number"
min={1} min={1}
max={65535} max={65535}
@@ -842,16 +919,16 @@ export function InternalResourceForm({
const raw = const raw =
e.target e.target
.value; .value;
if ( if (raw === "") {
raw === ""
) {
field.onChange( field.onChange(
null null
); );
return; return;
} }
const n = const n =
Number(raw); Number(
raw
);
field.onChange( field.onChange(
Number.isFinite( Number.isFinite(
n n
@@ -871,7 +948,7 @@ export function InternalResourceForm({
</div> </div>
</div> </div>
{isHttpOrHttps ? ( {isHttpMode ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="my-8"> <div className="my-8">
<label className="font-medium block"> <label className="font-medium block">
@@ -881,6 +958,29 @@ export function InternalResourceForm({
{t(httpConfigurationDescriptionKey)} {t(httpConfigurationDescriptionKey)}
</div> </div>
</div> </div>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(
enableSslLabelKey
)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
<DomainPicker <DomainPicker
key={ key={
variant === "edit" && siteResourceId variant === "edit" && siteResourceId
@@ -913,6 +1013,7 @@ export function InternalResourceForm({
"httpConfigFullDomain", "httpConfigFullDomain",
null null
); );
form.setValue("alias", null);
return; return;
} }
form.setValue( form.setValue(
@@ -927,6 +1028,7 @@ export function InternalResourceForm({
"httpConfigFullDomain", "httpConfigFullDomain",
res.fullDomain res.fullDomain
); );
form.setValue("alias", res.fullDomain);
}} }}
/> />
</div> </div>