improved private resource modal

This commit is contained in:
miloschwartz
2025-12-18 16:13:15 -05:00
parent 56b0185c8f
commit 2479a3c53c
7 changed files with 1598 additions and 1233 deletions

View File

@@ -2318,5 +2318,19 @@
"resourceLoginPageDescription": "Customize the login page for individual resources",
"enterConfirmation": "Enter confirmation",
"blueprintViewDetails": "Details",
"defaultIdentityProvider": "Default Identity Provider"
"defaultIdentityProvider": "Default Identity Provider",
"editInternalResourceDialogNetworkSettings": "Network Settings",
"editInternalResourceDialogAccessPolicy": "Access Policy",
"editInternalResourceDialogAddRoles": "Add Roles",
"editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP",
"editInternalResourceDialogIcmp": "ICMP",
"editInternalResourceDialogAccessControl": "Access Control",
"editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.",
"editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535."
}

View File

@@ -329,8 +329,11 @@ export default function ClientResourcesTable({
orgId={orgId}
sites={sites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
setEditingResource(null);
}, 150);
}}
/>
)}
@@ -341,7 +344,10 @@ export default function ClientResourcesTable({
orgId={orgId}
sites={sites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
}, 150);
}}
/>
</>

View File

@@ -58,6 +58,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
// import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format
@@ -108,17 +109,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => {
};
// Port range string schema for client-side validation
const portRangeStringSchema = z
// Note: This schema is defined outside the component, so we'll use a function to get the message
const getPortRangeValidationMessage = (t: (key: string) => string) =>
t("editInternalResourceDialogPortRangeValidationError");
const createPortRangeStringSchema = (t: (key: string) => string) =>
z
.string()
.optional()
.nullable()
.refine(
(val) => isValidPortRangeString(val),
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
}
);
.refine((val) => isValidPortRangeString(val), {
message: getPortRangeValidationMessage(t)
});
// Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom";
@@ -161,25 +163,18 @@ export default function CreateInternalResourceDialog({
.string()
.min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")),
// mode: z.enum(["host", "cidr", "port"]),
mode: z.enum(["host", "cidr"]),
destination: z.string().min(1),
siteId: z
.int()
.positive(t("createInternalResourceDialogPleaseSelectSite")),
// protocol: z.enum(["tcp", "udp"]),
// proxyPort: z.int()
// .positive()
// .min(1, t("createInternalResourceDialogProxyPortMin"))
// .max(65535, t("createInternalResourceDialogProxyPortMax")),
// destinationPort: z.int()
// .positive()
// .min(1, t("createInternalResourceDialogDestinationPortMin"))
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
// .nullish(),
// mode: z.enum(["host", "cidr", "port"]),
mode: z.enum(["host", "cidr"]),
// protocol: z.enum(["tcp", "udp"]).nullish(),
// proxyPort: z.int().positive().min(1, t("createInternalResourceDialogProxyPortMin")).max(65535, t("createInternalResourceDialogProxyPortMax")).nullish(),
destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("createInternalResourceDialogDestinationPortMin")).max(65535, t("createInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional(),
roles: z
.array(
@@ -453,8 +448,8 @@ export default function CreateInternalResourceDialog({
variant: "default"
});
onSuccess?.();
setOpen(false);
onSuccess?.();
} catch (error) {
console.error("Error creating internal resource:", error);
toast({
@@ -498,7 +493,7 @@ export default function CreateInternalResourceDialog({
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
@@ -516,14 +511,8 @@ export default function CreateInternalResourceDialog({
className="space-y-6"
id="create-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t(
"createInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
{/* Name and Site - Side by Side */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
@@ -547,11 +536,7 @@ export default function CreateInternalResourceDialog({
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t(
"site"
)}
</FormLabel>
<FormLabel>{t("site")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -566,9 +551,7 @@ export default function CreateInternalResourceDialog({
>
{field.value
? availableSites.find(
(
site
) =>
(site) =>
site.siteId ===
field.value
)?.name
@@ -594,9 +577,7 @@ export default function CreateInternalResourceDialog({
</CommandEmpty>
<CommandGroup>
{availableSites.map(
(
site
) => (
(site) => (
<CommandItem
key={
site.siteId
@@ -634,7 +615,59 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
</div>
{/* Tabs for Network Settings and Access Control */}
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
title: t(
"editInternalResourceDialogAccessPolicy"
),
href: "#"
}
]}
>
{/* Network Settings Tab */}
<div className="space-y-4 mt-4">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
{/* Mode - Smaller select */}
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField
control={form.control}
name="mode"
@@ -649,7 +682,9 @@ export default function CreateInternalResourceDialog({
onValueChange={
field.onChange
}
value={field.value}
value={
field.value
}
>
<FormControl>
<SelectTrigger>
@@ -657,7 +692,6 @@ export default function CreateInternalResourceDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
{/* <SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host">
{t(
"createInternalResourceDialogModeHost"
@@ -674,76 +708,16 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
{/*
{mode === "port" && (
<>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogProtocol")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
{t("createInternalResourceDialogTcp")}
</SelectItem>
<SelectItem value="udp">
{t("createInternalResourceDialogUdp")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
{/* Destination - Larger input */}
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-5"
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)} */}
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t(
"createInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
>
<FormField
control={form.control}
name="destination"
@@ -754,59 +728,20 @@ export default function CreateInternalResourceDialog({
"createInternalResourceDialogDestination"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" &&
t(
"createInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"createInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetPort")}
</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
{...field}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationPortDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)} */}
</div>
</div>
{/* Alias */}
{/* Alias - Equally sized input (if allowed) */}
{mode !== "cidr" && (
<div>
<div className="col-span-4">
<FormField
control={form.control}
name="alias"
@@ -821,82 +756,137 @@ export default function CreateInternalResourceDialog({
<Input
{...field}
value={
field.value ?? ""
field.value ??
""
}
/>
</FormControl>
<FormDescription>
{t(
"createInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
{/* Port Restrictions Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
{/* Ports and Restrictions */}
<div className="space-y-4">
{/* TCP Ports */}
<div className="my-8">
<label className="font-medium block">
{t("portRestrictions")}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogPortRestrictionsDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogTcp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField
control={form.control}
name="tcpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup
info={t("tcpPortsDescription")}
/>*/}
<Select
value={tcpPortMode}
onValueChange={(value: PortMode) => {
setTcpPortMode(value);
value={
tcpPortMode
}
onValueChange={(
value: PortMode
) => {
setTcpPortMode(
value
);
}}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
{t(
"allPorts"
)}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
{t(
"blocked"
)}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
{t(
"custom"
)}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
{tcpPortMode ===
"custom" ? (
<FormControl>
<Input
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(e.target.value)
value={
tcpCustomPorts
}
onChange={(
e
) =>
setTcpCustomPorts(
e
.target
.value
)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
tcpPortMode ===
"all"
? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
}
className="flex-1"
/>
)}
</div>
@@ -904,61 +894,114 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
</div>
</div>
{/* UDP Ports */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogUdp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField
control={form.control}
name="udpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup
info={t("udpPortsDescription")}
/>*/}
<Select
value={udpPortMode}
onValueChange={(value: PortMode) => {
setUdpPortMode(value);
value={
udpPortMode
}
onValueChange={(
value: PortMode
) => {
setUdpPortMode(
value
);
}}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
{t(
"allPorts"
)}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
{t(
"blocked"
)}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
{t(
"custom"
)}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
{udpPortMode ===
"custom" ? (
<FormControl>
<Input
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(e.target.value)
value={
udpCustomPorts
}
onChange={(
e
) =>
setUdpCustomPorts(
e
.target
.value
)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
udpPortMode ===
"all"
? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
}
className="flex-1"
/>
)}
</div>
@@ -966,25 +1009,66 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
</div>
</div>
{/* ICMP Toggle */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogIcmp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField
control={form.control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
checked={
!field.value
}
onCheckedChange={(
checked
) =>
field.onChange(
!checked
)
}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")}
{field.value
? t(
"blocked"
)
: t(
"allowed"
)}
</span>
</div>
<FormMessage />
@@ -993,13 +1077,25 @@ export default function CreateInternalResourceDialog({
/>
</div>
</div>
</div>
</div>
{/* Access Control Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("resourceUsersRoles")}
</h3>
{/* Access Control Tab */}
<div className="space-y-4 mt-4">
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogAccessControl"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogAccessControlDescription"
)}
</div>
</div>
<div className="space-y-4">
{/* Roles */}
<FormField
control={form.control}
name="roles"
@@ -1023,9 +1119,12 @@ export default function CreateInternalResourceDialog({
size="sm"
tags={
form.getValues()
.roles || []
.roles ||
[]
}
setTags={(newRoles) => {
setTags={(
newRoles
) => {
form.setValue(
"roles",
newRoles as [
@@ -1040,7 +1139,9 @@ export default function CreateInternalResourceDialog({
autocompleteOptions={
allRoles
}
allowDuplicates={false}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
@@ -1056,6 +1157,8 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
{/* Users */}
<FormField
control={form.control}
name="users"
@@ -1078,10 +1181,13 @@ export default function CreateInternalResourceDialog({
)}
tags={
form.getValues()
.users || []
.users ||
[]
}
size="sm"
setTags={(newUsers) => {
setTags={(
newUsers
) => {
form.setValue(
"users",
newUsers as [
@@ -1096,7 +1202,9 @@ export default function CreateInternalResourceDialog({
autocompleteOptions={
allUsers
}
allowDuplicates={false}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
@@ -1107,6 +1215,8 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
{/* Clients (Machines) */}
{hasMachineClients && (
<FormField
control={form.control}
@@ -1114,7 +1224,9 @@ export default function CreateInternalResourceDialog({
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("machineClients")}
{t(
"machineClients"
)}
</FormLabel>
<FormControl>
<TagInput
@@ -1160,7 +1272,9 @@ export default function CreateInternalResourceDialog({
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
sortTags={
true
}
/>
</FormControl>
<FormMessage />
@@ -1170,6 +1284,7 @@ export default function CreateInternalResourceDialog({
)}
</div>
</div>
</HorizontalTabs>
</form>
</Form>
</CredenzaBody>

View File

@@ -56,7 +56,14 @@ import {
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ListSitesResponse } from "@server/routers/site";
import { Check, ChevronsUpDown } from "lucide-react";
import { Check, ChevronsUpDown, ChevronDown } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import { Separator } from "@app/components/ui/separator";
// import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format
@@ -85,7 +92,12 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => {
return false;
}
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
if (
startPort < 1 ||
startPort > 65535 ||
endPort < 1 ||
endPort > 65535
) {
return false;
}
@@ -107,17 +119,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => {
};
// Port range string schema for client-side validation
const portRangeStringSchema = z
// Note: This schema is defined outside the component, so we'll use a function to get the message
const getPortRangeValidationMessage = (t: (key: string) => string) =>
t("editInternalResourceDialogPortRangeValidationError");
const createPortRangeStringSchema = (t: (key: string) => string) =>
z
.string()
.optional()
.nullable()
.refine(
(val) => isValidPortRangeString(val),
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
}
);
.refine((val) => isValidPortRangeString(val), {
message: getPortRangeValidationMessage(t)
});
// Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom";
@@ -128,7 +141,10 @@ const getPortModeFromString = (val: string | undefined | null): PortMode => {
};
// Helper to get the port string for API from mode and custom value
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
const getPortStringFromMode = (
mode: PortMode,
customValue: string
): string | undefined => {
if (mode === "all") return "*";
if (mode === "blocked") return "";
return customValue;
@@ -188,8 +204,8 @@ export default function EditInternalResourceDialog({
destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional(),
roles: z
.array(
@@ -352,6 +368,9 @@ export default function EditInternalResourceDialog({
number | null
>(null);
// Collapsible state for ports and restrictions
const [isPortsExpanded, setIsPortsExpanded] = useState(false);
// Port restriction UI state
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
getPortModeFromString(resource.tcpPortRangeString)
@@ -446,9 +465,7 @@ export default function EditInternalResourceDialog({
}
// Update the site resource
await api.post(
`/site-resource/${resource.id}`,
{
await api.post(`/site-resource/${resource.id}`, {
name: data.name,
siteId: data.siteId,
mode: data.mode,
@@ -468,8 +485,7 @@ export default function EditInternalResourceDialog({
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
}
);
});
// Update roles, users, and clients
// await Promise.all([
@@ -502,8 +518,8 @@ export default function EditInternalResourceDialog({
variant: "default"
});
onSuccess?.();
setOpen(false);
onSuccess?.();
} catch (error) {
console.error("Error updating internal resource:", error);
toast({
@@ -543,18 +559,26 @@ export default function EditInternalResourceDialog({
clients: []
});
// Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString)
);
setUdpPortMode(
getPortModeFromString(resource.udpPortRangeString)
);
setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
resource.tcpPortRangeString &&
resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
resource.udpPortRangeString &&
resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
// Reset visibility states
setIsPortsExpanded(false);
previousResourceId.current = resource.id;
}
@@ -602,25 +626,33 @@ export default function EditInternalResourceDialog({
clients: []
});
// Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString)
);
setUdpPortMode(
getPortModeFromString(resource.udpPortRangeString)
);
setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
resource.tcpPortRangeString &&
resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
resource.udpPortRangeString &&
resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
// Reset visibility states
setIsPortsExpanded(false);
// Reset previous resource ID to ensure clean state on next open
previousResourceId.current = null;
}
setOpen(open);
}}
>
<CredenzaContent className="max-w-2xl">
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
@@ -639,14 +671,8 @@ export default function EditInternalResourceDialog({
className="space-y-6"
id="edit-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
{/* Name and Site - Side by Side */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
@@ -670,11 +696,7 @@ export default function EditInternalResourceDialog({
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t(
"site"
)}
</FormLabel>
<FormLabel>{t("site")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -689,9 +711,7 @@ export default function EditInternalResourceDialog({
>
{field.value
? availableSites.find(
(
site
) =>
(site) =>
site.siteId ===
field.value
)?.name
@@ -717,9 +737,7 @@ export default function EditInternalResourceDialog({
</CommandEmpty>
<CommandGroup>
{availableSites.map(
(
site
) => (
(site) => (
<CommandItem
key={
site.siteId
@@ -757,7 +775,59 @@ export default function EditInternalResourceDialog({
</FormItem>
)}
/>
</div>
{/* Tabs for Network Settings and Access Control */}
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
title: t(
"editInternalResourceDialogAccessPolicy"
),
href: "#"
}
]}
>
{/* Network Settings Tab */}
<div className="space-y-4 mt-4">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
{/* Mode - Smaller select */}
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField
control={form.control}
name="mode"
@@ -772,7 +842,9 @@ export default function EditInternalResourceDialog({
onValueChange={
field.onChange
}
value={field.value}
value={
field.value
}
>
<FormControl>
<SelectTrigger>
@@ -780,7 +852,6 @@ export default function EditInternalResourceDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host">
{t(
"editInternalResourceDialogModeHost"
@@ -797,64 +868,16 @@ export default function EditInternalResourceDialog({
</FormItem>
)}
/>
</div>
{/* {mode === "port" && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
{/* Destination - Larger input */}
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-5"
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} */}
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="destination"
@@ -865,50 +888,20 @@ export default function EditInternalResourceDialog({
"editInternalResourceDialogDestination"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" &&
t(
"editInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"editInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("targetPort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)} */}
</div>
</div>
{/* Alias */}
{/* Alias - Equally sized input (if allowed) */}
{mode !== "cidr" && (
<div>
<div className="col-span-4">
<FormField
control={form.control}
name="alias"
@@ -923,82 +916,137 @@ export default function EditInternalResourceDialog({
<Input
{...field}
value={
field.value ?? ""
field.value ??
""
}
/>
</FormControl>
<FormDescription>
{t(
"editInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
{/* Port Restrictions Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
{/* Ports and Restrictions */}
<div className="space-y-4">
{/* TCP Ports */}
<div className="my-8">
<label className="font-medium block">
{t("portRestrictions")}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogPortRestrictionsDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogTcp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField
control={form.control}
name="tcpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup
info={t("tcpPortsDescription")}
/>*/}
<Select
value={tcpPortMode}
onValueChange={(value: PortMode) => {
setTcpPortMode(value);
value={
tcpPortMode
}
onValueChange={(
value: PortMode
) => {
setTcpPortMode(
value
);
}}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
{t(
"allPorts"
)}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
{t(
"blocked"
)}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
{t(
"custom"
)}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
{tcpPortMode ===
"custom" ? (
<FormControl>
<Input
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(e.target.value)
value={
tcpCustomPorts
}
onChange={(
e
) =>
setTcpCustomPorts(
e
.target
.value
)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
tcpPortMode ===
"all"
? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
}
className="flex-1"
/>
)}
</div>
@@ -1006,61 +1054,114 @@ export default function EditInternalResourceDialog({
</FormItem>
)}
/>
</div>
</div>
{/* UDP Ports */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogUdp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField
control={form.control}
name="udpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup
info={t("udpPortsDescription")}
/>*/}
<Select
value={udpPortMode}
onValueChange={(value: PortMode) => {
setUdpPortMode(value);
value={
udpPortMode
}
onValueChange={(
value: PortMode
) => {
setUdpPortMode(
value
);
}}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
{t(
"allPorts"
)}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
{t(
"blocked"
)}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
{t(
"custom"
)}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
{udpPortMode ===
"custom" ? (
<FormControl>
<Input
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(e.target.value)
value={
udpCustomPorts
}
onChange={(
e
) =>
setUdpCustomPorts(
e
.target
.value
)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
udpPortMode ===
"all"
? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
}
className="flex-1"
/>
)}
</div>
@@ -1068,25 +1169,66 @@ export default function EditInternalResourceDialog({
</FormItem>
)}
/>
</div>
</div>
{/* ICMP Toggle */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogIcmp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField
control={form.control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
checked={
!field.value
}
onCheckedChange={(
checked
) =>
field.onChange(
!checked
)
}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")}
{field.value
? t(
"blocked"
)
: t(
"allowed"
)}
</span>
</div>
<FormMessage />
@@ -1095,18 +1237,30 @@ export default function EditInternalResourceDialog({
/>
</div>
</div>
</div>
</div>
{/* Access Control Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("resourceUsersRoles")}
</h3>
{/* Access Control Tab */}
<div className="space-y-4 mt-4">
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogAccessControl"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogAccessControlDescription"
)}
</div>
</div>
{loadingRolesUsers ? (
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
) : (
<div className="space-y-4">
{/* Roles */}
<FormField
control={form.control}
name="roles"
@@ -1130,7 +1284,8 @@ export default function EditInternalResourceDialog({
size="sm"
tags={
form.getValues()
.roles || []
.roles ||
[]
}
setTags={(
newRoles
@@ -1159,14 +1314,11 @@ export default function EditInternalResourceDialog({
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{/* Users */}
<FormField
control={form.control}
name="users"
@@ -1189,7 +1341,8 @@ export default function EditInternalResourceDialog({
)}
tags={
form.getValues()
.users || []
.users ||
[]
}
size="sm"
setTags={(
@@ -1222,6 +1375,8 @@ export default function EditInternalResourceDialog({
</FormItem>
)}
/>
{/* Clients (Machines) */}
{hasMachineClients && (
<FormField
control={form.control}
@@ -1277,7 +1432,9 @@ export default function EditInternalResourceDialog({
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
sortTags={
true
}
/>
</FormControl>
<FormMessage />
@@ -1288,6 +1445,7 @@ export default function EditInternalResourceDialog({
</div>
)}
</div>
</HorizontalTabs>
</form>
</Form>
</CredenzaBody>

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState } from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
@@ -20,17 +20,22 @@ interface HorizontalTabsProps {
children: React.ReactNode;
items: TabItem[];
disabled?: boolean;
clientSide?: boolean;
defaultTab?: number;
}
export function HorizontalTabs({
children,
items,
disabled = false
disabled = false,
clientSide = false,
defaultTab = 0
}: HorizontalTabsProps) {
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const [activeClientTab, setActiveClientTab] = useState(defaultTab);
function hydrateHref(href: string) {
return href
@@ -43,6 +48,73 @@ export function HorizontalTabs({
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
}
// Client-side mode: render tabs as buttons with state management
if (clientSide) {
const childrenArray = React.Children.toArray(children);
const activeChild = childrenArray[activeClientTab] || null;
return (
<div className="space-y-3">
<div className="relative">
<div className="overflow-x-auto scrollbar-hide">
<div className="flex space-x-4 border-b min-w-max">
{items.map((item, index) => {
const isActive = activeClientTab === index;
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled =
disabled ||
(isProfessional && !isUnlocked());
return (
<button
key={index}
type="button"
onClick={() => {
if (!isDisabled) {
setActiveClientTab(index);
}
}}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",
isActive
? "text-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.75 after:bg-primary after:rounded-full"
: "text-muted-foreground hover:text-foreground",
isDisabled && "cursor-not-allowed"
)}
disabled={isDisabled}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
<div
className={cn(
"flex items-center space-x-2",
isDisabled && "opacity-60"
)}
>
{item.icon && item.icon}
<span>{item.title}</span>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
{t("licenseBadge")}
</Badge>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
<div className="space-y-6">{activeChild}</div>
</div>
);
}
// Server-side mode: original behavior with routing
return (
<div className="space-y-3">
<div className="relative">

View File

@@ -15,7 +15,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ",
outline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground ",
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground ",
outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary ",
secondary:

View File

@@ -228,7 +228,7 @@ export const resourceQueries = {
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListSiteResourceUsersResponse>
>(`/resource/${resourceId}/users`, { signal });
>(`/site-resource/${resourceId}/users`, { signal });
return res.data.data.users;
}
}),
@@ -238,7 +238,7 @@ export const resourceQueries = {
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListSiteResourceRolesResponse>
>(`/resource/${resourceId}/roles`, { signal });
>(`/site-resource/${resourceId}/roles`, { signal });
return res.data.data.roles;
}
@@ -249,7 +249,7 @@ export const resourceQueries = {
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListSiteResourceClientsResponse>
>(`/resource/${resourceId}/clients`, { signal });
>(`/site-resource/${resourceId}/clients`, { signal });
return res.data.data.clients;
}