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", "resourceLoginPageDescription": "Customize the login page for individual resources",
"enterConfirmation": "Enter confirmation", "enterConfirmation": "Enter confirmation",
"blueprintViewDetails": "Details", "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} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh(); router.refresh();
setEditingResource(null); setEditingResource(null);
}, 150);
}} }}
/> />
)} )}
@@ -341,7 +344,10 @@ export default function ClientResourcesTable({
orgId={orgId} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh(); router.refresh();
}, 150);
}} }}
/> />
</> </>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
@@ -20,17 +20,22 @@ interface HorizontalTabsProps {
children: React.ReactNode; children: React.ReactNode;
items: TabItem[]; items: TabItem[];
disabled?: boolean; disabled?: boolean;
clientSide?: boolean;
defaultTab?: number;
} }
export function HorizontalTabs({ export function HorizontalTabs({
children, children,
items, items,
disabled = false disabled = false,
clientSide = false,
defaultTab = 0
}: HorizontalTabsProps) { }: HorizontalTabsProps) {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const t = useTranslations(); const t = useTranslations();
const [activeClientTab, setActiveClientTab] = useState(defaultTab);
function hydrateHref(href: string) { function hydrateHref(href: string) {
return href return href
@@ -43,6 +48,73 @@ export function HorizontalTabs({
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string); .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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">

View File

@@ -15,7 +15,7 @@ const buttonVariants = cva(
destructive: destructive:
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ", "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ",
outline: 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: outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary ", "border border-primary bg-card hover:bg-primary/10 text-primary ",
secondary: secondary:

View File

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